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;