From 7628200f40a0623267a93f0276f97d0676cd878a Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Fri, 10 Jul 2026 13:39:00 +0200 Subject: [PATCH 01/27] Enable deterministic no-GUI oracle playback and capture Let RecompCore no-GUI play DTM input and record bounded FIFO windows with a named screenshot, then stop only after the frame dumper has completed a present. Invalid capture configurations fail before boot. Constraint: macOS null video does not emit presented-frame events, so automated captures use the Metal platform. Rejected: External UI automation | cannot guarantee exact capture boundaries. Confidence: high Scope-risk: narrow Directive: Keep every capture option opt-in so normal no-GUI launches remain unchanged. Tested: dolphin-emu-nogui build; 2-frame real GAFE01 DFF+PNG smoke; invalid-option exit gate; 90-frame attract capture Not-tested: Windows no-GUI screenshot timing --- Source/Core/DolphinNoGUI/MainNoGUI.cpp | 145 +++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/Source/Core/DolphinNoGUI/MainNoGUI.cpp b/Source/Core/DolphinNoGUI/MainNoGUI.cpp index ec6396444b..a9d2645a52 100644 --- a/Source/Core/DolphinNoGUI/MainNoGUI.cpp +++ b/Source/Core/DolphinNoGUI/MainNoGUI.cpp @@ -4,8 +4,12 @@ #include "DolphinNoGUI/Platform.h" #include +#include +#include #include #include +#include +#include #include #include @@ -20,7 +24,9 @@ #include "Core/BootManager.h" #include "Core/Core.h" #include "Core/DolphinAnalytics.h" +#include "Core/FifoPlayer/FifoRecorder.h" #include "Core/Host.h" +#include "Core/Movie.h" #include "Core/System.h" #include "UICommon/CommandLineParse.h" @@ -28,9 +34,36 @@ #include "UICommon/DiscordPresence.h" #endif #include "UICommon/UICommon.h" +#include "VideoCommon/VideoEvents.h" static std::unique_ptr s_platform; +struct AutoFifoCapture +{ + std::string path; + u64 start_presented_frame = 0; + s32 frame_count = 0; + std::string screenshot_name; + bool stop_after_capture = false; +}; + +template +static bool ParseUnsignedOption(const optparse::Values& options, const char* name, T* out) +{ + if (!options.is_set(name)) + return true; + const std::string text = static_cast(options.get(name)); + T parsed{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), parsed); + if (error != std::errc{} || end != text.data() + text.size()) + { + fprintf(stderr, "Invalid --%s value: %s\n", name, text.c_str()); + return false; + } + *out = parsed; + return true; +} + static void signal_handler(int) { constexpr char message[] = "A signal was received. A second signal will force Dolphin to stop.\n"; @@ -223,10 +256,56 @@ int main(const int argc, char* argv[]) "macos" #endif }); + parser->add_option("--fifo-record-path") + .action("store") + .help("Save an automated headless FIFO recording to this .dff path"); + parser->add_option("--fifo-record-start") + .action("store") + .help("Presented frames to skip before automated FIFO recording (default 0)"); + parser->add_option("--fifo-record-frames") + .action("store") + .help("Number of FIFO frames to record"); + parser->add_option("--fifo-record-stop") + .action("store_true") + .help("Stop emulation after the automated FIFO recording is saved"); + parser->add_option("--fifo-record-screenshot-name") + .action("store") + .help("Save a PNG after the automated FIFO window under ScreenShots/"); optparse::Values& options = CommandLineParse::ParseArguments(parser.get(), argc, argv); std::vector args = parser->args(); + std::optional auto_fifo; + const bool any_fifo_option = + options.is_set("fifo_record_path") || options.is_set("fifo_record_start") || + options.is_set("fifo_record_frames") || options.is_set("fifo_record_stop") || + options.is_set("fifo_record_screenshot_name"); + if (any_fifo_option) + { + if (!options.is_set("fifo_record_path") || !options.is_set("fifo_record_frames")) + { + fprintf(stderr, "--fifo-record-path and --fifo-record-frames are required together\n"); + return 1; + } + AutoFifoCapture capture; + capture.path = static_cast(options.get("fifo_record_path")); + u32 frame_count = 0; + if (capture.path.empty() || + !ParseUnsignedOption(options, "fifo_record_start", &capture.start_presented_frame) || + !ParseUnsignedOption(options, "fifo_record_frames", &frame_count) || frame_count == 0 || + frame_count > static_cast(std::numeric_limits::max())) + { + fprintf(stderr, "Invalid automated FIFO capture configuration\n"); + return 1; + } + capture.frame_count = static_cast(frame_count); + if (options.is_set("fifo_record_screenshot_name")) + capture.screenshot_name = + static_cast(options.get("fifo_record_screenshot_name")); + capture.stop_after_capture = options.is_set("fifo_record_stop"); + auto_fifo = std::move(capture); + } + std::optional save_state_path; if (options.is_set("save_state")) { @@ -297,6 +376,23 @@ int main(const int argc, char* argv[]) return 1; } + if (options.is_set("movie")) + { + std::optional movie_save_state_path; + const std::string movie_path = static_cast(options.get("movie")); + if (Core::System::GetInstance().GetMovie().PlayInput(movie_path, &movie_save_state_path)) + { + if (movie_save_state_path) + boot->boot_session_data.SetSavestateData(std::move(movie_save_state_path), + DeleteSavestateAfterBoot::No); + } + else + { + fprintf(stderr, "Could not play movie file: %s\n", movie_path.c_str()); + return 1; + } + } + auto core_state_changed_hook = Core::AddOnStateChangedCallback([](const Core::State state) { if (state == Core::State::Uninitialized) s_platform->Stop(); @@ -332,6 +428,55 @@ int main(const int argc, char* argv[]) return 1; } + Common::EventHook auto_fifo_hook; + u64 presented_frames = 0; + std::atomic auto_fifo_stop_delay = 0; + if (auto_fifo) + { + const AutoFifoCapture capture = *auto_fifo; + fprintf(stderr, + "[auto-fifo] armed path=%s start_presented=%llu frames=%d screenshot=%s stop=%d\n", + capture.path.c_str(), static_cast(capture.start_presented_frame), + capture.frame_count, + capture.screenshot_name.empty() ? "" : capture.screenshot_name.c_str(), + capture.stop_after_capture ? 1 : 0); + auto_fifo_hook = Core::System::GetInstance().GetVideoEvents().after_frame_event.Register( + [capture, &presented_frames, &auto_fifo_stop_delay](const Core::System& system) { + const u32 stop_delay = auto_fifo_stop_delay.load(); + if (stop_delay != 0 && auto_fifo_stop_delay.fetch_sub(1) == 1) + { + Core::QueueHostJob([](Core::System& queued_system) { Core::Stop(queued_system); }); + return; + } + const u64 current = presented_frames++; + if (current != capture.start_presented_frame) + return; + fprintf(stderr, "[auto-fifo] start presented=%llu frames=%d\n", + static_cast(current), capture.frame_count); + system.GetFifoRecorder().StartRecording(capture.frame_count, [capture, + &auto_fifo_stop_delay] { + FifoDataFile* file = Core::System::GetInstance().GetFifoRecorder().GetRecordedFile(); + const bool saved = file != nullptr && file->Save(capture.path); + fprintf(stderr, "[auto-fifo] complete saved=%d path=%s frames=%u\n", saved ? 1 : 0, + capture.path.c_str(), file != nullptr ? file->GetFrameCount() : 0u); + Core::QueueHostJob([capture, &auto_fifo_stop_delay](Core::System&) { + if (!capture.screenshot_name.empty()) + { + Core::SaveScreenShot(capture.screenshot_name); + fprintf(stderr, "[auto-fifo] screenshot requested name=%s\n", + capture.screenshot_name.c_str()); + } + if (capture.stop_after_capture) + { + // Give FrameDumper at least one complete present after the + // request before stopping the renderer. + auto_fifo_stop_delay.store(capture.screenshot_name.empty() ? 1u : 2u); + } + }); + }); + }); + } + #ifdef USE_DISCORD_PRESENCE Discord::UpdateDiscordPresence(); #endif From 0bc5c27fee8c7782fdd6c719244b0333005436b4 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Sun, 12 Jul 2026 12:43:53 +0200 Subject: [PATCH 02/27] Make Dolphin expose the first causal parity boundary A graphics-only FIFO capture cannot distinguish an early CPU, scheduler, memory, MMIO, or VI-phase cause from its later pixel symptom. Add bounded layered event capture, deterministic no-GUI recording controls, draw/copy pixel checkpoints, and a DOL-entry VI timing snapshot so the runtime can reproduce the console state it did not boot through. Constraint: Instrumentation must remain opt-in and bounded by environment-controlled parity levels. Rejected: Use screenshots as the sole oracle | they locate symptoms but not the first causal state change. Confidence: high Scope-risk: moderate Directive: Preserve the standard JSONL event schema and keep boot-wide interpreter tracing explicitly opt-in. Tested: dolphin-emu-nogui linked; AC boot capture emitted boot.vi_state and completed a two-frame DFF; Dolphin-to-GXRuntime analyzer passed. Not-tested: GUI Dolphin target and non-GameCube titles. --- Source/Core/Core/FifoPlayer/FifoRecorder.cpp | 41 +- Source/Core/Core/FifoPlayer/FifoRecorder.h | 6 +- Source/Core/Core/HW/MMIO.h | 7 +- Source/Core/Core/HW/VideoInterface.cpp | 48 ++ Source/Core/Core/HW/VideoInterface.h | 14 + .../Core/PowerPC/Interpreter/Interpreter.cpp | 789 ++++++++++++++++++ Source/Core/Core/PowerPC/MMU.cpp | 8 + Source/Core/Core/PowerPC/PowerPC.cpp | 22 + Source/Core/DolphinNoGUI/MainNoGUI.cpp | 87 +- Source/Core/VideoCommon/FrameDumper.cpp | 78 ++ Source/Core/VideoCommon/OpcodeDecoding.cpp | 5 +- Source/Core/VideoCommon/VertexManagerBase.cpp | 62 ++ 12 files changed, 1138 insertions(+), 29 deletions(-) diff --git a/Source/Core/Core/FifoPlayer/FifoRecorder.cpp b/Source/Core/Core/FifoPlayer/FifoRecorder.cpp index c589b97975..4cf92d9e4f 100644 --- a/Source/Core/Core/FifoPlayer/FifoRecorder.cpp +++ b/Source/Core/Core/FifoPlayer/FifoRecorder.cpp @@ -212,7 +212,8 @@ FifoRecorder::FifoRecorder(Core::System& system) : m_system(system) FifoRecorder::~FifoRecorder() = default; -void FifoRecorder::StartRecording(s32 numFrames, CallbackFunc finishedCb) +void FifoRecorder::StartRecording(s32 numFrames, CallbackFunc finishedCb, + bool record_current_frame) { std::lock_guard lk(m_mutex); @@ -242,13 +243,19 @@ void FifoRecorder::StartRecording(s32 numFrames, CallbackFunc finishedCb) if (!m_IsRecording) { m_WasRecording = false; - m_IsRecording = true; + m_IsRecording = !record_current_frame; m_RecordFramesRemaining = numFrames; } m_RequestedRecordingEnd = false; m_FinishedCb = std::move(finishedCb); + // Video globals are not guaranteed to exist when the no-GUI boot call + // returns. Arm here and let the first decoded GX command activate the + // recorder on the video thread, where both the snapshot and command bytes + // are available safely. + m_ActivateOnNextCommand.store(record_current_frame); + m_end_of_frame_event = m_system.GetVideoEvents().after_frame_event.Register([this](const Core::System& system) { const bool was_recording = OpcodeDecoder::g_record_fifo_data; @@ -272,6 +279,35 @@ void FifoRecorder::StartRecording(s32 numFrames, CallbackFunc finishedCb) }); } +void FifoRecorder::ActivatePendingRecording() +{ + if (!m_ActivateOnNextCommand.load()) + return; + // Dolphin can execute backend bootstrap FIFO work before emulated MEM1 is + // allocated. Keep the request armed until the first game FIFO batch where + // the recorder can safely shadow RAM-backed texture and vertex reads. + if (m_system.GetMemory().GetRAM() == nullptr) + return; + if (!m_ActivateOnNextCommand.exchange(false)) + return; + + std::lock_guard lk(m_mutex); + auto& memory = m_system.GetMemory(); + m_Ram.resize(memory.GetRamSize()); + m_ExRam.resize(memory.GetExRamSize()); + std::ranges::fill(m_Ram, 0); + std::ranges::fill(m_ExRam, 0); + m_IsRecording = true; + RecordInitialVideoMemory(); + m_WasRecording = true; + m_SkipNextData = false; + m_SkipFutureData = false; + m_FrameEnded = false; + m_FifoData.reserve(1024 * 1024 * 4); + m_FifoData.clear(); + OpcodeDecoder::g_record_fifo_data = true; +} + void FifoRecorder::RecordInitialVideoMemory() { const u32* bpmem_ptr = reinterpret_cast(&bpmem); @@ -290,6 +326,7 @@ void FifoRecorder::RecordInitialVideoMemory() void FifoRecorder::StopRecording() { std::lock_guard lk(m_mutex); + m_ActivateOnNextCommand.store(false); m_RequestedRecordingEnd = true; } diff --git a/Source/Core/Core/FifoPlayer/FifoRecorder.h b/Source/Core/Core/FifoPlayer/FifoRecorder.h index 637d386c39..6faeaff161 100644 --- a/Source/Core/Core/FifoPlayer/FifoRecorder.h +++ b/Source/Core/Core/FifoPlayer/FifoRecorder.h @@ -3,6 +3,7 @@ #pragma once +#include #include #include #include @@ -29,7 +30,9 @@ class FifoRecorder FifoRecorder& operator=(FifoRecorder&&) = delete; ~FifoRecorder(); - void StartRecording(s32 numFrames, CallbackFunc finishedCb); + void StartRecording(s32 numFrames, CallbackFunc finishedCb, + bool record_current_frame = false); + void ActivatePendingRecording(); void StopRecording(); bool IsRecordingDone() const; @@ -70,6 +73,7 @@ class FifoRecorder // True if m_IsRecording was true during last frame bool m_WasRecording = false; bool m_RequestedRecordingEnd = false; + std::atomic m_ActivateOnNextCommand = false; s32 m_RecordFramesRemaining = 0; CallbackFunc m_FinishedCb; std::unique_ptr m_File; diff --git a/Source/Core/Core/HW/MMIO.h b/Source/Core/Core/HW/MMIO.h index cee439ab43..167ef84473 100644 --- a/Source/Core/Core/HW/MMIO.h +++ b/Source/Core/Core/HW/MMIO.h @@ -22,6 +22,8 @@ class System; namespace MMIO { +void DolphinParityTraceMMIO(Core::System& system, bool write, u32 addr, + u32 size, u64 value); // There are three main MMIO blocks on the Wii (only one on the GameCube): // - 0x0C00xxxx: GameCube MMIOs (CP, PE, VI, PI, MI, DSP, DVD, SI, EI, AI, GP) // - 0x0D00xxxx: Wii MMIOs and GC mirrors (IPC, DVD, SI, EI, AI) @@ -136,12 +138,15 @@ class Mapping template Unit Read(Core::System& system, u32 addr) { - return GetHandlerForRead(addr).Read(system, addr); + const Unit value = GetHandlerForRead(addr).Read(system, addr); + DolphinParityTraceMMIO(system, false, addr, sizeof(Unit), value); + return value; } template void Write(Core::System& system, u32 addr, Unit val) { + DolphinParityTraceMMIO(system, true, addr, sizeof(Unit), val); GetHandlerForWrite(addr).Write(system, addr, val); } diff --git a/Source/Core/Core/HW/VideoInterface.cpp b/Source/Core/Core/HW/VideoInterface.cpp index 42e0f350ba..37eed792d7 100644 --- a/Source/Core/Core/HW/VideoInterface.cpp +++ b/Source/Core/Core/HW/VideoInterface.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include "Common/ChunkFile.h" #include "Common/CommonTypes.h" @@ -777,6 +778,53 @@ u32 VideoInterfaceManager::GetTicksPerField() const return GetTicksPerEvenField(); } +ParityTimingSnapshot VideoInterfaceManager::GetParityTimingSnapshot(u64 current_ticks) const +{ + ParityTimingSnapshot snapshot; + snapshot.half_line = m_half_line_count; + snapshot.half_lines_per_frame = + GetHalfLinesPerEvenField() + GetHalfLinesPerOddField(); + snapshot.ticks_per_half_line = GetTicksPerHalfLine(); + snapshot.ticks_per_field = GetTicksPerField(); + + if (snapshot.half_lines_per_frame == 0 || snapshot.ticks_per_half_line == 0) + return snapshot; + + // m_ticks_last_line_start is updated at every even half-line. Recover the + // current half-line's start without querying CoreTiming's private event + // heap, then account for the fractional half-line still in progress. + const u64 current_half_line_start = + m_ticks_last_line_start + + ((m_half_line_count & 1u) ? snapshot.ticks_per_half_line : 0u); + const u64 next_half_line_tick = current_half_line_start + snapshot.ticks_per_half_line; + const u64 until_next_half_line = + next_half_line_tick > current_ticks ? next_half_line_tick - current_ticks : 0u; + + u64 best = std::numeric_limits::max(); + for (const UVIInterruptRegister& reg : m_interrupt_register) + { + if (!reg.IR_MASK || reg.VCT == 0) + continue; + const u32 target_parity = reg.HCT > m_h_timing_0.HLW ? 1u : 0u; + const u32 target_half_line = 2u * (reg.VCT - 1u) + target_parity; + if (target_half_line >= snapshot.half_lines_per_frame) + continue; + u32 updates = + (target_half_line + snapshot.half_lines_per_frame - m_half_line_count) % + snapshot.half_lines_per_frame; + // A zero delta names the interrupt that fired on the preceding update; + // its next occurrence is one complete scan later. + if (updates == 0) + updates = snapshot.half_lines_per_frame; + const u64 candidate = until_next_half_line + + u64{updates - 1u} * snapshot.ticks_per_half_line; + best = std::min(best, candidate); + } + if (best != std::numeric_limits::max()) + snapshot.ticks_until_interrupt = best; + return snapshot; +} + void VideoInterfaceManager::LogField(FieldType field, u32 xfb_address) const { static constexpr std::array field_type_names{{"Odd", "Even"}}; diff --git a/Source/Core/Core/HW/VideoInterface.h b/Source/Core/Core/HW/VideoInterface.h index 6bf762aeab..e361ed4d90 100644 --- a/Source/Core/Core/HW/VideoInterface.h +++ b/Source/Core/Core/HW/VideoInterface.h @@ -350,6 +350,19 @@ union UVIHorizontalStepping }; }; +// Canonical hardware timing needed when a direct-DOL runtime starts after the +// VI has already been scanning since console power-on. Values are Broadway +// core cycles so an oracle adapter can convert them to the 40.5-MHz timebase +// without losing sub-half-line precision. +struct ParityTimingSnapshot +{ + u32 half_line = 0; + u32 half_lines_per_frame = 0; + u32 ticks_per_half_line = 0; + u64 ticks_until_interrupt = 0; + u32 ticks_per_field = 0; +}; + class VideoInterfaceManager { public: @@ -388,6 +401,7 @@ class VideoInterfaceManager u32 GetTicksPerSample() const; u32 GetTicksPerHalfLine() const; u32 GetTicksPerField() const; + ParityTimingSnapshot GetParityTimingSnapshot(u64 current_ticks) const; // Not adjusted by VBI Clock Override. u32 GetNominalTicksPerHalfLine() const; diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 5ff6fa08cb..43d3d171b8 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -4,7 +4,14 @@ #include "Core/PowerPC/Interpreter/Interpreter.h" #include +#include +#include +#include +#include +#include #include +#include +#include #include @@ -18,14 +25,711 @@ #include "Core/Debugger/Debugger_SymbolMap.h" #include "Core/HLE/HLE.h" #include "Core/HW/CPU.h" +#include "Core/HW/SystemTimers.h" +#include "Core/HW/VideoInterface.h" #include "Core/PowerPC/Interpreter/ExceptionUtils.h" #include "Core/PowerPC/MMU.h" #include "Core/PowerPC/PPCTables.h" #include "Core/PowerPC/PowerPC.h" #include "Core/System.h" +#include "VideoCommon/OpcodeDecoding.h" namespace { +std::once_flag s_parity_event_init; +std::mutex s_parity_event_mutex; +FILE* s_parity_event_file = nullptr; +std::atomic s_parity_event_sequence{0}; +std::atomic s_parity_fine_window_active{false}; +std::unordered_map s_parity_kind_counts; + +struct ParityJkrAllocation +{ + u32 heap = 0; + u32 size = 0; + u32 alignment = 0; + u32 caller_lr = 0; +}; + +// Keyed by the caller stack pointer. The wrapper reserves 16 bytes, so its +// common return PC can recover the exact request with sp + 16 even when the +// virtual allocator clobbered volatile registers. +std::unordered_map s_parity_jkr_allocations; + +int ParityCaptureLevel() +{ + static const int level = [] { + const char* raw = std::getenv("DOLPHIN_PARITY_LEVEL"); + const int parsed = raw ? std::atoi(raw) : 0; + return parsed >= 1 && parsed <= 5 ? parsed : 2; + }(); + return level; +} + +struct ParityLockstepConfig +{ + bool enabled = false; + bool started = false; + u32 start_pc = 0; + u64 limit = 1000; + u64 emitted = 0; +}; + +struct ParityRegisterConfig +{ + bool enabled = false; + u32 pc = 0; + std::string function_name; +}; + +const ParityRegisterConfig& SelectedParityRegisterConfig() +{ + static const ParityRegisterConfig config = [] { + ParityRegisterConfig value; + const char* raw_pc = std::getenv("DOLPHIN_PARITY_REGISTER_PC"); + const char* raw_name = std::getenv("DOLPHIN_PARITY_REGISTER_FUNC"); + if (!raw_pc || !raw_pc[0] || !raw_name || !raw_name[0]) + return value; + value.pc = static_cast(std::strtoul(raw_pc, nullptr, 0)); + value.function_name = raw_name; + value.enabled = value.pc != 0; + return value; + }(); + return config; +} + +u32 ParityFunctionId(const char* name) +{ + u32 hash = 2166136261u; + for (const unsigned char ch : std::string(name)) + hash = (hash ^ ch) * 16777619u; + return hash; +} + +void CloseParityEventFile() +{ + std::lock_guard lk(s_parity_event_mutex); + if (!s_parity_event_file) + return; + std::fflush(s_parity_event_file); + std::fclose(s_parity_event_file); + s_parity_event_file = nullptr; +} + +void InitParityEventFile() +{ + const char* path = std::getenv("DOLPHIN_PARITY_EVENT_FILE"); + if (!path || !path[0]) + return; + s_parity_event_file = std::fopen(path, "w"); + if (s_parity_event_file) + std::atexit(CloseParityEventFile); +} + +void EmitParityEvent(Core::System& system, PowerPC::PowerPCState& state, PowerPC::MMU& mmu, + const char* family, const char* action, u32 subject, + u64 a, u64 b, u64 c, u64 d) +{ + (void)state; + (void)mmu; + static const bool capture_boot_events = [] { + const char* raw = std::getenv("DOLPHIN_PARITY_CAPTURE_BOOT_EVENTS"); + return raw && raw[0] && raw[0] != '0'; + }(); + // Level 2 is the compact always-on lane around the selected FIFO/frame + // window. Emitting every OSGetTime/queue entry throughout interpreter boot + // produced megabytes before the first presented frame and prevented the + // requested window from being reached. Boot-wide events remain an explicit + // opt-in for dedicated investigations. + if (!capture_boot_events && !OpcodeDecoder::g_record_fifo_data) + return; + std::call_once(s_parity_event_init, InitParityEventFile); + if (!s_parity_event_file) + return; + const u64 timebase = system.GetSystemTimers().GetFakeTimeBase(); + std::lock_guard lk(s_parity_event_mutex); + if (ParityCaptureLevel() <= 2) + { + static const u64 max_per_kind = [] { + const char* raw = std::getenv("DOLPHIN_PARITY_LEVEL2_MAX_PER_KIND"); + const u64 parsed = raw ? std::strtoull(raw, nullptr, 0) : 0; + return parsed ? parsed : 64u; + }(); + std::string key(family); + key.push_back('\0'); + key.append(action); + const u64 occurrence = s_parity_kind_counts[key]++; + if (occurrence >= max_per_kind) + return; + } + const u64 sequence = s_parity_event_sequence.fetch_add(1); + std::fprintf(s_parity_event_file, + "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"%s\"," + "\"action\":\"%s\",\"subject\":%u,\"anchor\":{" + "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":null}," + "\"data\":{\"a\":%llu,\"b\":%llu,\"c\":%llu,\"d\":%llu}}\n", + static_cast(sequence), family, action, subject, + static_cast(timebase), + static_cast(a), static_cast(b), + static_cast(c), static_cast(d)); + if ((sequence & 0x3ffu) == 0) + std::fflush(s_parity_event_file); +} + +void EmitParityRegisterCheckpoint(Core::System& system, PowerPC::PowerPCState& state, + PowerPC::MMU& mmu, const char* function_name) +{ + if (ParityCaptureLevel() < 3) + return; + std::call_once(s_parity_event_init, InitParityEventFile); + if (!s_parity_event_file) + return; + const u64 sequence = s_parity_event_sequence.fetch_add(1); + const u64 timebase = system.GetSystemTimers().GetFakeTimeBase(); + const u32 function_id = ParityFunctionId(function_name); + const u32 guest_thread = mmu.Read(0x800000E4u); + std::lock_guard lk(s_parity_event_mutex); + std::fprintf(s_parity_event_file, + "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"register\"," + "\"action\":\"checkpoint\",\"subject\":%u,\"anchor\":{" + "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":null}," + "\"data\":{\"thread\":%u,\"gpr\":[", + static_cast(sequence), function_id, + static_cast(timebase), guest_thread); + for (size_t index = 0; index < std::size(state.gpr); ++index) + std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.gpr[index]); + std::fputs("],\"fpr\":[", s_parity_event_file); + for (size_t index = 0; index < std::size(state.ps); ++index) + { + const auto& ps = state.ps[index]; + std::fprintf(s_parity_event_file, "%s[%llu,%llu]", index ? "," : "", + static_cast(ps.PS0AsU64()), + static_cast(ps.PS1AsU64())); + } + std::fprintf(s_parity_event_file, + "],\"cr\":%u,\"lr\":%u,\"ctr\":%u,\"xer\":%u,\"fpscr\":%u," + "\"gqr\":[", + state.cr.Get(), state.spr[SPR_LR], state.spr[SPR_CTR], state.spr[SPR_XER], + state.fpscr.Hex); + for (u32 index = 0; index < 8; ++index) + std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.spr[SPR_GQR0 + index]); + std::fputs("]}}\n", s_parity_event_file); + std::fflush(s_parity_event_file); +} + +void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, + PowerPC::MMU& mmu) +{ + static ParityLockstepConfig config = [] { + ParityLockstepConfig value; + const char* enabled = std::getenv("DOLPHIN_PARITY_LOCKSTEP"); + if (!enabled || !enabled[0] || enabled[0] == '0') + return value; + value.enabled = true; + if (const char* start = std::getenv("DOLPHIN_PARITY_LOCKSTEP_START_PC")) + value.start_pc = static_cast(std::strtoul(start, nullptr, 0)); + if (const char* limit = std::getenv("DOLPHIN_PARITY_LOCKSTEP_LIMIT")) + { + const u64 parsed = std::strtoull(limit, nullptr, 0); + if (parsed != 0) + value.limit = parsed; + } + value.started = value.start_pc == 0; + return value; + }(); + if (!config.enabled || config.emitted >= config.limit) + return; + if (!config.started) + { + if (state.pc != config.start_pc) + return; + config.started = true; + } + + std::call_once(s_parity_event_init, InitParityEventFile); + if (!s_parity_event_file) + return; + const u64 sequence = s_parity_event_sequence.fetch_add(1); + const u64 timebase = system.GetSystemTimers().GetFakeTimeBase(); + const u32 opcode = mmu.Read(state.pc); + std::lock_guard lk(s_parity_event_mutex); + std::fprintf(s_parity_event_file, + "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"instruction\"," + "\"action\":\"checkpoint\",\"subject\":%u,\"anchor\":{" + "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":null}," + "\"data\":{\"opcode\":%u,\"gpr\":[", + static_cast(sequence), state.pc, + static_cast(timebase), opcode); + for (size_t index = 0; index < std::size(state.gpr); ++index) + std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.gpr[index]); + std::fprintf(s_parity_event_file, "],\"fpr\":["); + for (size_t index = 0; index < std::size(state.ps); ++index) + { + const auto& ps = state.ps[index]; + std::fprintf(s_parity_event_file, "%s[%llu,%llu]", index ? "," : "", + static_cast(ps.PS0AsU64()), + static_cast(ps.PS1AsU64())); + } + std::fprintf(s_parity_event_file, + "],\"cr\":%u,\"lr\":%u,\"ctr\":%u,\"xer\":%u,\"fpscr\":%u," + "\"gqr\":[", + state.cr.Get(), state.spr[SPR_LR], state.spr[SPR_CTR], state.spr[SPR_XER], + state.fpscr.Hex); + for (u32 index = 0; index < 8; ++index) + std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.spr[SPR_GQR0 + index]); + std::fprintf(s_parity_event_file, "]}}\n"); + std::fflush(s_parity_event_file); + ++config.emitted; +} + +void TraceParityFloatingPoint(Core::System& system, PowerPC::PowerPCState& state, + PowerPC::MMU& mmu) +{ + if (ParityCaptureLevel() < 4) + return; + static const u32 start_pc = [] { + const char* raw = std::getenv("DOLPHIN_PARITY_LOCKSTEP_START_PC"); + return raw ? static_cast(std::strtoul(raw, nullptr, 0)) : 0u; + }(); + static const u64 limit = [] { + const char* raw = std::getenv("DOLPHIN_PARITY_LOCKSTEP_LIMIT"); + const u64 parsed = raw ? std::strtoull(raw, nullptr, 0) : 0; + return parsed ? parsed : 1000u; + }(); + static bool started = start_pc == 0; + static u64 observed = 0; + if (!started) + { + if (state.pc != start_pc) + return; + started = true; + } + if (observed++ >= limit) + { + s_parity_fine_window_active.store(false, std::memory_order_release); + return; + } + s_parity_fine_window_active.store(true, std::memory_order_release); + const u32 opcode = mmu.Read(state.pc); + const u32 primary = opcode >> 26; + const bool paired = primary == 4; + const bool floating = paired || primary == 56 || primary == 57 || primary == 59 || + primary == 60 || primary == 61 || primary == 63; + if (!floating) + return; + u64 fpr_hash = 1469598103934665603ull; + const auto hash_u64 = [&](u64 value) { + for (u32 shift = 0; shift < 64; shift += 8) + fpr_hash = (fpr_hash ^ static_cast(value >> shift)) * 1099511628211ull; + }; + for (const auto& ps : state.ps) + { + hash_u64(ps.PS0AsU64()); + if (paired) + hash_u64(ps.PS1AsU64()); + } + u64 gqr_hash = 1469598103934665603ull; + for (u32 index = 0; index < 8; ++index) + { + const u32 value = state.spr[SPR_GQR0 + index]; + for (u32 shift = 0; shift < 32; shift += 8) + gqr_hash = (gqr_hash ^ static_cast(value >> shift)) * 1099511628211ull; + } + EmitParityEvent(system, state, mmu, "floating_point", "checkpoint", state.pc, + opcode, fpr_hash, state.fpscr.Hex, gqr_hash); +} + +void TraceParityMemoryPages(Core::System& system, PowerPC::PowerPCState& state, + PowerPC::MMU& mmu) +{ + struct Region + { + u32 address; + u32 length; + }; + struct Config + { + std::vector regions; + u32 page_size = 0x10000u; + u32 samples = 8; + }; + static const Config config = [] { + Config out; + const char* raw = std::getenv("DOLPHIN_PARITY_MEMORY_PAGES"); + if (!raw || !raw[0]) + return out; + std::string value(raw); + size_t pos = 0; + while (pos < value.size()) + { + const size_t comma = value.find(',', pos); + const std::string token = value.substr( + pos, comma == std::string::npos ? std::string::npos : comma - pos); + char* end = nullptr; + const unsigned long address = std::strtoul(token.c_str(), &end, 0); + unsigned long length = 0; + if (end && (*end == ':' || *end == '+')) + length = std::strtoul(end + 1, nullptr, 0); + if (address && length) + out.regions.push_back({static_cast(address), static_cast(length)}); + if (comma == std::string::npos) + break; + pos = comma + 1; + } + if (const char* page = std::getenv("DOLPHIN_PARITY_MEMORY_PAGE_SIZE")) + { + const unsigned long parsed = std::strtoul(page, nullptr, 0); + if (parsed >= 0x100u && parsed <= 0x100000u) + out.page_size = static_cast(parsed); + } + if (const char* count = std::getenv("DOLPHIN_PARITY_MEMORY_PAGE_SAMPLES")) + { + const unsigned long parsed = std::strtoul(count, nullptr, 0); + if (parsed) + out.samples = static_cast(parsed); + } + return out; + }(); + static u32 sample = 0; + if (config.regions.empty() || sample >= config.samples) + return; + const u32 checkpoint = ++sample; + for (const Region& region : config.regions) + { + const u64 region_end = u64{region.address} + region.length; + for (u64 address = region.address; address < region_end; address += config.page_size) + { + const u32 length = static_cast( + std::min(config.page_size, region_end - address)); + u64 hash = 1469598103934665603ull; + for (u32 index = 0; index < length; ++index) + hash = (hash ^ mmu.Read(static_cast(address) + index)) * 1099511628211ull; + EmitParityEvent(system, state, mmu, "memory", "page_hash", + static_cast(address), length, hash, checkpoint, config.page_size); + } + } +} + +void TraceAnimalCrossingParityEvent(Core::System& system, PowerPC::PowerPCState& state, + PowerPC::MMU& mmu) +{ + static u64 pad_reads = 0; + TraceParityFloatingPoint(system, state, mmu); + TraceParityInstruction(system, state, mmu); + const u32 pc = state.pc; + static const u32 boot_state_pc = [] { + const char* raw = std::getenv("DOLPHIN_PARITY_BOOT_STATE_PC"); + return raw && raw[0] ? static_cast(std::strtoul(raw, nullptr, 0)) : 0u; + }(); + static bool boot_state_emitted = false; + if (!boot_state_emitted && boot_state_pc != 0 && pc == boot_state_pc) + { + boot_state_emitted = true; + const auto snapshot = system.GetVideoInterface().GetParityTimingSnapshot( + system.GetCoreTiming().GetTicks()); + EmitParityEvent(system, state, mmu, "boot", "vi_state", snapshot.half_line, + snapshot.half_lines_per_frame, snapshot.ticks_per_half_line, + snapshot.ticks_until_interrupt, snapshot.ticks_per_field); + } + const ParityRegisterConfig& selected_register = SelectedParityRegisterConfig(); + if (ParityCaptureLevel() >= 3 && selected_register.enabled && + pc == selected_register.pc) + { + const u32 function_id = ParityFunctionId(selected_register.function_name.c_str()); + EmitParityEvent(system, state, mmu, "function", "enter", function_id, + state.gpr[3], state.gpr[4], state.gpr[5], state.spr[SPR_LR]); + EmitParityRegisterCheckpoint(system, state, mmu, + selected_register.function_name.c_str()); + } + bool have_current_thread = false; + u32 cached_current_thread = 0; + const auto current_thread = [&]() { + if (!have_current_thread) + { + cached_current_thread = mmu.Read(0x800000E4u); + have_current_thread = true; + } + return cached_current_thread; + }; + const u32 lr = state.spr[SPR_LR]; + const u32 sp = state.gpr[1]; + const auto emit_initial_menu_memory = [&](u32 function_id) { + constexpr u32 address = 0x800E2680u; + constexpr u32 size = 0x100u; + u64 hash = 1469598103934665603ull; + for (u32 offset = 0; offset < size; ++offset) + hash = (hash ^ mmu.Read(address + offset)) * 1099511628211ull; + EmitParityEvent(system, state, mmu, "memory", "checkpoint", address, size, hash, + function_id, lr); + }; + if (ParityCaptureLevel() >= 4 && + (pc == 0x80000100u || pc == 0x80000200u || pc == 0x80000300u || + pc == 0x80000600u || pc == 0x80000700u || pc == 0x80000900u)) + { + EmitParityEvent(system, state, mmu, "exception", "entry", pc, + SRR0(state), SRR1(state), state.spr[SPR_DAR], state.spr[SPR_DSISR]); + } + switch (pc) + { + case 0x80007CB0u: + if (ParityCaptureLevel() >= 3) + { + EmitParityEvent(system, state, mmu, "function", "enter", + ParityFunctionId("__imp__initial_menu_init"), state.gpr[3], state.gpr[4], + state.gpr[5], lr); + EmitParityRegisterCheckpoint(system, state, mmu, "__imp__initial_menu_init"); + } + break; + case 0x8005CF08u: + if (ParityCaptureLevel() < 3) + break; + EmitParityEvent(system, state, mmu, "allocation", "alloc_request", state.gpr[3], + lr, sp, 32, 0); + break; + case 0x8005CF34u: + if (ParityCaptureLevel() < 3) + break; + EmitParityEvent(system, state, mmu, "allocation", "free", state.gpr[3], + lr, sp, 0, 0); + break; + case 0x8005CCCCu: + case 0x8005CCECu: + case 0x8005CCF4u: + { + if (ParityCaptureLevel() < 3) + break; + const char* name = pc == 0x8005CCCCu ? "__imp__qrand" : + (pc == 0x8005CCECu ? "__imp__sqrand" : "__imp__fqrand"); + const u32 function_id = ParityFunctionId(name); + EmitParityEvent(system, state, mmu, "function", "enter", function_id, + state.gpr[3], state.gpr[4], state.gpr[5], lr); + const u32 seed_addr = state.gpr[13] - 32152u; + EmitParityEvent(system, state, mmu, "rng", "seed", seed_addr, + mmu.Read(seed_addr), function_id, lr, 0); + EmitParityRegisterCheckpoint(system, state, mmu, name); + break; + } + case 0x80079720u: + if (ParityCaptureLevel() < 3) + break; + EmitParityEvent(system, state, mmu, "allocation", "heap_alloc_request", state.gpr[3], + state.gpr[4], lr, sp, 0); + break; + case 0x80063A50u: + if (ParityCaptureLevel() < 3) + break; + // JKRHeap::alloc(size, alignment, heap) is the allocation boundary used + // by JKRThread for its stack and OSThread object. Capturing the explicit + // heap as the subject makes heap-stream alignment source-neutral while + // retaining the caller and stack for bounded causal zoom. + EmitParityEvent(system, state, mmu, "allocation", "jkr_alloc_request", state.gpr[5], + state.gpr[3], state.gpr[4], lr, sp); + s_parity_jkr_allocations[sp] = { + .heap = state.gpr[5], + .size = state.gpr[3], + .alignment = state.gpr[4], + .caller_lr = lr, + }; + EmitParityRegisterCheckpoint(system, state, mmu, "alloc__7JKRHeapFUliP7JKRHeap"); + break; + case 0x80063AB8u: + { + if (ParityCaptureLevel() < 3) + break; + const auto request = s_parity_jkr_allocations.find(sp + 16u); + if (request == s_parity_jkr_allocations.end()) + break; + EmitParityEvent(system, state, mmu, "allocation", "jkr_alloc_result", state.gpr[3], + request->second.heap, request->second.size, request->second.alignment, + request->second.caller_lr); + s_parity_jkr_allocations.erase(request); + break; + } + case 0x80079B54u: + if (ParityCaptureLevel() < 4) + break; + EmitParityEvent(system, state, mmu, "cache", "DCInvalidateRange", state.gpr[3], + state.gpr[4], lr, 0, 0); + break; + case 0x80079B84u: + if (ParityCaptureLevel() < 4) + break; + EmitParityEvent(system, state, mmu, "cache", "DCFlushRange", state.gpr[3], + state.gpr[4], lr, 0, 0); + break; + case 0x80079BB8u: + if (ParityCaptureLevel() < 4) + break; + EmitParityEvent(system, state, mmu, "cache", "DCStoreRange", state.gpr[3], + state.gpr[4], lr, 0, 0); + break; + case 0x80079BECu: + if (ParityCaptureLevel() < 4) + break; + EmitParityEvent(system, state, mmu, "cache", "DCFlushRangeNoSync", state.gpr[3], + state.gpr[4], lr, 0, 0); + break; + case 0x80079C1Cu: + if (ParityCaptureLevel() < 4) + break; + EmitParityEvent(system, state, mmu, "cache", "DCStoreRangeNoSync", state.gpr[3], + state.gpr[4], lr, 0, 0); + break; + case 0x80079CACu: + if (ParityCaptureLevel() < 4) + break; + EmitParityEvent(system, state, mmu, "cache", "ICInvalidateRange", state.gpr[3], + state.gpr[4], lr, 0, 0); + break; + case 0x80007A20u: + { + const u32 thread = current_thread(); + const u32 priority = thread ? mmu.Read(thread + 0x2D0u) : 16u; + EmitParityEvent(system, state, mmu, "scheduler", "dispatch", thread, pc, + state.gpr[3], sp, priority); + if (ParityCaptureLevel() >= 3) + { + EmitParityEvent(system, state, mmu, "function", "enter", ParityFunctionId("__imp__proc"), + state.gpr[3], state.gpr[4], state.gpr[5], lr); + emit_initial_menu_memory(ParityFunctionId("__imp__proc")); + EmitParityRegisterCheckpoint(system, state, mmu, "__imp__proc"); + } + break; + } + case 0x80007660u: + if (ParityCaptureLevel() >= 3) + { + EmitParityEvent(system, state, mmu, "function", "enter", ParityFunctionId("__imp__keycheck"), + state.gpr[3], state.gpr[4], state.gpr[5], lr); + emit_initial_menu_memory(ParityFunctionId("__imp__keycheck")); + EmitParityRegisterCheckpoint(system, state, mmu, "__imp__keycheck"); + } + break; + case 0x8007E2BCu: + EmitParityEvent(system, state, mmu, "thread", "create", state.gpr[3], state.gpr[4], + state.gpr[5], state.gpr[8], state.gpr[6]); + break; + case 0x8007E85Cu: + EmitParityEvent(system, state, mmu, "thread", "resume_request", state.gpr[3], + current_thread(), lr, sp, 0); + break; + case 0x8007BC80u: + EmitParityEvent(system, state, mmu, "queue", "send_begin", state.gpr[3], state.gpr[4], + state.gpr[5], current_thread(), lr); + break; + case 0x8007BD48u: + EmitParityEvent(system, state, mmu, "queue", "receive_begin", state.gpr[3], state.gpr[4], + state.gpr[5], current_thread(), lr); + break; + case 0x8007931Cu: + { + const bool shifted_ostime = (state.gpr[7] & 0x80000000u) != 0; + const u64 tick = shifted_ostime ? + ((u64{state.gpr[5]} << 32) | state.gpr[6]) : + ((u64{state.gpr[4]} << 32) | state.gpr[5]); + const u32 callback = shifted_ostime ? state.gpr[7] : state.gpr[6]; + EmitParityEvent(system, state, mmu, "timer", "set", state.gpr[3], + tick, callback, current_thread(), lr); + break; + } + case 0x8007AC24u: + EmitParityEvent(system, state, mmu, "interrupt", "disable", current_thread(), 0, 0, lr, 0); + break; + case 0x80078F04u: + if (ParityCaptureLevel() < 4) + break; + EmitParityEvent(system, state, mmu, "exception", "set_handler", state.gpr[3], + state.gpr[4], lr, 0, 0); + break; + case 0x8007AC38u: + EmitParityEvent(system, state, mmu, "interrupt", "enable", current_thread(), 0, lr, 0, 0); + break; + case 0x8007AC4Cu: + EmitParityEvent(system, state, mmu, "interrupt", "restore", current_thread(), + state.gpr[3], 0, 0, lr); + break; + case 0x8007F6F8u: + EmitParityEvent(system, state, mmu, "time", "get_time", current_thread(), + system.GetSystemTimers().GetFakeTimeBase(), lr, sp, 0); + break; + case 0x8007B718u: + { + const u32 module = state.gpr[3]; + const u32 module_id = module ? mmu.Read(module) : 0u; + EmitParityEvent(system, state, mmu, "loader", "rel_link_begin", module, + module_id, state.gpr[4], lr, 0); + break; + } + case 0x80088740u: + EmitParityEvent(system, state, mmu, "vi", "wait_begin", current_thread(), 0, lr, sp, 0); + TraceParityMemoryPages(system, state, mmu); + break; + case 0x80084A9Cu: + EmitParityEvent(system, state, mmu, "dvd", "open", state.gpr[4], state.gpr[3], + current_thread(), lr, 0); + break; + case 0x80084DACu: + case 0x80086C50u: + EmitParityEvent(system, state, mmu, "dvd", "read", state.gpr[3], state.gpr[4], + state.gpr[5], state.gpr[6], lr); + break; + case 0x8008C274u: + EmitParityEvent(system, state, mmu, "aram", "dma", state.gpr[3], state.gpr[4], + state.gpr[5], state.gpr[6], lr); + break; + case 0x8008D05Cu: + EmitParityEvent(system, state, mmu, "aram", "request", state.gpr[3], state.gpr[4], + state.gpr[5], state.gpr[6], lr); + break; + case 0x8008A9A8u: + EmitParityEvent(system, state, mmu, "input", "pad_read", state.gpr[3], + ++pad_reads, 0, 0, 0); + break; + case 0x8008B928u: + EmitParityEvent(system, state, mmu, "audio", "register_dma_callback", state.gpr[3], + 0, 0, 0, 0); + break; + case 0x8008B96Cu: + EmitParityEvent(system, state, mmu, "audio", "init_dma", state.gpr[3], + state.gpr[4], 0, 0, 0); + if (state.gpr[4] <= 0x100000u) + { + u64 hash = 1469598103934665603ull; + for (u32 index = 0; index < state.gpr[4]; ++index) + hash = (hash ^ mmu.Read(state.gpr[3] + index)) * 1099511628211ull; + EmitParityEvent(system, state, mmu, "audio", "buffer_hash", state.gpr[3], + state.gpr[4], hash, 0, 0); + } + break; + case 0x8008B9F4u: + EmitParityEvent(system, state, mmu, "audio", "start_dma", current_thread(), + lr, sp, 0, 0); + break; + case 0x800907E0u: + EmitParityEvent(system, state, mmu, "savecard", "unmount", state.gpr[3], + lr, 0, 0, 0); + break; + case 0x800914A4u: + EmitParityEvent(system, state, mmu, "savecard", "open", state.gpr[3], + state.gpr[4], state.gpr[5], lr, 0); + break; + case 0x80091CF8u: + EmitParityEvent(system, state, mmu, "savecard", "read", state.gpr[3], + state.gpr[4], state.gpr[5], state.gpr[6], state.gpr[7]); + break; + case 0x800917A8u: + EmitParityEvent(system, state, mmu, "savecard", "create", state.gpr[3], + state.gpr[4], state.gpr[5], state.gpr[6], state.gpr[7]); + break; + case 0x800920A8u: + EmitParityEvent(system, state, mmu, "savecard", "write", state.gpr[3], + state.gpr[4], state.gpr[5], state.gpr[6], state.gpr[7]); + break; + default: + break; + } +} + // Determines whether or not the given instruction is one where its execution // validity is determined by whether or not HID2's LSQE bit is set. // In other words, if the instruction is psq_l, psq_lu, psq_st, or psq_stu @@ -41,6 +745,86 @@ bool IsPairedSingleInstruction(UGeckoInstruction inst) } } // namespace +namespace +{ +void EmitRawParityEvent(Core::System& system, const char* family, const char* action, + u32 subject, u64 a, u64 b, u64 c, u64 d) +{ + std::call_once(s_parity_event_init, InitParityEventFile); + if (!s_parity_event_file) + return; + const u64 sequence = s_parity_event_sequence.fetch_add(1); + const u64 timebase = system.GetSystemTimers().GetFakeTimeBase(); + std::lock_guard lk(s_parity_event_mutex); + std::fprintf(s_parity_event_file, + "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"%s\"," + "\"action\":\"%s\",\"subject\":%u,\"anchor\":{" + "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":null}," + "\"data\":{\"a\":%llu,\"b\":%llu,\"c\":%llu,\"d\":%llu}}\n", + static_cast(sequence), family, action, subject, + static_cast(timebase), + static_cast(a), static_cast(b), + static_cast(c), static_cast(d)); + std::fflush(s_parity_event_file); +} +} // namespace + +namespace MMIO +{ +void DolphinParityTraceMMIO(Core::System& system, bool write, u32 addr, + u32 size, u64 value) +{ + if (ParityCaptureLevel() < 4 || + !s_parity_fine_window_active.load(std::memory_order_acquire)) + return; + const u32 guest_addr = addr < 0x10000000u ? addr | 0xC0000000u : addr; + EmitRawParityEvent(system, "mmio", write ? "store" : "load", guest_addr, + size, value, system.GetPPCState().pc, 0); +} +} // namespace MMIO + +namespace PowerPC +{ +void DolphinParityTraceMemoryWrite(Core::System& system, u32 pc, u32 addr, + u32 size, u32 value) +{ + struct Range + { + bool enabled = false; + u32 start = 0; + u32 end = 0; + }; + static const Range range = [] { + Range out; + const char* raw = std::getenv("DOLPHIN_PARITY_MEMORY_WRITE_RANGE"); + if (!raw || !raw[0]) + return out; + char* end = nullptr; + const unsigned long start = std::strtoul(raw, &end, 0); + unsigned long finish = start; + if (end && (*end == ':' || *end == '-' || *end == '+')) + { + const char separator = *end++; + const unsigned long rhs = std::strtoul(end, nullptr, 0); + finish = separator == '+' ? start + rhs : rhs; + } + out.enabled = true; + out.start = static_cast(start); + out.end = static_cast(std::max(start, finish)); + return out; + }(); + if (ParityCaptureLevel() < 4 || !range.enabled || + !s_parity_fine_window_active.load(std::memory_order_acquire)) + return; + const u32 guest_addr = addr < 0x10000000u ? addr | 0x80000000u : addr; + const u64 last = u64{guest_addr} + (size ? size - 1u : 0u); + if (last < range.start || guest_addr > range.end) + return; + EmitRawParityEvent(system, "memory", "write", guest_addr, + (u64{pc} << 32) | size, value, 0, 0); +} +} // namespace PowerPC + // Checks if a given instruction would be illegal to execute if it's a paired single instruction. // // Paired single instructions are illegal to execute if HID2.PSE is not set. @@ -73,6 +857,10 @@ Interpreter::~Interpreter() = default; void Interpreter::Init() { m_end_block = false; + // Create the sidecar as soon as the selected CPU core starts. An empty file + // then means "no configured checkpoint was reached" instead of being + // indistinguishable from a missing/disabled interpreter probe. + std::call_once(s_parity_event_init, InitParityEventFile); } void Interpreter::Shutdown() @@ -117,6 +905,7 @@ bool Interpreter::HandleFunctionHooking(u32 address) int Interpreter::SingleStepInner() { + TraceAnimalCrossingParityEvent(m_system, m_ppc_state, m_mmu); if (HandleFunctionHooking(m_ppc_state.pc)) { UpdatePC(); diff --git a/Source/Core/Core/PowerPC/MMU.cpp b/Source/Core/Core/PowerPC/MMU.cpp index 73e1f4cfd3..2a79210b1b 100644 --- a/Source/Core/Core/PowerPC/MMU.cpp +++ b/Source/Core/Core/PowerPC/MMU.cpp @@ -60,6 +60,8 @@ namespace PowerPC { +void DolphinParityTraceMemoryWrite(Core::System& system, u32 pc, u32 addr, + u32 size, u32 value); MMU::MMU(Core::System& system, Memory::MemoryManager& memory, PowerPC::PowerPCManager& power_pc) : m_system(system), m_memory(memory), m_power_pc(power_pc), m_ppc_state(power_pc.GetPPCState()) { @@ -350,6 +352,12 @@ void MMU::WriteToHardware(u32 em_address, const u32 data, const u32 size) return; } + // Interpreter helpers also use the NoException path for valid guest stores. + // The parity hook is independently gated by level, a bounded address range, + // and its fine window, so observing both paths is precise and remains inert + // in normal Dolphin runs. + DolphinParityTraceMemoryWrite(m_system, m_ppc_state.pc, em_address, size, data); + bool wi = false; if (!never_translate && m_ppc_state.msr.DR) diff --git a/Source/Core/Core/PowerPC/PowerPC.cpp b/Source/Core/Core/PowerPC/PowerPC.cpp index 38653f2a8d..58207886ff 100644 --- a/Source/Core/Core/PowerPC/PowerPC.cpp +++ b/Source/Core/Core/PowerPC/PowerPC.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include "Common/Assert.h" @@ -200,10 +202,30 @@ void PowerPCManager::ResetRegisters() void PowerPCManager::InitializeCPUCore(CPUCore cpu_core) { + const char* parity_path = std::getenv("DOLPHIN_PARITY_EVENT_FILE"); + const char* parity_fast_forward = std::getenv("DOLPHIN_PARITY_FAST_FORWARD"); + const bool fast_forward = parity_fast_forward && parity_fast_forward[0] && + parity_fast_forward[0] != '0'; + if (parity_path && parity_path[0] && !fast_forward) + { + // A parity capture must execute the instrumented interpreter. Command-line + // enum settings can be superseded by later Dolphin config layers, which + // previously left captures silently running JITARM64 with an empty event + // sidecar. The explicit event sink is the authoritative opt-in. + cpu_core = CPUCore::Interpreter; + } // We initialize the interpreter because // it is used on boot and code window independently. auto& interpreter = m_system.GetInterpreter(); interpreter.Init(); + if (parity_path && parity_path[0]) + { + std::fprintf(stderr, + "[parity-oracle] selected_cpu_core=%d interpreter=%d fast_forward=%d\n", + static_cast(cpu_core), + cpu_core == CPUCore::Interpreter ? 1 : 0, fast_forward ? 1 : 0); + std::fflush(stderr); + } switch (cpu_core) { diff --git a/Source/Core/DolphinNoGUI/MainNoGUI.cpp b/Source/Core/DolphinNoGUI/MainNoGUI.cpp index a9d2645a52..fa5d3123fe 100644 --- a/Source/Core/DolphinNoGUI/MainNoGUI.cpp +++ b/Source/Core/DolphinNoGUI/MainNoGUI.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,7 @@ #include "Core/FifoPlayer/FifoRecorder.h" #include "Core/Host.h" #include "Core/Movie.h" +#include "Core/PowerPC/PowerPC.h" #include "Core/System.h" #include "UICommon/CommandLineParse.h" @@ -431,6 +433,8 @@ int main(const int argc, char* argv[]) Common::EventHook auto_fifo_hook; u64 presented_frames = 0; std::atomic auto_fifo_stop_delay = 0; + std::atomic auto_fifo_started = false; + std::atomic auto_fifo_start_queued = false; if (auto_fifo) { const AutoFifoCapture capture = *auto_fifo; @@ -440,40 +444,75 @@ int main(const int argc, char* argv[]) capture.frame_count, capture.screenshot_name.empty() ? "" : capture.screenshot_name.c_str(), capture.stop_after_capture ? 1 : 0); - auto_fifo_hook = Core::System::GetInstance().GetVideoEvents().after_frame_event.Register( - [capture, &presented_frames, &auto_fifo_stop_delay](const Core::System& system) { - const u32 stop_delay = auto_fifo_stop_delay.load(); - if (stop_delay != 0 && auto_fifo_stop_delay.fetch_sub(1) == 1) - { - Core::QueueHostJob([](Core::System& queued_system) { Core::Stop(queued_system); }); - return; - } - const u64 current = presented_frames++; - if (current != capture.start_presented_frame) - return; - fprintf(stderr, "[auto-fifo] start presented=%llu frames=%d\n", - static_cast(current), capture.frame_count); - system.GetFifoRecorder().StartRecording(capture.frame_count, [capture, - &auto_fifo_stop_delay] { + + const auto start_auto_fifo = [&auto_fifo_started, + &auto_fifo_stop_delay](const AutoFifoCapture& pending) { + if (auto_fifo_started.exchange(true)) + return; + const char* parity_fast_forward = std::getenv("DOLPHIN_PARITY_FAST_FORWARD"); + const bool switch_to_interpreter = + parity_fast_forward && parity_fast_forward[0] && parity_fast_forward[0] != '0'; + if (switch_to_interpreter) + { + Core::System& system = Core::System::GetInstance(); + Core::CPUThreadGuard guard(system); + system.GetPowerPC().SetMode(PowerPC::CoreMode::Interpreter); + fprintf(stderr, "[parity-oracle] fast-forward complete; interpreter window active\n"); + } + fprintf(stderr, "[auto-fifo] start skipped_presented=%llu frames=%d mode=immediate\n", + static_cast(pending.start_presented_frame), + pending.frame_count); + Core::System::GetInstance().GetFifoRecorder().StartRecording( + pending.frame_count, + [pending, &auto_fifo_stop_delay] { FifoDataFile* file = Core::System::GetInstance().GetFifoRecorder().GetRecordedFile(); - const bool saved = file != nullptr && file->Save(capture.path); + const bool saved = file != nullptr && file->Save(pending.path); fprintf(stderr, "[auto-fifo] complete saved=%d path=%s frames=%u\n", saved ? 1 : 0, - capture.path.c_str(), file != nullptr ? file->GetFrameCount() : 0u); - Core::QueueHostJob([capture, &auto_fifo_stop_delay](Core::System&) { - if (!capture.screenshot_name.empty()) + pending.path.c_str(), file != nullptr ? file->GetFrameCount() : 0u); + Core::QueueHostJob([pending, &auto_fifo_stop_delay](Core::System&) { + if (!pending.screenshot_name.empty()) { - Core::SaveScreenShot(capture.screenshot_name); + Core::SaveScreenShot(pending.screenshot_name); fprintf(stderr, "[auto-fifo] screenshot requested name=%s\n", - capture.screenshot_name.c_str()); + pending.screenshot_name.c_str()); } - if (capture.stop_after_capture) + if (pending.stop_after_capture) { // Give FrameDumper at least one complete present after the // request before stopping the renderer. - auto_fifo_stop_delay.store(capture.screenshot_name.empty() ? 1u : 2u); + auto_fifo_stop_delay.store(pending.screenshot_name.empty() ? 1u : 2u); } }); - }); + }, + true); + }; + + if (capture.start_presented_frame == 0) + start_auto_fifo(capture); + + auto_fifo_hook = Core::System::GetInstance().GetVideoEvents().after_frame_event.Register( + [capture, start_auto_fifo, &presented_frames, &auto_fifo_stop_delay, + &auto_fifo_start_queued](const Core::System&) { + const u32 stop_delay = auto_fifo_stop_delay.load(); + if (stop_delay != 0 && auto_fifo_stop_delay.fetch_sub(1) == 1) + { + Core::QueueHostJob([](Core::System& queued_system) { Core::Stop(queued_system); }); + return; + } + const u64 current = presented_frames++; + if (capture.start_presented_frame == 0 || + current + 1 != capture.start_presented_frame) + return; + // Registering FifoRecorder's own after-frame hook from inside this + // event dispatch leaves the new hook inactive on macOS. Queue the + // recorder start onto the host job boundary so its callback is + // installed before the next presentation dispatch. + if (!auto_fifo_start_queued.exchange(true)) + { + Core::QueueHostJob([capture, start_auto_fifo](Core::System&) { + start_auto_fifo(capture); + }); + } }); } diff --git a/Source/Core/VideoCommon/FrameDumper.cpp b/Source/Core/VideoCommon/FrameDumper.cpp index 8bb98833f2..cbe8315b32 100644 --- a/Source/Core/VideoCommon/FrameDumper.cpp +++ b/Source/Core/VideoCommon/FrameDumper.cpp @@ -3,6 +3,10 @@ #include "VideoCommon/FrameDumper.h" +#include +#include +#include + #include "Common/Assert.h" #include "Common/FileUtil.h" #include "Common/Image.h" @@ -15,11 +19,73 @@ #include "VideoCommon/AbstractStagingTexture.h" #include "VideoCommon/AbstractTexture.h" #include "VideoCommon/OnScreenDisplay.h" +#include "VideoCommon/OpcodeDecoding.h" #include "VideoCommon/Present.h" // The video encoder needs the image to be a multiple of x samples. static constexpr int VIDEO_ENCODER_LCM = 4; +namespace +{ +FILE* s_parity_pixel_file = nullptr; +u32 s_parity_pixel_frame = 0; + +bool IsParityPixelCaptureActive() +{ + const char* path = std::getenv("DOLPHIN_PARITY_PIXEL_FILE"); + return path && path[0] && OpcodeDecoder::g_record_fifo_data; +} + +void CloseParityPixelFile() +{ + if (!s_parity_pixel_file) + return; + std::fflush(s_parity_pixel_file); + std::fclose(s_parity_pixel_file); + s_parity_pixel_file = nullptr; +} + +void EmitParityPixelDigest(const u8* data, int width, int height, int stride) +{ + if (!IsParityPixelCaptureActive() || !data || width <= 0 || height <= 0 || stride < width * 4) + return; + if (!s_parity_pixel_file) + { + s_parity_pixel_file = std::fopen(std::getenv("DOLPHIN_PARITY_PIXEL_FILE"), "w"); + if (!s_parity_pixel_file) + return; + std::atexit(CloseParityPixelFile); + } + constexpr u64 fnv_basis = 1469598103934665603ull; + constexpr u64 fnv_prime = 1099511628211ull; + u64 hash = fnv_basis; + for (int y = 0; y < height; ++y) + { + const u8* row = data + static_cast(y) * stride; + for (int x = 0; x < width * 4; ++x) + hash = (hash ^ row[x]) * fnv_prime; + } + ++s_parity_pixel_frame; + std::fprintf(s_parity_pixel_file, "frame %u pixels %dx%d fnv %016llx\n", + s_parity_pixel_frame, width, height, + static_cast(hash)); + std::fflush(s_parity_pixel_file); + + const char* png_dir = std::getenv("DOLPHIN_PARITY_PIXEL_PNG_DIR"); + const char* png_limit_text = std::getenv("DOLPHIN_PARITY_PIXEL_PNG_LIMIT"); + const unsigned long png_limit = png_limit_text ? std::strtoul(png_limit_text, nullptr, 0) : 0; + if (png_dir && png_dir[0] && s_parity_pixel_frame <= png_limit) + { + File::CreateFullPath(std::string(png_dir) + "/"); + char png_path[1024]; + std::snprintf(png_path, sizeof(png_path), "%s/frame_%06u.png", png_dir, + s_parity_pixel_frame); + Common::ConvertRGBAToRGBAndSavePNG(png_path, data, width, height, stride, + Config::Get(Config::GFX_PNG_COMPRESSION_LEVEL)); + } +} +} // namespace + static bool DumpFrameToPNG(const FrameData& frame, const std::string& file_name) { return Common::ConvertRGBAToRGBAndSavePNG(file_name, frame.data, frame.width, frame.height, @@ -48,6 +114,15 @@ void FrameDumper::DumpCurrentFrame(const AbstractTexture* src_texture, int target_width = target_rect.GetWidth(); int target_height = target_rect.GetHeight(); + // Parity capture compares the emulated video source, not the host window. + // Keeping the source dimensions avoids a platform/backend-dependent stretch + // (for example 640x480 to 640x511 on macOS) from changing every pixel hash. + if (IsParityPixelCaptureActive()) + { + target_width = source_width; + target_height = source_height; + } + // We only need to render a copy if we need to stretch/scale the XFB copy. MathUtil::Rectangle copy_rect = src_rect; if (source_width != target_width || source_height != target_height) @@ -170,6 +245,7 @@ void FrameDumper::ShutdownFrameDumping() void FrameDumper::DumpFrameData(const u8* data, int w, int h, int stride) { + EmitParityPixelDigest(data, w, h, stride); m_frame_dump_data = FrameData{data, w, h, stride, m_last_frame_state}; if (!m_frame_dump_thread_running.IsSet()) @@ -347,6 +423,8 @@ void FrameDumper::SaveScreenshot(std::string filename) bool FrameDumper::IsFrameDumping() const { + if (IsParityPixelCaptureActive()) + return true; if (m_screenshot_request.IsSet()) return true; diff --git a/Source/Core/VideoCommon/OpcodeDecoding.cpp b/Source/Core/VideoCommon/OpcodeDecoding.cpp index fc6f896cd7..186909dc7b 100644 --- a/Source/Core/VideoCommon/OpcodeDecoding.cpp +++ b/Source/Core/VideoCommon/OpcodeDecoding.cpp @@ -229,11 +229,12 @@ class RunCallback final : public Callback ASSERT(size >= 1); if constexpr (!is_preprocess) { + auto& recorder = Core::System::GetInstance().GetFifoRecorder(); // Display lists get added directly into the FIFO stream since this same callback is used to // process them. if (g_record_fifo_data && static_cast(data[0]) != Opcode::GX_CMD_CALL_DL) { - Core::System::GetInstance().GetFifoRecorder().WriteGPCommand(data, size); + recorder.WriteGPCommand(data, size); } } } @@ -261,6 +262,8 @@ u8* RunFifo(DataReader src, u32* cycles) { using CallbackT = RunCallback; auto callback = CallbackT{}; + if constexpr (!is_preprocess) + Core::System::GetInstance().GetFifoRecorder().ActivatePendingRecording(); u32 size = Run(src.GetPointer(), static_cast(src.size()), callback); if (cycles != nullptr) diff --git a/Source/Core/VideoCommon/VertexManagerBase.cpp b/Source/Core/VideoCommon/VertexManagerBase.cpp index 2bc6d586d7..813cfec298 100644 --- a/Source/Core/VideoCommon/VertexManagerBase.cpp +++ b/Source/Core/VideoCommon/VertexManagerBase.cpp @@ -5,12 +5,15 @@ #include #include +#include #include +#include #include "Common/ChunkFile.h" #include "Common/CommonTypes.h" #include "Common/Contains.h" #include "Common/EnumMap.h" +#include "Common/FileUtil.h" #include "Common/Logging/Log.h" #include "Common/MathUtil.h" #include "Common/SmallVector.h" @@ -46,6 +49,60 @@ std::unique_ptr g_vertex_manager; +namespace +{ +struct ParityEFBDrawDumpConfig +{ + u64 start = 0; + u64 end = 0; + std::string directory; +}; + +const ParityEFBDrawDumpConfig& GetParityEFBDrawDumpConfig() +{ + static const ParityEFBDrawDumpConfig config = [] { + ParityEFBDrawDumpConfig value; + const char* start = std::getenv("DOLPHIN_PARITY_DUMP_EFB_DRAW_START"); + const char* end = std::getenv("DOLPHIN_PARITY_DUMP_EFB_DRAW_END"); + const char* directory = std::getenv("DOLPHIN_PARITY_DUMP_EFB_DRAW_DIR"); + if (!start || !start[0] || !directory || !directory[0]) + return value; + + value.start = std::strtoull(start, nullptr, 0); + value.end = end && end[0] ? std::strtoull(end, nullptr, 0) : value.start; + value.directory = directory; + if (value.start != 0 && value.end >= value.start) + File::CreateFullPath(value.directory + "/"); + return value; + }(); + return config; +} + +void DumpParityEFBAfterDraw() +{ + static u64 draw = 0; + ++draw; + + const ParityEFBDrawDumpConfig& config = GetParityEFBDrawDumpConfig(); + if (config.start == 0 || draw < config.start || draw > config.end || !g_framebuffer_manager) + return; + + AbstractTexture* const efb = g_framebuffer_manager->GetEFBColorTexture(); + if (!efb) + return; + AbstractTexture* const resolved = g_framebuffer_manager->ResolveEFBColorTexture(efb->GetRect()); + if (!resolved) + return; + + const TextureConfig& texture_config = resolved->GetConfig(); + const std::string path = config.directory + "/efb_after_draw_" + std::to_string(draw) + "_" + + std::to_string(texture_config.width) + "x" + + std::to_string(texture_config.height) + ".png"; + if (!resolved->Save(path, 0, 1)) + ERROR_LOG_FMT(VIDEO, "Parity oracle failed to dump EFB after draw {} to {}", draw, path); +} +} // namespace + using OpcodeDecoder::Primitive; // GX primitive -> RenderState primitive, no primitive restart @@ -666,6 +723,11 @@ void VertexManagerBase::Flush() // Even if we skip the draw, emulated state should still be impacted OnDraw(); + // Optional oracle zoom: capture the raw EFB exactly at a selected draw + // boundary. Disabled unless all DOLPHIN_PARITY_DUMP_EFB_DRAW_* variables + // are supplied, so normal Dolphin rendering has no readback cost. + DumpParityEFBAfterDraw(); + // The EFB cache is now potentially stale. g_framebuffer_manager->FlagPeekCacheAsOutOfDate(); } From b09306289bceaab78ae16d9e991213e5cdd4ae55 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Sun, 12 Jul 2026 14:42:09 +0200 Subject: [PATCH 03/27] Let one Dolphin boot expose an entire causal function chain A single selected register PC could only confirm one endpoint and forced repeated emulator boots. Accept a bounded list of PC=name targets and emit the same function/register records at each entry so progressive zoom can subdivide startup work in one capture. Constraint: Boot-wide interpreter tracing must remain explicitly requested and bounded by the caller. Rejected: Record every instruction from boot | unnecessary volume before a function-chain zoom has localized the interval. Confidence: high Scope-risk: narrow Directive: Keep target names canonical across Dolphin and generated runtimes; hashes are comparison identities. Tested: RecompCore core rebuild and dolphin-emu-nogui relink; AC bounded boot probe captured fifteen selected OSInit checkpoints. Not-tested: JIT-side multi-target capture; boot probes intentionally select the interpreter. --- .../Core/PowerPC/Interpreter/Interpreter.cpp | 69 ++++++++++++++----- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 43d3d171b8..c165467c8f 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -77,22 +77,54 @@ struct ParityLockstepConfig struct ParityRegisterConfig { - bool enabled = false; - u32 pc = 0; - std::string function_name; + struct Target + { + u32 pc = 0; + std::string function_name; + }; + + std::vector targets; }; +void AddParityRegisterTarget(ParityRegisterConfig* config, const char* raw_pc, + const std::string& function_name) +{ + if (!raw_pc || !raw_pc[0] || function_name.empty()) + return; + const u32 pc = static_cast(std::strtoul(raw_pc, nullptr, 0)); + if (pc != 0) + config->targets.push_back({pc, function_name}); +} + const ParityRegisterConfig& SelectedParityRegisterConfig() { static const ParityRegisterConfig config = [] { ParityRegisterConfig value; + const char* raw_targets = std::getenv("DOLPHIN_PARITY_REGISTER_TARGETS"); + if (raw_targets && raw_targets[0]) + { + std::string targets(raw_targets); + size_t pos = 0; + while (pos < targets.size()) + { + const size_t comma = targets.find(',', pos); + const std::string token = targets.substr( + pos, comma == std::string::npos ? std::string::npos : comma - pos); + const size_t separator = token.find('='); + if (separator != std::string::npos) + { + const std::string raw_pc = token.substr(0, separator); + AddParityRegisterTarget(&value, raw_pc.c_str(), token.substr(separator + 1)); + } + if (comma == std::string::npos) + break; + pos = comma + 1; + } + return value; + } const char* raw_pc = std::getenv("DOLPHIN_PARITY_REGISTER_PC"); const char* raw_name = std::getenv("DOLPHIN_PARITY_REGISTER_FUNC"); - if (!raw_pc || !raw_pc[0] || !raw_name || !raw_name[0]) - return value; - value.pc = static_cast(std::strtoul(raw_pc, nullptr, 0)); - value.function_name = raw_name; - value.enabled = value.pc != 0; + AddParityRegisterTarget(&value, raw_pc, raw_name ? raw_name : ""); return value; }(); return config; @@ -431,15 +463,20 @@ void TraceAnimalCrossingParityEvent(Core::System& system, PowerPC::PowerPCState& snapshot.half_lines_per_frame, snapshot.ticks_per_half_line, snapshot.ticks_until_interrupt, snapshot.ticks_per_field); } - const ParityRegisterConfig& selected_register = SelectedParityRegisterConfig(); - if (ParityCaptureLevel() >= 3 && selected_register.enabled && - pc == selected_register.pc) + const ParityRegisterConfig& selected_registers = SelectedParityRegisterConfig(); + if (ParityCaptureLevel() >= 3) { - const u32 function_id = ParityFunctionId(selected_register.function_name.c_str()); - EmitParityEvent(system, state, mmu, "function", "enter", function_id, - state.gpr[3], state.gpr[4], state.gpr[5], state.spr[SPR_LR]); - EmitParityRegisterCheckpoint(system, state, mmu, - selected_register.function_name.c_str()); + for (const auto& selected_register : selected_registers.targets) + { + if (pc != selected_register.pc) + continue; + const u32 function_id = ParityFunctionId(selected_register.function_name.c_str()); + EmitParityEvent(system, state, mmu, "function", "enter", function_id, + state.gpr[3], state.gpr[4], state.gpr[5], state.spr[SPR_LR]); + EmitParityRegisterCheckpoint(system, state, mmu, + selected_register.function_name.c_str()); + break; + } } bool have_current_thread = false; u32 cached_current_thread = 0; From b3f99ea51023cc98fe372395824a7f2a11c347d4 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Sun, 12 Jul 2026 15:20:37 +0200 Subject: [PATCH 04/27] Expose the entire taken boot call chain without frame capture Manual checkpoint lists hid nested SDK owners after OSInit. Decode taken direct, conditional, LR, and CTR linking branches in the interpreter, publish the reached target at callee entry, and bound the stream from configured DOL entry through first thread creation. Constraint: Linking branches to proven interior labels are not generated function entries and are filtered with Dolphin's symbol database. Rejected: Log every executed PC | that is instruction lockstep volume and defeats the compact Level-3 zoom. Confidence: high Scope-risk: moderate Directive: Keep call-condition evaluation observational; it must never mutate CTR, LR, CR, or guest timing. Tested: ninja Binaries/dolphin-emu-nogui; AC boot capture reached thread.create with 232 unique address-keyed function targets Not-tested: REL-loaded call targets after dynamic module relocation --- .../Core/PowerPC/Interpreter/Interpreter.cpp | 113 +++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index c165467c8f..60ad626bbf 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -30,6 +30,7 @@ #include "Core/PowerPC/Interpreter/ExceptionUtils.h" #include "Core/PowerPC/MMU.h" #include "Core/PowerPC/PPCTables.h" +#include "Core/PowerPC/PPCSymbolDB.h" #include "Core/PowerPC/PowerPC.h" #include "Core/System.h" #include "VideoCommon/OpcodeDecoding.h" @@ -42,6 +43,9 @@ FILE* s_parity_event_file = nullptr; std::atomic s_parity_event_sequence{0}; std::atomic s_parity_fine_window_active{false}; std::unordered_map s_parity_kind_counts; +std::atomic s_parity_all_function_window_active{false}; +u32 s_parity_pending_call_target = 0; +u64 s_parity_all_function_count = 0; struct ParityJkrAllocation { @@ -130,6 +134,32 @@ const ParityRegisterConfig& SelectedParityRegisterConfig() return config; } +struct ParityAllFunctionConfig +{ + bool enabled = false; + u32 start_pc = 0; + u64 limit = 100000; +}; + +const ParityAllFunctionConfig& AllFunctionParityConfig() +{ + static const ParityAllFunctionConfig config = [] { + ParityAllFunctionConfig value; + const char* enabled = std::getenv("DOLPHIN_PARITY_TRACE_ALL_FUNCTIONS"); + value.enabled = enabled && enabled[0] && enabled[0] != '0'; + if (const char* raw_start = std::getenv("DOLPHIN_PARITY_ALL_FUNCTION_START_PC")) + value.start_pc = static_cast(std::strtoul(raw_start, nullptr, 0)); + if (const char* raw_limit = std::getenv("DOLPHIN_PARITY_ALL_FUNCTION_LIMIT")) + { + const u64 parsed = std::strtoull(raw_limit, nullptr, 0); + if (parsed != 0) + value.limit = parsed; + } + return value; + }(); + return config; +} + u32 ParityFunctionId(const char* name) { u32 hash = 2166136261u; @@ -208,6 +238,82 @@ void EmitParityEvent(Core::System& system, PowerPC::PowerPCState& state, PowerPC std::fflush(s_parity_event_file); } +bool ParityBranchCondition(const PowerPC::PowerPCState& state, UGeckoInstruction inst, + bool decrement_ctr) +{ + u32 ctr = state.spr[SPR_CTR]; + if (decrement_ctr && (inst.BO_2 & BO_DONT_DECREMENT_FLAG) == 0) + --ctr; + const u32 counter = ((inst.BO_2 >> 2) | ((ctr != 0) ^ (inst.BO_2 >> 1))) & 1; + const u32 condition = + ((inst.BO_2 >> 4) | (state.cr.GetBit(inst.BI_2) == ((inst.BO_2 >> 3) & 1))) & 1; + return (counter & condition) != 0; +} + +u32 ParityTakenCallTarget(const PowerPC::PowerPCState& state, UGeckoInstruction inst) +{ + if (inst.OPCD == 18 && inst.LK) + { + u32 target = u32(SignExt26(inst.LI << 2)); + return inst.AA ? target : target + state.pc; + } + if (inst.OPCD == 16 && inst.LK_2 && ParityBranchCondition(state, inst, true)) + { + u32 target = u32(SignExt16(s16(inst.BD << 2))); + return inst.AA_2 ? target : target + state.pc; + } + if (inst.OPCD != 19 || !inst.LK_3) + return 0; + if (inst.SUBOP10 == 16 && ParityBranchCondition(state, inst, true)) + return state.spr[SPR_LR] & ~3u; + if (inst.SUBOP10 == 528 && ParityBranchCondition(state, inst, false)) + return state.spr[SPR_CTR] & ~3u; + return 0; +} + +void TraceParityAllFunctionEntry(Core::System& system, PowerPC::PowerPCState& state, + PowerPC::MMU& mmu, PPCSymbolDB& symbol_db) +{ + const ParityAllFunctionConfig& config = AllFunctionParityConfig(); + if (!config.enabled || ParityCaptureLevel() < 3) + return; + + if (!s_parity_all_function_window_active.load(std::memory_order_acquire) && + config.start_pc != 0 && state.pc == config.start_pc) + { + s_parity_all_function_window_active.store(true, std::memory_order_release); + s_parity_all_function_count = 0; + EmitParityEvent(system, state, mmu, "function", "enter", state.pc, + state.gpr[3], state.gpr[4], state.gpr[5], state.spr[SPR_LR]); + ++s_parity_all_function_count; + } + if (!s_parity_all_function_window_active.load(std::memory_order_acquire)) + return; + + if (s_parity_pending_call_target != 0) + { + if (state.pc == s_parity_pending_call_target && s_parity_all_function_count < config.limit) + { + EmitParityEvent(system, state, mmu, "function", "enter", state.pc, + state.gpr[3], state.gpr[4], state.gpr[5], state.spr[SPR_LR]); + ++s_parity_all_function_count; + } + s_parity_pending_call_target = 0; + } + if (s_parity_all_function_count >= config.limit) + { + s_parity_all_function_window_active.store(false, std::memory_order_release); + return; + } + const UGeckoInstruction inst(mmu.Read_Opcode(state.pc)); + const u32 target = ParityTakenCallTarget(state, inst); + const Common::Symbol* symbol = target != 0 ? symbol_db.GetSymbolFromAddr(target) : nullptr; + // Linking branches are occasionally used as local PC/LR tricks. Generated + // recomp prologues exist only at real function starts, so suppress a target + // proven to be an interior label while retaining unknown/dynamic targets. + s_parity_pending_call_target = symbol && symbol->address != target ? 0 : target; +} + void EmitParityRegisterCheckpoint(Core::System& system, PowerPC::PowerPCState& state, PowerPC::MMU& mmu, const char* function_name) { @@ -443,7 +549,7 @@ void TraceParityMemoryPages(Core::System& system, PowerPC::PowerPCState& state, } void TraceAnimalCrossingParityEvent(Core::System& system, PowerPC::PowerPCState& state, - PowerPC::MMU& mmu) + PowerPC::MMU& mmu, PPCSymbolDB& symbol_db) { static u64 pad_reads = 0; TraceParityFloatingPoint(system, state, mmu); @@ -478,6 +584,7 @@ void TraceAnimalCrossingParityEvent(Core::System& system, PowerPC::PowerPCState& break; } } + TraceParityAllFunctionEntry(system, state, mmu, symbol_db); bool have_current_thread = false; u32 cached_current_thread = 0; const auto current_thread = [&]() { @@ -645,6 +752,8 @@ void TraceAnimalCrossingParityEvent(Core::System& system, PowerPC::PowerPCState& case 0x8007E2BCu: EmitParityEvent(system, state, mmu, "thread", "create", state.gpr[3], state.gpr[4], state.gpr[5], state.gpr[8], state.gpr[6]); + s_parity_all_function_window_active.store(false, std::memory_order_release); + s_parity_pending_call_target = 0; break; case 0x8007E85Cu: EmitParityEvent(system, state, mmu, "thread", "resume_request", state.gpr[3], @@ -942,7 +1051,7 @@ bool Interpreter::HandleFunctionHooking(u32 address) int Interpreter::SingleStepInner() { - TraceAnimalCrossingParityEvent(m_system, m_ppc_state, m_mmu); + TraceAnimalCrossingParityEvent(m_system, m_ppc_state, m_mmu, m_ppc_symbol_db); if (HandleFunctionHooking(m_ppc_state.pc)) { UpdatePC(); From db2ed651e0c60ccea658d6dcc026d2f625f20806 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Sun, 12 Jul 2026 15:49:35 +0200 Subject: [PATCH 05/27] Expose architectural boot registers at parity checkpoints Include PVR, HID0, HID1, HID2, HID4, and L2CR in Dolphin register checkpoints so a producer comparison can distinguish a wrong boot path from a later SDK or GX symptom. Constraint: Register payloads must remain producer-neutral JSON. Confidence: high Scope-risk: narrow Tested: ninja -C build-nogui Binaries/dolphin-emu-nogui and parsed live AC boot capture Not-tested: non-GameCube Wii HID defaults --- Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 60ad626bbf..215de439f3 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -351,7 +351,11 @@ void EmitParityRegisterCheckpoint(Core::System& system, PowerPC::PowerPCState& s state.fpscr.Hex); for (u32 index = 0; index < 8; ++index) std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.spr[SPR_GQR0 + index]); - std::fputs("]}}\n", s_parity_event_file); + std::fprintf(s_parity_event_file, + "],\"spr\":{\"pvr\":%u,\"hid0\":%u,\"hid1\":%u," + "\"hid2\":%u,\"hid4\":%u,\"l2cr\":%u}}}\n", + state.spr[SPR_PVR], state.spr[SPR_HID0], state.spr[SPR_HID1], + state.spr[SPR_HID2], state.spr[SPR_HID4], state.spr[SPR_L2CR]); std::fflush(s_parity_event_file); } From 4055a6ca92049768405b54fc60b770aa9c5f7db4 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Sun, 12 Jul 2026 21:41:34 +0200 Subject: [PATCH 06/27] Keep fallback execution coherent with native REL reloads Static recompiled code does not emulate the Gekko instruction cache, while Dolphin interpreter fallback did. Reusing a REL load address could therefore execute stale bytes only on the fallback side. Disable the interpreter cache once a native module loads and make the icbi fast path invalidate the interpreter cache as well as JIT observers. Constraint: Ordinary Dolphin and interpreter-only StaticRecomp runs retain their configured instruction-cache behavior. Rejected: Preserve interpreter cache emulation | native generated icbi is currently a no-op, so mixed execution would remain incoherent. Confidence: high Scope-risk: narrow Directive: Do not re-enable interpreter icache for a loaded native module until generated native cache operations share the same model. Tested: cmake --build build-nogui -j4 Not-tested: Windows WW actor-REL reload reproducer on this macOS host --- .../PowerPC/StaticRecomp/StaticRecompCore.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp index 479ac79ce4..171dc4362b 100644 --- a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp +++ b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp @@ -325,6 +325,15 @@ void StaticRecompCore::LoadModule() // lockstep stays disabled even if requested (warned in InitLockstep). m_set_mem_journal = reinterpret_cast( m_library.GetSymbolAddress("ppc_set_mem_write_journal")); + + // Native recompiled code has no instruction-cache model: generated icbi + // instructions are no-ops. Keep interpreter fallback coherent with that + // model, otherwise a REL reloaded at a reused address can execute stale + // bytes left in Dolphin's interpreter instruction cache. + m_system.GetPPCState().iCache.m_disable_icache = true; + // Keep the config layer in sync so a later RefreshConfig() cannot silently + // re-enable the interpreter cache while the native module remains active. + Config::SetCurrent(Config::MAIN_DISABLE_ICACHE, true); std::fprintf(stderr, "[staticrecomp] module loaded: %s entry=0x%08X (chassis built " __DATE__ " " __TIME__ ")\n", @@ -734,7 +743,13 @@ void StaticRecompCore::HookInstructionFallback(CPUState* cpu, u32 raw, u32 cia) const u32 ra = (raw >> 16) & 31u; const u32 rb = (raw >> 11) & 31u; const u32 ea = (ra ? cpu->gpr[ra] : 0u) + cpu->gpr[rb]; - system.GetJitInterface().InvalidateICacheLine(ea); + // Match Interpreter::icbi: invalidate both the interpreter cache and + // JIT/cache observers. The other data-cache operations only need the + // existing JIT invalidation when dcache emulation is disabled. + if (xo == 982u) + ppc.iCache.Invalidate(system.GetMemory(), system.GetJitInterface(), ea); + else + system.GetJitInterface().InvalidateICacheLine(ea); // These bypass SingleStepInner, so charge Dolphin's PPCTables cost // here (icbi 4, dcbf/dcbst/dcbi 5); their emitted block cost is zero. ppc.downcount -= (xo == 982u) ? 4 : 5; From 63cfc00c44891ae2ee6e94dc725c5bd95739fdd3 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Sun, 12 Jul 2026 22:36:24 +0200 Subject: [PATCH 07/27] Let DVD traces preserve their Nintendo SDK stage Distinguish DVDReadAsyncPrio from DVDReadAbsAsyncPrio in Dolphin parity events so the shared comparator does not align two different SDK layers as one operation. Constraint: Animal Crossing addresses are revision-specific capture probes. Rejected: Keep a generic dvd.read action | it produced false first divergences by conflating wrapper and absolute-read stages. Confidence: high Scope-risk: narrow Directive: New games should map equivalent SDK stages into the same standardized action names. Tested: cmake --build build-nogui -j4. Not-tested: Fresh Windows Dolphin capture. --- Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 215de439f3..19c534bf98 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -819,8 +819,11 @@ void TraceAnimalCrossingParityEvent(Core::System& system, PowerPC::PowerPCState& current_thread(), lr, 0); break; case 0x80084DACu: + EmitParityEvent(system, state, mmu, "dvd", "read_async", state.gpr[3], state.gpr[4], + state.gpr[5], state.gpr[6], lr); + break; case 0x80086C50u: - EmitParityEvent(system, state, mmu, "dvd", "read", state.gpr[3], state.gpr[4], + EmitParityEvent(system, state, mmu, "dvd", "read_abs_async", state.gpr[3], state.gpr[4], state.gpr[5], state.gpr[6], lr); break; case 0x8008C274u: From e998ad83bdb9604063203e5ddb51438b1b1beb6a Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Sun, 12 Jul 2026 23:48:54 +0200 Subject: [PATCH 08/27] Make function timing attributable to the guest thread Cross-thread function order cannot serve as a deterministic oracle once audio and main execution interleave. Attach Dolphin's current OSThread pointer to every parity event so the comparator can align original functions per guest thread. Constraint: Read the standard low-memory OS current-thread slot already used by the existing register and memory capture lanes. Confidence: high Scope-risk: narrow Directive: Keep thread identity as provenance; normalize logical roles before semantic comparison across producers. Tested: RecompCore build-nogui including dolphin-emu-nogui. --- Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 19c534bf98..624cbdf07c 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -193,7 +193,6 @@ void EmitParityEvent(Core::System& system, PowerPC::PowerPCState& state, PowerPC u64 a, u64 b, u64 c, u64 d) { (void)state; - (void)mmu; static const bool capture_boot_events = [] { const char* raw = std::getenv("DOLPHIN_PARITY_CAPTURE_BOOT_EVENTS"); return raw && raw[0] && raw[0] != '0'; @@ -225,15 +224,18 @@ void EmitParityEvent(Core::System& system, PowerPC::PowerPCState& state, PowerPC return; } const u64 sequence = s_parity_event_sequence.fetch_add(1); + const u32 guest_thread = mmu.Read(0x800000E4u); std::fprintf(s_parity_event_file, "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"%s\"," "\"action\":\"%s\",\"subject\":%u,\"anchor\":{" "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":null}," - "\"data\":{\"a\":%llu,\"b\":%llu,\"c\":%llu,\"d\":%llu}}\n", + "\"data\":{\"a\":%llu,\"b\":%llu,\"c\":%llu,\"d\":%llu," + "\"thread\":%u}}\n", static_cast(sequence), family, action, subject, static_cast(timebase), static_cast(a), static_cast(b), - static_cast(c), static_cast(d)); + static_cast(c), static_cast(d), + guest_thread); if ((sequence & 0x3ffu) == 0) std::fflush(s_parity_event_file); } From 2472590aca68286a762c1eb78d485cd875a49670 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 01:22:34 +0200 Subject: [PATCH 09/27] Keep function-slice lockstep inside one guest PC range Static recomp diagnostics instrument only the selected function while Dolphin previously followed every nested callee. An optional inclusive end PC now filters Dolphin to the same caller range so returns remain alignable. Constraint: Preserve the existing unbounded instruction stream when no end PC is supplied. Rejected: Instrument every nested recomp callee | it defeats bounded progressive zoom and expands diagnostic builds. Confidence: high Scope-risk: narrow Directive: Pass matching start/end PCs from the parity profile that generated the recomp slice. Tested: dolphin-emu-nogui build; AC sound_initial slice captured 30 in-range Dolphin instructions with no false callee control-flow mismatch. Not-tested: JIT-mode lockstep; the oracle uses the instrumented interpreter. --- Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 624cbdf07c..af9f867e0f 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -75,6 +75,7 @@ struct ParityLockstepConfig bool enabled = false; bool started = false; u32 start_pc = 0; + u32 end_pc = 0; u64 limit = 1000; u64 emitted = 0; }; @@ -372,6 +373,8 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, value.enabled = true; if (const char* start = std::getenv("DOLPHIN_PARITY_LOCKSTEP_START_PC")) value.start_pc = static_cast(std::strtoul(start, nullptr, 0)); + if (const char* end = std::getenv("DOLPHIN_PARITY_LOCKSTEP_END_PC")) + value.end_pc = static_cast(std::strtoul(end, nullptr, 0)); if (const char* limit = std::getenv("DOLPHIN_PARITY_LOCKSTEP_LIMIT")) { const u64 parsed = std::strtoull(limit, nullptr, 0); @@ -389,6 +392,13 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, return; config.started = true; } + // A static-recompiler Level-5 slice instruments only the selected guest-PC + // range. Apply the same filter in Dolphin so calls into nested functions + // do not look like a control-flow divergence; execution resumes at the + // caller's next in-range instruction and remains directly comparable. + if (config.end_pc != 0 && + (state.pc < config.start_pc || state.pc > config.end_pc)) + return; std::call_once(s_parity_event_init, InitParityEventFile); if (!s_parity_event_file) From eaea260d757137576a507ddb94ee070ac22bc9f4 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 02:39:07 +0200 Subject: [PATCH 10/27] Let strict FIFO cuts retain only attributable CPU evidence Parity events previously carried no graphics boundary, so trimming an asynchronously closed DFF forced the oracle to discard the entire OS and timing sidecar. Share a capture-local completed-draw counter between the FIFO recorder, video backend, and interpreter; events are anchored to the next draw interval and every recording resets the counter. Constraint: CPU and video execution are asynchronous, so the anchor names the next draw rather than the last completed draw; work after draw N therefore belongs to N+1 and cannot leak through a cut at N. Rejected: Keep unanchored events across a truncated DFF | post-boundary work could satisfy parity profiles falsely. Confidence: high Scope-risk: moderate Directive: Reset the capture-local draw counter on every FIFO recording activation and use the shared anchor for every new parity-event writer. Tested: RecompCore nogui build; fresh AC 131-to-128 DFF cut retained 880/880 anchored events with zero null anchors; 128-draw Level-2 report passes thread, alarm, queue, deadline, lifecycle, DVD and input prefixes. Not-tested: late-start nonzero-frame capture. --- Source/Core/Core/FifoPlayer/FifoRecorder.cpp | 2 ++ .../Core/PowerPC/Interpreter/Interpreter.cpp | 20 ++++++++++++------ Source/Core/VideoCommon/OpcodeDecoding.cpp | 21 +++++++++++++++++++ Source/Core/VideoCommon/OpcodeDecoding.h | 8 +++++++ Source/Core/VideoCommon/VertexManagerBase.cpp | 5 +++-- 5 files changed, 48 insertions(+), 8 deletions(-) diff --git a/Source/Core/Core/FifoPlayer/FifoRecorder.cpp b/Source/Core/Core/FifoPlayer/FifoRecorder.cpp index 4cf92d9e4f..3db73d2a96 100644 --- a/Source/Core/Core/FifoPlayer/FifoRecorder.cpp +++ b/Source/Core/Core/FifoPlayer/FifoRecorder.cpp @@ -270,6 +270,7 @@ void FifoRecorder::StartRecording(s32 numFrames, CallbackFunc finishedCb, if (!was_recording) { + OpcodeDecoder::ResetParityRecordingDraw(); RecordInitialVideoMemory(); } @@ -305,6 +306,7 @@ void FifoRecorder::ActivatePendingRecording() m_FrameEnded = false; m_FifoData.reserve(1024 * 1024 * 4); m_FifoData.clear(); + OpcodeDecoder::ResetParityRecordingDraw(); OpcodeDecoder::g_record_fifo_data = true; } diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index af9f867e0f..4aa473c9c4 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -226,14 +226,16 @@ void EmitParityEvent(Core::System& system, PowerPC::PowerPCState& state, PowerPC } const u64 sequence = s_parity_event_sequence.fetch_add(1); const u32 guest_thread = mmu.Read(0x800000E4u); + const u64 draw = OpcodeDecoder::GetParityEventDrawAnchor(); std::fprintf(s_parity_event_file, "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"%s\"," "\"action\":\"%s\",\"subject\":%u,\"anchor\":{" - "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":null}," + "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":%llu}," "\"data\":{\"a\":%llu,\"b\":%llu,\"c\":%llu,\"d\":%llu," "\"thread\":%u}}\n", static_cast(sequence), family, action, subject, static_cast(timebase), + static_cast(draw), static_cast(a), static_cast(b), static_cast(c), static_cast(d), guest_thread); @@ -329,14 +331,16 @@ void EmitParityRegisterCheckpoint(Core::System& system, PowerPC::PowerPCState& s const u64 timebase = system.GetSystemTimers().GetFakeTimeBase(); const u32 function_id = ParityFunctionId(function_name); const u32 guest_thread = mmu.Read(0x800000E4u); + const u64 draw = OpcodeDecoder::GetParityEventDrawAnchor(); std::lock_guard lk(s_parity_event_mutex); std::fprintf(s_parity_event_file, "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"register\"," "\"action\":\"checkpoint\",\"subject\":%u,\"anchor\":{" - "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":null}," + "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":%llu}," "\"data\":{\"thread\":%u,\"gpr\":[", static_cast(sequence), function_id, - static_cast(timebase), guest_thread); + static_cast(timebase), + static_cast(draw), guest_thread); for (size_t index = 0; index < std::size(state.gpr); ++index) std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.gpr[index]); std::fputs("],\"fpr\":[", s_parity_event_file); @@ -406,14 +410,16 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, const u64 sequence = s_parity_event_sequence.fetch_add(1); const u64 timebase = system.GetSystemTimers().GetFakeTimeBase(); const u32 opcode = mmu.Read(state.pc); + const u64 draw = OpcodeDecoder::GetParityEventDrawAnchor(); std::lock_guard lk(s_parity_event_mutex); std::fprintf(s_parity_event_file, "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"instruction\"," "\"action\":\"checkpoint\",\"subject\":%u,\"anchor\":{" - "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":null}," + "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":%llu}," "\"data\":{\"opcode\":%u,\"gpr\":[", static_cast(sequence), state.pc, - static_cast(timebase), opcode); + static_cast(timebase), + static_cast(draw), opcode); for (size_t index = 0; index < std::size(state.gpr); ++index) std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.gpr[index]); std::fprintf(s_parity_event_file, "],\"fpr\":["); @@ -920,14 +926,16 @@ void EmitRawParityEvent(Core::System& system, const char* family, const char* ac return; const u64 sequence = s_parity_event_sequence.fetch_add(1); const u64 timebase = system.GetSystemTimers().GetFakeTimeBase(); + const u64 draw = OpcodeDecoder::GetParityEventDrawAnchor(); std::lock_guard lk(s_parity_event_mutex); std::fprintf(s_parity_event_file, "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"%s\"," "\"action\":\"%s\",\"subject\":%u,\"anchor\":{" - "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":null}," + "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":%llu}," "\"data\":{\"a\":%llu,\"b\":%llu,\"c\":%llu,\"d\":%llu}}\n", static_cast(sequence), family, action, subject, static_cast(timebase), + static_cast(draw), static_cast(a), static_cast(b), static_cast(c), static_cast(d)); std::fflush(s_parity_event_file); diff --git a/Source/Core/VideoCommon/OpcodeDecoding.cpp b/Source/Core/VideoCommon/OpcodeDecoding.cpp index 186909dc7b..6d54dc8dd3 100644 --- a/Source/Core/VideoCommon/OpcodeDecoding.cpp +++ b/Source/Core/VideoCommon/OpcodeDecoding.cpp @@ -14,6 +14,8 @@ #include "VideoCommon/OpcodeDecoding.h" +#include + #include "Common/Assert.h" #include "Common/Logging/Log.h" #include "Core/FifoPlayer/FifoRecorder.h" @@ -33,6 +35,25 @@ namespace OpcodeDecoder { bool g_record_fifo_data = false; +namespace +{ +std::atomic s_parity_recording_draw{0}; +} + +void ResetParityRecordingDraw() +{ + s_parity_recording_draw.store(0, std::memory_order_release); +} + +u64 AdvanceParityRecordingDraw() +{ + return s_parity_recording_draw.fetch_add(1, std::memory_order_acq_rel) + 1; +} + +u64 GetParityEventDrawAnchor() +{ + return s_parity_recording_draw.load(std::memory_order_acquire) + 1; +} template class RunCallback final : public Callback diff --git a/Source/Core/VideoCommon/OpcodeDecoding.h b/Source/Core/VideoCommon/OpcodeDecoding.h index bf9b94d218..46c8000164 100644 --- a/Source/Core/VideoCommon/OpcodeDecoding.h +++ b/Source/Core/VideoCommon/OpcodeDecoding.h @@ -21,6 +21,14 @@ namespace OpcodeDecoder // Global flag to signal if FifoRecorder is active. extern bool g_record_fifo_data; +// Capture-local draw boundary shared by graphics and parity-event producers. +// An event is anchored to the next draw: events observed after draw N and +// before draw N+1 therefore carry N+1. This makes cutting a trace through +// draw N fail-closed without retaining CPU/OS work that occurred afterward. +void ResetParityRecordingDraw(); +u64 AdvanceParityRecordingDraw(); +u64 GetParityEventDrawAnchor(); + enum class Opcode { GX_NOP = 0x00, diff --git a/Source/Core/VideoCommon/VertexManagerBase.cpp b/Source/Core/VideoCommon/VertexManagerBase.cpp index 813cfec298..bceba13bdc 100644 --- a/Source/Core/VideoCommon/VertexManagerBase.cpp +++ b/Source/Core/VideoCommon/VertexManagerBase.cpp @@ -80,8 +80,9 @@ const ParityEFBDrawDumpConfig& GetParityEFBDrawDumpConfig() void DumpParityEFBAfterDraw() { - static u64 draw = 0; - ++draw; + if (!OpcodeDecoder::g_record_fifo_data) + return; + const u64 draw = OpcodeDecoder::AdvanceParityRecordingDraw(); const ParityEFBDrawDumpConfig& config = GetParityEFBDrawDumpConfig(); if (config.start == 0 || draw < config.start || draw > config.end || !g_framebuffer_manager) From e4796b7aa85be3680fa382e84477962249324d25 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 03:44:06 +0200 Subject: [PATCH 11/27] Keep lower oracle layers compact during state zooms Level-3 function and register capture should not reopen unbounded Level-2 DVD, queue, timer, and device streams. Apply the same per-kind compact policy to system families at every detail level while leaving function/register/memory and instruction evidence uncapped. Constraint: Dolphin and the Universal Runtime must use identical lower-layer retention when a higher profile is active. Rejected: Cap every event family at Level 3 | targeted state evidence must remain complete. Confidence: high Scope-risk: narrow Directive: Add future Level-1/2 event families to the compact-system list; never add Level-3+ causal families there. Tested: RecompCore nogui build; shared oracle tests 209/209 in parent integration. Not-tested: targeted AC Level-3 draw-323 capture. --- .../Core/PowerPC/Interpreter/Interpreter.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 4aa473c9c4..370b27cdf1 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -70,6 +71,19 @@ int ParityCaptureLevel() return level; } +bool IsCompactParitySystemFamily(const char* family) +{ + if (!family) + return false; + static constexpr std::array families = { + "thread", "scheduler", "queue", "timer", "interrupt", "time", "vi", "dvd", + "aram", "input", "dsp", "audio", "exi", "rtc", "savecard", "bba", + }; + return std::any_of(families.begin(), families.end(), [family](const char* candidate) { + return std::strcmp(family, candidate) == 0; + }); +} + struct ParityLockstepConfig { bool enabled = false; @@ -210,7 +224,7 @@ void EmitParityEvent(Core::System& system, PowerPC::PowerPCState& state, PowerPC return; const u64 timebase = system.GetSystemTimers().GetFakeTimeBase(); std::lock_guard lk(s_parity_event_mutex); - if (ParityCaptureLevel() <= 2) + if (ParityCaptureLevel() <= 2 || IsCompactParitySystemFamily(family)) { static const u64 max_per_kind = [] { const char* raw = std::getenv("DOLPHIN_PARITY_LEVEL2_MAX_PER_KIND"); From a461978fac2c466e95b6759bb1617a077e3fd8ee Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 04:06:00 +0200 Subject: [PATCH 12/27] Keep targeted parity zooms bounded around their causal layer Loader, allocation, RNG, and resource lifecycle streams can overwhelm Level-3 captures even after scheduler and device families are capped. Treat those broad lower-layer families consistently while preserving uncapped promoted register, function, memory-write, and instruction evidence. Constraint: Progressive captures must fit bounded storage without hiding the selected high-detail layer. Rejected: Increase the global trace budget | it scales the duplicate noise instead of preserving causal evidence. Confidence: high Scope-risk: narrow Directive: Never add promoted function/register/memory/instruction families to the compact set. Tested: ninja -C build-nogui Source/Core/Core/CMakeFiles/core.dir/PowerPC/Interpreter/Interpreter.cpp.o (object newer than source). Not-tested: Full dolphin-emu-nogui relink; unrelated MoltenVK packaging failed for lack of disk space. --- Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 370b27cdf1..63e79b2070 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -75,9 +75,10 @@ bool IsCompactParitySystemFamily(const char* family) { if (!family) return false; - static constexpr std::array families = { + static constexpr std::array families = { "thread", "scheduler", "queue", "timer", "interrupt", "time", "vi", "dvd", - "aram", "input", "dsp", "audio", "exi", "rtc", "savecard", "bba", + "aram", "input", "dsp", "audio", "exi", "rtc", "savecard", "bba", "loader", + "allocation", "rng", "resource", }; return std::any_of(families.begin(), families.end(), [family](const char* candidate) { return std::strcmp(family, candidate) == 0; From dc2c8e1ac7f0637ae9b1d664791091ebc325d20c Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 07:24:40 +0200 Subject: [PATCH 13/27] Expose drive scheduling before callback timing can obscure it The SDK request cadence cannot distinguish DI latency from guest wakeup and scheduler work. Emit the raw scheduled delay at the Dolphin drive boundary so the shared oracle can assign ownership before changing runtime timing. Constraint: The event remains dormant below parity level 2. Rejected: Infer drive time from consecutive SDK requests | those timestamps include callback and CPU work. Confidence: high Scope-risk: narrow Directive: Keep this event at the hardware scheduling boundary, before DVD-thread completion callbacks. Tested: Incremental dolphin-emu-nogui Release build on macOS arm64. Not-tested: Wii partition-address translation in a live parity capture. --- Source/Core/Core/HW/DVD/DVDInterface.cpp | 14 ++++++++++++++ .../Core/Core/PowerPC/Interpreter/Interpreter.cpp | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/Source/Core/Core/HW/DVD/DVDInterface.cpp b/Source/Core/Core/HW/DVD/DVDInterface.cpp index ce8cb72e42..a1875b6c37 100644 --- a/Source/Core/Core/HW/DVD/DVDInterface.cpp +++ b/Source/Core/Core/HW/DVD/DVDInterface.cpp @@ -48,6 +48,13 @@ #include "VideoCommon/OnScreenDisplay.h" +namespace PowerPC +{ +void DolphinParityTraceHardwareEvent(Core::System& system, const char* family, + const char* action, u32 subject, u64 a, + u64 b, u64 c, u64 d); +} + // The minimum time it takes for the DVD drive to process a command (in microseconds) constexpr u64 MINIMUM_COMMAND_LATENCY_US = 300; @@ -1357,6 +1364,8 @@ void DVDInterface::FinishExecutingCommand(ReplyType reply_type, DIInterruptType void DVDInterface::ScheduleReads(u64 offset, u32 length, const DiscIO::Partition& partition, u32 output_address, ReplyType reply_type) { + const u64 request_offset = offset; + const u32 request_length = length; // The drive continues to read 1 MiB beyond the last read position when idle. // If a future read falls within this window, part of the read may be returned // from the buffer. Data can be transferred from the buffer at up to 32 MiB/s. @@ -1576,6 +1585,11 @@ void DVDInterface::ScheduleReads(u64 offset, u32 length, const DiscIO::Partition "ticks={}, time={} us", unbuffered_blocks, buffered_blocks, ticks_until_completion, ticks_until_completion * 1000000 / m_system.GetSystemTimers().GetTicksPerSecond()); + + PowerPC::DolphinParityTraceHardwareEvent( + m_system, "dvd_hw", "di_schedule", m_DICMDBUF[0], request_offset, request_length, + static_cast(ticks_until_completion) / SystemTimers::TIMER_RATIO, + (static_cast(unbuffered_blocks) << 32) | buffered_blocks); } } // namespace DVD diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 63e79b2070..4f61e23e01 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -957,6 +957,18 @@ void EmitRawParityEvent(Core::System& system, const char* family, const char* ac } } // namespace +namespace PowerPC +{ +void DolphinParityTraceHardwareEvent(Core::System& system, const char* family, + const char* action, u32 subject, u64 a, + u64 b, u64 c, u64 d) +{ + if (ParityCaptureLevel() < 2) + return; + EmitRawParityEvent(system, family, action, subject, a, b, c, d); +} +} // namespace PowerPC + namespace MMIO { void DolphinParityTraceMMIO(Core::System& system, bool write, u32 addr, From 6e053e564784a5c42ac3014cd105844c8387b39e Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 10:21:09 +0200 Subject: [PATCH 14/27] Make DVD timing phase observable before handoff drift The first AC lifecycle displacement survives matching read geometry, so the trace now records the first seek's rotational phase together with buffered and unbuffered ECC counts. This lets the shared comparator distinguish a bad boot/core-timing seed from a transfer-model mismatch. Constraint: Preserve Dolphin's scheduling behavior; telemetry only. Rejected: Infer phase from the final delay | seek and transfer time make that attribution ambiguous. Confidence: high Scope-risk: narrow Directive: Keep the packed di_schedule detail layout synchronized with the Universal Runtime producer. Tested: ninja -C build-nogui dolphin-emu-nogui -j4 Not-tested: Real IPL boot; no user IPL dump is present on this Mac. --- Source/Core/Core/HW/DVD/DVDInterface.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/HW/DVD/DVDInterface.cpp b/Source/Core/Core/HW/DVD/DVDInterface.cpp index a1875b6c37..1d901a6bb3 100644 --- a/Source/Core/Core/HW/DVD/DVDInterface.cpp +++ b/Source/Core/Core/HW/DVD/DVDInterface.cpp @@ -1460,6 +1460,8 @@ void DVDInterface::ScheduleReads(u64 offset, u32 length, const DiscIO::Partition u32 buffered_blocks = 0; u32 unbuffered_blocks = 0; + u32 first_seek_phase = 0; + bool seek_phase_recorded = false; const u32 bytes_per_chunk = partition != DiscIO::PARTITION_NONE && dvd_thread.HasWiiHashes() ? DiscIO::VolumeWii::BLOCK_DATA_SIZE : @@ -1496,6 +1498,17 @@ void DVDInterface::ScheduleReads(u64 offset, u32 length, const DiscIO::Partition ticks_until_completion += static_cast( ticks_per_second * DVDMath::CalculateSeekTime(head_position, dvd_offset)); + if (!seek_phase_recorded) + { + const u64 timebase_hz = ticks_per_second / SystemTimers::TIMER_RATIO; + const u64 phase_denominator = 2 * timebase_hz; + const u64 rotation_input_timebase = + (core_timing.GetTicks() + ticks_until_completion) / SystemTimers::TIMER_RATIO; + first_seek_phase = static_cast( + ((rotation_input_timebase % phase_denominator) * 57) % phase_denominator); + seek_phase_recorded = true; + } + // TODO: The above emulates seeking and then reading one ECC block of data, // and then the below emulates the rotational latency. The rotational latency // should actually happen before reading data from the disc. @@ -1589,7 +1602,8 @@ void DVDInterface::ScheduleReads(u64 offset, u32 length, const DiscIO::Partition PowerPC::DolphinParityTraceHardwareEvent( m_system, "dvd_hw", "di_schedule", m_DICMDBUF[0], request_offset, request_length, static_cast(ticks_until_completion) / SystemTimers::TIMER_RATIO, - (static_cast(unbuffered_blocks) << 32) | buffered_blocks); + (static_cast(unbuffered_blocks & 0xFFFF) << 48) | + (static_cast(buffered_blocks & 0xFFFF) << 32) | first_seek_phase); } } // namespace DVD From 08dc741fe0074abbb65a6d1e01aba7315425d9d0 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 10:37:26 +0200 Subject: [PATCH 15/27] Expose the real IPL interval as deterministic oracle boundaries Headless captures can now distinguish entry into the descrambled BS2 body from the eventual DOL handoff, with full register checkpoints at both points. Boot events flush immediately so a reached milestone cannot be misreported as a timeout. Constraint: Dolphin still HLEs BS1 and enters genuine IPL code at 0x81200150. Rejected: Treat the first GX draw as IPL start | renderer activity is later and cannot prove CPU handoff state. Confidence: high Scope-risk: narrow Directive: Preserve boot.ipl_start and boot.dol_handoff names when reset-vector LLE is added. Tested: ninja -C build-nogui dolphin-emu-nogui -j4; live headless capture reached both boundaries. Not-tested: Universal Runtime IPL execution. --- .../Core/PowerPC/Interpreter/Interpreter.cpp | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 4f61e23e01..7967b22008 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -254,7 +254,10 @@ void EmitParityEvent(Core::System& system, PowerPC::PowerPCState& state, PowerPC static_cast(a), static_cast(b), static_cast(c), static_cast(d), guest_thread); - if ((sequence & 0x3ffu) == 0) + // Boot milestones are used as process-stop boundaries by the headless + // oracle. Keeping them in stdio's buffer can make a successful IPL boot + // look like a timeout until another 1024 events happen to flush the file. + if (std::strcmp(family, "boot") == 0 || (sequence & 0x3ffu) == 0) std::fflush(s_parity_event_file); } @@ -592,6 +595,18 @@ void TraceAnimalCrossingParityEvent(Core::System& system, PowerPC::PowerPCState& TraceParityFloatingPoint(system, state, mmu); TraceParityInstruction(system, state, mmu); const u32 pc = state.pc; + static const u32 ipl_start_pc = [] { + const char* raw = std::getenv("DOLPHIN_PARITY_IPL_START_PC"); + return raw && raw[0] ? static_cast(std::strtoul(raw, nullptr, 0)) : 0u; + }(); + static bool ipl_start_emitted = false; + if (!ipl_start_emitted && ipl_start_pc != 0 && pc == ipl_start_pc) + { + ipl_start_emitted = true; + EmitParityEvent(system, state, mmu, "boot", "ipl_start", pc, state.gpr[3], + state.gpr[4], state.gpr[5], state.msr.Hex); + EmitParityRegisterCheckpoint(system, state, mmu, "boot.ipl_start"); + } static const u32 boot_state_pc = [] { const char* raw = std::getenv("DOLPHIN_PARITY_BOOT_STATE_PC"); return raw && raw[0] ? static_cast(std::strtoul(raw, nullptr, 0)) : 0u; @@ -600,6 +615,9 @@ void TraceAnimalCrossingParityEvent(Core::System& system, PowerPC::PowerPCState& if (!boot_state_emitted && boot_state_pc != 0 && pc == boot_state_pc) { boot_state_emitted = true; + EmitParityEvent(system, state, mmu, "boot", "dol_handoff", pc, state.gpr[1], + state.gpr[2], state.gpr[13], state.msr.Hex); + EmitParityRegisterCheckpoint(system, state, mmu, "boot.dol_handoff"); const auto snapshot = system.GetVideoInterface().GetParityTimingSnapshot( system.GetCoreTiming().GetTicks()); EmitParityEvent(system, state, mmu, "boot", "vi_state", snapshot.half_line, From 0c2c044d73c50834ab0dd82b09a16a8a604f5985 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 10:56:34 +0200 Subject: [PATCH 16/27] Measure IPL state where the boot loader actually owns it A PC observer confused Animal Crossing code at the same address with the initial BS2 handoff. Emit the checkpoint directly from Load_BS2 and expose the selected boot route while a parity sink is active. Constraint: Dolphin still HLEs BS1 and starts genuine descrambled BS2 at 0x81200150. Rejected: Infer IPL start from the first matching PC | game DOL code may legally reuse that address. Confidence: high Scope-risk: narrow Directive: Keep boot-boundary events at the component that changes ownership of CPU state. Tested: ninja -C build-nogui dolphin-emu-nogui -j4; live headless SkipIPL=false capture emits r3=0xFFF0001F r4=0x2030 r5=0x9C at boot.ipl_start. Not-tested: complete sparse-disc IPL-to-DOL handoff; the synthetic sparse image does not reach the game entry. --- Source/Core/Core/Boot/Boot.cpp | 2 ++ Source/Core/Core/BootManager.cpp | 13 +++++++++++ Source/Core/Core/HW/DVD/DVDInterface.cpp | 8 +------ .../Core/PowerPC/Interpreter/Interpreter.cpp | 22 +++++++++---------- Source/Core/Core/PowerPC/ParityTrace.h | 21 ++++++++++++++++++ 5 files changed, 47 insertions(+), 19 deletions(-) create mode 100644 Source/Core/Core/PowerPC/ParityTrace.h diff --git a/Source/Core/Core/Boot/Boot.cpp b/Source/Core/Core/Boot/Boot.cpp index 2ddadfec72..37be007cb3 100644 --- a/Source/Core/Core/Boot/Boot.cpp +++ b/Source/Core/Core/Boot/Boot.cpp @@ -51,6 +51,7 @@ #include "Core/PatchEngine.h" #include "Core/PowerPC/PPCAnalyst.h" #include "Core/PowerPC/PPCSymbolDB.h" +#include "Core/PowerPC/ParityTrace.h" #include "Core/PowerPC/PowerPC.h" #include "Core/System.h" @@ -471,6 +472,7 @@ bool CBoot::Load_BS2(Core::System& system, const std::string& boot_rom_filename) ppc_state.pc = 0x81200150; system.GetPowerPC().MSRUpdated(); + PowerPC::DolphinParityTraceBootContext(system, "ipl_start", ppc_state.pc); return true; } diff --git a/Source/Core/Core/BootManager.cpp b/Source/Core/Core/BootManager.cpp index 85444ef0b6..17e88ae5ce 100644 --- a/Source/Core/Core/BootManager.cpp +++ b/Source/Core/Core/BootManager.cpp @@ -17,6 +17,9 @@ #include "Core/BootManager.h" +#include +#include + #include #include "Common/CommonTypes.h" @@ -187,6 +190,16 @@ bool BootCore(Core::System& system, std::unique_ptr boot, const bool load_ipl = !system.IsWii() && !Config::Get(Config::MAIN_SKIP_IPL) && std::holds_alternative(boot->parameters); + if (const char* parity_path = std::getenv("DOLPHIN_PARITY_EVENT_FILE"); + parity_path && parity_path[0]) + { + std::fprintf(stderr, + "[parity-oracle] boot_manager skip_ipl=%d is_wii=%d disc_boot=%d load_ipl=%d\n", + Config::Get(Config::MAIN_SKIP_IPL) ? 1 : 0, system.IsWii() ? 1 : 0, + std::holds_alternative(boot->parameters) ? 1 : 0, + load_ipl ? 1 : 0); + std::fflush(stderr); + } if (load_ipl) { return Core::Init( diff --git a/Source/Core/Core/HW/DVD/DVDInterface.cpp b/Source/Core/Core/HW/DVD/DVDInterface.cpp index 1d901a6bb3..056bcb7534 100644 --- a/Source/Core/Core/HW/DVD/DVDInterface.cpp +++ b/Source/Core/Core/HW/DVD/DVDInterface.cpp @@ -38,6 +38,7 @@ #include "Core/IOS/DI/DI.h" #include "Core/IOS/IOS.h" #include "Core/Movie.h" +#include "Core/PowerPC/ParityTrace.h" #include "Core/System.h" #include "DiscIO/Blob.h" @@ -48,13 +49,6 @@ #include "VideoCommon/OnScreenDisplay.h" -namespace PowerPC -{ -void DolphinParityTraceHardwareEvent(Core::System& system, const char* family, - const char* action, u32 subject, u64 a, - u64 b, u64 c, u64 d); -} - // The minimum time it takes for the DVD drive to process a command (in microseconds) constexpr u64 MINIMUM_COMMAND_LATENCY_US = 300; diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 7967b22008..5974d1bfaf 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -595,18 +595,6 @@ void TraceAnimalCrossingParityEvent(Core::System& system, PowerPC::PowerPCState& TraceParityFloatingPoint(system, state, mmu); TraceParityInstruction(system, state, mmu); const u32 pc = state.pc; - static const u32 ipl_start_pc = [] { - const char* raw = std::getenv("DOLPHIN_PARITY_IPL_START_PC"); - return raw && raw[0] ? static_cast(std::strtoul(raw, nullptr, 0)) : 0u; - }(); - static bool ipl_start_emitted = false; - if (!ipl_start_emitted && ipl_start_pc != 0 && pc == ipl_start_pc) - { - ipl_start_emitted = true; - EmitParityEvent(system, state, mmu, "boot", "ipl_start", pc, state.gpr[3], - state.gpr[4], state.gpr[5], state.msr.Hex); - EmitParityRegisterCheckpoint(system, state, mmu, "boot.ipl_start"); - } static const u32 boot_state_pc = [] { const char* raw = std::getenv("DOLPHIN_PARITY_BOOT_STATE_PC"); return raw && raw[0] ? static_cast(std::strtoul(raw, nullptr, 0)) : 0u; @@ -977,6 +965,16 @@ void EmitRawParityEvent(Core::System& system, const char* family, const char* ac namespace PowerPC { +void DolphinParityTraceBootContext(Core::System& system, const char* action, u32 pc) +{ + auto& state = system.GetPPCState(); + auto& mmu = system.GetMMU(); + EmitParityEvent(system, state, mmu, "boot", action, pc, state.gpr[3], state.gpr[4], + state.gpr[5], state.msr.Hex); + const std::string checkpoint_name = fmt::format("boot.{}", action); + EmitParityRegisterCheckpoint(system, state, mmu, checkpoint_name.c_str()); +} + void DolphinParityTraceHardwareEvent(Core::System& system, const char* family, const char* action, u32 subject, u64 a, u64 b, u64 c, u64 d) diff --git a/Source/Core/Core/PowerPC/ParityTrace.h b/Source/Core/Core/PowerPC/ParityTrace.h new file mode 100644 index 0000000000..5c8a64b59f --- /dev/null +++ b/Source/Core/Core/PowerPC/ParityTrace.h @@ -0,0 +1,21 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "Common/CommonTypes.h" + +namespace Core +{ +class System; +} + +namespace PowerPC +{ +// Emit CPU state at a boot boundary owned outside the interpreter run loop. +void DolphinParityTraceBootContext(Core::System& system, const char* action, u32 pc); + +void DolphinParityTraceHardwareEvent(Core::System& system, const char* family, + const char* action, u32 subject, u64 a, + u64 b, u64 c, u64 d); +} // namespace PowerPC From da83f0226361251e89bc3e0d3f104d62ab3d4261 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 15:26:45 +0200 Subject: [PATCH 17/27] Make the no-disc IPL a scriptable oracle target Expose the existing BootParameters::IPL route in DolphinNoGUI so firmware parity can be captured headlessly without inserting a game or driving the Qt menu. Region selection is explicit and conflicting boot inputs fail closed. Constraint: The user must supply the matching installed IPL ROM in Dolphin storage. Rejected: Boot an arbitrary disc and eject it after startup | changes the early firmware path and hides true no-disc behavior. Confidence: high Scope-risk: narrow Directive: Keep ordinary disc and NAND boot parsing unchanged. Tested: dolphin-emu-nogui build; help output; live NTSC-U no-disc IPL GDB entry at 0x81200150; conflicting input rejection. Not-tested: PAL and NTSC-J ROM availability on this host. --- Source/Core/DolphinNoGUI/MainNoGUI.cpp | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/Source/Core/DolphinNoGUI/MainNoGUI.cpp b/Source/Core/DolphinNoGUI/MainNoGUI.cpp index fa5d3123fe..e1c9a7e3a1 100644 --- a/Source/Core/DolphinNoGUI/MainNoGUI.cpp +++ b/Source/Core/DolphinNoGUI/MainNoGUI.cpp @@ -30,6 +30,7 @@ #include "Core/Movie.h" #include "Core/PowerPC/PowerPC.h" #include "Core/System.h" +#include "DiscIO/Enums.h" #include "UICommon/CommandLineParse.h" #ifdef USE_DISCORD_PRESENCE @@ -273,6 +274,11 @@ int main(const int argc, char* argv[]) parser->add_option("--fifo-record-screenshot-name") .action("store") .help("Save a PNG after the automated FIFO window under ScreenShots/"); + parser->add_option("--boot-gc-ipl") + .action("store") + .choices({"usa", "japan", "europe"}) + .metavar("") + .help("Boot the installed GameCube IPL with no disc for the selected region"); optparse::Values& options = CommandLineParse::ParseArguments(parser.get(), argc, argv); std::vector args = parser->args(); @@ -315,8 +321,25 @@ int main(const int argc, char* argv[]) } std::unique_ptr boot; + std::optional gc_ipl_region; bool game_specified = false; - if (options.is_set("exec")) + if (options.is_set("boot_gc_ipl")) + { + if (options.is_set("exec") || options.is_set("nand_title") || !args.empty()) + { + fprintf(stderr, "--boot-gc-ipl cannot be combined with a game or NAND title\n"); + return 1; + } + + const std::string region = static_cast(options.get("boot_gc_ipl")); + if (region == "usa") + gc_ipl_region = DiscIO::Region::NTSC_U; + else if (region == "japan") + gc_ipl_region = DiscIO::Region::NTSC_J; + else + gc_ipl_region = DiscIO::Region::PAL; + } + else if (options.is_set("exec")) { const std::list paths_list = options.all("exec"); const std::vector paths{std::make_move_iterator(std::begin(paths_list)), @@ -372,6 +395,9 @@ int main(const int argc, char* argv[]) UICommon::Shutdown(); }); + if (gc_ipl_region) + boot = std::make_unique(BootParameters::IPL{*gc_ipl_region}); + if (save_state_path && !game_specified) { fprintf(stderr, "A save state cannot be loaded without specifying a game to launch.\n"); From ee8ea325ee1996fcca29bfcb6eed65efcfec2986 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 18:54:46 +0200 Subject: [PATCH 18/27] Make per-draw IPL diagnosis align with GX primitives Dolphin normally batches compatible primitives, which made an EFB probe labelled as a draw disagree with DFF and GXRuntime ordinals. Opt-in parity captures now force a boundary after each recorded GX primitive and advance exactly once there. Constraint: Normal emulation and recording must retain Dolphin batching when the EFB probe is disabled. Rejected: Count VertexManager flushes | one flush can contain many GX primitives and cannot localize DFF draw divergence. Confidence: high Scope-risk: narrow Directive: Keep primitive counting outside preprocess and do not restore counting in VertexManagerBase::Flush. Tested: dolphin-emu-nogui build; target draw 277 PNG from a 553-draw IPL DFF; identical GXRuntime FNV/state digest with probe disabled. Not-tested: Long multi-frame per-primitive EFB capture performance. --- Source/Core/VideoCommon/OpcodeDecoding.cpp | 6 +++++ Source/Core/VideoCommon/VertexManagerBase.cpp | 22 ++++++++++--------- Source/Core/VideoCommon/VertexManagerBase.h | 4 ++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Source/Core/VideoCommon/OpcodeDecoding.cpp b/Source/Core/VideoCommon/OpcodeDecoding.cpp index 6d54dc8dd3..f21da090b9 100644 --- a/Source/Core/VideoCommon/OpcodeDecoding.cpp +++ b/Source/Core/VideoCommon/OpcodeDecoding.cpp @@ -29,6 +29,7 @@ #include "VideoCommon/Statistics.h" #include "VideoCommon/VertexLoaderBase.h" #include "VideoCommon/VertexLoaderManager.h" +#include "VideoCommon/VertexManagerBase.h" #include "VideoCommon/XFMemory.h" #include "VideoCommon/XFStateManager.h" @@ -155,6 +156,11 @@ class RunCallback final : public Callback ASSERT(bytes == size); + // Count the exact GX primitive boundary written to the DFF command stream. Dolphin normally + // batches compatible primitives, so an active per-draw EFB oracle also flushes here. + if constexpr (!is_preprocess) + OnParityRecordingPrimitiveBoundary(); + // 4 GPU ticks per vertex, 3 CPU ticks per GPU tick m_cycles += num_vertices * 4 * 3 + 6; } diff --git a/Source/Core/VideoCommon/VertexManagerBase.cpp b/Source/Core/VideoCommon/VertexManagerBase.cpp index bceba13bdc..3ef0502e98 100644 --- a/Source/Core/VideoCommon/VertexManagerBase.cpp +++ b/Source/Core/VideoCommon/VertexManagerBase.cpp @@ -78,12 +78,8 @@ const ParityEFBDrawDumpConfig& GetParityEFBDrawDumpConfig() return config; } -void DumpParityEFBAfterDraw() +void DumpParityEFBAfterDraw(u64 draw) { - if (!OpcodeDecoder::g_record_fifo_data) - return; - const u64 draw = OpcodeDecoder::AdvanceParityRecordingDraw(); - const ParityEFBDrawDumpConfig& config = GetParityEFBDrawDumpConfig(); if (config.start == 0 || draw < config.start || draw > config.end || !g_framebuffer_manager) return; @@ -104,6 +100,17 @@ void DumpParityEFBAfterDraw() } } // namespace +void OnParityRecordingPrimitiveBoundary() +{ + if (!OpcodeDecoder::g_record_fifo_data) + return; + + if (GetParityEFBDrawDumpConfig().start != 0 && g_vertex_manager) + g_vertex_manager->Flush(); + + DumpParityEFBAfterDraw(OpcodeDecoder::AdvanceParityRecordingDraw()); +} + using OpcodeDecoder::Primitive; // GX primitive -> RenderState primitive, no primitive restart @@ -724,11 +731,6 @@ void VertexManagerBase::Flush() // Even if we skip the draw, emulated state should still be impacted OnDraw(); - // Optional oracle zoom: capture the raw EFB exactly at a selected draw - // boundary. Disabled unless all DOLPHIN_PARITY_DUMP_EFB_DRAW_* variables - // are supplied, so normal Dolphin rendering has no readback cost. - DumpParityEFBAfterDraw(); - // The EFB cache is now potentially stale. g_framebuffer_manager->FlagPeekCacheAsOutOfDate(); } diff --git a/Source/Core/VideoCommon/VertexManagerBase.h b/Source/Core/VideoCommon/VertexManagerBase.h index 715dbee692..b40fee901a 100644 --- a/Source/Core/VideoCommon/VertexManagerBase.h +++ b/Source/Core/VideoCommon/VertexManagerBase.h @@ -256,3 +256,7 @@ class VertexManagerBase }; extern std::unique_ptr g_vertex_manager; + +// Record one exact GX primitive boundary for the parity oracle. When per-draw EFB dumping is +// enabled this also flushes Dolphin's normally batched rendering before capturing the EFB. +void OnParityRecordingPrimitiveBoundary(); From 1e06fdacbc54c2d9967bb71ee96ff51b85af1887 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 22:28:07 +0200 Subject: [PATCH 19/27] Make firmware lockstep observe Dolphin's architectural state Direct IPL execution now bridges decrementer and supervisor SPR effects through optional module callbacks, materializes only current packed-single tags, and recognizes emitted conditional branch boundaries without false loop exits. The no-disc synthetic ID exception remains deliberately narrow. Constraint: Keep the existing StaticRecomp ABI version and the Windows read/write SPR-hook ordering. Rejected: Copy the Windows adapter wholesale | it mixes portable semantics with x86 state, platform paths, generated artifacts, and game-specific probes. Confidence: high Scope-risk: moderate Directive: Do not broaden empty module IDs into a game wildcard; preserve Dolphin as the owner of DEC/HID side effects. Tested: Apple ARM DolphinNoGUI rebuild; real no-disc IPL lockstep from dispatch zero, 578 distinct blocks, 0 reports, 2 zero-charge skips, 111 timing undercharges, max deficit 12. Not-tested: Native MSVC chassis build in this macOS workspace. --- .../PowerPC/StaticRecomp/StaticRecompCore.cpp | 206 +++++++++++++++++- .../PowerPC/StaticRecomp/StaticRecompCore.h | 7 + 2 files changed, 201 insertions(+), 12 deletions(-) diff --git a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp index 171dc4362b..30af5c52e9 100644 --- a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp +++ b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp @@ -22,6 +22,7 @@ #include "Core/HW/SystemTimers.h" #include "Core/PowerPC/Gekko.h" #include "Core/PowerPC/Interpreter/Interpreter.h" +#include "Core/PowerPC/Interpreter/Interpreter_FPUtils.h" #include "Core/PowerPC/JitInterface.h" #include "Core/PowerPC/MMU.h" #include "Core/PowerPC/PowerPC.h" @@ -61,6 +62,22 @@ constexpr s64 LS_UNDERCHARGE_GRACE = 256; constexpr u32 SYNC_EXCEPTION_MASK = ~static_cast( EXCEPTION_EXTERNAL_INT | EXCEPTION_DECREMENTER | EXCEPTION_PERFORMANCE_MONITOR); +constexpr u64 PACKED_SINGLE_TAG = 1ull << 32; + +constexpr bool HasCurrentPackedSingleTag(u64 ps0_bits, u64 ps1_bits) +{ + if ((ps1_bits & PACKED_SINGLE_TAG) == 0) + return false; + const u32 ps0_hi = static_cast(ps0_bits >> 32); + const u32 ps0_lo = static_cast(ps0_bits); + return ps0_hi == ps0_lo || static_cast(ps1_bits) == ps0_lo; +} + +static_assert(HasCurrentPackedSingleTag(0x3F8000003F800000ull, 0x000000013F000000ull)); +static_assert(HasCurrentPackedSingleTag(0x4000000000000000ull, 0x0000000100000000ull)); +static_assert(!HasCurrentPackedSingleTag(0x4000000000000000ull, 0x000000013F000000ull)); +static_assert(!HasCurrentPackedSingleTag(0x3F8000003F800000ull, 0x000000003F000000ull)); + #ifdef __APPLE__ constexpr const char* MODULE_SUFFIX = ".dylib"; #elif defined(_WIN32) @@ -243,6 +260,13 @@ void StaticRecompCore::Shutdown() if (m_set_mem_journal) m_set_mem_journal(nullptr, nullptr); } + // These hooks are installed whenever the module exports them, independently + // of lockstep. Clear them before unloading the shared library so no callback + // can retain a pointer into the unloaded RecompCore instance. + if (m_set_dec_hooks) + m_set_dec_hooks(nullptr, nullptr, nullptr); + if (m_set_spr_hooks) + m_set_spr_hooks(nullptr, nullptr, nullptr); m_block_cache.Shutdown(); m_module = nullptr; if (m_library.IsOpen()) @@ -314,7 +338,12 @@ void StaticRecompCore::LoadModule() return reject("no dispatch entry or empty code ranges"); if (!desc->chunk_ranges || desc->num_chunk_ranges == 0 || !desc->chunk_hashes) return reject("no chunk ranges/hashes (required for the SMC guard)"); - if (!game_id.empty() && game_id != desc->game_id) + // A direct no-disc GameCube IPL boot has no disc game ID. Dolphin represents + // that boot session as the synthetic ID "00000000", while an IPL recomp + // module deliberately leaves its game_id empty. Accept exactly that pair; + // an empty module ID is not a wildcard for normal games. + const bool is_no_disc_ipl_module = game_id == "00000000" && desc->game_id[0] == '\0'; + if (!game_id.empty() && game_id != desc->game_id && !is_no_disc_ipl_module) return reject(fmt::format("module game_id '{}' != running game '{}'", desc->game_id, game_id)); m_module = desc; @@ -325,6 +354,42 @@ void StaticRecompCore::LoadModule() // lockstep stays disabled even if requested (warned in InitLockstep). m_set_mem_journal = reinterpret_cast( m_library.GetSymbolAddress("ppc_set_mem_write_journal")); + m_set_dec_hooks = reinterpret_cast( + m_library.GetSymbolAddress("ppc_set_dec_hooks")); + if (m_set_dec_hooks) + { + m_set_dec_hooks( + [](u32 value, void* user) { + auto* system = static_cast(user); + auto& ppc = system->GetPPCState(); + const u32 old_value = ppc.spr[SPR_DEC]; + ppc.spr[SPR_DEC] = value; + // Match Interpreter::mtspr: software can trigger the decrementer + // exception immediately by changing DEC's sign bit from 0 to 1. + if ((old_value >> 31) == 0 && (value >> 31) != 0) + ppc.Exceptions |= EXCEPTION_DECREMENTER; + system->GetSystemTimers().DecrementerSet(); + }, + [](void* user) -> u32 { + auto* system = static_cast(user); + auto& ppc = system->GetPPCState(); + // Match Interpreter::mfspr: only a live, non-negative decrementer is + // derived from CoreTiming. Once expired, the stored value is read. + if ((ppc.spr[SPR_DEC] & 0x80000000u) == 0) + ppc.spr[SPR_DEC] = system->GetSystemTimers().GetFakeDecrementer(); + return ppc.spr[SPR_DEC]; + }, + &m_system); + std::fprintf(stderr, "[staticrecomp] decrementer hooks installed\n"); + } + m_set_spr_hooks = reinterpret_cast( + m_library.GetSymbolAddress("ppc_set_spr_hooks")); + if (m_set_spr_hooks) + { + m_set_spr_hooks(&StaticRecompCore::HookSupervisorSprRead, + &StaticRecompCore::HookSupervisorSprWrite, this); + std::fprintf(stderr, "[staticrecomp] supervisor SPR hooks installed\n"); + } // Native recompiled code has no instruction-cache model: generated icbi // instructions are no-ops. Keep interpreter fallback coherent with that @@ -568,6 +633,73 @@ void StaticRecompCore::PropagateGuestMSR() } } +void StaticRecompCore::HookSupervisorSprWrite(u32 spr, u32 value, void* user) +{ + auto* core = static_cast(user); + auto& ppc = core->m_system.GetPPCState(); + if (spr >= 1024) + return; + + const u32 old_value = ppc.spr[spr]; + ppc.spr[spr] = value; + switch (spr) + { + case SPR_PVR: + ppc.spr[spr] = old_value; + break; + case SPR_HID0: + if (HID0(ppc).ICFI) + { + HID0(ppc).ICFI = 0; + ppc.iCache.Reset(core->m_system.GetJitInterface()); + } + break; + case SPR_HID1: + ppc.spr[spr] &= 0xF8000000u; + break; + case SPR_HID2: + ppc.spr[spr] = (ppc.spr[spr] & 0xF0FF0000u) | (old_value & 0x0F000000u); + core->m_guest.hid2 = ppc.spr[spr]; + break; + case SPR_HID4: + if (old_value != ppc.spr[spr]) + { + core->m_system.GetMMU().IBATUpdated(); + core->m_system.GetMMU().DBATUpdated(); + } + break; + case SPR_MMCR0: + case SPR_MMCR1: + PowerPC::MMCRUpdated(ppc); + break; + default: + break; + } +} + +u32 StaticRecompCore::HookSupervisorSprRead(u32 spr, void* user) +{ + auto* core = static_cast(user); + auto& ppc = core->m_system.GetPPCState(); + if (spr >= 1024) + return 0; + switch (spr) + { + case SPR_UPMC1: + return ppc.spr[SPR_PMC1]; + case SPR_UPMC2: + return ppc.spr[SPR_PMC2]; + case SPR_UPMC3: + return ppc.spr[SPR_PMC3]; + case SPR_UPMC4: + return ppc.spr[SPR_PMC4]; + case SPR_IABR: + return ppc.spr[spr] & ~1u; + default: + return ppc.spr[spr]; + } +} + u64 StaticRecompCore::HookExternalRead(CPUState* cpu, u32 ea, u8 size) { auto* core = static_cast(cpu->external_user_data); @@ -840,8 +972,20 @@ void StaticRecompCore::LoadEntryRegsToPPC(const CPUState& s) std::memcpy(ppc.gpr, s.gpr, sizeof(ppc.gpr)); for (int i = 0; i < 32; ++i) { - std::memcpy(&ppc.ps[i].ps0, &s.fpr[i], sizeof(u64)); - std::memcpy(&ppc.ps[i].ps1, &s.ps1[i], sizeof(u64)); + u64 ps0_bits; + u64 ps1_bits; + std::memcpy(&ps0_bits, &s.fpr[i], sizeof(u64)); + std::memcpy(&ps1_bits, &s.ps1[i], sizeof(u64)); + // GekkoRecomp preserves paired singles as tagged raw f32 lanes across + // native dispatches. The interpreter shadow expects Dolphin's materialized + // f64 representation, including its bit-exact signaling-NaN conversion. + if (HasCurrentPackedSingleTag(ps0_bits, ps1_bits)) + { + ps0_bits = ConvertToDouble(static_cast(ps0_bits >> 32)); + ps1_bits = ConvertToDouble(static_cast(ps1_bits)); + } + std::memcpy(&ppc.ps[i].ps0, &ps0_bits, sizeof(u64)); + std::memcpy(&ppc.ps[i].ps1, &ps1_bits, sizeof(u64)); } ppc.pc = s.pc; ppc.npc = s.pc; @@ -1183,10 +1327,40 @@ void StaticRecompCore::LockstepCheck(u32 entry_pc, u32 end_pc, const CPUState& e // trip the emitter DID return from — a real boundary (e.g. a `bl` to a // function whose own entry is a loop header, GetLinearVelocity/AngularVelocity // in Strikers) — so keep it. - if (ppc.pc == end_pc && ppc.pc != before + 4) + const u32 branch_offset = before - 0x80000000u; + const u32 branch_insn = + (branch_offset + 4u <= ram_size) ? Common::swap32(&ram[branch_offset]) : 0u; + const u32 branch_opcode = branch_insn >> 26; + const s32 bc_displacement = static_cast(branch_insn & 0xFFFCu); + const u32 bc_target = (branch_insn & 2u) != 0 ? static_cast(bc_displacement) : + before + bc_displacement; + const u32 bc_bo = (branch_insn >> 21) & 0x1Fu; + const u32 bc_bi = (branch_insn >> 16) & 0x1Fu; + // SingleStepInner has already applied the optional CTR decrement. Reuse + // Dolphin's branch-condition equations on the resulting CTR and unchanged + // CR so an untaken conditional branch cannot masquerade as a boundary when + // a later loop happens to return to its fall-through address. + const u32 bc_counter = + ((bc_bo >> 2) | ((static_cast(ppc.spr[SPR_CTR] != 0) ^ (bc_bo >> 1)))) & 1u; + const u32 bc_condition = + ((bc_bo >> 4) | (static_cast(ppc.cr.GetBit(bc_bi)) == ((bc_bo >> 3) & 1u))) & 1u; + const bool bc_taken = branch_opcode == 16u && (bc_counter & bc_condition) != 0; + // GekkoRecomp intentionally lets CodeWarrior's `bcl ...,+4` PIC idiom + // fall through after publishing LR. Other taken opcode-16 branches return + // from block mode just like direct b/bl and must count as boundaries. + const bool bcl_next_falls_through = + branch_opcode == 16u && (branch_insn & 1u) != 0 && bc_target == before + 4; + const bool emitted_direct_branch = + branch_opcode == 18u || (bc_taken && !bcl_next_falls_through); + // Block-return GekkoRecomp returns for every direct b/bl. A branch whose + // target happens to equal CIA+4 still is a native dispatch boundary even + // though the interpreter's resulting PC looks like ordinary fall-through. + const bool sequential_direct_branch = + emitted_direct_branch && ppc.pc == end_pc && ppc.pc == before + 4; + if ((ppc.pc == end_pc && ppc.pc != before + 4) || sequential_direct_branch) { bool is_boundary = true; - if (end_is_loop_header && before < end_pc) + if (end_is_loop_header && before < end_pc && !emitted_direct_branch) { const u32 boff = before - 0x80000000u; const u32 binsn = (boff + 4u <= ram_size) ? Common::swap32(&ram[boff]) : 0u; @@ -1258,13 +1432,21 @@ void StaticRecompCore::LockstepCheck(u32 entry_pc, u32 end_pc, const CPUState& e addu(fmt::format("r{}", r), m_guest.gpr[r], ppc.gpr[r]); for (int r = 0; r < 32; ++r) { - u64 n, i; - std::memcpy(&n, &m_guest.fpr[r], sizeof(u64)); - std::memcpy(&i, &ppc.ps[r].ps0, sizeof(u64)); - addu(fmt::format("f{}", r), n, i); - std::memcpy(&n, &m_guest.ps1[r], sizeof(u64)); - std::memcpy(&i, &ppc.ps[r].ps1, sizeof(u64)); - addu(fmt::format("ps1_{}", r), n, i); + u64 native_ps0; + u64 native_ps1; + u64 interpreter_ps0; + u64 interpreter_ps1; + std::memcpy(&native_ps0, &m_guest.fpr[r], sizeof(u64)); + std::memcpy(&native_ps1, &m_guest.ps1[r], sizeof(u64)); + std::memcpy(&interpreter_ps0, &ppc.ps[r].ps0, sizeof(u64)); + std::memcpy(&interpreter_ps1, &ppc.ps[r].ps1, sizeof(u64)); + if (HasCurrentPackedSingleTag(native_ps0, native_ps1)) + { + native_ps0 = ConvertToDouble(static_cast(native_ps0 >> 32)); + native_ps1 = ConvertToDouble(static_cast(native_ps1)); + } + addu(fmt::format("f{}", r), native_ps0, interpreter_ps0); + addu(fmt::format("ps1_{}", r), native_ps1, interpreter_ps1); } addu("lr", m_guest.lr, ppc.spr[SPR_LR]); addu("ctr", m_guest.ctr, ppc.spr[SPR_CTR]); diff --git a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.h b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.h index 203cada760..74a4eeedc7 100644 --- a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.h +++ b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.h @@ -139,6 +139,11 @@ class StaticRecompCore : public JitBase static u32 LsHwReadTrampoline(u32 physical_address, u32 size, void* user); using SetMemJournalFn = void (*)(void (*)(u32, u32, void*), void*); + using SetDecHooksFn = void (*)(void (*)(u32, void*), u32 (*)(void*), void*); + using SetSprHooksFn = void (*)(u32 (*)(u32, void*), void (*)(u32, u32, void*), void*); + + static void HookSupervisorSprWrite(u32 spr, u32 value, void* user); + static u32 HookSupervisorSprRead(u32 spr, void* user); struct LsWrite { @@ -159,6 +164,8 @@ class StaticRecompCore : public JitBase u64 m_ls_undercharges = 0; // blocks regs-exact but native undercharged downcount (D3) s64 m_ls_max_undercharge = 0; // worst per-block cycle deficit observed SetMemJournalFn m_set_mem_journal = nullptr; // resolved from the module + SetDecHooksFn m_set_dec_hooks = nullptr; // optional real decrementer bridge + SetSprHooksFn m_set_spr_hooks = nullptr; // optional supervisor-register bridge std::unordered_set m_ls_checked; // entry PCs already checked (dedupe) std::unordered_set m_ls_whitelist; // entry PCs never reported (known-benign) u32 m_ls_trace_pc = 0; // STATICRECOMP_LOCKSTEP_TRACE: per-instr shadow dump for one entry PC From 01d2cad765a80645da81bfdfcec75c77f1c29b7d Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Mon, 13 Jul 2026 22:50:38 +0200 Subject: [PATCH 20/27] Make timing parity fail in both directions The lockstep oracle previously quantified only generated blocks that charged too few Gekko cycles. Record charge excess as a separate architectural-clean diagnostic so a zero-undercharge summary cannot hide early block boundaries. Constraint: Exception exits can legitimately expose static overcharge, so excess remains diagnostic rather than an architectural report. Confidence: high Scope-risk: narrow Directive: Keep undercharge and overcharge counters separate; do not fold either into register divergence. Tested: macOS no-GUI RecompCore build; real no-disc IPL lockstep summary reports zero deficits and zero excess across 576 charge-bearing PCs. Not-tested: Windows 11 rebuild. --- .../PowerPC/StaticRecomp/StaticRecompCore.cpp | 25 ++++++++++++++++++- .../PowerPC/StaticRecomp/StaticRecompCore.h | 2 ++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp index 30af5c52e9..951e841340 100644 --- a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp +++ b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.cpp @@ -252,10 +252,12 @@ void StaticRecompCore::Shutdown() { std::fprintf(stderr, "[lockstep] summary: checks=%llu reports=%llu skipped_fallback=%llu " - "skipped_zero=%llu undercharges=%llu max_deficit=%lld distinct_pcs=%zu\n", + "skipped_zero=%llu undercharges=%llu max_deficit=%lld " + "overcharges=%llu max_excess=%lld distinct_pcs=%zu\n", (unsigned long long)m_ls_checks, (unsigned long long)m_ls_reports, (unsigned long long)m_ls_skipped_fallback, (unsigned long long)m_ls_skipped_zero, (unsigned long long)m_ls_undercharges, (long long)m_ls_max_undercharge, + (unsigned long long)m_ls_overcharges, (long long)m_ls_max_overcharge, m_ls_checked.size()); if (m_set_mem_journal) m_set_mem_journal(nullptr, nullptr); @@ -1401,6 +1403,7 @@ void StaticRecompCore::LockstepCheck(u32 entry_pc, u32 end_pc, const CPUState& e } const bool reached = (ppc.pc == end_pc); const bool undercharged = reached && interp_cycles > native_charge; + const bool overcharged = reached && interp_cycles < native_charge; StaticRecompLockstep::g_hw_write_sink = nullptr; StaticRecompLockstep::g_hw_write_sink_user = nullptr; @@ -1550,6 +1553,26 @@ void StaticRecompCore::LockstepCheck(u32 entry_pc, u32 end_pc, const CPUState& e (long long)native_charge, (long long)interp_cycles, (long long)deficit); } } + else if (overcharged) + { + // Reaching native's actual dispatch boundary before consuming its charge + // means the generated block charged more than Dolphin's interpreter cost. + // Keep this separate from architectural reports: an exception can + // legitimately leave a statically charged block early, while ordinary + // blocks should remain at zero excess. + ++m_ls_overcharges; + const s64 excess = native_charge - interp_cycles; + if (excess > m_ls_max_overcharge) + m_ls_max_overcharge = excess; + if (m_ls_overcharges <= 64) + { + std::fprintf(stderr, + "[lockstep] OVERCHARGE #%llu entry=0x%08X end=0x%08X: " + "N_cyc=%lld I_cyc=%lld excess=%lld (regs/mem exact)\n", + (unsigned long long)m_ls_overcharges, entry_pc, end_pc, + (long long)native_charge, (long long)interp_cycles, (long long)excess); + } + } // Undo the shadow's MEM1 stores (back to the block pre-image), then redo // native's writes so RAM is canonical for continued native execution. Undo diff --git a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.h b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.h index 74a4eeedc7..4c9146e762 100644 --- a/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.h +++ b/Source/Core/Core/PowerPC/StaticRecomp/StaticRecompCore.h @@ -163,6 +163,8 @@ class StaticRecompCore : public JitBase u64 m_ls_skipped_zero = 0; // blocks skipped (zero cycle charge: no alignable work) u64 m_ls_undercharges = 0; // blocks regs-exact but native undercharged downcount (D3) s64 m_ls_max_undercharge = 0; // worst per-block cycle deficit observed + u64 m_ls_overcharges = 0; // blocks regs-exact but native charged beyond their boundary + s64 m_ls_max_overcharge = 0; // worst per-block excess observed SetMemJournalFn m_set_mem_journal = nullptr; // resolved from the module SetDecHooksFn m_set_dec_hooks = nullptr; // optional real decrementer bridge SetSprHooksFn m_set_spr_hooks = nullptr; // optional supervisor-register bridge From 42a6686ca4ac748ace212c59feae86d94de8661c Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Tue, 14 Jul 2026 05:52:52 +0200 Subject: [PATCH 21/27] Let late FIFO frames reveal their function boundary A selected graphics frame previously required a known function PC before Dolphin could expose its function stream, and unconditional closure at OSCreateThread truncated late menu discovery. Zero-PC discovery now opens at the active FIFO window while the original exact-PC mode remains unchanged; thread closure is explicitly configurable and defaults to the old behavior.\n\nConstraint: Function events remain gated to the bounded FIFO window and configured event limit.\nRejected: Guess a stable IPL render function from generated code | it cannot establish an independent oracle boundary.\nConfidence: high\nScope-risk: narrow\nDirective: Keep non-zero start-PC behavior exact for producer-neutral lockstep comparisons.\nTested: Rebuilt dolphin-emu-nogui; frame-650 IPL discovery captured 1,038 function entries across 122 unique guest PCs.\nNot-tested: Windows build in this worktree. --- .../Core/PowerPC/Interpreter/Interpreter.cpp | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 5974d1bfaf..c0b6126bd7 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -153,6 +153,7 @@ const ParityRegisterConfig& SelectedParityRegisterConfig() struct ParityAllFunctionConfig { bool enabled = false; + bool close_on_first_thread = true; u32 start_pc = 0; u64 limit = 100000; }; @@ -163,6 +164,9 @@ const ParityAllFunctionConfig& AllFunctionParityConfig() ParityAllFunctionConfig value; const char* enabled = std::getenv("DOLPHIN_PARITY_TRACE_ALL_FUNCTIONS"); value.enabled = enabled && enabled[0] && enabled[0] != '0'; + if (const char* raw_close = + std::getenv("DOLPHIN_PARITY_ALL_FUNCTION_CLOSE_ON_FIRST_THREAD")) + value.close_on_first_thread = raw_close[0] && raw_close[0] != '0'; if (const char* raw_start = std::getenv("DOLPHIN_PARITY_ALL_FUNCTION_START_PC")) value.start_pc = static_cast(std::strtoul(raw_start, nullptr, 0)); if (const char* raw_limit = std::getenv("DOLPHIN_PARITY_ALL_FUNCTION_LIMIT")) @@ -301,8 +305,16 @@ void TraceParityAllFunctionEntry(Core::System& system, PowerPC::PowerPCState& st if (!config.enabled || ParityCaptureLevel() < 3) return; + // A zero start PC is the discovery mode used for a selected FIFO frame: + // open on the first interpreted instruction after recording starts. This + // avoids needing to know a late-frame function address before the oracle + // can discover that address. A non-zero start PC retains the exact + // producer-neutral lockstep boundary used by normal comparisons. + const bool starts_at_selected_fifo = + config.start_pc == 0 && OpcodeDecoder::g_record_fifo_data; if (!s_parity_all_function_window_active.load(std::memory_order_acquire) && - config.start_pc != 0 && state.pc == config.start_pc) + (starts_at_selected_fifo || + (config.start_pc != 0 && state.pc == config.start_pc))) { s_parity_all_function_window_active.store(true, std::memory_order_release); s_parity_all_function_count = 0; @@ -795,8 +807,11 @@ void TraceAnimalCrossingParityEvent(Core::System& system, PowerPC::PowerPCState& case 0x8007E2BCu: EmitParityEvent(system, state, mmu, "thread", "create", state.gpr[3], state.gpr[4], state.gpr[5], state.gpr[8], state.gpr[6]); - s_parity_all_function_window_active.store(false, std::memory_order_release); - s_parity_pending_call_target = 0; + if (AllFunctionParityConfig().close_on_first_thread) + { + s_parity_all_function_window_active.store(false, std::memory_order_release); + s_parity_pending_call_target = 0; + } break; case 0x8007E85Cu: EmitParityEvent(system, state, mmu, "thread", "resume_request", state.gpr[3], From 71564bf4d7243d085dc115350843190860b8279d Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Tue, 14 Jul 2026 06:47:37 +0200 Subject: [PATCH 22/27] Let late FIFO captures expose their causal CPU window The GX recorder can finish queued work before an interpreter probe reaches the CPU dispatcher that produced it. Switch to the instrumented interpreter a configurable number of presented frames early, arm one bounded discovery window, and allow PC+LR selection so repeated state-machine functions are compared at the same caller. Constraint: Fast-forward must remain JIT-backed until shortly before the selected FIFO frame. Rejected: Start tracing only when FIFO recording begins | queued GPU work can complete before the interpreter observes the causal CPU path. Confidence: high Scope-risk: narrow Directive: Keep all-function discovery one-shot; reopening at every matching function makes counts and first-divergence reports invalid. Tested: cmake --build build-nogui --target dolphin-emu-nogui -j4; external all_function_boot_zoom_sources contract Not-tested: Long-running GUI capture outside the headless parity path --- .../Core/PowerPC/Interpreter/Interpreter.cpp | 34 +++++++++++++++++-- .../Core/PowerPC/Interpreter/Interpreter.h | 5 +++ Source/Core/DolphinNoGUI/MainNoGUI.cpp | 32 ++++++++++++++--- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index c0b6126bd7..b29c53d9da 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -45,6 +45,8 @@ std::atomic s_parity_event_sequence{0}; std::atomic s_parity_fine_window_active{false}; std::unordered_map s_parity_kind_counts; std::atomic s_parity_all_function_window_active{false}; +std::atomic s_parity_all_function_window_consumed{false}; +std::atomic s_parity_all_function_window_armed{false}; u32 s_parity_pending_call_target = 0; u64 s_parity_all_function_count = 0; @@ -154,7 +156,9 @@ struct ParityAllFunctionConfig { bool enabled = false; bool close_on_first_thread = true; + bool start_on_interpreter = false; u32 start_pc = 0; + u32 start_lr = 0; u64 limit = 100000; }; @@ -167,8 +171,17 @@ const ParityAllFunctionConfig& AllFunctionParityConfig() if (const char* raw_close = std::getenv("DOLPHIN_PARITY_ALL_FUNCTION_CLOSE_ON_FIRST_THREAD")) value.close_on_first_thread = raw_close[0] && raw_close[0] != '0'; + if (const char* raw_start_on_interpreter = + std::getenv("DOLPHIN_PARITY_ALL_FUNCTION_START_ON_INTERPRETER")) + { + value.start_on_interpreter = + raw_start_on_interpreter[0] && raw_start_on_interpreter[0] != '0'; + } if (const char* raw_start = std::getenv("DOLPHIN_PARITY_ALL_FUNCTION_START_PC")) value.start_pc = static_cast(std::strtoul(raw_start, nullptr, 0)); + if (const char* raw_start_lr = + std::getenv("DOLPHIN_PARITY_ALL_FUNCTION_START_LR")) + value.start_lr = static_cast(std::strtoul(raw_start_lr, nullptr, 0)); if (const char* raw_limit = std::getenv("DOLPHIN_PARITY_ALL_FUNCTION_LIMIT")) { const u64 parsed = std::strtoull(raw_limit, nullptr, 0); @@ -312,10 +325,17 @@ void TraceParityAllFunctionEntry(Core::System& system, PowerPC::PowerPCState& st // producer-neutral lockstep boundary used by normal comparisons. const bool starts_at_selected_fifo = config.start_pc == 0 && OpcodeDecoder::g_record_fifo_data; + const bool starts_on_interpreter = + config.start_pc == 0 && config.start_on_interpreter && + s_parity_all_function_window_armed.load(std::memory_order_acquire); if (!s_parity_all_function_window_active.load(std::memory_order_acquire) && - (starts_at_selected_fifo || - (config.start_pc != 0 && state.pc == config.start_pc))) + !s_parity_all_function_window_consumed.load(std::memory_order_acquire) && + (starts_at_selected_fifo || starts_on_interpreter || + (config.start_pc != 0 && state.pc == config.start_pc && + (config.start_lr == 0 || state.spr[SPR_LR] == config.start_lr)))) { + s_parity_all_function_window_consumed.store(true, std::memory_order_release); + s_parity_all_function_window_armed.store(false, std::memory_order_release); s_parity_all_function_window_active.store(true, std::memory_order_release); s_parity_all_function_count = 0; EmitParityEvent(system, state, mmu, "function", "enter", state.pc, @@ -1085,9 +1105,19 @@ Interpreter::Interpreter(Core::System& system, PowerPC::PowerPCState& ppc_state, Interpreter::~Interpreter() = default; +void Interpreter::ArmParityAllFunctionWindow() +{ + s_parity_all_function_window_armed.store(true, std::memory_order_release); +} + void Interpreter::Init() { m_end_block = false; + s_parity_all_function_window_active.store(false, std::memory_order_release); + s_parity_all_function_window_consumed.store(false, std::memory_order_release); + s_parity_all_function_window_armed.store(false, std::memory_order_release); + s_parity_all_function_count = 0; + s_parity_pending_call_target = 0; // Create the sidecar as soon as the selected CPU core starts. An empty file // then means "no configured checkpoint was reached" instead of being // indistinguishable from a missing/disabled interpreter probe. diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.h b/Source/Core/Core/PowerPC/Interpreter/Interpreter.h index b3816e701b..fbc2843ea0 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.h +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.h @@ -41,6 +41,11 @@ class Interpreter : public CPUCoreBase void ClearCache() override; const char* GetName() const override; + // The headless FIFO oracle switches from JIT to the interpreter immediately + // before its selected frame. Arm a single bounded function-discovery window + // at that transition instead of accidentally tracing early boot setup. + static void ArmParityAllFunctionWindow(); + static void unknown_instruction(Interpreter& interpreter, UGeckoInstruction inst); // Branch Instructions diff --git a/Source/Core/DolphinNoGUI/MainNoGUI.cpp b/Source/Core/DolphinNoGUI/MainNoGUI.cpp index e1c9a7e3a1..c4e94fe63b 100644 --- a/Source/Core/DolphinNoGUI/MainNoGUI.cpp +++ b/Source/Core/DolphinNoGUI/MainNoGUI.cpp @@ -28,6 +28,7 @@ #include "Core/FifoPlayer/FifoRecorder.h" #include "Core/Host.h" #include "Core/Movie.h" +#include "Core/PowerPC/Interpreter/Interpreter.h" #include "Core/PowerPC/PowerPC.h" #include "Core/System.h" #include "DiscIO/Enums.h" @@ -461,9 +462,14 @@ int main(const int argc, char* argv[]) std::atomic auto_fifo_stop_delay = 0; std::atomic auto_fifo_started = false; std::atomic auto_fifo_start_queued = false; + std::atomic parity_interpreter_switched = false; if (auto_fifo) { const AutoFifoCapture capture = *auto_fifo; + const u64 parity_interpreter_lead_frames = [] { + const char* raw = std::getenv("DOLPHIN_PARITY_FAST_FORWARD_LEAD_FRAMES"); + return raw ? std::strtoull(raw, nullptr, 0) : 0; + }(); fprintf(stderr, "[auto-fifo] armed path=%s start_presented=%llu frames=%d screenshot=%s stop=%d\n", capture.path.c_str(), static_cast(capture.start_presented_frame), @@ -471,9 +477,8 @@ int main(const int argc, char* argv[]) capture.screenshot_name.empty() ? "" : capture.screenshot_name.c_str(), capture.stop_after_capture ? 1 : 0); - const auto start_auto_fifo = [&auto_fifo_started, - &auto_fifo_stop_delay](const AutoFifoCapture& pending) { - if (auto_fifo_started.exchange(true)) + const auto switch_to_parity_interpreter = [&parity_interpreter_switched] { + if (parity_interpreter_switched.exchange(true)) return; const char* parity_fast_forward = std::getenv("DOLPHIN_PARITY_FAST_FORWARD"); const bool switch_to_interpreter = @@ -483,8 +488,17 @@ int main(const int argc, char* argv[]) Core::System& system = Core::System::GetInstance(); Core::CPUThreadGuard guard(system); system.GetPowerPC().SetMode(PowerPC::CoreMode::Interpreter); + Interpreter::ArmParityAllFunctionWindow(); fprintf(stderr, "[parity-oracle] fast-forward complete; interpreter window active\n"); } + }; + + const auto start_auto_fifo = [&auto_fifo_started, &auto_fifo_stop_delay, + switch_to_parity_interpreter]( + const AutoFifoCapture& pending) { + if (auto_fifo_started.exchange(true)) + return; + switch_to_parity_interpreter(); fprintf(stderr, "[auto-fifo] start skipped_presented=%llu frames=%d mode=immediate\n", static_cast(pending.start_presented_frame), pending.frame_count); @@ -518,7 +532,8 @@ int main(const int argc, char* argv[]) auto_fifo_hook = Core::System::GetInstance().GetVideoEvents().after_frame_event.Register( [capture, start_auto_fifo, &presented_frames, &auto_fifo_stop_delay, - &auto_fifo_start_queued](const Core::System&) { + &auto_fifo_start_queued, parity_interpreter_lead_frames, + switch_to_parity_interpreter](const Core::System&) { const u32 stop_delay = auto_fifo_stop_delay.load(); if (stop_delay != 0 && auto_fifo_stop_delay.fetch_sub(1) == 1) { @@ -526,6 +541,15 @@ int main(const int argc, char* argv[]) return; } const u64 current = presented_frames++; + if (parity_interpreter_lead_frames != 0 && + capture.start_presented_frame > parity_interpreter_lead_frames && + current + 1 == + capture.start_presented_frame - parity_interpreter_lead_frames) + { + Core::QueueHostJob([switch_to_parity_interpreter](Core::System&) { + switch_to_parity_interpreter(); + }); + } if (capture.start_presented_frame == 0 || current + 1 != capture.start_presented_frame) return; From 6ecf5092c99b45208715475152139db409054677 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Tue, 14 Jul 2026 07:32:00 +0200 Subject: [PATCH 23/27] Let repeated IPL calls expose the correct causal state Late state-machine functions can repeat many times with identical PCs but different memory. Add a bounded start-memory predicate, retain address-bounded write tracing outside the instruction window, report writer LR/SP provenance, and read XER from its live architectural accessor so Level-5 comparisons select the intended occurrence without false register differences. Constraint: Late FIFO captures must stay small enough for automated headless runs. Rejected: Select repeated calls by presented-frame number alone | CPU/GPU queue timing can choose another occurrence within the same frame. Confidence: high Scope-risk: narrow Directive: Keep explicit write ranges independent from verbose instruction emission; the address range is the hard bound. Tested: Rebuilt dolphin-emu-nogui; live IPL start-memory capture and bounded write provenance capture. Not-tested: Windows build. --- .../Core/PowerPC/Interpreter/Interpreter.cpp | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index b29c53d9da..903e9750e7 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -97,6 +97,49 @@ struct ParityLockstepConfig u64 emitted = 0; }; +struct ParityStartMemoryPredicate +{ + bool enabled = false; + u32 address = 0; + u32 size = 0; + u32 value = 0; +}; + +const ParityStartMemoryPredicate& LockstepStartMemoryPredicate() +{ + static const ParityStartMemoryPredicate predicate = [] { + ParityStartMemoryPredicate out; + const char* raw = std::getenv("DOLPHIN_PARITY_LOCKSTEP_START_MEMORY"); + if (!raw || !raw[0]) + return out; + char* end = nullptr; + out.address = static_cast(std::strtoul(raw, &end, 0)); + if (!end || *end != ':') + return ParityStartMemoryPredicate{}; + out.size = static_cast(std::strtoul(end + 1, &end, 0)); + if (!end || *end != ':' || (out.size != 1 && out.size != 2 && out.size != 4)) + return ParityStartMemoryPredicate{}; + out.value = static_cast(std::strtoul(end + 1, &end, 0)); + if (!end || *end != '\0') + return ParityStartMemoryPredicate{}; + out.enabled = true; + return out; + }(); + return predicate; +} + +bool LockstepStartMemoryMatches(PowerPC::MMU& mmu) +{ + const ParityStartMemoryPredicate& predicate = LockstepStartMemoryPredicate(); + if (!predicate.enabled) + return true; + if (predicate.size == 1) + return mmu.Read(predicate.address) == predicate.value; + if (predicate.size == 2) + return mmu.Read(predicate.address) == predicate.value; + return mmu.Read(predicate.address) == predicate.value; +} + struct ParityRegisterConfig { struct Target @@ -404,7 +447,7 @@ void EmitParityRegisterCheckpoint(Core::System& system, PowerPC::PowerPCState& s std::fprintf(s_parity_event_file, "],\"cr\":%u,\"lr\":%u,\"ctr\":%u,\"xer\":%u,\"fpscr\":%u," "\"gqr\":[", - state.cr.Get(), state.spr[SPR_LR], state.spr[SPR_CTR], state.spr[SPR_XER], + state.cr.Get(), state.spr[SPR_LR], state.spr[SPR_CTR], state.GetXER().Hex, state.fpscr.Hex); for (u32 index = 0; index < 8; ++index) std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.spr[SPR_GQR0 + index]); @@ -442,7 +485,7 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, return; if (!config.started) { - if (state.pc != config.start_pc) + if (state.pc != config.start_pc || !LockstepStartMemoryMatches(mmu)) return; config.started = true; } @@ -483,7 +526,7 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, std::fprintf(s_parity_event_file, "],\"cr\":%u,\"lr\":%u,\"ctr\":%u,\"xer\":%u,\"fpscr\":%u," "\"gqr\":[", - state.cr.Get(), state.spr[SPR_LR], state.spr[SPR_CTR], state.spr[SPR_XER], + state.cr.Get(), state.spr[SPR_LR], state.spr[SPR_CTR], state.GetXER().Hex, state.fpscr.Hex); for (u32 index = 0; index < 8; ++index) std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.spr[SPR_GQR0 + index]); @@ -510,7 +553,7 @@ void TraceParityFloatingPoint(Core::System& system, PowerPC::PowerPCState& state static u64 observed = 0; if (!started) { - if (state.pc != start_pc) + if (state.pc != start_pc || !LockstepStartMemoryMatches(mmu)) return; started = true; } @@ -1064,15 +1107,20 @@ void DolphinParityTraceMemoryWrite(Core::System& system, u32 pc, u32 addr, out.end = static_cast(std::max(start, finish)); return out; }(); - if (ParityCaptureLevel() < 4 || !range.enabled || - !s_parity_fine_window_active.load(std::memory_order_acquire)) + // An explicit address range is already a hard output bound. Keep this + // independent from the instruction checkpoint window so a headless oracle + // can switch from JIT shortly before a late FIFO frame and retain only the + // causal writes, without first emitting millions of register snapshots. + if (ParityCaptureLevel() < 4 || !range.enabled) return; const u32 guest_addr = addr < 0x10000000u ? addr | 0x80000000u : addr; const u64 last = u64{guest_addr} + (size ? size - 1u : 0u); if (last < range.start || guest_addr > range.end) return; + const PowerPCState& state = system.GetPPCState(); EmitRawParityEvent(system, "memory", "write", guest_addr, - (u64{pc} << 32) | size, value, 0, 0); + (u64{pc} << 32) | size, value, 0, + (u64{state.spr[SPR_LR]} << 32) | state.gpr[1]); } } // namespace PowerPC From ec23b7619abc726b63d43bafa3b75f3cc5845662 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Tue, 14 Jul 2026 08:18:10 +0200 Subject: [PATCH 24/27] Make nested IPL lockstep captures semantically selectable Repeated guest PCs can represent different state-machine occurrences and callers. Add LR and GPR start predicates, and publish logical FPR values with lane validity so Dolphin traces align with the recomp producer without conflating storage formats. Constraint: Raw FPR payloads remain in the trace for backward compatibility. Rejected: Select repeated calls by frame number alone | one frame contains multiple semantically distinct invocations. Confidence: high Scope-risk: narrow Directive: Keep Dolphin and runtime lockstep selector names and FPR schema source-neutral. Tested: dolphin-emu-nogui rebuilt; IPL FIFO capture produced 512 instruction checkpoints with LR selection and logical FPR fields. Not-tested: GPR predicate against every supported CPU core. --- .../Core/PowerPC/Interpreter/Interpreter.cpp | 81 ++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 903e9750e7..eebecac5e6 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -3,13 +3,14 @@ #include "Core/PowerPC/Interpreter/Interpreter.h" -#include #include +#include #include #include #include #include #include +#include #include #include #include @@ -91,7 +92,12 @@ struct ParityLockstepConfig { bool enabled = false; bool started = false; + bool start_lr_set = false; + bool start_gpr_set = false; u32 start_pc = 0; + u32 start_lr = 0; + u32 start_gpr_index = 0; + u32 start_gpr_value = 0; u32 end_pc = 0; u64 limit = 1000; u64 emitted = 0; @@ -444,6 +450,17 @@ void EmitParityRegisterCheckpoint(Core::System& system, PowerPC::PowerPCState& s static_cast(ps.PS0AsU64()), static_cast(ps.PS1AsU64())); } + std::fputs("],\"fpr_logical\":[", s_parity_event_file); + for (size_t index = 0; index < std::size(state.ps); ++index) + { + const auto& ps = state.ps[index]; + std::fprintf(s_parity_event_file, "%s[%llu,%llu]", index ? "," : "", + static_cast(ps.PS0AsU64()), + static_cast(ps.PS1AsU64())); + } + std::fputs("],\"fpr_valid\":[", s_parity_event_file); + for (size_t index = 0; index < std::size(state.ps); ++index) + std::fprintf(s_parity_event_file, "%s[1,1]", index ? "," : ""); std::fprintf(s_parity_event_file, "],\"cr\":%u,\"lr\":%u,\"ctr\":%u,\"xer\":%u,\"fpscr\":%u," "\"gqr\":[", @@ -470,6 +487,26 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, value.enabled = true; if (const char* start = std::getenv("DOLPHIN_PARITY_LOCKSTEP_START_PC")) value.start_pc = static_cast(std::strtoul(start, nullptr, 0)); + if (const char* start_lr = std::getenv("DOLPHIN_PARITY_LOCKSTEP_START_LR")) + { + value.start_lr_set = start_lr[0] != '\0'; + value.start_lr = static_cast(std::strtoul(start_lr, nullptr, 0)); + } + if (const char* start_gpr = std::getenv("DOLPHIN_PARITY_LOCKSTEP_START_GPR")) + { + char* end = nullptr; + const u32 index = static_cast(std::strtoul(start_gpr, &end, 0)); + if (end && *end == ':' && index < 32) + { + const u32 expected = static_cast(std::strtoul(end + 1, &end, 0)); + if (end && *end == '\0') + { + value.start_gpr_set = true; + value.start_gpr_index = index; + value.start_gpr_value = expected; + } + } + } if (const char* end = std::getenv("DOLPHIN_PARITY_LOCKSTEP_END_PC")) value.end_pc = static_cast(std::strtoul(end, nullptr, 0)); if (const char* limit = std::getenv("DOLPHIN_PARITY_LOCKSTEP_LIMIT")) @@ -485,7 +522,11 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, return; if (!config.started) { - if (state.pc != config.start_pc || !LockstepStartMemoryMatches(mmu)) + if (state.pc != config.start_pc || + (config.start_lr_set && state.spr[SPR_LR] != config.start_lr) || + (config.start_gpr_set && + state.gpr[config.start_gpr_index] != config.start_gpr_value) || + !LockstepStartMemoryMatches(mmu)) return; config.started = true; } @@ -523,6 +564,17 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, static_cast(ps.PS0AsU64()), static_cast(ps.PS1AsU64())); } + std::fputs("],\"fpr_logical\":[", s_parity_event_file); + for (size_t index = 0; index < std::size(state.ps); ++index) + { + const auto& ps = state.ps[index]; + std::fprintf(s_parity_event_file, "%s[%llu,%llu]", index ? "," : "", + static_cast(ps.PS0AsU64()), + static_cast(ps.PS1AsU64())); + } + std::fputs("],\"fpr_valid\":[", s_parity_event_file); + for (size_t index = 0; index < std::size(state.ps); ++index) + std::fprintf(s_parity_event_file, "%s[1,1]", index ? "," : ""); std::fprintf(s_parity_event_file, "],\"cr\":%u,\"lr\":%u,\"ctr\":%u,\"xer\":%u,\"fpscr\":%u," "\"gqr\":[", @@ -544,6 +596,26 @@ void TraceParityFloatingPoint(Core::System& system, PowerPC::PowerPCState& state const char* raw = std::getenv("DOLPHIN_PARITY_LOCKSTEP_START_PC"); return raw ? static_cast(std::strtoul(raw, nullptr, 0)) : 0u; }(); + static const std::optional start_lr = [] -> std::optional { + const char* raw = std::getenv("DOLPHIN_PARITY_LOCKSTEP_START_LR"); + if (!raw || !raw[0]) + return std::nullopt; + return static_cast(std::strtoul(raw, nullptr, 0)); + }(); + static const std::optional> start_gpr = + [] -> std::optional> { + const char* raw = std::getenv("DOLPHIN_PARITY_LOCKSTEP_START_GPR"); + if (!raw || !raw[0]) + return std::nullopt; + char* end = nullptr; + const u32 index = static_cast(std::strtoul(raw, &end, 0)); + if (!end || *end != ':' || index >= 32) + return std::nullopt; + const u32 value = static_cast(std::strtoul(end + 1, &end, 0)); + if (!end || *end != '\0') + return std::nullopt; + return std::array{index, value}; + }(); static const u64 limit = [] { const char* raw = std::getenv("DOLPHIN_PARITY_LOCKSTEP_LIMIT"); const u64 parsed = raw ? std::strtoull(raw, nullptr, 0) : 0; @@ -553,7 +625,10 @@ void TraceParityFloatingPoint(Core::System& system, PowerPC::PowerPCState& state static u64 observed = 0; if (!started) { - if (state.pc != start_pc || !LockstepStartMemoryMatches(mmu)) + if (state.pc != start_pc || + (start_lr && state.spr[SPR_LR] != *start_lr) || + (start_gpr && state.gpr[(*start_gpr)[0]] != (*start_gpr)[1]) || + !LockstepStartMemoryMatches(mmu)) return; started = true; } From de8c918599198606d032fe73f9911baf21f69573 Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Tue, 14 Jul 2026 09:34:43 +0200 Subject: [PATCH 25/27] Anchor IPL parity evidence to emulated video time Host-presented frame numbers selected different firmware state-machine invocations and delayed host jobs could start a CPU capture before the interpreter switch completed. Count VI boundaries from the real IPL entry, expose them on every parity event, and make automated FIFO capture start on the exact requested guest retrace. No-disc screenshots now wait for asynchronous encoding before shutdown. Constraint: Direct IPL boot has game ID 00000000 and raw DFF headers contain non-semantic host metadata. Rejected: Presented-frame anchoring | host scheduling moved the selected guest state between runs. Rejected: Synchronous SetMode from the VI callback | the active JIT run loop may continue after swapping the next CPU core. Confidence: high Scope-risk: moderate Directive: No-disc IPL authority captures must use --boot-gc-ipl with --fifo-record-start-retrace; never compare a disc-inserted SkipIPL=False run as the same boot route. Tested: dolphin-emu-nogui build; exact retrace-0 and retrace-221 captures; repeated no-disc retrace-221 normalized frame/draw equality; 180 repeated Level-5 instruction states; screenshot completion. Not-tested: Windows build. --- Source/Core/Core/Boot/Boot.cpp | 1 + Source/Core/Core/HW/VideoInterface.cpp | 15 ++ Source/Core/Core/HW/VideoInterface.h | 4 + .../Core/PowerPC/Interpreter/Interpreter.cpp | 16 +- Source/Core/DolphinNoGUI/MainNoGUI.cpp | 155 +++++++++++++++--- Source/Core/VideoCommon/FrameDumper.cpp | 7 + Source/Core/VideoCommon/FrameDumper.h | 1 + 7 files changed, 168 insertions(+), 31 deletions(-) diff --git a/Source/Core/Core/Boot/Boot.cpp b/Source/Core/Core/Boot/Boot.cpp index 37be007cb3..9287280e87 100644 --- a/Source/Core/Core/Boot/Boot.cpp +++ b/Source/Core/Core/Boot/Boot.cpp @@ -472,6 +472,7 @@ bool CBoot::Load_BS2(Core::System& system, const std::string& boot_rom_filename) ppc_state.pc = 0x81200150; system.GetPowerPC().MSRUpdated(); + system.GetVideoInterface().ResetParityRetraceOrigin(); PowerPC::DolphinParityTraceBootContext(system, "ipl_start", ppc_state.pc); return true; diff --git a/Source/Core/Core/HW/VideoInterface.cpp b/Source/Core/Core/HW/VideoInterface.cpp index 37eed792d7..d52df299ba 100644 --- a/Source/Core/Core/HW/VideoInterface.cpp +++ b/Source/Core/Core/HW/VideoInterface.cpp @@ -85,6 +85,8 @@ void VideoInterfaceManager::DoState(PointerWrap& p) p.Do(m_target_refresh_rate_numerator); p.Do(m_target_refresh_rate_denominator); p.Do(m_ticks_last_line_start); + p.Do(m_parity_retrace_count); + p.Do(m_parity_retrace_origin); p.Do(m_half_line_count); p.Do(m_half_line_of_next_si_poll); p.Do(m_even_field_first_hl); @@ -778,6 +780,16 @@ u32 VideoInterfaceManager::GetTicksPerField() const return GetTicksPerEvenField(); } +u64 VideoInterfaceManager::GetParityRetraceCount() const +{ + return m_parity_retrace_count - m_parity_retrace_origin; +} + +void VideoInterfaceManager::ResetParityRetraceOrigin() +{ + m_parity_retrace_origin = m_parity_retrace_count; +} + ParityTimingSnapshot VideoInterfaceManager::GetParityTimingSnapshot(u64 current_ticks) const { ParityTimingSnapshot snapshot; @@ -963,7 +975,10 @@ void VideoInterfaceManager::Update(u64 ticks) // in case frame counter display is enabled if (is_at_field_boundary) + { + ++m_parity_retrace_count; m_system.GetMovie().FrameUpdate(); + } // If this half-line is at some boundary of the "active video lines" in either field, we either // need to (a) send a request to the GPU thread to actually render the XFB, or (b) increment diff --git a/Source/Core/Core/HW/VideoInterface.h b/Source/Core/Core/HW/VideoInterface.h index e361ed4d90..50dbd8e911 100644 --- a/Source/Core/Core/HW/VideoInterface.h +++ b/Source/Core/Core/HW/VideoInterface.h @@ -402,6 +402,8 @@ class VideoInterfaceManager u32 GetTicksPerHalfLine() const; u32 GetTicksPerField() const; ParityTimingSnapshot GetParityTimingSnapshot(u64 current_ticks) const; + u64 GetParityRetraceCount() const; + void ResetParityRetraceOrigin(); // Not adjusted by VBI Clock Override. u32 GetNominalTicksPerHalfLine() const; @@ -459,6 +461,8 @@ class VideoInterfaceManager u32 m_target_refresh_rate_denominator = 1; u64 m_ticks_last_line_start = 0; // number of ticks when the current full scanline started + u64 m_parity_retrace_count = 0; + u64 m_parity_retrace_origin = 0; u32 m_half_line_count = 0; // number of halflines that have occurred for this full frame u32 m_half_line_of_next_si_poll = 0; // halfline when next SI poll results should be available diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index eebecac5e6..1b74938379 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -308,14 +308,16 @@ void EmitParityEvent(Core::System& system, PowerPC::PowerPCState& state, PowerPC const u64 sequence = s_parity_event_sequence.fetch_add(1); const u32 guest_thread = mmu.Read(0x800000E4u); const u64 draw = OpcodeDecoder::GetParityEventDrawAnchor(); + const u64 retrace = system.GetVideoInterface().GetParityRetraceCount(); std::fprintf(s_parity_event_file, "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"%s\"," "\"action\":\"%s\",\"subject\":%u,\"anchor\":{" - "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":%llu}," + "\"timebase\":%llu,\"retrace\":%llu,\"copy_epoch\":null,\"draw\":%llu}," "\"data\":{\"a\":%llu,\"b\":%llu,\"c\":%llu,\"d\":%llu," "\"thread\":%u}}\n", static_cast(sequence), family, action, subject, static_cast(timebase), + static_cast(retrace), static_cast(draw), static_cast(a), static_cast(b), static_cast(c), static_cast(d), @@ -431,14 +433,16 @@ void EmitParityRegisterCheckpoint(Core::System& system, PowerPC::PowerPCState& s const u32 function_id = ParityFunctionId(function_name); const u32 guest_thread = mmu.Read(0x800000E4u); const u64 draw = OpcodeDecoder::GetParityEventDrawAnchor(); + const u64 retrace = system.GetVideoInterface().GetParityRetraceCount(); std::lock_guard lk(s_parity_event_mutex); std::fprintf(s_parity_event_file, "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"register\"," "\"action\":\"checkpoint\",\"subject\":%u,\"anchor\":{" - "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":%llu}," + "\"timebase\":%llu,\"retrace\":%llu,\"copy_epoch\":null,\"draw\":%llu}," "\"data\":{\"thread\":%u,\"gpr\":[", static_cast(sequence), function_id, static_cast(timebase), + static_cast(retrace), static_cast(draw), guest_thread); for (size_t index = 0; index < std::size(state.gpr); ++index) std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.gpr[index]); @@ -545,14 +549,16 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, const u64 timebase = system.GetSystemTimers().GetFakeTimeBase(); const u32 opcode = mmu.Read(state.pc); const u64 draw = OpcodeDecoder::GetParityEventDrawAnchor(); + const u64 retrace = system.GetVideoInterface().GetParityRetraceCount(); std::lock_guard lk(s_parity_event_mutex); std::fprintf(s_parity_event_file, "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"instruction\"," "\"action\":\"checkpoint\",\"subject\":%u,\"anchor\":{" - "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":%llu}," + "\"timebase\":%llu,\"retrace\":%llu,\"copy_epoch\":null,\"draw\":%llu}," "\"data\":{\"opcode\":%u,\"gpr\":[", static_cast(sequence), state.pc, static_cast(timebase), + static_cast(retrace), static_cast(draw), opcode); for (size_t index = 0; index < std::size(state.gpr); ++index) std::fprintf(s_parity_event_file, "%s%u", index ? "," : "", state.gpr[index]); @@ -1101,14 +1107,16 @@ void EmitRawParityEvent(Core::System& system, const char* family, const char* ac const u64 sequence = s_parity_event_sequence.fetch_add(1); const u64 timebase = system.GetSystemTimers().GetFakeTimeBase(); const u64 draw = OpcodeDecoder::GetParityEventDrawAnchor(); + const u64 retrace = system.GetVideoInterface().GetParityRetraceCount(); std::lock_guard lk(s_parity_event_mutex); std::fprintf(s_parity_event_file, "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"%s\"," "\"action\":\"%s\",\"subject\":%u,\"anchor\":{" - "\"timebase\":%llu,\"retrace\":null,\"copy_epoch\":null,\"draw\":%llu}," + "\"timebase\":%llu,\"retrace\":%llu,\"copy_epoch\":null,\"draw\":%llu}," "\"data\":{\"a\":%llu,\"b\":%llu,\"c\":%llu,\"d\":%llu}}\n", static_cast(sequence), family, action, subject, static_cast(timebase), + static_cast(retrace), static_cast(draw), static_cast(a), static_cast(b), static_cast(c), static_cast(d)); diff --git a/Source/Core/DolphinNoGUI/MainNoGUI.cpp b/Source/Core/DolphinNoGUI/MainNoGUI.cpp index c4e94fe63b..894956d6c8 100644 --- a/Source/Core/DolphinNoGUI/MainNoGUI.cpp +++ b/Source/Core/DolphinNoGUI/MainNoGUI.cpp @@ -27,6 +27,7 @@ #include "Core/DolphinAnalytics.h" #include "Core/FifoPlayer/FifoRecorder.h" #include "Core/Host.h" +#include "Core/HW/VideoInterface.h" #include "Core/Movie.h" #include "Core/PowerPC/Interpreter/Interpreter.h" #include "Core/PowerPC/PowerPC.h" @@ -38,6 +39,7 @@ #include "UICommon/DiscordPresence.h" #endif #include "UICommon/UICommon.h" +#include "VideoCommon/FrameDumper.h" #include "VideoCommon/VideoEvents.h" static std::unique_ptr s_platform; @@ -46,6 +48,8 @@ struct AutoFifoCapture { std::string path; u64 start_presented_frame = 0; + u64 start_guest_retrace = 0; + bool start_guest_retrace_set = false; s32 frame_count = 0; std::string screenshot_name; bool stop_after_capture = false; @@ -266,6 +270,9 @@ int main(const int argc, char* argv[]) parser->add_option("--fifo-record-start") .action("store") .help("Presented frames to skip before automated FIFO recording (default 0)"); + parser->add_option("--fifo-record-start-retrace") + .action("store") + .help("Guest VI retrace at which automated FIFO recording starts"); parser->add_option("--fifo-record-frames") .action("store") .help("Number of FIFO frames to record"); @@ -287,6 +294,7 @@ int main(const int argc, char* argv[]) std::optional auto_fifo; const bool any_fifo_option = options.is_set("fifo_record_path") || options.is_set("fifo_record_start") || + options.is_set("fifo_record_start_retrace") || options.is_set("fifo_record_frames") || options.is_set("fifo_record_stop") || options.is_set("fifo_record_screenshot_name"); if (any_fifo_option) @@ -299,8 +307,15 @@ int main(const int argc, char* argv[]) AutoFifoCapture capture; capture.path = static_cast(options.get("fifo_record_path")); u32 frame_count = 0; + if (options.is_set("fifo_record_start") && options.is_set("fifo_record_start_retrace")) + { + fprintf(stderr, "--fifo-record-start and --fifo-record-start-retrace are exclusive\n"); + return 1; + } + capture.start_guest_retrace_set = options.is_set("fifo_record_start_retrace"); if (capture.path.empty() || !ParseUnsignedOption(options, "fifo_record_start", &capture.start_presented_frame) || + !ParseUnsignedOption(options, "fifo_record_start_retrace", &capture.start_guest_retrace) || !ParseUnsignedOption(options, "fifo_record_frames", &frame_count) || frame_count == 0 || frame_count > static_cast(std::numeric_limits::max())) { @@ -458,11 +473,13 @@ int main(const int argc, char* argv[]) } Common::EventHook auto_fifo_hook; + Common::EventHook auto_fifo_retrace_hook; u64 presented_frames = 0; std::atomic auto_fifo_stop_delay = 0; std::atomic auto_fifo_started = false; std::atomic auto_fifo_start_queued = false; - std::atomic parity_interpreter_switched = false; + std::atomic parity_interpreter_switch_requested = false; + std::atomic parity_interpreter_switch_completed = false; if (auto_fifo) { const AutoFifoCapture capture = *auto_fifo; @@ -471,37 +488,52 @@ int main(const int argc, char* argv[]) return raw ? std::strtoull(raw, nullptr, 0) : 0; }(); fprintf(stderr, - "[auto-fifo] armed path=%s start_presented=%llu frames=%d screenshot=%s stop=%d\n", + "[auto-fifo] armed path=%s start_presented=%llu start_retrace_set=%d " + "start_retrace=%llu " + "frames=%d screenshot=%s stop=%d\n", capture.path.c_str(), static_cast(capture.start_presented_frame), - capture.frame_count, + capture.start_guest_retrace_set ? 1 : 0, + static_cast(capture.start_guest_retrace), capture.frame_count, capture.screenshot_name.empty() ? "" : capture.screenshot_name.c_str(), capture.stop_after_capture ? 1 : 0); - const auto switch_to_parity_interpreter = [&parity_interpreter_switched] { - if (parity_interpreter_switched.exchange(true)) + const char* parity_fast_forward = std::getenv("DOLPHIN_PARITY_FAST_FORWARD"); + const bool parity_fast_forward_enabled = + parity_fast_forward && parity_fast_forward[0] && parity_fast_forward[0] != '0'; + if (!parity_fast_forward_enabled) + parity_interpreter_switch_completed.store(true); + + const auto switch_to_parity_interpreter = + [&parity_interpreter_switch_requested, &parity_interpreter_switch_completed, + parity_fast_forward_enabled] { + if (!parity_fast_forward_enabled || parity_interpreter_switch_requested.exchange(true)) return; - const char* parity_fast_forward = std::getenv("DOLPHIN_PARITY_FAST_FORWARD"); - const bool switch_to_interpreter = - parity_fast_forward && parity_fast_forward[0] && parity_fast_forward[0] != '0'; - if (switch_to_interpreter) - { - Core::System& system = Core::System::GetInstance(); - Core::CPUThreadGuard guard(system); - system.GetPowerPC().SetMode(PowerPC::CoreMode::Interpreter); - Interpreter::ArmParityAllFunctionWindow(); - fprintf(stderr, "[parity-oracle] fast-forward complete; interpreter window active\n"); - } + Core::System& system = Core::System::GetInstance(); + Core::CPUThreadGuard guard(system); + system.GetPowerPC().SetMode(PowerPC::CoreMode::Interpreter); + Interpreter::ArmParityAllFunctionWindow(); + parity_interpreter_switch_completed.store(true); + fprintf(stderr, "[parity-oracle] fast-forward complete; interpreter window active\n"); }; const auto start_auto_fifo = [&auto_fifo_started, &auto_fifo_stop_delay, switch_to_parity_interpreter]( - const AutoFifoCapture& pending) { + const AutoFifoCapture& pending, + bool ensure_interpreter) { if (auto_fifo_started.exchange(true)) return; - switch_to_parity_interpreter(); - fprintf(stderr, "[auto-fifo] start skipped_presented=%llu frames=%d mode=immediate\n", + if (ensure_interpreter) + switch_to_parity_interpreter(); + const u64 actual_retrace = Core::System::GetInstance() + .GetVideoInterface() + .GetParityRetraceCount(); + fprintf(stderr, + "[auto-fifo] start skipped_presented=%llu requested_retrace=%s%llu " + "actual_retrace=%llu frames=%d mode=immediate\n", static_cast(pending.start_presented_frame), - pending.frame_count); + pending.start_guest_retrace_set ? "" : "off:", + static_cast(pending.start_guest_retrace), + static_cast(actual_retrace), pending.frame_count); Core::System::GetInstance().GetFifoRecorder().StartRecording( pending.frame_count, [pending, &auto_fifo_stop_delay] { @@ -518,28 +550,97 @@ int main(const int argc, char* argv[]) } if (pending.stop_after_capture) { - // Give FrameDumper at least one complete present after the - // request before stopping the renderer. - auto_fifo_stop_delay.store(pending.screenshot_name.empty() ? 1u : 2u); + // Screenshot encoding is asynchronous. The after-frame hook + // waits for FrameDumper's completion signal before stopping. + auto_fifo_stop_delay.store(1u); } }); }, true); }; - if (capture.start_presented_frame == 0) - start_auto_fifo(capture); + if (!capture.start_guest_retrace_set && capture.start_presented_frame == 0) + start_auto_fifo(capture, true); + + if (capture.start_guest_retrace_set) + { + const u64 armed_retrace = Core::System::GetInstance() + .GetVideoInterface() + .GetParityRetraceCount(); + if (armed_retrace == capture.start_guest_retrace) + { + auto_fifo_start_queued.store(true); + start_auto_fifo(capture, true); + } + else if (armed_retrace > capture.start_guest_retrace) + { + auto_fifo_start_queued.store(true); + fprintf(stderr, + "[auto-fifo] requested guest retrace already passed requested=%llu actual=%llu\n", + static_cast(capture.start_guest_retrace), + static_cast(armed_retrace)); + Core::QueueHostJob([](Core::System& system) { Core::Stop(system); }); + } + auto_fifo_retrace_hook = + Core::System::GetInstance().GetVideoEvents().vi_end_field_event.Register( + [capture, start_auto_fifo, &auto_fifo_start_queued, + &parity_interpreter_switch_completed, parity_fast_forward_enabled, + parity_interpreter_lead_frames, switch_to_parity_interpreter] { + const u64 current = Core::System::GetInstance() + .GetVideoInterface() + .GetParityRetraceCount(); + if (parity_interpreter_lead_frames != 0 && + capture.start_guest_retrace > parity_interpreter_lead_frames && + current == capture.start_guest_retrace - parity_interpreter_lead_frames) + { + Core::QueueHostJob([switch_to_parity_interpreter](Core::System&) { + switch_to_parity_interpreter(); + }); + } + if (current < capture.start_guest_retrace) + return; + if (auto_fifo_start_queued.exchange(true)) + return; + if (current != capture.start_guest_retrace) + { + fprintf(stderr, + "[auto-fifo] missed requested guest retrace=%llu actual=%llu\n", + static_cast(capture.start_guest_retrace), + static_cast(current)); + Core::QueueHostJob([](Core::System& system) { Core::Stop(system); }); + return; + } + if (parity_fast_forward_enabled && + !parity_interpreter_switch_completed.load()) + { + fprintf(stderr, + "[auto-fifo] interpreter switch incomplete at guest retrace=%llu\n", + static_cast(current)); + Core::QueueHostJob([](Core::System& system) { Core::Stop(system); }); + return; + } + // This callback runs on the emulated VI boundary, outside the + // after-frame event that FifoRecorder registers against. Arm + // the next GX command here so host presentation scheduling + // cannot move the selected guest frame between runs. + start_auto_fifo(capture, false); + }); + } auto_fifo_hook = Core::System::GetInstance().GetVideoEvents().after_frame_event.Register( [capture, start_auto_fifo, &presented_frames, &auto_fifo_stop_delay, &auto_fifo_start_queued, parity_interpreter_lead_frames, switch_to_parity_interpreter](const Core::System&) { const u32 stop_delay = auto_fifo_stop_delay.load(); - if (stop_delay != 0 && auto_fifo_stop_delay.fetch_sub(1) == 1) + if (stop_delay != 0 && + (capture.screenshot_name.empty() || g_frame_dumper->PollScreenshotCompleted()) && + auto_fifo_stop_delay.fetch_sub(1) == 1) { Core::QueueHostJob([](Core::System& queued_system) { Core::Stop(queued_system); }); return; } + if (capture.start_guest_retrace_set) + return; const u64 current = presented_frames++; if (parity_interpreter_lead_frames != 0 && capture.start_presented_frame > parity_interpreter_lead_frames && @@ -560,7 +661,7 @@ int main(const int argc, char* argv[]) if (!auto_fifo_start_queued.exchange(true)) { Core::QueueHostJob([capture, start_auto_fifo](Core::System&) { - start_auto_fifo(capture); + start_auto_fifo(capture, true); }); } }); diff --git a/Source/Core/VideoCommon/FrameDumper.cpp b/Source/Core/VideoCommon/FrameDumper.cpp index cbe8315b32..d62a4c5653 100644 --- a/Source/Core/VideoCommon/FrameDumper.cpp +++ b/Source/Core/VideoCommon/FrameDumper.cpp @@ -3,6 +3,7 @@ #include "VideoCommon/FrameDumper.h" +#include #include #include #include @@ -417,10 +418,16 @@ void FrameDumper::DumpFrameToImage(const FrameData& frame) void FrameDumper::SaveScreenshot(std::string filename) { std::lock_guard lk(m_screenshot_lock); + m_screenshot_completed.Reset(); m_screenshot_name = std::move(filename); m_screenshot_request.Set(); } +bool FrameDumper::PollScreenshotCompleted() +{ + return m_screenshot_completed.WaitFor(std::chrono::milliseconds(0)); +} + bool FrameDumper::IsFrameDumping() const { if (IsParityPixelCaptureActive()) diff --git a/Source/Core/VideoCommon/FrameDumper.h b/Source/Core/VideoCommon/FrameDumper.h index abf3bac38a..a21db64bcc 100644 --- a/Source/Core/VideoCommon/FrameDumper.h +++ b/Source/Core/VideoCommon/FrameDumper.h @@ -31,6 +31,7 @@ class FrameDumper const MathUtil::Rectangle& target_rect, u64 ticks, int frame_number); void SaveScreenshot(std::string filename); + bool PollScreenshotCompleted(); bool IsFrameDumping() const; int GetRequiredResolutionLeastCommonMultiple() const; From 4c7dde40b6d11afa179a79b34e958eb11e2f4e2f Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Tue, 14 Jul 2026 10:00:49 +0200 Subject: [PATCH 26/27] Find inherited parity faults before their visible trigger A bounded interpreter-side history ring retains compact PC, opcode, FPSCR and guest anchors while waiting for a Level-5 lockstep predicate. Once the predicate matches, chronological prehistory events expose the earlier state producer without emitting an unbounded boot trace. Constraint: Full instruction checkpoints across an IPL boot are prohibitively large. Rejected: Emit every pre-trigger register checkpoint | produces millions of rows and obscures the selected window. Confidence: high Scope-risk: narrow Directive: Keep prehistory bounded and action-tagged separately from comparable checkpoint rows. Tested: dolphin-emu-nogui build; live USA no-disc IPL retrace-221 capture emitted 65536 prehistory and 180 checkpoint rows. Not-tested: Other IPL regions and Wii boot paths. --- .../Core/PowerPC/Interpreter/Interpreter.cpp | 83 +++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index 1b74938379..fcb4d333da 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -90,6 +90,16 @@ bool IsCompactParitySystemFamily(const char* family) struct ParityLockstepConfig { + struct HistoryEntry + { + u32 pc = 0; + u32 opcode = 0; + u32 fpscr = 0; + u64 timebase = 0; + u64 retrace = 0; + u64 draw = 0; + }; + bool enabled = false; bool started = false; bool start_lr_set = false; @@ -101,6 +111,10 @@ struct ParityLockstepConfig u32 end_pc = 0; u64 limit = 1000; u64 emitted = 0; + u64 prehistory_limit = 0; + size_t prehistory_next = 0; + bool prehistory_full = false; + std::vector prehistory; }; struct ParityStartMemoryPredicate @@ -519,6 +533,12 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, if (parsed != 0) value.limit = parsed; } + if (const char* prehistory = std::getenv("DOLPHIN_PARITY_LOCKSTEP_PREHISTORY")) + { + const u64 parsed = std::strtoull(prehistory, nullptr, 0); + value.prehistory_limit = std::min(parsed, 65536); + value.prehistory.reserve(static_cast(value.prehistory_limit)); + } value.started = value.start_pc == 0; return value; }(); @@ -526,13 +546,66 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, return; if (!config.started) { - if (state.pc != config.start_pc || - (config.start_lr_set && state.spr[SPR_LR] != config.start_lr) || - (config.start_gpr_set && - state.gpr[config.start_gpr_index] != config.start_gpr_value) || - !LockstepStartMemoryMatches(mmu)) + const bool start_matches = + state.pc == config.start_pc && + (!config.start_lr_set || state.spr[SPR_LR] == config.start_lr) && + (!config.start_gpr_set || + state.gpr[config.start_gpr_index] == config.start_gpr_value) && + LockstepStartMemoryMatches(mmu); + if (!start_matches) + { + if (config.prehistory_limit != 0) + { + ParityLockstepConfig::HistoryEntry entry; + entry.pc = state.pc; + entry.opcode = mmu.Read(state.pc); + entry.fpscr = state.fpscr.Hex; + entry.timebase = system.GetSystemTimers().GetFakeTimeBase(); + entry.retrace = system.GetVideoInterface().GetParityRetraceCount(); + entry.draw = OpcodeDecoder::GetParityEventDrawAnchor(); + if (config.prehistory.size() < config.prehistory_limit) + { + config.prehistory.push_back(entry); + } + else + { + config.prehistory[config.prehistory_next] = entry; + config.prehistory_next = + (config.prehistory_next + 1) % config.prehistory.size(); + config.prehistory_full = true; + } + } return; + } config.started = true; + + std::call_once(s_parity_event_init, InitParityEventFile); + if (s_parity_event_file && !config.prehistory.empty()) + { + const size_t count = config.prehistory.size(); + const size_t oldest = config.prehistory_full ? config.prehistory_next : 0; + std::lock_guard lk(s_parity_event_mutex); + for (size_t index = 0; index < count; ++index) + { + const auto& entry = config.prehistory[(oldest + index) % count]; + const u64 sequence = s_parity_event_sequence.fetch_add(1); + std::fprintf( + s_parity_event_file, + "{\"record\":\"event\",\"sequence\":%llu,\"family\":\"instruction\"," + "\"action\":\"prehistory\",\"subject\":%u,\"anchor\":{" + "\"timebase\":%llu,\"retrace\":%llu,\"copy_epoch\":null," + "\"draw\":%llu},\"data\":{\"opcode\":%u,\"fpscr\":%u," + "\"distance_to_trigger\":%llu}}\n", + static_cast(sequence), entry.pc, + static_cast(entry.timebase), + static_cast(entry.retrace), + static_cast(entry.draw), entry.opcode, entry.fpscr, + static_cast(count - index)); + } + std::fflush(s_parity_event_file); + } + config.prehistory.clear(); + config.prehistory.shrink_to_fit(); } // A static-recompiler Level-5 slice instruments only the selected guest-PC // range. Apply the same filter in Dolphin so calls into nested functions From eb1031a6af0506d16f1143e870ebf8b25c41ec0c Mon Sep 17 00:00:00 2001 From: ai-tdd-labs Date: Tue, 14 Jul 2026 10:14:40 +0200 Subject: [PATCH 27/27] Select repeated lockstep functions by guest video time A minimum-retrace predicate now participates in the Dolphin instruction trigger, allowing repeated IPL math helpers to be aligned to the same guest invocation as the runtime rather than merely the same program counter. Constraint: IPL render helpers execute many times with identical PC and caller state. Rejected: Compare the first matching invocation | selected retrace 94 in Dolphin and retrace 18 in the runtime. Confidence: high Scope-risk: narrow Directive: Apply retrace bounds before opening full checkpoints or prehistory output. Tested: dolphin-emu-nogui build; live USA no-disc capture selected sub_81367824 at retrace 220. Not-tested: Wii boot paths. --- .../Core/Core/PowerPC/Interpreter/Interpreter.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp index fcb4d333da..93cca7f672 100644 --- a/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp +++ b/Source/Core/Core/PowerPC/Interpreter/Interpreter.cpp @@ -104,10 +104,12 @@ struct ParityLockstepConfig bool started = false; bool start_lr_set = false; bool start_gpr_set = false; + bool start_min_retrace_set = false; u32 start_pc = 0; u32 start_lr = 0; u32 start_gpr_index = 0; u32 start_gpr_value = 0; + u64 start_min_retrace = 0; u32 end_pc = 0; u64 limit = 1000; u64 emitted = 0; @@ -525,6 +527,12 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, } } } + if (const char* min_retrace = + std::getenv("DOLPHIN_PARITY_LOCKSTEP_MIN_RETRACE")) + { + value.start_min_retrace_set = min_retrace[0] != '\0'; + value.start_min_retrace = std::strtoull(min_retrace, nullptr, 0); + } if (const char* end = std::getenv("DOLPHIN_PARITY_LOCKSTEP_END_PC")) value.end_pc = static_cast(std::strtoul(end, nullptr, 0)); if (const char* limit = std::getenv("DOLPHIN_PARITY_LOCKSTEP_LIMIT")) @@ -546,11 +554,14 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, return; if (!config.started) { + const u64 current_retrace = system.GetVideoInterface().GetParityRetraceCount(); const bool start_matches = state.pc == config.start_pc && (!config.start_lr_set || state.spr[SPR_LR] == config.start_lr) && (!config.start_gpr_set || state.gpr[config.start_gpr_index] == config.start_gpr_value) && + (!config.start_min_retrace_set || + current_retrace >= config.start_min_retrace) && LockstepStartMemoryMatches(mmu); if (!start_matches) { @@ -561,7 +572,7 @@ void TraceParityInstruction(Core::System& system, PowerPC::PowerPCState& state, entry.opcode = mmu.Read(state.pc); entry.fpscr = state.fpscr.Hex; entry.timebase = system.GetSystemTimers().GetFakeTimeBase(); - entry.retrace = system.GetVideoInterface().GetParityRetraceCount(); + entry.retrace = current_retrace; entry.draw = OpcodeDecoder::GetParityEventDrawAnchor(); if (config.prehistory.size() < config.prehistory_limit) {