From e9a61c24dff647b0ee7a12a6720b82389ca8b7a4 Mon Sep 17 00:00:00 2001 From: Connor McLaughlin Date: Sun, 19 Jun 2022 15:43:55 +1000 Subject: [PATCH] GSRunner: Add standalone GS runner/dumper --- PCSX2_qt.sln | 8 + pcsx2-gsrunner/CMakeLists.txt | 21 + pcsx2-gsrunner/Main.cpp | 719 ++++++++++++++++++ pcsx2-gsrunner/pcsx2-gsrunner.vcxproj | 123 +++ pcsx2-gsrunner/pcsx2-gsrunner.vcxproj.filters | 6 + pcsx2-gsrunner/test_check_dumps.py | 127 ++++ pcsx2-gsrunner/test_run_dumps.py | 81 ++ 7 files changed, 1085 insertions(+) create mode 100644 pcsx2-gsrunner/CMakeLists.txt create mode 100644 pcsx2-gsrunner/Main.cpp create mode 100644 pcsx2-gsrunner/pcsx2-gsrunner.vcxproj create mode 100644 pcsx2-gsrunner/pcsx2-gsrunner.vcxproj.filters create mode 100644 pcsx2-gsrunner/test_check_dumps.py create mode 100644 pcsx2-gsrunner/test_run_dumps.py diff --git a/PCSX2_qt.sln b/PCSX2_qt.sln index 3c141ab77b..037f6dbba2 100644 --- a/PCSX2_qt.sln +++ b/PCSX2_qt.sln @@ -61,6 +61,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "rainterface", "3rdparty\rai EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "discord-rpc", "3rdparty\discord-rpc\discord-rpc.vcxproj", "{E960DFDF-1BD3-4C29-B251-D1A0919C9B09}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "pcsx2-gsrunner", "pcsx2-gsrunner\pcsx2-gsrunner.vcxproj", "{BB98BF81-A132-444A-BB81-96D510F433A8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug AVX2|x64 = Debug AVX2|x64 @@ -407,6 +409,12 @@ Global {E960DFDF-1BD3-4C29-B251-D1A0919C9B09}.Release AVX2|x64.Build.0 = Release|x64 {E960DFDF-1BD3-4C29-B251-D1A0919C9B09}.Release|x64.ActiveCfg = Release|x64 {E960DFDF-1BD3-4C29-B251-D1A0919C9B09}.Release|x64.Build.0 = Release|x64 + {BB98BF81-A132-444A-BB81-96D510F433A8}.Debug AVX2|x64.ActiveCfg = Debug AVX2|x64 + {BB98BF81-A132-444A-BB81-96D510F433A8}.Debug|x64.ActiveCfg = Debug|x64 + {BB98BF81-A132-444A-BB81-96D510F433A8}.Devel AVX2|x64.ActiveCfg = Devel AVX2|x64 + {BB98BF81-A132-444A-BB81-96D510F433A8}.Devel|x64.ActiveCfg = Devel|x64 + {BB98BF81-A132-444A-BB81-96D510F433A8}.Release AVX2|x64.ActiveCfg = Release AVX2|x64 + {BB98BF81-A132-444A-BB81-96D510F433A8}.Release|x64.ActiveCfg = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/pcsx2-gsrunner/CMakeLists.txt b/pcsx2-gsrunner/CMakeLists.txt new file mode 100644 index 0000000000..d2746009f3 --- /dev/null +++ b/pcsx2-gsrunner/CMakeLists.txt @@ -0,0 +1,21 @@ +add_executable(pcsx2-gsrunnner) + +if (PACKAGE_MODE) + install(TARGETS pcsx2-gsrunnner DESTINATION ${CMAKE_INSTALL_BINDIR}) +else() + install(TARGETS pcsx2-gsrunnner DESTINATION ${CMAKE_SOURCE_DIR}/bin) +endif() + +target_sources(pcsx2-gsrunner PRIVATE + Main.cpp +) + +target_include_directories(pcsx2-gsrunner PRIVATE + "${CMAKE_BINARY_DIR}/common/include" + "${CMAKE_SOURCE_DIR}/pcsx2" +) + +target_link_libraries(pcsx2-gsrunner PRIVATE + PCSX2_FLAGS + PCSX2 +) diff --git a/pcsx2-gsrunner/Main.cpp b/pcsx2-gsrunner/Main.cpp new file mode 100644 index 0000000000..63ab732eab --- /dev/null +++ b/pcsx2-gsrunner/Main.cpp @@ -0,0 +1,719 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include "common/RedtapeWindows.h" +#endif + +#include "fmt/core.h" + +#include "common/Assertions.h" +#include "common/Console.h" +#include "common/Exceptions.h" +#include "common/FileSystem.h" +#include "common/MemorySettingsInterface.h" +#include "common/Path.h" +#include "common/SettingsWrapper.h" +#include "common/StringUtil.h" + +#include "pcsx2/PrecompiledHeader.h" + +#include "pcsx2/CDVD/CDVD.h" +#include "pcsx2/Frontend/CommonHost.h" +#include "pcsx2/Frontend/InputManager.h" +#include "pcsx2/Frontend/ImGuiManager.h" +#include "pcsx2/Frontend/LogSink.h" +#include "pcsx2/GS.h" +#include "pcsx2/GS/GS.h" +#include "pcsx2/GSDumpReplayer.h" +#include "pcsx2/HostDisplay.h" +#include "pcsx2/HostSettings.h" +#include "pcsx2/INISettingsInterface.h" +#include "pcsx2/PerformanceMetrics.h" +#include "pcsx2/VMManager.h" + +#ifdef ENABLE_ACHIEVEMENTS +#include "pcsx2/Frontend/Achievements.h" +#endif + +#include "svnrev.h" + +namespace GSRunner +{ + static bool InitializeConfig(); + static bool SetCriticalFolders(); + + static bool CreatePlatformWindow(); + static void DestroyPlatformWindow(); + static std::optional GetPlatformWindowInfo(); + static void PumpPlatformMessages(); +} // namespace GSRunner + +static constexpr u32 WINDOW_WIDTH = 640; +static constexpr u32 WINDOW_HEIGHT = 480; + +static MemorySettingsInterface s_settings_interface; +alignas(16) static SysMtgsThread s_mtgs_thread; + +static std::string s_output_prefix; +static s32 s_loop_count = 1; + +bool GSRunner::SetCriticalFolders() +{ + EmuFolders::AppRoot = Path::Canonicalize(Path::GetDirectory(FileSystem::GetProgramPath())); + EmuFolders::Resources = Path::Combine(EmuFolders::AppRoot, "resources"); + EmuFolders::DataRoot = EmuFolders::AppRoot; + + // allow SetDataDirectory() to change settings directory (if we want to split config later on) + if (EmuFolders::Settings.empty()) + EmuFolders::Settings = Path::Combine(EmuFolders::DataRoot, "inis"); + + // the resources directory should exist, bail out if not + if (!FileSystem::DirectoryExists(EmuFolders::Resources.c_str())) + { + Console.Error("Resources directory is missing, your installation is incomplete."); + return false; + } + + return true; +} + +bool GSRunner::InitializeConfig() +{ + if (!CommonHost::InitializeCriticalFolders()) + return false; + + // don't provide an ini path, or bother loading. we'll store everything in memory. + MemorySettingsInterface& si = s_settings_interface; + Host::Internal::SetBaseSettingsLayer(&si); + + CommonHost::SetDefaultSettings(si, true, true, true, true, true); + + // complete as quickly as possible + si.SetBoolValue("EmuCore/GS", "FrameLimitEnable", false); + si.SetIntValue("EmuCore/GS", "VsyncEnable", static_cast(VsyncMode::Off)); + + // ensure all input sources are disabled, we're not using them + si.SetBoolValue("InputSources", "SDL", false); + si.SetBoolValue("InputSources", "XInput", false); + + // we don't need any sound output + si.SetStringValue("SPU2/Output", "OutputModule", "nullout"); + + // force logging + si.SetBoolValue("Logging", "EnableSystemConsole", true); + si.SetBoolValue("Logging", "EnableTimestamps", true); + si.SetBoolValue("Logging", "EnableVerbose", true); + + // and show some stats :) + si.SetBoolValue("EmuCore/GS", "OsdShowFPS", true); + si.SetBoolValue("EmuCore/GS", "OsdShowResolution", true); + si.SetBoolValue("EmuCore/GS", "OsdShowGSStats", true); + + CommonHost::LoadStartupSettings(); + return true; +} + +void Host::CommitBaseSettingChanges() +{ + // nothing to save, we're all in memory +} + +void Host::LoadSettings(SettingsInterface& si, std::unique_lock& lock) +{ + CommonHost::LoadSettings(si, lock); +} + +void Host::CheckForSettingsChanges(const Pcsx2Config& old_config) +{ + CommonHost::CheckForSettingsChanges(old_config); +} + +bool Host::RequestResetSettings(bool folders, bool core, bool controllers, bool hotkeys, bool ui) +{ + // not running any UI, so no settings requests will come in + return false; +} + +void Host::SetDefaultUISettings(SettingsInterface& si) +{ + // nothing +} + +std::optional> Host::ReadResourceFile(const char* filename) +{ + const std::string path(Path::Combine(EmuFolders::Resources, filename)); + std::optional> ret(FileSystem::ReadBinaryFile(path.c_str())); + if (!ret.has_value()) + Console.Error("Failed to read resource file '%s'", filename); + return ret; +} + +std::optional Host::ReadResourceFileToString(const char* filename) +{ + const std::string path(Path::Combine(EmuFolders::Resources, filename)); + std::optional ret(FileSystem::ReadFileToString(path.c_str())); + if (!ret.has_value()) + Console.Error("Failed to read resource file to string '%s'", filename); + return ret; +} + +std::optional Host::GetResourceFileTimestamp(const char* filename) +{ + const std::string path(Path::Combine(EmuFolders::Resources, filename)); + FILESYSTEM_STAT_DATA sd; + if (!FileSystem::StatFile(filename, &sd)) + return std::nullopt; + + return sd.ModificationTime; +} + +void Host::ReportErrorAsync(const std::string_view& title, const std::string_view& message) +{ + if (!title.empty() && !message.empty()) + { + Console.Error( + "ReportErrorAsync: %.*s: %.*s", static_cast(title.size()), title.data(), static_cast(message.size()), message.data()); + } + else if (!message.empty()) + { + Console.Error("ReportErrorAsync: %.*s", static_cast(message.size()), message.data()); + } +} + +bool Host::ConfirmMessage(const std::string_view& title, const std::string_view& message) +{ + if (!title.empty() && !message.empty()) + { + Console.Error( + "ConfirmMessage: %.*s: %.*s", static_cast(title.size()), title.data(), static_cast(message.size()), message.data()); + } + else if (!message.empty()) + { + Console.Error("ConfirmMessage: %.*s", static_cast(message.size()), message.data()); + } + + return true; +} + +void Host::OpenURL(const std::string_view& url) +{ + // noop +} + +bool Host::CopyTextToClipboard(const std::string_view& text) +{ + return false; +} + +void Host::BeginTextInput() +{ + // noop +} + +void Host::EndTextInput() +{ + // noop +} + +std::optional Host::GetTopLevelWindowInfo() +{ + return GSRunner::GetPlatformWindowInfo(); +} + +void Host::OnInputDeviceConnected(const std::string_view& identifier, const std::string_view& device_name) +{ +} + +void Host::OnInputDeviceDisconnected(const std::string_view& identifier) +{ +} + +bool Host::AcquireHostDisplay(RenderAPI api) +{ + const std::optional wi(GSRunner::GetPlatformWindowInfo()); + if (!wi.has_value()) + return false; + + g_host_display = HostDisplay::CreateForAPI(api); + if (!g_host_display) + return false; + + if (!g_host_display->CreateDevice(wi.value()) || !g_host_display->MakeCurrent() || !g_host_display->SetupDevice() || !ImGuiManager::Initialize()) + { + ReleaseHostDisplay(); + return false; + } + + Console.WriteLn(Color_StrongGreen, "%s Graphics Driver Info:", HostDisplay::RenderAPIToString(g_host_display->GetRenderAPI())); + Console.Indent().WriteLn(g_host_display->GetDriverInfo()); + + return g_host_display.get(); +} + +void Host::ReleaseHostDisplay() +{ + if (!g_host_display) + return; + + ImGuiManager::Shutdown(); + g_host_display.reset(); +} + +VsyncMode Host::GetEffectiveVSyncMode() +{ + // Never vsync! We want to finish as quickly as possible. + return VsyncMode::Off; +} + +bool Host::BeginPresentFrame(bool frame_skip) +{ + return g_host_display->BeginPresent(frame_skip); +} + +void Host::EndPresentFrame() +{ + if (GSDumpReplayer::IsReplayingDump()) + GSDumpReplayer::RenderUI(); + + ImGuiManager::RenderOSD(); + g_host_display->EndPresent(); + ImGuiManager::NewFrame(); +} + +void Host::ResizeHostDisplay(u32 new_window_width, u32 new_window_height, float new_window_scale) +{ +} + +void Host::UpdateHostDisplay() +{ +} + +void Host::RequestResizeHostDisplay(s32 width, s32 height) +{ +} + +void Host::OnVMStarting() +{ +} + +void Host::OnVMStarted() +{ +} + +void Host::OnVMDestroyed() +{ +} + +void Host::OnVMPaused() +{ +} + +void Host::OnVMResumed() +{ +} + +void Host::OnGameChanged(const std::string& disc_path, const std::string& game_serial, const std::string& game_name, u32 game_crc) +{ +} + +void Host::OnPerformanceMetricsUpdated() +{ +} + +void Host::OnSaveStateLoading(const std::string_view& filename) +{ +} + +void Host::OnSaveStateLoaded(const std::string_view& filename, bool was_successful) +{ +} + +void Host::OnSaveStateSaved(const std::string_view& filename) +{ +} + +void Host::InvalidateSaveStateCache() +{ +} + +void Host::RunOnCPUThread(std::function function, bool block /* = false */) +{ + pxFailRel("Not implemented"); +} + +void Host::RefreshGameListAsync(bool invalidate_cache) +{ +} + +void Host::CancelGameListRefresh() +{ +} + +bool Host::IsFullscreen() +{ + return false; +} + +void Host::SetFullscreen(bool enabled) +{ +} + +void Host::RequestExit(bool save_state_if_running) +{ +} + +void Host::RequestVMShutdown(bool allow_confirm, bool allow_save_state, bool default_save_state) +{ + VMManager::SetState(VMState::Stopping); +} + +#ifdef ENABLE_ACHIEVEMENTS +void Host::OnAchievementsRefreshed() +{ + // noop +} +#endif + +std::optional InputManager::ConvertHostKeyboardStringToCode(const std::string_view& str) +{ + return std::nullopt; +} + +std::optional InputManager::ConvertHostKeyboardCodeToString(u32 code) +{ + return std::nullopt; +} + +SysMtgsThread& GetMTGS() +{ + return s_mtgs_thread; +} + +////////////////////////////////////////////////////////////////////////// +// Interface Stuff +////////////////////////////////////////////////////////////////////////// + +const IConsoleWriter* PatchesCon = &Console; +BEGIN_HOTKEY_LIST(g_host_hotkeys) +END_HOTKEY_LIST() + +static void PrintCommandLineVersion() +{ + std::fprintf(stderr, "PCSX2 GS Runner Version %s\n", GIT_REV); + std::fprintf(stderr, "https://pcsx2.net/\n"); + std::fprintf(stderr, "\n"); +} + +static void PrintCommandLineHelp(const char* progname) +{ + PrintCommandLineVersion(); + std::fprintf(stderr, "Usage: %s [parameters] [--] [filename]\n", progname); + std::fprintf(stderr, "\n"); + std::fprintf(stderr, " -help: Displays this information and exits.\n"); + std::fprintf(stderr, " -version: Displays version information and exits.\n"); + std::fprintf(stderr, " -dumpdir : Frame dump directory (will be dumped as filename_frameN.png).\n"); + std::fprintf(stderr, " -loop : Loops dump playback N times. Defaults to 1. 0 will loop infinitely.\n"); + std::fprintf(stderr, " -renderer : Sets the graphics renderer. Defaults to Auto.\n"); + std::fprintf(stderr, " --: Signals that no more arguments will follow and the remaining\n" + " parameters make up the filename. Use when the filename contains\n" + " spaces or starts with a dash.\n"); + std::fprintf(stderr, "\n"); +} + +static bool ParseCommandLineArgs(int argc, char* argv[], VMBootParameters& params) +{ + bool no_more_args = false; + for (int i = 1; i < argc; i++) + { + if (!no_more_args) + { +#define CHECK_ARG(str) !std::strcmp(argv[i], str) +#define CHECK_ARG_PARAM(str) (!std::strcmp(argv[i], str) && ((i + 1) < argc)) + + if (CHECK_ARG("-help")) + { + PrintCommandLineHelp(argv[0]); + return false; + } + else if (CHECK_ARG("-version")) + { + PrintCommandLineVersion(); + return false; + } + else if (CHECK_ARG_PARAM("-dumpdir")) + { + s_output_prefix = argv[++i]; + if (s_output_prefix.empty()) + { + Console.Error("Invalid dump directory specified."); + return false; + } + + if (!FileSystem::DirectoryExists(s_output_prefix.c_str()) && !FileSystem::CreateDirectoryPath(s_output_prefix.c_str(), false)) + { + Console.Error("Failed to create output directory"); + return false; + } + + continue; + } + else if (CHECK_ARG_PARAM("-loop")) + { + s_loop_count = StringUtil::FromChars(argv[++i]).value_or(0); + Console.WriteLn("Looping dump playback %d times.", s_loop_count); + continue; + } + else if (CHECK_ARG_PARAM("-renderer")) + { + const char* rname = argv[++i]; + + GSRendererType type = GSRendererType::Auto; + if (StringUtil::Strcasecmp(rname, "Auto") == 0) + type = GSRendererType::Auto; +#ifdef _WIN32 + else if (StringUtil::Strcasecmp(rname, "dx11") == 0) + type = GSRendererType::DX11; + else if (StringUtil::Strcasecmp(rname, "dx12") == 0) + type = GSRendererType::DX12; +#endif +#ifdef ENABLE_OPENGL + else if (StringUtil::Strcasecmp(rname, "gl") == 0) + type = GSRendererType::OGL; +#endif +#ifdef ENABLE_VULKAN + else if (StringUtil::Strcasecmp(rname, "vulkan") == 0) + type = GSRendererType::VK; +#endif +#ifdef __APPLE__ + else if (StringUtil::Strcasecmp(rname, "metal") == 0) + type = GSRendererType::Metal; +#endif + else if (StringUtil::Strcasecmp(rname, "sw") == 0) + type = GSRendererType::SW; + else + { + Console.Error("Unknown renderer '%s'", rname); + return false; + } + + Console.WriteLn("Using %s renderer.", Pcsx2Config::GSOptions::GetRendererName(type)); + s_settings_interface.SetIntValue("EmuCore/GS", "Renderer", static_cast(type)); + continue; + } + else if (CHECK_ARG("--")) + { + no_more_args = true; + continue; + } + else if (argv[i][0] == '-') + { + Console.Error("Unknown parameter: '%s'", argv[i]); + return false; + } + +#undef CHECK_ARG +#undef CHECK_ARG_PARAM + } + + if (!params.filename.empty()) + params.filename += ' '; + params.filename += argv[i]; + } + + if (params.filename.empty()) + { + Console.Error("No dump filename provided."); + return false; + } + + if (!VMManager::IsGSDumpFileName(params.filename)) + { + Console.Error("Provided filename is not a GS dump."); + return false; + } + + // set up the frame dump directory + if (!s_output_prefix.empty()) + { + // strip off all extensions + std::string_view title(Path::GetFileTitle(params.filename)); + if (StringUtil::EndsWithNoCase(title, ".gs")) + title = Path::GetFileTitle(title); + + s_output_prefix = Path::Combine(s_output_prefix, title); + Console.WriteLn(fmt::format("Saving dumps as {}_frameN.png", s_output_prefix)); + } + + return true; +} + +int main(int argc, char* argv[]) +{ + CommonHost::InitializeEarlyConsole(); + + if (!GSRunner::InitializeConfig()) + { + Console.Error("Failed to initialize config."); + return false; + } + + VMBootParameters params; + if (!ParseCommandLineArgs(argc, argv, params)) + return false; + + PerformanceMetrics::SetCPUThread(Threading::ThreadHandle::GetForCallingThread()); + if (!VMManager::Internal::InitializeGlobals() || !VMManager::Internal::InitializeMemory()) + { + Console.Error("Failed to allocate globals/memory."); + return false; + } + + if (!GSRunner::CreatePlatformWindow()) + { + Console.Error("Failed to create window."); + return false; + } + + if (VMManager::Initialize(params)) + { + // run until end + GSDumpReplayer::SetLoopCount(s_loop_count); + VMManager::SetState(VMState::Running); + while (VMManager::GetState() == VMState::Running) + VMManager::Execute(); + VMManager::Shutdown(false); + } + + InputManager::CloseSources(); + VMManager::Internal::ReleaseMemory(); + PerformanceMetrics::SetCPUThread(Threading::ThreadHandle()); + GSRunner::DestroyPlatformWindow(); + + return EXIT_SUCCESS; +} + +void Host::CPUThreadVSync() +{ + if (!s_output_prefix.empty()) + { + // wait for the previous frame to complete (and thus dump) + GetMTGS().WaitGS(false, false, false); + + // queue dumping of this frame + std::string dump_path(fmt::format("{}_frame{}.png", s_output_prefix, GSDumpReplayer::GetFrameNumber())); + GetMTGS().RunOnGSThread([dump_path = std::move(dump_path)]() { GSQueueSnapshot(dump_path); }); + } + + // process any window messages (but we shouldn't really have any) + GSRunner::PumpPlatformMessages(); +} + +////////////////////////////////////////////////////////////////////////// +// Platform specific code +////////////////////////////////////////////////////////////////////////// + +#ifdef _WIN32 + +static constexpr LPCWSTR WINDOW_CLASS_NAME = L"PCSX2GSRunner"; +static HWND s_hwnd = NULL; + +static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); + +bool GSRunner::CreatePlatformWindow() +{ + WNDCLASSEXW wc = {}; + wc.cbSize = sizeof(WNDCLASSEXW); + wc.style = 0; + wc.lpfnWndProc = WndProc; + wc.cbClsExtra = 0; + wc.cbWndExtra = 0; + wc.hInstance = GetModuleHandle(nullptr); + wc.hIcon = NULL; + wc.hCursor = LoadCursor(NULL, IDC_ARROW); + wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); + wc.lpszMenuName = NULL; + wc.lpszClassName = WINDOW_CLASS_NAME; + wc.hIconSm = NULL; + + if (!RegisterClassExW(&wc)) + { + Console.Error("Window registration failed."); + return false; + } + + s_hwnd = CreateWindowExW(WS_EX_CLIENTEDGE, WINDOW_CLASS_NAME, L"PCSX2 GS Runner", + WS_OVERLAPPEDWINDOW | WS_CAPTION | WS_MINIMIZEBOX | WS_SYSMENU | WS_SIZEBOX, CW_USEDEFAULT, CW_USEDEFAULT, WINDOW_WIDTH, + WINDOW_HEIGHT, nullptr, nullptr, GetModuleHandleW(nullptr), nullptr); + if (!s_hwnd) + { + Console.Error("CreateWindowEx failed."); + return false; + } + + ShowWindow(s_hwnd, SW_SHOW); + UpdateWindow(s_hwnd); + + // make sure all messages are processed before returning + PumpPlatformMessages(); + return true; +} + +void GSRunner::DestroyPlatformWindow() +{ + if (!s_hwnd) + return; + + PumpPlatformMessages(); + DestroyWindow(s_hwnd); + s_hwnd = {}; +} + +std::optional GSRunner::GetPlatformWindowInfo() +{ + RECT rc = {}; + GetWindowRect(s_hwnd, &rc); + + WindowInfo wi; + wi.surface_width = static_cast(rc.right - rc.left); + wi.surface_height = static_cast(rc.bottom - rc.top); + wi.surface_scale = 1.0f; + wi.type = WindowInfo::Type::Win32; + wi.window_handle = s_hwnd; + return wi; +} + +void GSRunner::PumpPlatformMessages() +{ + MSG msg; + while (PeekMessageW(&msg, NULL, 0, 0, PM_REMOVE)) + { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } +} + +LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) +{ + return DefWindowProcW(hwnd, msg, wParam, lParam); +} + +#endif // _WIN32 \ No newline at end of file diff --git a/pcsx2-gsrunner/pcsx2-gsrunner.vcxproj b/pcsx2-gsrunner/pcsx2-gsrunner.vcxproj new file mode 100644 index 0000000000..3d70501906 --- /dev/null +++ b/pcsx2-gsrunner/pcsx2-gsrunner.vcxproj @@ -0,0 +1,123 @@ + + + + + + + {BB98BF81-A132-444A-BB81-96D510F433A8} + + + + Application + Unicode + $(DefaultPlatformToolset) + true + true + false + + + + + + + + + + + + + + + AllRules.ruleset + $(EXEString) + + + + $(SolutionDir)3rdparty\xbyak;%(AdditionalIncludeDirectories) + $(SolutionDir)3rdparty\freetype\include;%(AdditionalIncludeDirectories) + $(SolutionDir)3rdparty\xz\xz\src\liblzma\api;%(AdditionalIncludeDirectories) + $(SolutionDir)3rdparty\baseclasses;%(AdditionalIncludeDirectories) + $(SolutionDir)3rdparty\zlib;%(AdditionalIncludeDirectories) + $(SolutionDir)3rdparty\libpng;%(AdditionalIncludeDirectories) + $(SolutionDir)3rdparty\glad\include;%(AdditionalIncludeDirectories) + $(SolutionDir)3rdparty\simpleini\include;%(AdditionalIncludeDirectories) + $(SolutionDir)3rdparty\rapidyaml\rapidyaml\ext\c4core\src\c4\ext\fast_float\include;%(AdditionalIncludeDirectories); + $(ProjectDir);$(SolutionDir)pcsx2;%(AdditionalIncludeDirectories) + + %(AdditionalIncludeDirectories);$(ProjectDir)\Settings;$(ProjectDir)\GameList + Async + NotUsing + NoExtensions + WIN32_LEAN_AND_MEAN;LZMA_API_STATIC;BUILD_DX=1;ENABLE_RAINTEGRATION;ENABLE_ACHIEVEMENTS;ENABLE_DISCORD_PRESENCE;ENABLE_OPENGL;ENABLE_VULKAN;DIRECTINPUT_VERSION=0x0800;PCSX2_CORE;%(PreprocessorDefinitions) + PCSX2_DEBUG;PCSX2_DEVBUILD;_SECURE_SCL_=1;%(PreprocessorDefinitions) + PCSX2_DEVEL;PCSX2_DEVBUILD;NDEBUG;_SECURE_SCL_=1;%(PreprocessorDefinitions) + NDEBUG;_SECURE_SCL_=0;%(PreprocessorDefinitions) + PCSX2_CI;%(PreprocessorDefinitions) + _M_SSE=0x401;%(PreprocessorDefinitions) + _M_SSE=0x501;%(PreprocessorDefinitions) + NotSet + AdvancedVectorExtensions2 + false + $(IntDir)%(RelativeDir) + + + Precise + true + true + /Zc:__cplusplus /Zo /utf-8%(AdditionalOptions) + + + Console + Yes + comctl32.lib;ws2_32.lib;shlwapi.lib;winmm.lib;rpcrt4.lib;iphlpapi.lib;dsound.lib;%(AdditionalDependencies) + dxguid.lib;dinput8.lib;hid.lib;PowrProf.lib;d3dcompiler.lib;d3d11.lib;dxgi.lib;strmiids.lib;opengl32.lib;comsuppw.lib;OneCore.lib;%(AdditionalDependencies) + + + + + {27f17499-a372-4408-8afa-4f9f4584fbd3} + + + {449ad25e-424a-4714-babc-68706cdcc33b} + + + {bc236261-77e8-4567-8d09-45cd02965eb6} + + + {d6973076-9317-4ef2-a0b8-b7a18ac0713e} + + + {e9b51944-7e6d-4bcd-83f2-7bbd5a46182d} + + + {12728250-16ec-4dc6-94d7-e21dd88947f8} + + + {a0d2b3ad-1f72-4ee3-8b5c-f2c358da35f0} + + + {2f6c0388-20cb-4242-9f6c-a6ebb6a83f47} + false + + + {ed2f21fd-0a36-4a8f-9b90-e7d92a2acb63} + + + {88fb34ec-845e-4f21-a552-f1573b9ed167} + + + {4639972e-424e-4e13-8b07-ca403c481346} + + + {6c7986c4-3e4d-4dcc-b3c6-6bb12b238995} + + + {c0293b32-5acf-40f0-aa6c-e6da6f3bf33a} + + + + + + + + \ No newline at end of file diff --git a/pcsx2-gsrunner/pcsx2-gsrunner.vcxproj.filters b/pcsx2-gsrunner/pcsx2-gsrunner.vcxproj.filters new file mode 100644 index 0000000000..74ee8d56cb --- /dev/null +++ b/pcsx2-gsrunner/pcsx2-gsrunner.vcxproj.filters @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pcsx2-gsrunner/test_check_dumps.py b/pcsx2-gsrunner/test_check_dumps.py new file mode 100644 index 0000000000..413b37381c --- /dev/null +++ b/pcsx2-gsrunner/test_check_dumps.py @@ -0,0 +1,127 @@ +import argparse +import glob +import sys +import os +import re +import hashlib + +from pathlib import Path + + +FILE_HEADER = """ + + + +Comparison + + +""" + +FILE_FOOTER = """ + + +""" + +outfile = None +def write(line): + outfile.write(line + "\n") + + +def compare_frames(path1, path2): + try: + with open(path1, "rb") as f: + hash1 = hashlib.md5(f.read()).digest() + with open(path2, "rb") as f: + hash2 = hashlib.md5(f.read()).digest() + + return hash1 == hash2 + except (FileNotFoundError, IOError): + return False + + +def check_regression_test(baselinedir, testdir, name): + #print("Checking '%s'..." % name) + + dir1 = os.path.join(baselinedir, name) + dir2 = os.path.join(testdir, name) + if not os.path.isdir(dir2): + #print("*** %s is missing in test set" % name) + return False + + images = glob.glob(os.path.join(dir1, "*_frame*.png")) + diff_frames = [] + first_fail = True + + for imagepath in images: + imagename = Path(imagepath).name + matches = re.match(".*_frame([0-9]+).png", imagename) + if matches is None: + continue + + framenum = int(matches[1]) + + path1 = os.path.join(dir1, imagename) + path2 = os.path.join(dir2, imagename) + if not os.path.isfile(path2): + print("--- Frame %u for %s is missing in test set" % (framenum, name)) + write("

{}

".format(name)) + write("--- Frame %u for %s is missing in test set" % (framenum, name)) + return False + + if not compare_frames(path1, path2): + diff_frames.append(framenum) + + if first_fail: + write("

{}

".format(name)) + write("") + first_fail = False + + imguri1 = Path(path1).as_uri() + imguri2 = Path(path2).as_uri() + write("" % (framenum)) + write("" % (imguri1, imguri2)) + + if len(diff_frames) > 0: + write("
Frame %d
") + write("
Difference in frames [%s] for %s
" % (",".join(map(str, diff_frames)), name)) + print("*** Difference in frames [%s] for %s" % (",".join(map(str, diff_frames)), name)) + return False + + return True + + +def check_regression_tests(baselinedir, testdir): + gamedirs = glob.glob(baselinedir + "/*", recursive=False) + + success = 0 + failure = 0 + + for gamedir in gamedirs: + name = Path(gamedir).name + if check_regression_test(baselinedir, testdir, name): + success += 1 + else: + failure += 1 + + return (failure == 0) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Check frame dump images for regression tests") + parser.add_argument("-baselinedir", action="store", required=True, help="Directory containing baseline frames to check against") + parser.add_argument("-testdir", action="store", required=True, help="Directory containing frames to check") + parser.add_argument("outfile", action="store", help="The file to write the output to") + + args = parser.parse_args() + + outfile = open(args.outfile, "w") + write(FILE_HEADER) + + if not check_regression_tests(os.path.realpath(args.baselinedir), os.path.realpath(args.testdir)): + write(FILE_FOOTER) + outfile.close() + sys.exit(1) + else: + outfile.close() + os.remove(args.outfile) + sys.exit(0) diff --git a/pcsx2-gsrunner/test_run_dumps.py b/pcsx2-gsrunner/test_run_dumps.py new file mode 100644 index 0000000000..2e673f3080 --- /dev/null +++ b/pcsx2-gsrunner/test_run_dumps.py @@ -0,0 +1,81 @@ +import argparse +import glob +import sys +import os +import subprocess +import multiprocessing +from pathlib import Path +from functools import partial + +def is_gs_path(path): + ppath = Path(path) + for extension in [[".gs"], [".gs", ".xz"], [".gs", ".zst"]]: + if ppath.suffixes == extension: + return True + + return False + + +def run_regression_test(runner, dumpdir, renderer, gspath): + args = [runner] + gsname = Path(gspath).name + while gsname.rfind('.') >= 0: + gsname = gsname[:gsname.rfind('.')] + + real_dumpdir = os.path.join(dumpdir, gsname) + if not os.path.exists(real_dumpdir): + os.mkdir(real_dumpdir) + + if renderer is not None: + args.extend(["-renderer", renderer]) + args.extend(["-dumpdir", real_dumpdir]) + + # loop a couple of times for those stubborn merge/interlace dumps that don't render anything + # the first time around + args.extend(["-loop", "2"]) + + args.append("--") + args.append(gspath) + + print("Running '%s'" % (" ".join(args))) + subprocess.run(args) + + +def run_regression_tests(runner, gsdir, dumpdir, renderer, parallel=1): + paths = glob.glob(gsdir + "/*.*", recursive=True) + gamepaths = list(filter(is_gs_path, paths)) + + if not os.path.isdir(dumpdir): + os.mkdir(dumpdir) + + print("Found %u GS dumps" % len(gamepaths)) + + if parallel <= 1: + for game in gamepaths: + run_regression_test(runner, dumpdir, renderer, game) + else: + print("Processing %u games on %u processors" % (len(gamepaths), parallel)) + func = partial(run_regression_test, runner, dumpdir, renderer) + pool = multiprocessing.Pool(parallel) + pool.map(func, gamepaths) + pool.close() + + + return True + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate frame dump images for regression tests") + parser.add_argument("-runner", action="store", required=True, help="Path to PCSX2 GS runner") + parser.add_argument("-gsdir", action="store", required=True, help="Directory containing GS dumps") + parser.add_argument("-dumpdir", action="store", required=True, help="Base directory to dump frames to") + parser.add_argument("-renderer", action="store", required=False, help="Renderer to use") + parser.add_argument("-parallel", action="store", type=int, default=1, help="Number of proceeses to run") + + args = parser.parse_args() + + if not run_regression_tests(args.runner, os.path.realpath(args.gsdir), os.path.realpath(args.dumpdir), args.renderer, args.parallel): + sys.exit(1) + else: + sys.exit(0) +