From 4ab22921c4fe3a9d8e11611a783cbc27ef6445da Mon Sep 17 00:00:00 2001 From: Stenzek Date: Sun, 20 Oct 2024 22:02:24 +1000 Subject: [PATCH] GPUDump: Add GPU dump recording and playback Implements the specification from: https://github.com/ps1dev/standards/blob/main/GPUDUMP.md --- src/core/CMakeLists.txt | 2 + src/core/core.vcxproj | 2 + src/core/core.vcxproj.filters | 2 + src/core/cpu_core.cpp | 5 + src/core/cpu_core_private.h | 6 + src/core/dma.cpp | 24 + src/core/game_database.cpp | 2 +- src/core/game_list.cpp | 6 +- src/core/gpu.cpp | 322 +++++++++-- src/core/gpu.h | 77 +-- src/core/gpu_commands.cpp | 6 + src/core/gpu_dump.cpp | 534 ++++++++++++++++++ src/core/gpu_dump.h | 143 +++++ src/core/gpu_types.h | 84 +++ src/core/hotkeys.cpp | 14 + src/core/settings.cpp | 38 ++ src/core/settings.h | 6 + src/core/system.cpp | 244 ++++++-- src/core/system.h | 27 +- src/core/timers.cpp | 3 +- src/core/timing_event.cpp | 5 + src/core/timing_event.h | 3 + src/core/types.h | 10 + src/duckstation-qt/graphicssettingswidget.cpp | 6 + src/duckstation-qt/graphicssettingswidget.ui | 26 + src/duckstation-qt/mainwindow.cpp | 20 +- src/duckstation-qt/mainwindow.ui | 6 + src/duckstation-qt/qthost.cpp | 12 + src/duckstation-qt/qthost.h | 1 + src/duckstation-qt/settingswindow.cpp | 2 +- 30 files changed, 1450 insertions(+), 188 deletions(-) create mode 100644 src/core/gpu_dump.cpp create mode 100644 src/core/gpu_dump.h diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 78baf0d26..53799dc22 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -47,6 +47,8 @@ add_library(core gpu_backend.cpp gpu_backend.h gpu_commands.cpp + gpu_dump.cpp + gpu_dump.h gpu_hw.cpp gpu_hw.h gpu_hw_shadergen.cpp diff --git a/src/core/core.vcxproj b/src/core/core.vcxproj index 7fbb29d2a..11f87d0ea 100644 --- a/src/core/core.vcxproj +++ b/src/core/core.vcxproj @@ -46,6 +46,7 @@ + @@ -124,6 +125,7 @@ + diff --git a/src/core/core.vcxproj.filters b/src/core/core.vcxproj.filters index 8978026ea..53e35e9dd 100644 --- a/src/core/core.vcxproj.filters +++ b/src/core/core.vcxproj.filters @@ -68,6 +68,7 @@ + @@ -142,6 +143,7 @@ + diff --git a/src/core/cpu_core.cpp b/src/core/cpu_core.cpp index 0185ce381..277d8677c 100644 --- a/src/core/cpu_core.cpp +++ b/src/core/cpu_core.cpp @@ -2492,6 +2492,11 @@ void CPU::ExecuteInterpreter() } } +fastjmp_buf* CPU::GetExecutionJmpBuf() +{ + return &s_jmp_buf; +} + void CPU::Execute() { CheckForExecutionModeChange(); diff --git a/src/core/cpu_core_private.h b/src/core/cpu_core_private.h index 7e52952cd..1f49e71e8 100644 --- a/src/core/cpu_core_private.h +++ b/src/core/cpu_core_private.h @@ -2,9 +2,12 @@ // SPDX-License-Identifier: CC-BY-NC-ND-4.0 #pragma once + #include "bus.h" #include "cpu_core.h" +struct fastjmp_buf; + namespace CPU { void SetPC(u32 new_pc); @@ -28,6 +31,9 @@ ALWAYS_INLINE static void CheckForPendingInterrupt() void DispatchInterrupt(); +// access to execution jump buffer, use with care! +fastjmp_buf* GetExecutionJmpBuf(); + // icache stuff ALWAYS_INLINE static bool IsCachedAddress(VirtualMemoryAddress address) { diff --git a/src/core/dma.cpp b/src/core/dma.cpp index a5b7dd280..2a05ebab3 100644 --- a/src/core/dma.cpp +++ b/src/core/dma.cpp @@ -6,12 +6,14 @@ #include "cdrom.h" #include "cpu_core.h" #include "gpu.h" +#include "gpu_dump.h" #include "imgui.h" #include "interrupt_controller.h" #include "mdec.h" #include "pad.h" #include "spu.h" #include "system.h" +#include "timing_event.h" #include "util/imgui_manager.h" #include "util/state_wrapper.h" @@ -802,6 +804,28 @@ TickCount DMA::TransferMemoryToDevice(u32 address, u32 increment, u32 word_count { if (g_gpu->BeginDMAWrite()) [[likely]] { + if (GPUDump::Recorder* dump = g_gpu->GetGPUDump()) [[unlikely]] + { + // No wraparound? + dump->BeginGP0Packet(word_count); + if (((address + (increment * (word_count - 1))) & mask) >= address) [[likely]] + { + dump->WriteWords(reinterpret_cast(&Bus::g_ram[address]), word_count); + } + else + { + u32 dump_address = address; + for (u32 i = 0; i < word_count; i++) + { + u32 value; + std::memcpy(&value, &Bus::g_ram[dump_address], sizeof(u32)); + dump->WriteWord(value); + dump_address = (dump_address + increment) & mask; + } + } + dump->EndGP0Packet(); + } + u8* ram_pointer = Bus::g_ram; for (u32 i = 0; i < word_count; i++) { diff --git a/src/core/game_database.cpp b/src/core/game_database.cpp index 0940d02cd..84f2c11e9 100644 --- a/src/core/game_database.cpp +++ b/src/core/game_database.cpp @@ -216,7 +216,7 @@ std::string GameDatabase::GetSerialForPath(const char* path) { std::string ret; - if (System::IsLoadableFilename(path) && !System::IsExeFileName(path) && !System::IsPsfFileName(path)) + if (System::IsLoadablePath(path) && !System::IsExePath(path) && !System::IsPsfPath(path)) { std::unique_ptr image(CDImage::Open(path, false, nullptr)); if (image) diff --git a/src/core/game_list.cpp b/src/core/game_list.cpp index 7cea59d38..4cf1e4c9f 100644 --- a/src/core/game_list.cpp +++ b/src/core/game_list.cpp @@ -165,7 +165,7 @@ bool GameList::IsScannableFilename(std::string_view path) if (StringUtil::EndsWithNoCase(path, ".bin")) return false; - return System::IsLoadableFilename(path); + return System::IsLoadablePath(path); } bool GameList::GetExeListEntry(const std::string& path, GameList::Entry* entry) @@ -317,9 +317,9 @@ bool GameList::GetDiscListEntry(const std::string& path, Entry* entry) bool GameList::PopulateEntryFromPath(const std::string& path, Entry* entry) { - if (System::IsExeFileName(path)) + if (System::IsExePath(path)) return GetExeListEntry(path, entry); - if (System::IsPsfFileName(path.c_str())) + if (System::IsPsfPath(path.c_str())) return GetPsfListEntry(path, entry); return GetDiscListEntry(path, entry); } diff --git a/src/core/gpu.cpp b/src/core/gpu.cpp index c79c85850..3b8a42f8a 100644 --- a/src/core/gpu.cpp +++ b/src/core/gpu.cpp @@ -3,6 +3,7 @@ #include "gpu.h" #include "dma.h" +#include "gpu_dump.h" #include "gpu_shadergen.h" #include "gpu_sw_rasterizer.h" #include "host.h" @@ -10,6 +11,7 @@ #include "settings.h" #include "system.h" #include "timers.h" +#include "timing_event.h" #include "util/gpu_device.h" #include "util/image.h" @@ -72,6 +74,7 @@ static bool CompressAndWriteTextureToFile(u32 width, u32 height, std::string fil u8 quality, bool clear_alpha, bool flip_y, std::vector texture_data, u32 texture_data_stride, GPUTexture::Format texture_format, bool display_osd_message, bool use_thread); +static void RemoveSelfFromScreenshotThreads(); static void JoinScreenshotThreads(); GPU::GPU() @@ -86,6 +89,7 @@ GPU::~GPU() s_crtc_tick_event.Deactivate(); s_frame_done_event.Deactivate(); + StopRecordingGPUDump(); JoinScreenshotThreads(); DestroyDeinterlaceTextures(); g_gpu_device->RecycleTexture(std::move(m_chroma_smoothing_texture)); @@ -93,9 +97,11 @@ GPU::~GPU() bool GPU::Initialize() { + if (!System::IsReplayingGPUDump()) + s_crtc_tick_event.Activate(); + m_force_progressive_scan = (g_settings.display_deinterlacing_mode == DisplayDeinterlacingMode::Progressive); m_force_frame_timings = g_settings.gpu_force_video_timing; - s_crtc_tick_event.Activate(); m_fifo_size = g_settings.gpu_fifo_size; m_max_run_ahead = g_settings.gpu_max_run_ahead; m_console_is_pal = System::IsPALRegion(); @@ -226,7 +232,7 @@ void GPU::SoftReset() m_GPUSTAT.display_area_color_depth_24 = false; m_GPUSTAT.vertical_interlace = false; m_GPUSTAT.display_disable = true; - m_GPUSTAT.dma_direction = DMADirection::Off; + m_GPUSTAT.dma_direction = GPUDMADirection::Off; m_drawing_area = {}; m_drawing_area_changed = true; m_drawing_offset = {}; @@ -420,19 +426,19 @@ void GPU::UpdateDMARequest() bool dma_request; switch (m_GPUSTAT.dma_direction) { - case DMADirection::Off: + case GPUDMADirection::Off: dma_request = false; break; - case DMADirection::FIFO: + case GPUDMADirection::FIFO: dma_request = m_GPUSTAT.ready_to_recieve_dma; break; - case DMADirection::CPUtoGP0: + case GPUDMADirection::CPUtoGP0: dma_request = m_GPUSTAT.ready_to_recieve_dma; break; - case DMADirection::GPUREADtoCPU: + case GPUDMADirection::GPUREADtoCPU: dma_request = m_GPUSTAT.ready_to_send_vram; break; @@ -479,23 +485,35 @@ void GPU::WriteRegister(u32 offset, u32 value) switch (offset) { case 0x00: + { + if (m_gpu_dump) [[unlikely]] + m_gpu_dump->WriteGP0Packet(value); + m_fifo.Push(value); ExecuteCommands(); return; + } case 0x04: + { + if (m_gpu_dump) [[unlikely]] + m_gpu_dump->WriteGP1Packet(value); + WriteGP1(value); return; + } default: + { ERROR_LOG("Unhandled register write: {:02X} <- {:08X}", offset, value); return; + } } } void GPU::DMARead(u32* words, u32 word_count) { - if (m_GPUSTAT.dma_direction != DMADirection::GPUREADtoCPU) + if (m_GPUSTAT.dma_direction != GPUDMADirection::GPUREADtoCPU) { ERROR_LOG("Invalid DMA direction from GPU DMA read"); std::fill_n(words, word_count, UINT32_C(0xFFFFFFFF)); @@ -877,6 +895,11 @@ TickCount GPU::GetPendingCommandTicks() const return SystemTicksToGPUTicks(s_command_tick_event.GetTicksSinceLastExecution()); } +TickCount GPU::GetRemainingCommandTicks() const +{ + return std::max(m_pending_command_ticks - GetPendingCommandTicks(), 0); +} + void GPU::UpdateCRTCTickEvent() { // figure out how many GPU ticks until the next vblank or event @@ -931,7 +954,8 @@ void GPU::UpdateCRTCTickEvent() ticks_until_event = std::min(ticks_until_event, ticks_until_hblank_start_or_end); } - s_crtc_tick_event.Schedule(CRTCTicksToSystemTicks(ticks_until_event, m_crtc_state.fractional_ticks)); + if (!System::IsReplayingGPUDump()) [[likely]] + s_crtc_tick_event.Schedule(CRTCTicksToSystemTicks(ticks_until_event, m_crtc_state.fractional_ticks)); } bool GPU::IsCRTCScanlinePending() const @@ -1030,6 +1054,13 @@ void GPU::CRTCTickEvent(TickCount ticks) { DEBUG_LOG("Now in v-blank"); + if (m_gpu_dump) [[unlikely]] + { + m_gpu_dump->WriteVSync(System::GetGlobalTickCounter()); + if (m_gpu_dump->IsFinished()) [[unlikely]] + StopRecordingGPUDump(); + } + // flush any pending draws and "scan out" the image // TODO: move present in here I guess FlushRender(); @@ -1273,7 +1304,7 @@ void GPU::WriteGP1(u32 value) const u32 param = value & UINT32_C(0x00FFFFFF); switch (command) { - case 0x00: // Reset GPU + case static_cast(GP1Command::ResetGPU): { DEBUG_LOG("GP1 reset GPU"); s_command_tick_event.InvokeEarly(); @@ -1282,7 +1313,7 @@ void GPU::WriteGP1(u32 value) } break; - case 0x01: // Clear FIFO + case static_cast(GP1Command::ClearFIFO): { DEBUG_LOG("GP1 clear FIFO"); s_command_tick_event.InvokeEarly(); @@ -1305,7 +1336,7 @@ void GPU::WriteGP1(u32 value) } break; - case 0x02: // Acknowledge Interrupt + case static_cast(GP1Command::AcknowledgeInterrupt): { DEBUG_LOG("Acknowledge interrupt"); m_GPUSTAT.interrupt_request = false; @@ -1313,7 +1344,7 @@ void GPU::WriteGP1(u32 value) } break; - case 0x03: // Display on/off + case static_cast(GP1Command::SetDisplayDisable): { const bool disable = ConvertToBoolUnchecked(value & 0x01); DEBUG_LOG("Display {}", disable ? "disabled" : "enabled"); @@ -1326,18 +1357,18 @@ void GPU::WriteGP1(u32 value) } break; - case 0x04: // DMA Direction + case static_cast(GP1Command::SetDMADirection): { DEBUG_LOG("DMA direction <- 0x{:02X}", static_cast(param)); - if (m_GPUSTAT.dma_direction != static_cast(param)) + if (m_GPUSTAT.dma_direction != static_cast(param)) { - m_GPUSTAT.dma_direction = static_cast(param); + m_GPUSTAT.dma_direction = static_cast(param); UpdateDMARequest(); } } break; - case 0x05: // Set display start address + case static_cast(GP1Command::SetDisplayStartAddress): { const u32 new_value = param & CRTCState::Regs::DISPLAY_ADDRESS_START_MASK; DEBUG_LOG("Display address start <- 0x{:08X}", new_value); @@ -1353,7 +1384,7 @@ void GPU::WriteGP1(u32 value) } break; - case 0x06: // Set horizontal display range + case static_cast(GP1Command::SetHorizontalDisplayRange): { const u32 new_value = param & CRTCState::Regs::HORIZONTAL_DISPLAY_RANGE_MASK; DEBUG_LOG("Horizontal display range <- 0x{:08X}", new_value); @@ -1367,7 +1398,7 @@ void GPU::WriteGP1(u32 value) } break; - case 0x07: // Set vertical display range + case static_cast(GP1Command::SetVerticalDisplayRange): { const u32 new_value = param & CRTCState::Regs::VERTICAL_DISPLAY_RANGE_MASK; DEBUG_LOG("Vertical display range <- 0x{:08X}", new_value); @@ -1381,22 +1412,9 @@ void GPU::WriteGP1(u32 value) } break; - case 0x08: // Set display mode + case static_cast(GP1Command::SetDisplayMode): { - union GP1_08h - { - u32 bits; - - BitField horizontal_resolution_1; - BitField vertical_resolution; - BitField pal_mode; - BitField display_area_color_depth; - BitField vertical_interlace; - BitField horizontal_resolution_2; - BitField reverse_flag; - }; - - const GP1_08h dm{param}; + const GP1SetDisplayMode dm{param}; GPUSTAT new_GPUSTAT{m_GPUSTAT.bits}; new_GPUSTAT.horizontal_resolution_1 = dm.horizontal_resolution_1; new_GPUSTAT.vertical_resolution = dm.vertical_resolution; @@ -1425,7 +1443,7 @@ void GPU::WriteGP1(u32 value) } break; - case 0x09: // Allow texture disable + case static_cast(GP1Command::SetAllowTextureDisable): { m_set_texture_disable_mask = ConvertToBoolUnchecked(param & 0x01); DEBUG_LOG("Set texture disable mask <- {}", m_set_texture_disable_mask ? "allowed" : "ignored"); @@ -2471,20 +2489,7 @@ bool CompressAndWriteTextureToFile(u32 width, u32 height, std::string filename, } if (use_thread) - { - // remove ourselves from the list, if the GS thread is waiting for us, we won't be in there - const auto this_id = std::this_thread::get_id(); - std::unique_lock lock(s_screenshot_threads_mutex); - for (auto it = s_screenshot_threads.begin(); it != s_screenshot_threads.end(); ++it) - { - if (it->get_id() == this_id) - { - it->detach(); - s_screenshot_threads.erase(it); - break; - } - } - } + RemoveSelfFromScreenshotThreads(); return result; }; @@ -2502,6 +2507,21 @@ bool CompressAndWriteTextureToFile(u32 width, u32 height, std::string filename, return true; } +void RemoveSelfFromScreenshotThreads() +{ + const auto this_id = std::this_thread::get_id(); + std::unique_lock lock(s_screenshot_threads_mutex); + for (auto it = s_screenshot_threads.begin(); it != s_screenshot_threads.end(); ++it) + { + if (it->get_id() == this_id) + { + it->detach(); + s_screenshot_threads.erase(it); + break; + } + } +} + void JoinScreenshotThreads() { std::unique_lock lock(s_screenshot_threads_mutex); @@ -2886,3 +2906,209 @@ void GPU::UpdateStatistics(u32 frame_count) ResetStatistics(); } + +bool GPU::StartRecordingGPUDump(const char* path, u32 num_frames /* = 1 */) +{ + if (m_gpu_dump) + StopRecordingGPUDump(); + + // if we're not dumping forever, compute the frame count based on the internal fps + // +1 because we want to actually see the buffer swap... + if (num_frames != 0) + { + num_frames = std::max(num_frames, static_cast(static_cast(num_frames + 1) * + std::ceil(System::GetVPS() / System::GetFPS()))); + } + + // ensure vram is up to date + ReadVRAM(0, 0, VRAM_WIDTH, VRAM_HEIGHT); + + std::string osd_key = fmt::format("GPUDump_{}", Path::GetFileName(path)); + Error error; + m_gpu_dump = GPUDump::Recorder::Create(path, System::GetGameSerial(), num_frames, &error); + if (!m_gpu_dump) + { + Host::AddIconOSDWarning( + std::move(osd_key), ICON_EMOJI_CAMERA_WITH_FLASH, + fmt::format("{}\n{}", TRANSLATE_SV("GPU", "Failed to start GPU trace:"), error.GetDescription()), + Host::OSD_ERROR_DURATION); + return false; + } + + Host::AddIconOSDMessage( + std::move(osd_key), ICON_EMOJI_CAMERA_WITH_FLASH, + (num_frames != 0) ? + fmt::format(TRANSLATE_FS("GPU", "Saving {0} frame GPU trace to '{1}'."), num_frames, Path::GetFileName(path)) : + fmt::format(TRANSLATE_FS("GPU", "Saving multi-frame frame GPU trace to '{1}'."), num_frames, + Path::GetFileName(path)), + Host::OSD_QUICK_DURATION); + + // save screenshot to same location to identify it + RenderScreenshotToFile(Path::ReplaceExtension(path, "png"), DisplayScreenshotMode::ScreenResolution, 85, true, false); + return true; +} + +void GPU::StopRecordingGPUDump() +{ + if (!m_gpu_dump) + return; + + Error error; + if (!m_gpu_dump->Close(&error)) + { + Host::AddIconOSDWarning( + "GPUDump", ICON_EMOJI_CAMERA_WITH_FLASH, + fmt::format("{}\n{}", TRANSLATE_SV("GPU", "Failed to close GPU trace:"), error.GetDescription()), + Host::OSD_ERROR_DURATION); + m_gpu_dump.reset(); + } + + // Are we compressing the dump? + const GPUDumpCompressionMode compress_mode = + Settings::ParseGPUDumpCompressionMode(Host::GetTinyStringSettingValue("GPU", "DumpCompressionMode")) + .value_or(Settings::DEFAULT_GPU_DUMP_COMPRESSION_MODE); + std::string osd_key = fmt::format("GPUDump_{}", Path::GetFileName(m_gpu_dump->GetPath())); + if (compress_mode == GPUDumpCompressionMode::Disabled) + { + Host::AddIconOSDMessage( + "GPUDump", ICON_EMOJI_CAMERA_WITH_FLASH, + fmt::format(TRANSLATE_FS("GPU", "Saved GPU trace to '{}'."), Path::GetFileName(m_gpu_dump->GetPath())), + Host::OSD_QUICK_DURATION); + m_gpu_dump.reset(); + return; + } + + std::string source_path = m_gpu_dump->GetPath(); + m_gpu_dump.reset(); + + // Use a 60 second timeout to give it plenty of time to actually save. + Host::AddIconOSDMessage( + osd_key, ICON_EMOJI_CAMERA_WITH_FLASH, + fmt::format(TRANSLATE_FS("GPU", "Compressing GPU trace '{}'..."), Path::GetFileName(source_path)), 60.0f); + std::unique_lock screenshot_lock(s_screenshot_threads_mutex); + s_screenshot_threads.emplace_back( + [compress_mode, source_path = std::move(source_path), osd_key = std::move(osd_key)]() mutable { + Error error; + if (GPUDump::Recorder::Compress(source_path, compress_mode, &error)) + { + Host::AddIconOSDMessage( + std::move(osd_key), ICON_EMOJI_CAMERA_WITH_FLASH, + fmt::format(TRANSLATE_FS("GPU", "Saved GPU trace to '{}'."), Path::GetFileName(source_path)), + Host::OSD_QUICK_DURATION); + } + else + { + Host::AddIconOSDWarning( + std::move(osd_key), ICON_EMOJI_CAMERA_WITH_FLASH, + fmt::format("{}\n{}", + SmallString::from_format(TRANSLATE_FS("GPU", "Failed to save GPU trace to '{}':"), + Path::GetFileName(source_path)), + error.GetDescription()), + Host::OSD_ERROR_DURATION); + } + + RemoveSelfFromScreenshotThreads(); + }); +} + +void GPU::WriteCurrentVideoModeToDump(GPUDump::Recorder* dump) const +{ + // display disable + dump->WriteGP1Command(GP1Command::SetDisplayDisable, BoolToUInt32(m_GPUSTAT.display_disable)); + dump->WriteGP1Command(GP1Command::SetDisplayStartAddress, m_crtc_state.regs.display_address_start); + dump->WriteGP1Command(GP1Command::SetHorizontalDisplayRange, m_crtc_state.regs.horizontal_display_range); + dump->WriteGP1Command(GP1Command::SetVerticalDisplayRange, m_crtc_state.regs.vertical_display_range); + dump->WriteGP1Command(GP1Command::SetAllowTextureDisable, BoolToUInt32(m_set_texture_disable_mask)); + + // display mode + GP1SetDisplayMode dispmode = {}; + dispmode.horizontal_resolution_1 = m_GPUSTAT.horizontal_resolution_1.GetValue(); + dispmode.vertical_resolution = m_GPUSTAT.vertical_resolution.GetValue(); + dispmode.pal_mode = m_GPUSTAT.pal_mode.GetValue(); + dispmode.display_area_color_depth = m_GPUSTAT.display_area_color_depth_24.GetValue(); + dispmode.vertical_interlace = m_GPUSTAT.vertical_interlace.GetValue(); + dispmode.horizontal_resolution_2 = m_GPUSTAT.horizontal_resolution_2.GetValue(); + dispmode.reverse_flag = m_GPUSTAT.reverse_flag.GetValue(); + dump->WriteGP1Command(GP1Command::SetDisplayMode, dispmode.bits); +} + +void GPU::ProcessGPUDumpPacket(GPUDump::PacketType type, const std::span data) +{ + switch (type) + { + case GPUDump::PacketType::GPUPort0Data: + { + if (data.empty()) [[unlikely]] + { + WARNING_LOG("Empty GPU dump GP0 packet!"); + return; + } + + // ensure it doesn't block + m_pending_command_ticks = 0; + UpdateCommandTickEvent(); + + if (data.size() == 1) [[unlikely]] + { + // direct GP0 write + WriteRegister(0, data[0]); + } + else + { + // don't overflow the fifo... + size_t current_word = 0; + while (current_word < data.size()) + { + const u32 block_size = std::min(m_fifo_size - m_fifo.GetSize(), static_cast(data.size() - current_word)); + if (block_size == 0) + { + ERROR_LOG("FIFO overflow while processing dump packet of {} words", data.size()); + break; + } + + for (u32 i = 0; i < block_size; i++) + m_fifo.Push(ZeroExtend64(data[current_word++])); + ExecuteCommands(); + } + } + } + break; + + case GPUDump::PacketType::GPUPort1Data: + { + if (data.size() != 1) [[unlikely]] + { + WARNING_LOG("Incorrectly-sized GPU dump GP1 packet: {} words", data.size()); + return; + } + + WriteRegister(4, data[0]); + } + break; + + case GPUDump::PacketType::VSyncEvent: + { + // don't play silly buggers with events + m_pending_command_ticks = 0; + UpdateCommandTickEvent(); + + // we _should_ be using the tick count for the event, but it breaks with looping. + // instead, just add a fixed amount + const TickCount crtc_ticks_per_frame = + static_cast(m_crtc_state.horizontal_total) * static_cast(m_crtc_state.vertical_total); + const TickCount system_ticks_per_frame = + CRTCTicksToSystemTicks(crtc_ticks_per_frame, m_crtc_state.fractional_ticks); + SystemTicksToCRTCTicks(system_ticks_per_frame, &m_crtc_state.fractional_ticks); + TimingEvents::SetGlobalTickCounter(TimingEvents::GetGlobalTickCounter() + + static_cast(system_ticks_per_frame)); + + FlushRender(); + UpdateDisplay(); + System::FrameDone(); + } + break; + + default: + break; + } +} diff --git a/src/core/gpu.h b/src/core/gpu.h index 3f4b88cd2..d6b7f10de 100644 --- a/src/core/gpu.h +++ b/src/core/gpu.h @@ -5,7 +5,6 @@ #include "gpu_types.h" #include "timers.h" -#include "timing_event.h" #include "types.h" #include "util/gpu_device.h" @@ -20,9 +19,11 @@ #include #include #include +#include #include #include +class Error; class SmallStringBase; class StateWrapper; @@ -32,6 +33,11 @@ class GPUTexture; class GPUPipeline; class MediaCapture; +namespace GPUDump { +enum class PacketType : u8; +class Recorder; +class Player; +} struct Settings; namespace Threading { @@ -49,14 +55,6 @@ public: DrawingPolyLine }; - enum class DMADirection : u32 - { - Off = 0, - FIFO = 1, - CPUtoGP0 = 2, - GPUREADtoCPU = 3 - }; - enum : u32 { MAX_FIFO_SIZE = 4096, @@ -120,7 +118,7 @@ public: ALWAYS_INLINE bool BeginDMAWrite() const { - return (m_GPUSTAT.dma_direction == DMADirection::CPUtoGP0 || m_GPUSTAT.dma_direction == DMADirection::FIFO); + return (m_GPUSTAT.dma_direction == GPUDMADirection::CPUtoGP0 || m_GPUSTAT.dma_direction == GPUDMADirection::FIFO); } ALWAYS_INLINE void DMAWrite(u32 address, u32 value) { @@ -128,6 +126,13 @@ public: } void EndDMAWrite(); + /// Writing to GPU dump. + GPUDump::Recorder* GetGPUDump() const { return m_gpu_dump.get(); } + bool StartRecordingGPUDump(const char* path, u32 num_frames = 1); + void StopRecordingGPUDump(); + void WriteCurrentVideoModeToDump(GPUDump::Recorder* dump) const; + void ProcessGPUDumpPacket(GPUDump::PacketType type, const std::span data); + /// Returns true if no data is being sent from VRAM to the DAC or that no portion of VRAM would be visible on screen. ALWAYS_INLINE bool IsDisplayDisabled() const { @@ -152,6 +157,7 @@ public: /// Returns the number of pending GPU ticks. TickCount GetPendingCRTCTicks() const; TickCount GetPendingCommandTicks() const; + TickCount GetRemainingCommandTicks() const; /// Returns true if enough ticks have passed for the raster to be on the next line. bool IsCRTCScanlinePending() const; @@ -414,54 +420,7 @@ protected: AddCommandTicks(std::max(drawn_width, drawn_height)); } - union GPUSTAT - { - // During transfer/render operations, if ((dst_pixel & mask_and) == 0) { pixel = src_pixel | mask_or } - - u32 bits; - BitField texture_page_x_base; - BitField texture_page_y_base; - BitField semi_transparency_mode; - BitField texture_color_mode; - BitField dither_enable; - BitField draw_to_displayed_field; - BitField set_mask_while_drawing; - BitField check_mask_before_draw; - BitField interlaced_field; - BitField reverse_flag; - BitField texture_disable; - BitField horizontal_resolution_2; - BitField horizontal_resolution_1; - BitField vertical_resolution; - BitField pal_mode; - BitField display_area_color_depth_24; - BitField vertical_interlace; - BitField display_disable; - BitField interrupt_request; - BitField dma_data_request; - BitField gpu_idle; - BitField ready_to_send_vram; - BitField ready_to_recieve_dma; - BitField dma_direction; - BitField display_line_lsb; - - ALWAYS_INLINE bool IsMaskingEnabled() const - { - static constexpr u32 MASK = ((1 << 11) | (1 << 12)); - return ((bits & MASK) != 0); - } - ALWAYS_INLINE bool SkipDrawingToActiveField() const - { - static constexpr u32 MASK = (1 << 19) | (1 << 22) | (1 << 10); - static constexpr u32 ACTIVE = (1 << 19) | (1 << 22); - return ((bits & MASK) == ACTIVE); - } - ALWAYS_INLINE bool InInterleaved480iMode() const - { - static constexpr u32 ACTIVE = (1 << 19) | (1 << 22); - return ((bits & ACTIVE) == ACTIVE); - } - } m_GPUSTAT = {}; + GPUSTAT m_GPUSTAT = {}; struct DrawMode { @@ -606,6 +565,8 @@ protected: u32 m_blit_remaining_words; GPURenderCommand m_render_command{}; + std::unique_ptr m_gpu_dump; + ALWAYS_INLINE u32 FifoPop() { return Truncate32(m_fifo.Pop()); } ALWAYS_INLINE u32 FifoPeek() { return Truncate32(m_fifo.Peek()); } ALWAYS_INLINE u32 FifoPeek(u32 i) { return Truncate32(m_fifo.Peek(i)); } diff --git a/src/core/gpu_commands.cpp b/src/core/gpu_commands.cpp index 0f6f408cd..73c4a9d21 100644 --- a/src/core/gpu_commands.cpp +++ b/src/core/gpu_commands.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: CC-BY-NC-ND-4.0 #include "gpu.h" +#include "gpu_dump.h" #include "gpu_hw_texture_cache.h" #include "interrupt_controller.h" #include "system.h" @@ -604,6 +605,11 @@ bool GPU::HandleCopyRectangleVRAMToCPUCommand() m_counters.num_reads++; m_blitter_state = BlitterState::ReadingVRAM; m_command_total_words = 0; + + // toss the entire read in the recorded trace. we might want to change this to mirroring GPUREAD in the future.. + if (m_gpu_dump) [[unlikely]] + m_gpu_dump->WriteDiscardVRAMRead(m_vram_transfer.width, m_vram_transfer.height); + return true; } diff --git a/src/core/gpu_dump.cpp b/src/core/gpu_dump.cpp new file mode 100644 index 000000000..02c5b1427 --- /dev/null +++ b/src/core/gpu_dump.cpp @@ -0,0 +1,534 @@ +// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin +// SPDX-License-Identifier: CC-BY-NC-ND-4.0 + +#include "gpu_dump.h" +#include "cpu_core.h" +#include "cpu_core_private.h" +#include "gpu.h" +#include "settings.h" + +#include "scmversion/scmversion.h" + +#include "util/compress_helpers.h" + +#include "common/align.h" +#include "common/assert.h" +#include "common/binary_reader_writer.h" +#include "common/error.h" +#include "common/fastjmp.h" +#include "common/file_system.h" +#include "common/log.h" +#include "common/path.h" +#include "common/string_util.h" +#include "common/timer.h" + +#include "fmt/format.h" + +LOG_CHANNEL(GPUDump); + +namespace GPUDump { +static constexpr GPUVersion GPU_VERSION = GPUVersion::V2_1MB_VRAM; + +// Write the file header. +static constexpr u8 FILE_HEADER[] = {'P', 'S', 'X', 'G', 'P', 'U', 'D', 'U', 'M', 'P', 'v', '1', '\0', '\0'}; + +}; // namespace GPUDump + +GPUDump::Recorder::Recorder(FileSystem::AtomicRenamedFile fp, u32 vsyncs_remaining, std::string path) + : m_fp(std::move(fp)), m_vsyncs_remaining(vsyncs_remaining), m_path(path) +{ +} + +GPUDump::Recorder::~Recorder() +{ + if (m_fp) + FileSystem::DiscardAtomicRenamedFile(m_fp); +} + +bool GPUDump::Recorder::IsFinished() +{ + if (m_vsyncs_remaining == 0) + return false; + + m_vsyncs_remaining--; + return (m_vsyncs_remaining == 0); +} + +bool GPUDump::Recorder::Close(Error* error) +{ + if (m_write_error) + { + Error::SetStringView(error, "Previous write error occurred."); + return false; + } + + return FileSystem::CommitAtomicRenamedFile(m_fp, error); +} + +std::unique_ptr GPUDump::Recorder::Create(std::string path, std::string_view serial, u32 num_frames, + Error* error) +{ + std::unique_ptr ret; + + auto fp = FileSystem::CreateAtomicRenamedFile(path, error); + if (!fp) + return ret; + + ret = std::unique_ptr(new Recorder(std::move(fp), num_frames, std::move(path))); + ret->WriteHeaders(serial); + g_gpu->WriteCurrentVideoModeToDump(ret.get()); + ret->WriteCurrentVRAM(); + + // Write start of stream. + ret->BeginPacket(PacketType::TraceBegin); + ret->EndPacket(); + + if (ret->m_write_error) + { + Error::SetStringView(error, "Previous write error occurred."); + ret.reset(); + } + + return ret; +} + +bool GPUDump::Recorder::Compress(const std::string& source_path, GPUDumpCompressionMode mode, Error* error) +{ + if (mode == GPUDumpCompressionMode::Disabled) + return true; + + std::optional> data = FileSystem::ReadBinaryFile(source_path.c_str(), error); + if (!data) + return false; + + if (mode >= GPUDumpCompressionMode::ZstLow && mode <= GPUDumpCompressionMode::ZstHigh) + { + const int clevel = + ((mode == GPUDumpCompressionMode::ZstLow) ? 1 : ((mode == GPUDumpCompressionMode::ZstHigh) ? 19 : 0)); + if (!CompressHelpers::CompressToFile(fmt::format("{}.zst", source_path).c_str(), std::move(data.value()), clevel, + true, error)) + { + return false; + } + } + else + { + Error::SetStringView(error, "Unknown compression mode."); + return false; + } + + // remove original file + return FileSystem::DeleteFile(source_path.c_str(), error); +} + +void GPUDump::Recorder::BeginGP0Packet(u32 size) +{ + BeginPacket(PacketType::GPUPort0Data, size); +} + +void GPUDump::Recorder::WriteGP0Packet(u32 word) +{ + BeginGP0Packet(1); + WriteWord(word); + EndGP0Packet(); +} + +void GPUDump::Recorder::EndGP0Packet() +{ + DebugAssert(!m_packet_buffer.empty()); + EndPacket(); +} + +void GPUDump::Recorder::WriteGP1Packet(u32 value) +{ + const u32 command = (value >> 24) & 0x3F; + + // only in-range commands, no info + if (command > static_cast(GP1Command::SetAllowTextureDisable)) + return; + + // filter DMA direction, we don't want to screw with that + if (command == static_cast(GP1Command::SetDMADirection)) + return; + + WriteGP1Command(static_cast(command), value & 0x00FFFFFFu); +} + +void GPUDump::Recorder::WriteDiscardVRAMRead(u32 width, u32 height) +{ + const u32 num_words = Common::AlignUpPow2(width * height * static_cast(sizeof(u16)), sizeof(u32)) / sizeof(u32); + if (num_words == 0) + return; + + BeginPacket(GPUDump::PacketType::DiscardPort0Data, 1); + WriteWord(num_words); + EndPacket(); +} + +void GPUDump::Recorder::WriteVSync(u64 ticks) +{ + BeginPacket(GPUDump::PacketType::VSyncEvent, 2); + WriteWord(static_cast(ticks)); + WriteWord(static_cast(ticks >> 32)); + EndPacket(); +} + +void GPUDump::Recorder::BeginPacket(PacketType packet, u32 minimum_size) +{ + DebugAssert(m_packet_buffer.empty()); + m_current_packet = packet; + m_packet_buffer.reserve(minimum_size); +} + +void GPUDump::Recorder::WriteWords(const u32* words, size_t word_count) +{ + Assert(((m_packet_buffer.size() + word_count) * sizeof(u32)) <= MAX_PACKET_LENGTH); + + // we don't need the zeroing here... + const size_t current_offset = m_packet_buffer.size(); + m_packet_buffer.resize(current_offset + word_count); + std::memcpy(&m_packet_buffer[current_offset], words, sizeof(u32) * word_count); +} + +void GPUDump::Recorder::WriteWords(const std::span words) +{ + WriteWords(words.data(), words.size()); +} + +void GPUDump::Recorder::WriteString(std::string_view str) +{ + const size_t aligned_length = Common::AlignDownPow2(str.length(), sizeof(u32)); + for (size_t i = 0; i < aligned_length; i += sizeof(u32)) + { + u32 word; + std::memcpy(&word, &str[i], sizeof(word)); + WriteWord(word); + } + + // zero termination and/or padding for last bytes + u8 pad_word[4] = {}; + for (size_t i = aligned_length, pad_i = 0; i < str.length(); i++, pad_i++) + pad_word[pad_i] = str[i]; + + WriteWord(std::bit_cast(pad_word)); +} + +void GPUDump::Recorder::WriteBytes(const void* data, size_t data_size_in_bytes) +{ + Assert(((m_packet_buffer.size() * sizeof(u32)) + data_size_in_bytes) <= MAX_PACKET_LENGTH); + const u32 num_words = Common::AlignUpPow2(static_cast(data_size_in_bytes), sizeof(u32)) / sizeof(u32); + const size_t current_offset = m_packet_buffer.size(); + + // NOTE: assumes resize() zeros it out + m_packet_buffer.resize(current_offset + num_words); + std::memcpy(&m_packet_buffer[current_offset], data, data_size_in_bytes); +} + +void GPUDump::Recorder::EndPacket() +{ + if (m_write_error) + return; + + Assert(m_packet_buffer.size() <= MAX_PACKET_LENGTH); + + PacketHeader hdr = {}; + hdr.length = static_cast(m_packet_buffer.size()); + hdr.type = m_current_packet; + if (std::fwrite(&hdr, sizeof(hdr), 1, m_fp.get()) != 1 || + (!m_packet_buffer.empty() && + std::fwrite(m_packet_buffer.data(), m_packet_buffer.size() * sizeof(u32), 1, m_fp.get()) != 1)) + { + ERROR_LOG("Failed to write packet to file: {}", Error::CreateErrno(errno).GetDescription()); + m_write_error = true; + m_packet_buffer.clear(); + return; + } + + m_packet_buffer.clear(); +} + +void GPUDump::Recorder::WriteGP1Command(GP1Command command, u32 param) +{ + BeginPacket(PacketType::GPUPort1Data, 1); + WriteWord(((static_cast(command) & 0x3F) << 24) | (param & 0x00FFFFFFu)); + EndPacket(); +} + +void GPUDump::Recorder::WriteHeaders(std::string_view serial) +{ + if (std::fwrite(FILE_HEADER, sizeof(FILE_HEADER), 1, m_fp.get()) != 1) + { + ERROR_LOG("Failed to write file header: {}", Error::CreateErrno(errno).GetDescription()); + m_write_error = true; + return; + } + + // Write GPU version. + BeginPacket(PacketType::GPUVersion, 1); + WriteWord(static_cast(GPU_VERSION)); + EndPacket(); + + // Write Game ID. + BeginPacket(PacketType::GameID); + WriteString(serial.empty() ? std::string_view("UNKNOWN") : serial); + EndPacket(); + + // Write textual video mode. + BeginPacket(PacketType::TextualVideoFormat); + WriteString(g_gpu->IsInPALMode() ? "PAL" : "NTSC"); + EndPacket(); + + // Write DuckStation version. + BeginPacket(PacketType::Comment); + WriteString( + SmallString::from_format("Created by DuckStation {} for {}/{}.", g_scm_tag_str, TARGET_OS_STR, CPU_ARCH_STR)); + EndPacket(); +} + +void GPUDump::Recorder::WriteCurrentVRAM() +{ + BeginPacket(PacketType::GPUPort0Data, sizeof(u32) * 2 + (VRAM_SIZE / sizeof(u32))); + + // command, coords, size. size is written as zero, for 1024x512 + WriteWord(0xA0u << 24); + WriteWord(0); + WriteWord(0); + + // actual vram data + WriteBytes(g_vram, VRAM_SIZE); + + EndPacket(); +} + +GPUDump::Player::Player(std::string path, DynamicHeapArray data) : m_data(std::move(data)), m_path(std::move(path)) +{ +} + +GPUDump::Player::~Player() = default; + +std::unique_ptr GPUDump::Player::Open(std::string path, Error* error) +{ + std::unique_ptr ret; + + Common::Timer timer; + + std::optional> data; + if (StringUtil::EndsWithNoCase(path, ".psxgpu.zst")) + data = CompressHelpers::DecompressFile(path.c_str(), std::nullopt, error); + else + data = FileSystem::ReadBinaryFile(path.c_str(), error); + if (!data.has_value()) + return ret; + + ret = std::unique_ptr(new Player(std::move(path), std::move(data.value()))); + if (!ret->Preprocess(error)) + { + ret.reset(); + return ret; + } + + INFO_LOG("Loading {} took {:.0f}ms.", Path::GetFileName(ret->GetPath()), timer.GetTimeMilliseconds()); + return ret; +} + +std::optional GPUDump::Player::GetNextPacket() +{ + std::optional ret; + + if (m_position >= m_data.size()) + return ret; + + size_t new_position = m_position; + + PacketHeader hdr; + std::memcpy(&hdr, &m_data[new_position], sizeof(hdr)); + new_position += sizeof(hdr); + + if ((new_position + (hdr.length * sizeof(u32))) > m_data.size()) + return ret; + + ret = PacketRef{.type = hdr.type, + .data = (hdr.length > 0) ? + std::span(reinterpret_cast(&m_data[new_position]), hdr.length) : + std::span()}; + new_position += (hdr.length * sizeof(u32)); + m_position = new_position; + return ret; +} + +std::string_view GPUDump::Player::PacketRef::GetNullTerminatedString() const +{ + return data.empty() ? + std::string_view() : + std::string_view(reinterpret_cast(data.data()), + StringUtil::Strnlen(reinterpret_cast(data.data()), data.size_bytes())); +} + +bool GPUDump::Player::Preprocess(Error* error) +{ + if (!ProcessHeader(error)) + { + Error::AddPrefix(error, "Failed to process header: "); + return false; + } + + m_position = m_start_offset; + + if (!FindFrameStarts(error)) + { + Error::AddPrefix(error, "Failed to process header: "); + return false; + } + + m_position = m_start_offset; + return true; +} + +bool GPUDump::Player::ProcessHeader(Error* error) +{ + if (m_data.size() < sizeof(FILE_HEADER) || std::memcmp(m_data.data(), FILE_HEADER, sizeof(FILE_HEADER)) != 0) + { + Error::SetStringView(error, "File does not have the correct header."); + return false; + } + + m_start_offset = sizeof(FILE_HEADER); + m_position = m_start_offset; + + for (;;) + { + const std::optional packet = GetNextPacket(); + if (!packet.has_value()) + { + Error::SetStringView(error, "EOF reached before reaching trace begin."); + return false; + } + + switch (packet->type) + { + case PacketType::TextualVideoFormat: + { + const std::string_view region_str = packet->GetNullTerminatedString(); + DEV_LOG("Dump video format: {}", region_str); + if (StringUtil::EqualNoCase(region_str, "NTSC")) + m_region = ConsoleRegion::NTSC_U; + else if (StringUtil::EqualNoCase(region_str, "PAL")) + m_region = ConsoleRegion::PAL; + else + WARNING_LOG("Unknown console region: {}", region_str); + } + break; + + case PacketType::GameID: + { + const std::string_view serial = packet->GetNullTerminatedString(); + DEV_LOG("Dump serial: {}", serial); + m_serial = serial; + } + break; + + case PacketType::Comment: + { + const std::string_view comment = packet->GetNullTerminatedString(); + DEV_LOG("Dump comment: {}", comment); + } + break; + + case PacketType::TraceBegin: + { + DEV_LOG("Trace start found at offset {}", m_position); + return true; + } + + default: + { + // ignore packet + } + break; + } + } +} + +bool GPUDump::Player::FindFrameStarts(Error* error) +{ + for (;;) + { + const std::optional packet = GetNextPacket(); + if (!packet.has_value()) + break; + + switch (packet->type) + { + case PacketType::TraceBegin: + { + if (!m_frame_offsets.empty()) + { + Error::SetStringView(error, "VSync or trace begin event found before final trace begin."); + return false; + } + + m_frame_offsets.push_back(m_position); + } + break; + + case PacketType::VSyncEvent: + { + if (m_frame_offsets.empty()) + { + Error::SetStringView(error, "Trace begin event missing before first VSync."); + return false; + } + + m_frame_offsets.push_back(m_position); + } + break; + + default: + { + // ignore packet + } + break; + } + } + + if (m_frame_offsets.size() < 2) + { + Error::SetStringView(error, "Dump does not contain at least one frame."); + return false; + } + +#ifdef _DEBUG + for (size_t i = 0; i < m_frame_offsets.size(); i++) + DEBUG_LOG("Frame {} starts at offset {}", i, m_frame_offsets[i]); +#endif + + return true; +} + +void GPUDump::Player::ProcessPacket(const PacketRef& pkt) +{ + if (pkt.type <= PacketType::VSyncEvent) + { + // gp0/gp1/vsync => direct to gpu + g_gpu->ProcessGPUDumpPacket(pkt.type, pkt.data); + return; + } +} + +void GPUDump::Player::Execute() +{ + if (fastjmp_set(CPU::GetExecutionJmpBuf()) != 0) + return; + + for (;;) + { + const std::optional packet = GetNextPacket(); + if (!packet.has_value()) + { + m_position = g_settings.gpu_dump_fast_replay_mode ? m_frame_offsets.front() : m_start_offset; + continue; + } + + ProcessPacket(packet.value()); + } +} diff --git a/src/core/gpu_dump.h b/src/core/gpu_dump.h new file mode 100644 index 000000000..3afa50aca --- /dev/null +++ b/src/core/gpu_dump.h @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin +// SPDX-License-Identifier: CC-BY-NC-ND-4.0 + +#pragma once + +#include "gpu_types.h" + +#include "common/bitfield.h" +#include "common/file_system.h" + +#include + +// Implements the specification from https://github.com/ps1dev/standards/blob/main/GPUDUMP.md + +class Error; + +namespace GPUDump { + +enum class GPUVersion : u8 +{ + V1_1MB_VRAM, + V2_1MB_VRAM, + V2_2MB_VRAM, +}; + +enum class PacketType : u8 +{ + GPUPort0Data = 0x00, + GPUPort1Data = 0x01, + VSyncEvent = 0x02, + DiscardPort0Data = 0x03, + ReadbackPort0Data = 0x04, + TraceBegin = 0x05, + GPUVersion = 0x06, + GameID = 0x10, + TextualVideoFormat = 0x11, + Comment = 0x12, +}; + +static constexpr u32 MAX_PACKET_LENGTH = ((1u << 24) - 1); // 3 bytes for packet size + +union PacketHeader +{ + // Length0,Length1,Length2,Type + BitField length; + BitField type; + u32 bits; +}; +static_assert(sizeof(PacketHeader) == 4); + +class Recorder +{ +public: + ~Recorder(); + + static std::unique_ptr Create(std::string path, std::string_view serial, u32 num_frames, Error* error); + + /// Compresses an already-created dump. + static bool Compress(const std::string& source_path, GPUDumpCompressionMode mode, Error* error); + + ALWAYS_INLINE const std::string& GetPath() const { return m_path; } + + /// Returns true if the caller should stop recording data. + bool IsFinished(); + + bool Close(Error* error); + + void BeginPacket(PacketType packet, u32 minimum_size = 0); + ALWAYS_INLINE void WriteWord(u32 word) { m_packet_buffer.push_back(word); } + void WriteWords(const u32* words, size_t word_count); + void WriteWords(const std::span words); + void WriteString(std::string_view str); + void WriteBytes(const void* data, size_t data_size_in_bytes); + void EndPacket(); + + void WriteGP1Command(GP1Command command, u32 param); + + void BeginGP0Packet(u32 size); + void WriteGP0Packet(u32 word); + void EndGP0Packet(); + void WriteGP1Packet(u32 value); + + void WriteDiscardVRAMRead(u32 width, u32 height); + void WriteVSync(u64 ticks); + +private: + Recorder(FileSystem::AtomicRenamedFile fp, u32 vsyncs_remaining, std::string path); + + void WriteHeaders(std::string_view serial); + void WriteCurrentVRAM(); + + FileSystem::AtomicRenamedFile m_fp; + std::vector m_packet_buffer; + u32 m_vsyncs_remaining = 0; + PacketType m_current_packet = PacketType::Comment; + bool m_write_error = false; + + std::string m_path; +}; + +class Player +{ +public: + ~Player(); + + ALWAYS_INLINE const std::string& GetPath() const { return m_path; } + ALWAYS_INLINE const std::string& GetSerial() const { return m_serial; } + ALWAYS_INLINE ConsoleRegion GetRegion() const { return m_region; } + + static std::unique_ptr Open(std::string path, Error* error); + + void Execute(); + +private: + Player(std::string path, DynamicHeapArray data); + + struct PacketRef + { + PacketType type; + std::span data; + + std::string_view GetNullTerminatedString() const; + }; + + std::optional GetNextPacket(); + + bool Preprocess(Error* error); + bool ProcessHeader(Error* error); + bool FindFrameStarts(Error* error); + + void ProcessPacket(const PacketRef& pkt); + + DynamicHeapArray m_data; + size_t m_start_offset = 0; + size_t m_position = 0; + + std::string m_path; + std::string m_serial; + ConsoleRegion m_region = ConsoleRegion::NTSC_U; + std::vector m_frame_offsets; +}; + +} // namespace GPUDump diff --git a/src/core/gpu_types.h b/src/core/gpu_types.h index b9a52ffc9..137264ec5 100644 --- a/src/core/gpu_types.h +++ b/src/core/gpu_types.h @@ -44,6 +44,14 @@ enum : s32 MAX_PRIMITIVE_HEIGHT = 512, }; +enum class GPUDMADirection : u8 +{ + Off = 0, + FIFO = 1, + CPUtoGP0 = 2, + GPUREADtoCPU = 3 +}; + enum class GPUPrimitive : u8 { Reserved = 0, @@ -92,6 +100,20 @@ enum class GPUInterlacedDisplayMode : u8 SeparateFields }; +enum class GP1Command : u8 +{ + ResetGPU = 0x00, + ClearFIFO = 0x01, + AcknowledgeInterrupt = 0x02, + SetDisplayDisable = 0x03, + SetDMADirection = 0x04, + SetDisplayStartAddress = 0x05, + SetHorizontalDisplayRange = 0x06, + SetVerticalDisplayRange = 0x07, + SetDisplayMode = 0x08, + SetAllowTextureDisable = 0x09, +}; + // NOTE: Inclusive, not exclusive on the upper bounds. struct GPUDrawingArea { @@ -142,6 +164,68 @@ union GPURenderCommand } }; +union GP1SetDisplayMode +{ + u32 bits; + + BitField horizontal_resolution_1; + BitField vertical_resolution; + BitField pal_mode; + BitField display_area_color_depth; + BitField vertical_interlace; + BitField horizontal_resolution_2; + BitField reverse_flag; +}; + +union GPUSTAT +{ + // During transfer/render operations, if ((dst_pixel & mask_and) == 0) { pixel = src_pixel | mask_or } + + u32 bits; + BitField texture_page_x_base; + BitField texture_page_y_base; + BitField semi_transparency_mode; + BitField texture_color_mode; + BitField dither_enable; + BitField draw_to_displayed_field; + BitField set_mask_while_drawing; + BitField check_mask_before_draw; + BitField interlaced_field; + BitField reverse_flag; + BitField texture_disable; + BitField horizontal_resolution_2; + BitField horizontal_resolution_1; + BitField vertical_resolution; + BitField pal_mode; + BitField display_area_color_depth_24; + BitField vertical_interlace; + BitField display_disable; + BitField interrupt_request; + BitField dma_data_request; + BitField gpu_idle; + BitField ready_to_send_vram; + BitField ready_to_recieve_dma; + BitField dma_direction; + BitField display_line_lsb; + + ALWAYS_INLINE bool IsMaskingEnabled() const + { + static constexpr u32 MASK = ((1 << 11) | (1 << 12)); + return ((bits & MASK) != 0); + } + ALWAYS_INLINE bool SkipDrawingToActiveField() const + { + static constexpr u32 MASK = (1 << 19) | (1 << 22) | (1 << 10); + static constexpr u32 ACTIVE = (1 << 19) | (1 << 22); + return ((bits & MASK) == ACTIVE); + } + ALWAYS_INLINE bool InInterleaved480iMode() const + { + static constexpr u32 ACTIVE = (1 << 19) | (1 << 22); + return ((bits & ACTIVE) == ACTIVE); + } +}; + ALWAYS_INLINE static constexpr u32 VRAMRGBA5551ToRGBA8888(u32 color) { // Helper/format conversion functions - constants from https://stackoverflow.com/a/9069480 diff --git a/src/core/hotkeys.cpp b/src/core/hotkeys.cpp index e0143abd2..9d1078b9f 100644 --- a/src/core/hotkeys.cpp +++ b/src/core/hotkeys.cpp @@ -224,6 +224,20 @@ DEFINE_HOTKEY("Screenshot", TRANSLATE_NOOP("Hotkeys", "General"), TRANSLATE_NOOP System::SaveScreenshot(); }) +DEFINE_HOTKEY("RecordSingleFrameGPUDump", TRANSLATE_NOOP("Hotkeys", "Graphics"), + TRANSLATE_NOOP("Hotkeys", "Record Single Frame GPU Trace"), [](s32 pressed) { + if (!pressed) + System::StartRecordingGPUDump(nullptr, 1); + }) + +DEFINE_HOTKEY("RecordMultiFrameGPUDump", TRANSLATE_NOOP("Hotkeys", "Graphics"), + TRANSLATE_NOOP("Hotkeys", "Record Multi-Frame GPU Trace"), [](s32 pressed) { + if (pressed > 0) + System::StartRecordingGPUDump(nullptr, 0); + else + System::StopRecordingGPUDump(); + }) + #ifndef __ANDROID__ DEFINE_HOTKEY("ToggleMediaCapture", TRANSLATE_NOOP("Hotkeys", "General"), TRANSLATE_NOOP("Hotkeys", "Toggle Media Capture"), [](s32 pressed) { diff --git a/src/core/settings.cpp b/src/core/settings.cpp index 9be77feb8..afd58d2b7 100644 --- a/src/core/settings.cpp +++ b/src/core/settings.cpp @@ -248,6 +248,7 @@ void Settings::Load(SettingsInterface& si, SettingsInterface& controller_si) gpu_pgxp_depth_buffer = si.GetBoolValue("GPU", "PGXPDepthBuffer", false); gpu_pgxp_disable_2d = si.GetBoolValue("GPU", "PGXPDisableOn2DPolygons", false); SetPGXPDepthClearThreshold(si.GetFloatValue("GPU", "PGXPDepthClearThreshold", DEFAULT_GPU_PGXP_DEPTH_THRESHOLD)); + gpu_dump_fast_replay_mode = si.GetBoolValue("GPU", "DumpFastReplayMode", false); display_deinterlacing_mode = ParseDisplayDeinterlacingMode( @@ -570,6 +571,7 @@ void Settings::Save(SettingsInterface& si, bool ignore_base) const si.SetBoolValue("GPU", "PGXPDepthBuffer", gpu_pgxp_depth_buffer); si.SetBoolValue("GPU", "PGXPDisableOn2DPolygons", gpu_pgxp_disable_2d); si.SetFloatValue("GPU", "PGXPDepthClearThreshold", GetPGXPDepthClearThreshold()); + si.SetBoolValue("GPU", "DumpFastReplayMode", gpu_dump_fast_replay_mode); si.SetStringValue("GPU", "DeinterlacingMode", GetDisplayDeinterlacingModeName(display_deinterlacing_mode)); si.SetStringValue("Display", "CropMode", GetDisplayCropModeName(display_crop_mode)); @@ -1519,6 +1521,42 @@ const char* Settings::GetGPUWireframeModeDisplayName(GPUWireframeMode mode) "GPUWireframeMode"); } +static constexpr const std::array s_gpu_dump_compression_mode_names = {"Disabled", "ZstLow", "ZstDefault", "ZstHigh"}; +static constexpr const std::array s_gpu_dump_compression_mode_display_names = { + TRANSLATE_DISAMBIG_NOOP("Settings", "Disabled", "GPUDumpCompressionMode"), + TRANSLATE_DISAMBIG_NOOP("Settings", "Zstandard (Low)", "GPUDumpCompressionMode"), + TRANSLATE_DISAMBIG_NOOP("Settings", "Zstandard (Default)", "GPUDumpCompressionMode"), + TRANSLATE_DISAMBIG_NOOP("Settings", "Zstandard (High)", "GPUDumpCompressionMode"), +}; +static_assert(s_gpu_dump_compression_mode_names.size() == static_cast(GPUDumpCompressionMode::MaxCount)); +static_assert(s_gpu_dump_compression_mode_display_names.size() == + static_cast(GPUDumpCompressionMode::MaxCount)); + +std::optional Settings::ParseGPUDumpCompressionMode(const char* str) +{ + int index = 0; + for (const char* name : s_gpu_dump_compression_mode_names) + { + if (StringUtil::Strcasecmp(name, str) == 0) + return static_cast(index); + + index++; + } + + return std::nullopt; +} + +const char* Settings::GetGPUDumpCompressionModeName(GPUDumpCompressionMode mode) +{ + return s_gpu_dump_compression_mode_names[static_cast(mode)]; +} + +const char* Settings::GetGPUDumpCompressionModeDisplayName(GPUDumpCompressionMode mode) +{ + return Host::TranslateToCString("Settings", s_gpu_dump_compression_mode_display_names[static_cast(mode)], + "GPUDumpCompressionMode"); +} + static constexpr const std::array s_display_deinterlacing_mode_names = { "Disabled", "Weave", "Blend", "Adaptive", "Progressive", }; diff --git a/src/core/settings.h b/src/core/settings.h index bac091b50..d5a134778 100644 --- a/src/core/settings.h +++ b/src/core/settings.h @@ -279,6 +279,7 @@ struct Settings bool bios_patch_fast_boot : 1 = DEFAULT_FAST_BOOT_VALUE; bool bios_fast_forward_boot : 1 = false; bool enable_8mb_ram : 1 = false; + bool gpu_dump_fast_replay_mode : 1 = false; std::array controller_types{}; std::array memory_card_types{}; @@ -423,6 +424,10 @@ struct Settings static const char* GetGPUWireframeModeName(GPUWireframeMode mode); static const char* GetGPUWireframeModeDisplayName(GPUWireframeMode mode); + static std::optional ParseGPUDumpCompressionMode(const char* str); + static const char* GetGPUDumpCompressionModeName(GPUDumpCompressionMode mode); + static const char* GetGPUDumpCompressionModeDisplayName(GPUDumpCompressionMode mode); + static std::optional ParseDisplayDeinterlacingMode(const char* str); static const char* GetDisplayDeinterlacingModeName(DisplayDeinterlacingMode mode); static const char* GetDisplayDeinterlacingModeDisplayName(DisplayDeinterlacingMode mode); @@ -485,6 +490,7 @@ struct Settings static constexpr GPULineDetectMode DEFAULT_GPU_LINE_DETECT_MODE = GPULineDetectMode::Disabled; static constexpr GPUDownsampleMode DEFAULT_GPU_DOWNSAMPLE_MODE = GPUDownsampleMode::Disabled; static constexpr GPUWireframeMode DEFAULT_GPU_WIREFRAME_MODE = GPUWireframeMode::Disabled; + static constexpr GPUDumpCompressionMode DEFAULT_GPU_DUMP_COMPRESSION_MODE = GPUDumpCompressionMode::ZstDefault; static constexpr ConsoleRegion DEFAULT_CONSOLE_REGION = ConsoleRegion::Auto; static constexpr float DEFAULT_GPU_PGXP_DEPTH_THRESHOLD = 300.0f; static constexpr float GPU_PGXP_DEPTH_THRESHOLD_SCALE = 4096.0f; diff --git a/src/core/system.cpp b/src/core/system.cpp index 43ae3510b..61c2a0b50 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -16,6 +16,7 @@ #include "game_database.h" #include "game_list.h" #include "gpu.h" +#include "gpu_dump.h" #include "gpu_hw_texture_cache.h" #include "gte.h" #include "host.h" @@ -167,6 +168,7 @@ static bool CreateGPU(GPURenderer renderer, bool is_switching, bool fullscreen, static bool RecreateGPU(GPURenderer renderer, bool force_recreate_device = false, bool update_display = true); static void HandleHostGPUDeviceLost(); static void HandleExclusiveFullscreenLost(); +static std::string GetScreenshotPath(const char* extension); /// Returns true if boot is being fast forwarded. static bool IsFastForwardingBoot(); @@ -224,6 +226,9 @@ static void DoRewind(); static void SaveRunaheadState(); static bool DoRunahead(); +static bool OpenGPUDump(std::string path, Error* error); +static bool ChangeGPUDump(std::string new_path); + static void UpdateSessionTime(const std::string& prev_serial); #ifdef ENABLE_DISCORD_PRESENCE @@ -320,6 +325,7 @@ static Common::Timer s_frame_timer; static Threading::ThreadHandle s_cpu_thread_handle; static std::unique_ptr s_media_capture; +static std::unique_ptr s_gpu_dump_player; // temporary save state, created when loading, used to undo load state static std::optional s_undo_load_state; @@ -631,6 +637,11 @@ bool System::IsExecuting() return s_system_executing; } +bool System::IsReplayingGPUDump() +{ + return static_cast(s_gpu_dump_player); +} + bool System::IsStartupCancelled() { return s_startup_cancelled.load(); @@ -845,24 +856,30 @@ u32 System::GetFrameTimeHistoryPos() return s_frame_time_history_pos; } -bool System::IsExeFileName(std::string_view path) +bool System::IsExePath(std::string_view path) { return (StringUtil::EndsWithNoCase(path, ".exe") || StringUtil::EndsWithNoCase(path, ".psexe") || StringUtil::EndsWithNoCase(path, ".ps-exe") || StringUtil::EndsWithNoCase(path, ".psx")); } -bool System::IsPsfFileName(std::string_view path) +bool System::IsPsfPath(std::string_view path) { return (StringUtil::EndsWithNoCase(path, ".psf") || StringUtil::EndsWithNoCase(path, ".minipsf")); } -bool System::IsLoadableFilename(std::string_view path) +bool System::IsGPUDumpPath(std::string_view path) +{ + return (StringUtil::EndsWithNoCase(path, ".psxgpu") || StringUtil::EndsWithNoCase(path, ".psxgpu.zst")); +} + +bool System::IsLoadablePath(std::string_view path) { static constexpr const std::array extensions = { - ".bin", ".cue", ".img", ".iso", ".chd", ".ecm", ".mds", // discs - ".exe", ".psexe", ".ps-exe", ".psx", // exes - ".psf", ".minipsf", // psf - ".m3u", // playlists + ".bin", ".cue", ".img", ".iso", ".chd", ".ecm", ".mds", // discs + ".exe", ".psexe", ".ps-exe", ".psx", // exes + ".psf", ".minipsf", // psf + ".psxgpu", ".psxgpu.zst", // gpu dump + ".m3u", // playlists ".pbp", }; @@ -875,7 +892,7 @@ bool System::IsLoadableFilename(std::string_view path) return false; } -bool System::IsSaveStateFilename(std::string_view path) +bool System::IsSaveStatePath(std::string_view path) { return StringUtil::EndsWithNoCase(path, ".sav"); } @@ -1556,7 +1573,8 @@ bool System::UpdateGameSettingsLayer() s_input_settings_interface = std::move(input_interface); s_input_profile_name = std::move(input_profile_name); - Cheats::ReloadCheats(false, true, false, true); + if (!IsReplayingGPUDump()) + Cheats::ReloadCheats(false, true, false, true); return true; } @@ -1693,16 +1711,29 @@ bool System::BootSystem(SystemBootParameters parameters, Error* error) std::string exe_override; if (!parameters.filename.empty()) { - if (IsExeFileName(parameters.filename)) + if (IsExePath(parameters.filename)) { boot_mode = BootMode::BootEXE; exe_override = parameters.filename; } - else if (IsPsfFileName(parameters.filename)) + else if (IsPsfPath(parameters.filename)) { boot_mode = BootMode::BootPSF; exe_override = parameters.filename; } + else if (IsGPUDumpPath(parameters.filename)) + { + if (!OpenGPUDump(parameters.filename, error)) + { + s_state = State::Shutdown; + Host::OnSystemDestroyed(); + Host::OnIdleStateChanged(); + return false; + } + + boot_mode = BootMode::ReplayGPUDump; + } + if (boot_mode == BootMode::BootEXE || boot_mode == BootMode::BootPSF) { if (s_region == ConsoleRegion::Auto) @@ -1714,7 +1745,7 @@ bool System::BootSystem(SystemBootParameters parameters, Error* error) s_region = GetConsoleRegionForDiscRegion(file_region); } } - else + else if (boot_mode != BootMode::ReplayGPUDump) { INFO_LOG("Loading CD image '{}'...", Path::GetFileName(parameters.filename)); disc = CDImage::Open(parameters.filename.c_str(), g_settings.cdrom_load_image_patches, error); @@ -1770,6 +1801,7 @@ bool System::BootSystem(SystemBootParameters parameters, Error* error) Error::AddPrefixFmt(error, "Failed to switch to subimage {} in '{}':\n", parameters.media_playlist_index, Path::GetFileName(parameters.filename)); s_state = State::Shutdown; + s_gpu_dump_player.reset(); Host::OnSystemDestroyed(); Host::OnIdleStateChanged(); return false; @@ -1781,11 +1813,12 @@ bool System::BootSystem(SystemBootParameters parameters, Error* error) // Get boot EXE override. if (!parameters.override_exe.empty()) { - if (!FileSystem::FileExists(parameters.override_exe.c_str()) || !IsExeFileName(parameters.override_exe)) + if (!FileSystem::FileExists(parameters.override_exe.c_str()) || !IsExePath(parameters.override_exe)) { Error::SetStringFmt(error, "File '{}' is not a valid executable to boot.", Path::GetFileName(parameters.override_exe)); s_state = State::Shutdown; + s_gpu_dump_player.reset(); Cheats::UnloadAll(); ClearRunningGame(); Host::OnSystemDestroyed(); @@ -1802,6 +1835,7 @@ bool System::BootSystem(SystemBootParameters parameters, Error* error) if (!CheckForSBIFile(disc.get(), error)) { s_state = State::Shutdown; + s_gpu_dump_player.reset(); Cheats::UnloadAll(); ClearRunningGame(); Host::OnSystemDestroyed(); @@ -1840,6 +1874,7 @@ bool System::BootSystem(SystemBootParameters parameters, Error* error) if (cancelled) { s_state = State::Shutdown; + s_gpu_dump_player.reset(); Cheats::UnloadAll(); ClearRunningGame(); Host::OnSystemDestroyed(); @@ -1854,6 +1889,7 @@ bool System::BootSystem(SystemBootParameters parameters, Error* error) if (!SetBootMode(boot_mode, disc_region, error)) { s_state = State::Shutdown; + s_gpu_dump_player.reset(); Cheats::UnloadAll(); ClearRunningGame(); Host::OnSystemDestroyed(); @@ -1867,6 +1903,7 @@ bool System::BootSystem(SystemBootParameters parameters, Error* error) { s_boot_mode = System::BootMode::None; s_state = State::Shutdown; + s_gpu_dump_player.reset(); Cheats::UnloadAll(); ClearRunningGame(); Host::OnSystemDestroyed(); @@ -2038,6 +2075,8 @@ void System::DestroySystem() ImGuiManager::DestroyAllDebugWindows(); + s_gpu_dump_player.reset(); + s_undo_load_state.reset(); #ifdef ENABLE_GDB_SERVER @@ -2133,7 +2172,9 @@ void System::Execute() g_gpu->RestoreDeviceContext(); TimingEvents::CommitLeftoverTicks(); - if (s_rewind_load_counter >= 0) + if (s_gpu_dump_player) [[unlikely]] + s_gpu_dump_player->Execute(); + else if (s_rewind_load_counter >= 0) DoRewind(); else CPU::Execute(); @@ -2164,12 +2205,15 @@ void System::FrameDone() // Generate any pending samples from the SPU before sleeping, this way we reduce the chances of underruns. // TODO: when running ahead, we can skip this (and the flush above) - SPU::GeneratePendingSamples(); + if (!IsReplayingGPUDump()) [[likely]] + { + SPU::GeneratePendingSamples(); - Cheats::ApplyFrameEndCodes(); + Cheats::ApplyFrameEndCodes(); - if (Achievements::IsActive()) - Achievements::FrameUpdate(); + if (Achievements::IsActive()) + Achievements::FrameUpdate(); + } #ifdef ENABLE_DISCORD_PRESENCE PollDiscordPresence(); @@ -2697,7 +2741,7 @@ bool System::SetBootMode(BootMode new_boot_mode, DiscRegion disc_region, Error* return true; // Need to reload the BIOS to wipe out the patching. - if (!LoadBIOS(error)) + if (new_boot_mode != BootMode::ReplayGPUDump && !LoadBIOS(error)) return false; // Handle the case of BIOSes not being able to full boot. @@ -2745,9 +2789,9 @@ std::string System::GetMediaPathFromSaveState(const char* path) bool System::LoadState(const char* path, Error* error, bool save_undo_state) { - if (!IsValid()) + if (!IsValid() || IsReplayingGPUDump()) { - Error::SetStringView(error, "System is not booted."); + Error::SetStringView(error, TRANSLATE_SV("System", "System is not in correct state.")); return false; } @@ -3067,7 +3111,12 @@ bool System::ReadAndDecompressStateData(std::FILE* fp, std::span dst, u32 fi bool System::SaveState(const char* path, Error* error, bool backup_existing_save) { - if (IsSavingMemoryCards()) + if (!IsValid() || IsReplayingGPUDump()) + { + Error::SetStringView(error, TRANSLATE_SV("System", "System is not in correct state.")); + return false; + } + else if (IsSavingMemoryCards()) { Error::SetStringView(error, TRANSLATE_SV("System", "Cannot save state while memory card is being saved.")); return false; @@ -3780,7 +3829,7 @@ void System::ResetControllers() std::unique_ptr System::GetMemoryCardForSlot(u32 slot, MemoryCardType type) { // Disable memory cards when running PSFs. - const bool is_running_psf = !s_running_game_path.empty() && IsPsfFileName(s_running_game_path.c_str()); + const bool is_running_psf = !s_running_game_path.empty() && IsPsfPath(s_running_game_path.c_str()); if (is_running_psf) return nullptr; @@ -4070,6 +4119,9 @@ std::string System::GetMediaFileName() bool System::InsertMedia(const char* path) { + if (IsGPUDumpPath(path)) [[unlikely]] + return ChangeGPUDump(path); + Error error; std::unique_ptr image = CDImage::Open(path, g_settings.cdrom_load_image_patches, &error); if (!image) @@ -4130,7 +4182,7 @@ void System::UpdateRunningGame(const std::string_view path, CDImage* image, bool s_running_game_title = GameList::GetCustomTitleForPath(s_running_game_path); s_running_game_custom_title = !s_running_game_title.empty(); - if (IsExeFileName(path)) + if (IsExePath(path)) { if (s_running_game_title.empty()) s_running_game_title = Path::GetFileTitle(FileSystem::GetDisplayNameFromPath(path)); @@ -4139,12 +4191,28 @@ void System::UpdateRunningGame(const std::string_view path, CDImage* image, bool if (s_running_game_hash != 0) s_running_game_serial = GetGameHashId(s_running_game_hash); } - else if (IsPsfFileName(path)) + else if (IsPsfPath(path)) { // TODO: We could pull the title from the PSF. if (s_running_game_title.empty()) s_running_game_title = Path::GetFileTitle(path); } + else if (IsGPUDumpPath(path)) + { + DebugAssert(s_gpu_dump_player); + if (s_gpu_dump_player) + { + s_running_game_serial = s_gpu_dump_player->GetSerial(); + if (!s_running_game_serial.empty()) + { + s_running_game_entry = GameDatabase::GetEntryForSerial(s_running_game_serial); + if (s_running_game_entry && s_running_game_title.empty()) + s_running_game_title = s_running_game_entry->title; + else if (s_running_game_title.empty()) + s_running_game_title = s_running_game_serial; + } + } + } // Check for an audio CD. Those shouldn't set any title. else if (image && image->GetTrack(1).mode != CDImage::TrackMode::Audio) { @@ -4180,13 +4248,17 @@ void System::UpdateRunningGame(const std::string_view path, CDImage* image, bool if (!booting) GPUTextureCache::SetGameID(s_running_game_serial); - if (booting) - Achievements::ResetHardcoreMode(true); + if (!IsReplayingGPUDump()) + { + if (booting) + Achievements::ResetHardcoreMode(true); - Achievements::GameChanged(s_running_game_path, image); + Achievements::GameChanged(s_running_game_path, image); + + // game layer reloads cheats, but only the active list, we need new files + Cheats::ReloadCheats(true, false, false, true); + } - // game layer reloads cheats, but only the active list, we need new files - Cheats::ReloadCheats(true, false, false, true); UpdateGameSettingsLayer(); ApplySettings(true); @@ -4879,6 +4951,14 @@ void System::UpdateMemorySaveStateSettings() { ClearMemorySaveStates(); + if (IsReplayingGPUDump()) [[unlikely]] + { + s_memory_saves_enabled = false; + s_rewind_save_counter = -1; + s_runahead_frames = 0; + return; + } + s_memory_saves_enabled = g_settings.rewind_enable; if (g_settings.rewind_enable) @@ -5259,38 +5339,60 @@ void System::UpdateVolume() SPU::GetOutputStream()->SetOutputVolume(GetAudioOutputVolume()); } -bool System::SaveScreenshot(const char* filename, DisplayScreenshotMode mode, DisplayScreenshotFormat format, - u8 quality, bool compress_on_thread) +std::string System::GetScreenshotPath(const char* extension) { - if (!System::IsValid()) - return false; + const std::string sanitized_name = Path::SanitizeFileName(System::GetGameTitle()); + std::string basename; + if (sanitized_name.empty()) + basename = fmt::format("{}", GetTimestampStringForFileName()); + else + basename = fmt::format("{} {}", sanitized_name, GetTimestampStringForFileName()); - std::string auto_filename; - if (!filename) + std::string path = fmt::format("{}" FS_OSPATH_SEPARATOR_STR "{}.{}", EmuFolders::Screenshots, basename, extension); + + // handle quick screenshots to the same filename + u32 next_suffix = 1; + while (FileSystem::FileExists(path.c_str())) { - const std::string sanitized_name = Path::SanitizeFileName(System::GetGameTitle()); - const char* extension = Settings::GetDisplayScreenshotFormatExtension(format); - std::string basename; - if (sanitized_name.empty()) - basename = fmt::format("{}", GetTimestampStringForFileName()); - else - basename = fmt::format("{} {}", sanitized_name, GetTimestampStringForFileName()); - - auto_filename = fmt::format("{}" FS_OSPATH_SEPARATOR_STR "{}.{}", EmuFolders::Screenshots, basename, extension); - - // handle quick screenshots to the same filename - u32 next_suffix = 1; - while (FileSystem::FileExists(auto_filename.c_str())) - { - auto_filename = fmt::format("{}" FS_OSPATH_SEPARATOR_STR "{} ({}).{}", EmuFolders::Screenshots, basename, - next_suffix, extension); - next_suffix++; - } - - filename = auto_filename.c_str(); + path = + fmt::format("{}" FS_OSPATH_SEPARATOR_STR "{} ({}).{}", EmuFolders::Screenshots, basename, next_suffix, extension); + next_suffix++; } - return g_gpu->RenderScreenshotToFile(filename, mode, quality, compress_on_thread, true); + return path; +} + +bool System::SaveScreenshot(const char* path, DisplayScreenshotMode mode, DisplayScreenshotFormat format, u8 quality, + bool compress_on_thread) +{ + if (!IsValid()) + return false; + + std::string auto_path; + if (!path) + path = (auto_path = GetScreenshotPath(Settings::GetDisplayScreenshotFormatExtension(format))).c_str(); + + return g_gpu->RenderScreenshotToFile(path, mode, quality, compress_on_thread, true); +} + +bool System::StartRecordingGPUDump(const char* path /*= nullptr*/, u32 num_frames /*= 0*/) +{ + if (!IsValid() || IsReplayingGPUDump()) + return false; + + std::string auto_path; + if (!path) + path = (auto_path = GetScreenshotPath("psxgpu")).c_str(); + + return g_gpu->StartRecordingGPUDump(path, num_frames); +} + +void System::StopRecordingGPUDump() +{ + if (!IsValid()) + return; + + g_gpu->StopRecordingGPUDump(); } static std::string_view GetCaptureTypeForMessage(bool capture_video, bool capture_audio) @@ -5833,6 +5935,34 @@ void System::InvalidateDisplay() g_gpu->RestoreDeviceContext(); } +bool System::OpenGPUDump(std::string path, Error* error) +{ + std::unique_ptr new_dump = GPUDump::Player::Open(std::move(path), error); + if (!new_dump) + return false; + + // set properties + s_gpu_dump_player = std::move(new_dump); + s_region = s_gpu_dump_player->GetRegion(); + return true; +} + +bool System::ChangeGPUDump(std::string new_path) +{ + Error error; + if (!OpenGPUDump(std::move(new_path), &error)) + { + Host::ReportErrorAsync("Error", fmt::format(TRANSLATE_FS("Failed to change GPU dump: {}", error.GetDescription()))); + return false; + } + + UpdateRunningGame(s_gpu_dump_player->GetPath(), nullptr, false); + + // current player object has been changed out, toss call stack + InterruptExecution(); + return true; +} + void System::UpdateSessionTime(const std::string& prev_serial) { const u64 ctime = Common::Timer::GetCurrentValue(); diff --git a/src/core/system.h b/src/core/system.h index 885e53247..29f6eff0f 100644 --- a/src/core/system.h +++ b/src/core/system.h @@ -102,6 +102,7 @@ enum class BootMode FastBoot, BootEXE, BootPSF, + ReplayGPUDump, }; enum class Taint : u8 @@ -118,17 +119,20 @@ enum class Taint : u8 extern TickCount g_ticks_per_second; -/// Returns true if the filename is a PlayStation executable we can inject. -bool IsExeFileName(std::string_view path); +/// Returns true if the path is a PlayStation executable we can inject. +bool IsExePath(std::string_view path); -/// Returns true if the filename is a Portable Sound Format file we can uncompress/load. -bool IsPsfFileName(std::string_view path); +/// Returns true if the path is a Portable Sound Format file we can uncompress/load. +bool IsPsfPath(std::string_view path); -/// Returns true if the filename is one we can load. -bool IsLoadableFilename(std::string_view path); +/// Returns true if the path is a GPU dump that we can replay. +bool IsGPUDumpPath(std::string_view path); -/// Returns true if the filename is a save state. -bool IsSaveStateFilename(std::string_view path); +/// Returns true if the path is one we can load. +bool IsLoadablePath(std::string_view path); + +/// Returns true if the path is a save state. +bool IsSaveStatePath(std::string_view path); /// Returns the preferred console type for a disc. ConsoleRegion GetConsoleRegionForDiscRegion(DiscRegion region); @@ -159,6 +163,7 @@ bool IsShutdown(); bool IsValid(); bool IsValidOrInitializing(); bool IsExecuting(); +bool IsReplayingGPUDump(); bool IsStartupCancelled(); void CancelPendingStartup(); @@ -390,10 +395,14 @@ s32 GetAudioOutputVolume(); void UpdateVolume(); /// Saves a screenshot to the specified file. If no file name is provided, one will be generated automatically. -bool SaveScreenshot(const char* filename = nullptr, DisplayScreenshotMode mode = g_settings.display_screenshot_mode, +bool SaveScreenshot(const char* path = nullptr, DisplayScreenshotMode mode = g_settings.display_screenshot_mode, DisplayScreenshotFormat format = g_settings.display_screenshot_format, u8 quality = g_settings.display_screenshot_quality, bool compress_on_thread = true); +/// Starts/stops GPU dump/trace recording. +bool StartRecordingGPUDump(const char* path = nullptr, u32 num_frames = 1); +void StopRecordingGPUDump(); + /// Returns the path that a new media capture would be saved to by default. Safe to call from any thread. std::string GetNewMediaCapturePath(const std::string_view title, const std::string_view container); diff --git a/src/core/timers.cpp b/src/core/timers.cpp index 8e6860b9a..4f99eefa1 100644 --- a/src/core/timers.cpp +++ b/src/core/timers.cpp @@ -5,6 +5,7 @@ #include "gpu.h" #include "interrupt_controller.h" #include "system.h" +#include "timing_event.h" #include "util/imgui_manager.h" #include "util/state_wrapper.h" @@ -74,7 +75,7 @@ static void UpdateSysClkEvent(); namespace { struct TimersState { - TimingEvent sysclk_event{ "Timer SysClk Interrupt", 1, 1, &Timers::AddSysClkTicks, nullptr }; + TimingEvent sysclk_event{"Timer SysClk Interrupt", 1, 1, &Timers::AddSysClkTicks, nullptr}; std::array counters{}; TickCount sysclk_ticks_carry = 0; // 0 unless overclocking is enabled diff --git a/src/core/timing_event.cpp b/src/core/timing_event.cpp index e0882726a..41101d95d 100644 --- a/src/core/timing_event.cpp +++ b/src/core/timing_event.cpp @@ -88,6 +88,11 @@ TimingEvent** TimingEvents::GetHeadEventPtr() return &s_state.active_events_head; } +void TimingEvents::SetGlobalTickCounter(GlobalTicks ticks) +{ + s_state.global_tick_counter = ticks; +} + void TimingEvents::SortEvent(TimingEvent* event) { const GlobalTicks event_runtime = event->m_next_run_time; diff --git a/src/core/timing_event.h b/src/core/timing_event.h index c481ad79a..778287443 100644 --- a/src/core/timing_event.h +++ b/src/core/timing_event.h @@ -94,4 +94,7 @@ void UpdateCPUDowncount(); TimingEvent** GetHeadEventPtr(); +// Tick counter injection, only for GPU dump replayer. +void SetGlobalTickCounter(GlobalTicks ticks); + } // namespace TimingEvents \ No newline at end of file diff --git a/src/core/types.h b/src/core/types.h index bcf3cd30c..918670f8d 100644 --- a/src/core/types.h +++ b/src/core/types.h @@ -126,6 +126,16 @@ enum class GPULineDetectMode : u8 Count }; +enum class GPUDumpCompressionMode : u8 +{ + Disabled, + ZstLow, + ZstDefault, + ZstHigh, + // TODO: XZ + MaxCount +}; + enum class DisplayCropMode : u8 { None, diff --git a/src/duckstation-qt/graphicssettingswidget.cpp b/src/duckstation-qt/graphicssettingswidget.cpp index 947a23cb7..c0b52fd92 100644 --- a/src/duckstation-qt/graphicssettingswidget.cpp +++ b/src/duckstation-qt/graphicssettingswidget.cpp @@ -294,6 +294,12 @@ GraphicsSettingsWidget::GraphicsSettingsWidget(SettingsWindow* dialog, QWidget* SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.gpuThread, "GPU", "UseThread", true); + SettingWidgetBinder::BindWidgetToEnumSetting( + sif, m_ui.gpuDumpCompressionMode, "GPU", "DumpCompressionMode", &Settings::ParseGPUDumpCompressionMode, + &Settings::GetGPUDumpCompressionModeName, &Settings::GetGPUDumpCompressionModeDisplayName, + Settings::DEFAULT_GPU_DUMP_COMPRESSION_MODE, GPUDumpCompressionMode::MaxCount); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.gpuDumpFastReplayMode, "GPU", "DumpFastReplayMode", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.useDebugDevice, "GPU", "UseDebugDevice", false); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.disableShaderCache, "GPU", "DisableShaderCache", false); SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.disableDualSource, "GPU", "DisableDualSourceBlend", false); diff --git a/src/duckstation-qt/graphicssettingswidget.ui b/src/duckstation-qt/graphicssettingswidget.ui index b42ef0649..e5e7c2f0e 100644 --- a/src/duckstation-qt/graphicssettingswidget.ui +++ b/src/duckstation-qt/graphicssettingswidget.ui @@ -1297,6 +1297,32 @@ + + + + GPU Dump Recording/Playback + + + + + + Dump Compression Mode: + + + + + + + + + + Fast Dump Playback + + + + + + diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp index b83ce567b..99e8b7011 100644 --- a/src/duckstation-qt/mainwindow.cpp +++ b/src/duckstation-qt/mainwindow.cpp @@ -61,12 +61,11 @@ LOG_CHANNEL(MainWindow); static constexpr char DISC_IMAGE_FILTER[] = QT_TRANSLATE_NOOP( - "MainWindow", - "All File Types (*.bin *.img *.iso *.cue *.chd *.ecm *.mds *.pbp *.exe *.psexe *.ps-exe *.psx *.psf *.minipsf " - "*.m3u);;Single-Track " - "Raw Images (*.bin *.img *.iso);;Cue Sheets (*.cue);;MAME CHD Images (*.chd);;Error Code Modeler Images " - "(*.ecm);;Media Descriptor Sidecar Images (*.mds);;PlayStation EBOOTs (*.pbp *.PBP);;PlayStation Executables (*.exe " - "*.psexe *.ps-exe, *.psx);;Portable Sound Format Files (*.psf *.minipsf);;Playlists (*.m3u)"); + "MainWindow", "All File Types (*.bin *.img *.iso *.cue *.chd *.ecm *.mds *.pbp *.exe *.psexe *.ps-exe *.psx *.psf " + "*.minipsf *.m3u *.psxgpu);;Single-Track Raw Images (*.bin *.img *.iso);;Cue Sheets (*.cue);;MAME CHD " + "Images (*.chd);;Error Code Modeler Images (*.ecm);;Media Descriptor Sidecar Images " + "(*.mds);;PlayStation EBOOTs (*.pbp *.PBP);;PlayStation Executables (*.exe *.psexe *.ps-exe, " + "*.psx);;Portable Sound Format Files (*.psf *.minipsf);;Playlists (*.m3u);;PSX GPU Dumps (*.psxgpu)"); MainWindow* g_main_window = nullptr; @@ -1158,7 +1157,7 @@ void MainWindow::promptForDiscChange(const QString& path) SystemLock lock(pauseAndLockSystem()); bool reset_system = false; - if (!m_was_disc_change_request) + if (!m_was_disc_change_request && !System::IsGPUDumpPath(path.toStdString())) { QMessageBox mb(QMessageBox::Question, tr("Confirm Disc Change"), tr("Do you want to swap discs or boot the new image (via system reset)?"), QMessageBox::NoButton, @@ -2002,6 +2001,7 @@ void MainWindow::connectSignals() connect(m_ui.actionMemoryScanner, &QAction::triggered, this, &MainWindow::onToolsMemoryScannerTriggered); connect(m_ui.actionCoverDownloader, &QAction::triggered, this, &MainWindow::onToolsCoverDownloaderTriggered); connect(m_ui.actionMediaCapture, &QAction::toggled, this, &MainWindow::onToolsMediaCaptureToggled); + connect(m_ui.actionCaptureGPUFrame, &QAction::triggered, g_emu_thread, &EmuThread::captureGPUFrameDump); connect(m_ui.actionCPUDebugger, &QAction::triggered, this, &MainWindow::openCPUDebugger); connect(m_ui.actionOpenDataDirectory, &QAction::triggered, this, &MainWindow::onToolsOpenDataDirectoryTriggered); connect(m_ui.actionOpenTextureDirectory, &QAction::triggered, this, @@ -2416,7 +2416,7 @@ static QString getFilenameFromMimeData(const QMimeData* md) void MainWindow::dragEnterEvent(QDragEnterEvent* event) { const std::string filename(getFilenameFromMimeData(event->mimeData()).toStdString()); - if (!System::IsLoadableFilename(filename) && !System::IsSaveStateFilename(filename)) + if (!System::IsLoadablePath(filename) && !System::IsSaveStatePath(filename)) return; event->acceptProposedAction(); @@ -2426,12 +2426,12 @@ void MainWindow::dropEvent(QDropEvent* event) { const QString qfilename(getFilenameFromMimeData(event->mimeData())); const std::string filename(qfilename.toStdString()); - if (!System::IsLoadableFilename(filename) && !System::IsSaveStateFilename(filename)) + if (!System::IsLoadablePath(filename) && !System::IsSaveStatePath(filename)) return; event->acceptProposedAction(); - if (System::IsSaveStateFilename(filename)) + if (System::IsSaveStatePath(filename)) { g_emu_thread->loadState(qfilename); return; diff --git a/src/duckstation-qt/mainwindow.ui b/src/duckstation-qt/mainwindow.ui index b917ac204..687312e91 100644 --- a/src/duckstation-qt/mainwindow.ui +++ b/src/duckstation-qt/mainwindow.ui @@ -214,6 +214,7 @@ + @@ -912,6 +913,11 @@ Reload Texture Replacements + + + Capture GPU Frame + + diff --git a/src/duckstation-qt/qthost.cpp b/src/duckstation-qt/qthost.cpp index 0759437a1..d99242f9c 100644 --- a/src/duckstation-qt/qthost.cpp +++ b/src/duckstation-qt/qthost.cpp @@ -1366,6 +1366,18 @@ void EmuThread::reloadTextureReplacements() GPUTextureCache::ReloadTextureReplacements(true); } +void EmuThread::captureGPUFrameDump() +{ + if (!isCurrentThread()) + { + QMetaObject::invokeMethod(this, "captureGPUFrameDump", Qt::QueuedConnection); + return; + } + + if (System::IsValid()) + System::StartRecordingGPUDump(); +} + void EmuThread::runOnEmuThread(std::function callback) { callback(); diff --git a/src/duckstation-qt/qthost.h b/src/duckstation-qt/qthost.h index 7e21a7ebe..6fa2d6609 100644 --- a/src/duckstation-qt/qthost.h +++ b/src/duckstation-qt/qthost.h @@ -210,6 +210,7 @@ public Q_SLOTS: void updatePostProcessingSettings(); void clearInputBindStateFromSource(InputBindingKey key); void reloadTextureReplacements(); + void captureGPUFrameDump(); private Q_SLOTS: void stopInThread(); diff --git a/src/duckstation-qt/settingswindow.cpp b/src/duckstation-qt/settingswindow.cpp index 6c1c88409..669b2c620 100644 --- a/src/duckstation-qt/settingswindow.cpp +++ b/src/duckstation-qt/settingswindow.cpp @@ -659,7 +659,7 @@ SettingsWindow* SettingsWindow::openGamePropertiesDialog(const std::string& path const char* category /* = nullptr */) { const GameDatabase::Entry* dentry = nullptr; - if (!System::IsExeFileName(path) && !System::IsPsfFileName(path)) + if (!System::IsExePath(path) && !System::IsPsfPath(path)) { // Need to resolve hash games. Error error;