mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
Refresh netplay validation against master retaining audited startup cache and local views
This commit is contained in:
@@ -1051,6 +1051,18 @@ remain user-owned and outside the bundled package directory.
|
||||
|
||||
Register via `mod_register_media_preparer` in mod_packages.h. Ship any worker
|
||||
and its runtime dependencies with the title: players should only supply media.
|
||||
|
||||
Launcher preparation is synchronous by default. A title may set
|
||||
`PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE=ON` only after auditing all its trusted
|
||||
preparers and services for serialized worker execution without UI, SDL, GL,
|
||||
or main-thread affinity. With a recomp-ui header advertising
|
||||
`RECOMP_LAUNCHER_HAS_WORKER_MOD_COMMIT`, preboot offline PLAY then commits on an
|
||||
owned worker while the launcher renders an exclusive progress view. The UI
|
||||
does not access the provider until that worker has joined; close is queued
|
||||
until completion. Verification, preparation, persistence, and failure gates
|
||||
are unchanged. Older UI pins, other titles, netplay, and in-session commits
|
||||
retain synchronous behavior. Plugin activation remains on the runtime's
|
||||
existing post-commit path, not the preparation worker.
|
||||
The `prepare_resources` method is separate from read-only `resolve`, so editing
|
||||
launcher settings does not trigger conversions. Runtime activation still sees
|
||||
only fully verified immutable resources.
|
||||
|
||||
+26
-5
@@ -221,6 +221,15 @@ mod; `psx_memory.h`), scratchpad, I-cache tags, I_STAT/I_MASK, timers, DMA
|
||||
and GPU registers (without the widescreen side effects of a savestate load), the
|
||||
VRAM rect (hr colour, mask stencil, raw 16-bit mirror, native-wide band, CPU
|
||||
VRAM rows), the renderer's coherency bookkeeping, and every clock value.
|
||||
|
||||
RAM-keyed GPU presentation provenance is restored too: sprite/HUD/background
|
||||
tags, primitive roles, reveal-clear/repeat/mask proofs, and the automatic UI
|
||||
prepass. A synthetic draw can rewrite and retag the same packet addresses;
|
||||
restoring only their RAM would leave the canonical draw comparing its original
|
||||
words against a synthetic proof. Packet freshness, the full-composite latch,
|
||||
backdrop phase, and scene classification evidence return to their pre-pass
|
||||
values. Diagnostic counters and rings still include synthetic activity.
|
||||
|
||||
A watchdog abort leaves by longjmp from inside guest code, skipping the
|
||||
exits of the frames it leaves, so the host nesting those frames own is put
|
||||
back from the checkpoint too: the cycle-deferral depth, the native overlay
|
||||
@@ -294,13 +303,17 @@ first pass copies the rect out (`backups_reused` in `render_pass_stats`).
|
||||
|
||||
Passes cost host time inside the game's frame: they run on the emulation
|
||||
thread. The budget per frame is `PSX_RENDER_PASS_BUDGET` percent (default 80)
|
||||
of the presenter's idle time plus the presents beyond two per frame, learnt
|
||||
from the previous frame, over a smoothed per-pass cost. When the budget runs
|
||||
of the presenter's idle time plus the presents beyond two per frame and prior
|
||||
replay time, learnt from the previous frame, over a smoothed per-pass cost.
|
||||
Zero measured credit means zero budget, including startup and a frame after
|
||||
replay was shed. It does not grant a fraction of the nominal frame: that would
|
||||
repeatedly readmit expensive replay on an already overloaded host.
|
||||
When the budget runs
|
||||
out, fewer passes are rendered, and a pass that costs more than the budget is
|
||||
not planned at all. The cost is measured per presented image size: until
|
||||
three passes have been measured at the current size (the first plans, and
|
||||
after an aspect or internal-resolution change) a plan asks for one pass, so
|
||||
an expensive size costs at most one pass per frame while it is learnt. The
|
||||
after an aspect or internal-resolution change) a positive-budget plan asks for
|
||||
one pass, so an expensive size costs at most one pass per frame while it is learnt. The
|
||||
three seed the average with their median, so one slow pass on a busy host
|
||||
does not price passes out. A pass that creates pass textures or
|
||||
framebuffers (the first frames at a size, or a slot filled for the first
|
||||
@@ -310,7 +323,7 @@ busy host, code run for the first time) that costs several times the steady
|
||||
state; an estimate that high prices every plan out, so no pass would ever
|
||||
correct it. An estimate that no pass has been measured against for 30
|
||||
plans that wanted passes (about a second of a 30 Hz game) is measured again:
|
||||
the warm-up restarts, with one pass per plan (`cost_rewarms` in
|
||||
the warm-up restarts, with one pass per positive-budget plan (`cost_rewarms` in
|
||||
`render_pass_stats`). When the new median is not at least a quarter below
|
||||
the old estimate, the old one was right (a size that is truly too
|
||||
expensive, or a host at its limit), and the next wait doubles, up to 960
|
||||
@@ -336,6 +349,14 @@ internal resolutions; a size change frees the old set.
|
||||
source path and resource stage). FBO status and GL errors are numeric enums;
|
||||
`gl_error_before` is distinct from errors produced during that allocation.
|
||||
Success does not erase the record; a new mod session clears it.
|
||||
`admission` records the last eligible planner call's guest `frame`, measured
|
||||
`idle_ms`, `present_ms`, `prior_pass_ms`, two-present `present_reserve_ms`,
|
||||
clamped `spare_ms`, nominal `frame_ms`, effective `cost_ms` (zero during a
|
||||
warm-up), `budget_ms`, `wanted` and `planned`. Its `plans` and
|
||||
`zero_credit_refusals` are lifetime counters; use deltas over an active window.
|
||||
The latter counts wanted plans shed with zero budget. Backend refusal leaves
|
||||
this snapshot unchanged, so compare its frame and count before interpreting
|
||||
it as current evidence. Queries neither run a plan nor reset admission history.
|
||||
- `render_pass_refuse on=1` (TCP) or `PSX_RENDER_PASS_REFUSE=1`: the backend
|
||||
declines passes (`BACKEND`), to test a plugin's fallback.
|
||||
- `render_pass_dump path=<dir> count=<n>`: PNGs of the next n frames' images
|
||||
|
||||
@@ -432,6 +432,35 @@ If an inspection need isn't covered by the existing commands, **do not fall back
|
||||
|
||||
The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` is absolute: **no `fprintf(stderr, …)` in source code, ever, for any reason**.
|
||||
|
||||
## `host_launch_timings` — retained host startup and mod-commit costs
|
||||
|
||||
`{"cmd":"host_launch_timings","count":256}` reads the newest completed host
|
||||
scopes, oldest completion first. The fixed 256-entry ring is always on, including
|
||||
before TCP starts, and reading it never drains or resets it. `total` is the
|
||||
lifetime completion count; `overwritten` counts completions displaced by ring
|
||||
wrap. `count` is clamped to 1..256. Only completed work is represented.
|
||||
|
||||
Each entry has `seq`, `scope_id`, `parent_id`, `stage`, `start_us`, `duration_us`,
|
||||
`ok`, `package`, `feature`, and `labels_truncated`. Times use a monotonic host
|
||||
clock, independent of guest frames/cycles. A parent includes its children:
|
||||
**nested durations overlap and must not be summed**. Sequence orders completion;
|
||||
scope IDs identify starts. Separate threads have independent parent chains.
|
||||
Package/feature labels retain at most 63 bytes each; no owner-media paths are
|
||||
recorded. `ok=0` indicates an unsuccessful or incomplete scope exit, including
|
||||
exceptions; it is not a replacement for the existing launch error message.
|
||||
|
||||
`provider_commit` encloses the launcher's offline mod callback;
|
||||
`provider_netplay_commit` identifies its netplay counterpart. `runtime_commit`
|
||||
encloses the later main-thread commit/clear. Within `commit`, stages distinguish
|
||||
`disc_hash` (absent when the existing digest is reused), `prepare_resources`
|
||||
(with nested per-package/feature `media_provider`), `resolve`, `overlay_verify`,
|
||||
`derived_disc`, `save_state`, and `build_disc_index`. `overlay_worker` records
|
||||
the concurrent startup work; `overlay_join` measures only the later wait.
|
||||
`plugin_activation` contains per-package/feature `plugin_callback` measurements,
|
||||
including Resident Loading when enabled. No work is moved off-thread and no
|
||||
verification or cache policy changes. These scopes do not cover every action
|
||||
between Play and TCP availability.
|
||||
|
||||
<!-- BEGIN AUTOGENERATED COMMAND INDEX -- edit tools/gen_tcp_commands.py, not this block -->
|
||||
|
||||
## Complete command index (generated)
|
||||
@@ -588,6 +617,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
|
||||
| `guest_tty_dump` | ✓ | | |
|
||||
| `history` | ✓ | ✓ | ✓ |
|
||||
| `hle_dump` | ✓ | | ✓ |
|
||||
| `host_launch_timings` | ✓ | | ✓ |
|
||||
| `idle_skip` | ✓ | | |
|
||||
| `imask_trace` | ✓ | | |
|
||||
| `input_route_append` | ✓ | | |
|
||||
|
||||
@@ -1 +1 @@
|
||||
36e72e6875b4a8567d163199dff9ac9f840ef895a780db6ccd3126db4b8493a9
|
||||
df844aebb7ff1a0e2bdb4b472c3d6a1586b0e0e2012c261b80480ee6e0a27b01
|
||||
|
||||
@@ -1 +1 @@
|
||||
c86327fcff32f4cabcb49c990da7e2b2b6af021d6de60877ec1a50f119b92dbc
|
||||
4737d2f5a48b6185367edfc266b344793ba43c5a8305511fb0d4b4dc39f34603
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
|
||||
namespace PSXRecompV4 {
|
||||
|
||||
// generic_string() can collapse a MinGW UNC root from "//server" to
|
||||
// "/server". Persist the native spelling, changing separators only on
|
||||
// Windows; a backslash in a POSIX filename is an ordinary character.
|
||||
inline std::string host_path_forward_slashes(const std::filesystem::path& path) {
|
||||
std::string result = path.string();
|
||||
#ifdef _WIN32
|
||||
for (char& c : result) if (c == '\\') c = '/';
|
||||
#endif
|
||||
return result;
|
||||
}
|
||||
|
||||
// Some MinGW libstdc++ builds treat a readable UNC path as drive-relative:
|
||||
// absolute("\\\\server\\share\\file") becomes "D:\\server\\share\\file".
|
||||
// Windows already considers UNC and device namespace paths fully qualified.
|
||||
|
||||
@@ -3036,10 +3036,9 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) {
|
||||
std::ofstream f(path, std::ios::trunc);
|
||||
if (!f.is_open()) return false;
|
||||
|
||||
// TOML strings use forward slashes so backslash escaping is never an issue.
|
||||
// Windows paths use forward slashes so separators need no TOML escaping.
|
||||
auto fwd = [](const fs::path& p) {
|
||||
std::string str = p.generic_string();
|
||||
return str;
|
||||
return host_path_forward_slashes(p);
|
||||
};
|
||||
// Paths inside the game folder are stored relative to it, so a portable
|
||||
// folder still finds its disc, BIOS and memory cards after it is moved or
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* anchoring resolves it on the new PC.
|
||||
*/
|
||||
#include "config_loader.h"
|
||||
#include "host_path.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
@@ -71,6 +72,33 @@ int main() {
|
||||
check(back.has_memcard1_path && back.memcard1_path == fs::path("saves/card1.mcd"),
|
||||
"relative card path reads back unchanged");
|
||||
|
||||
#ifdef _WIN32
|
||||
// A NAS selection must survive a launcher save and reopen. No network
|
||||
// asset is opened: only the task-local settings file is written/read.
|
||||
const fs::path expected("//server/share/Game files/disc.chd");
|
||||
for (const char* spelling : {
|
||||
"//server/share/Game files/disc.chd",
|
||||
R"(\\server\share\Game files\disc.chd)"}) {
|
||||
PSXRecompV4::UserSettings network{};
|
||||
network.disc_path = spelling; network.has_disc_path = true;
|
||||
network.bios_path = spelling; network.has_bios_path = true;
|
||||
network.memcard1_path = spelling; network.has_memcard1_path = true;
|
||||
check(PSXRecompV4::save_user_settings(settings, network), "UNC settings save");
|
||||
const auto restored = PSXRecompV4::load_user_settings(settings);
|
||||
check(restored.has_disc_path &&
|
||||
restored.disc_path.native() == expected.native(),
|
||||
"saved UNC disc keeps both leading separators");
|
||||
check(restored.has_bios_path &&
|
||||
restored.bios_path.native() == expected.native(),
|
||||
"saved UNC BIOS keeps both leading separators");
|
||||
check(restored.has_memcard1_path &&
|
||||
restored.memcard1_path.native() == expected.native(),
|
||||
"saved UNC memory card keeps both leading separators");
|
||||
check(PSXRecompV4::host_resolve(game, restored.disc_path).native() == expected.native(),
|
||||
"reopened UNC disc never becomes a path on the current drive");
|
||||
}
|
||||
#endif
|
||||
|
||||
fs::remove_all(game.parent_path(), ec);
|
||||
if (failures) return 1;
|
||||
std::printf("portable_settings_paths_test: all checks passed\n");
|
||||
|
||||
+34
-1
@@ -23,6 +23,12 @@ psxrecomp_add_runtime_target(psx-runtime
|
||||
# (docs/ENHANCEMENTS.md G1.10). Titles get their <exe>_pgxp the same way.
|
||||
|
||||
if(BUILD_TESTING)
|
||||
find_package(Threads REQUIRED)
|
||||
add_executable(host_launch_timing_test
|
||||
tests/test_host_launch_timing.cpp tests/host_launch_timing_helper.cpp)
|
||||
target_include_directories(host_launch_timing_test PRIVATE include)
|
||||
target_link_libraries(host_launch_timing_test PRIVATE Threads::Threads)
|
||||
add_test(NAME host_launch_timing_test COMMAND host_launch_timing_test)
|
||||
add_executable(disasm_test tests/test_disasm.cpp src/disasm_shim.cpp
|
||||
../recompiler/src/mips_decoder.cpp)
|
||||
target_include_directories(disasm_test PRIVATE include ../recompiler/include)
|
||||
@@ -136,6 +142,12 @@ if(BUILD_TESTING)
|
||||
set(PSX_GL_READBACK_COMPILER_BIN "${_gl_test_default_bin}" CACHE PATH "GCC/MinGW bin for GL fixture")
|
||||
find_package(Python3 QUIET COMPONENTS Interpreter)
|
||||
if(Python3_Interpreter_FOUND)
|
||||
add_test(NAME host_launch_timings_protocol_test COMMAND ${CMAKE_COMMAND} -E env
|
||||
"CC=${CMAKE_C_COMPILER}" "CXX=${CMAKE_CXX_COMPILER}" "${Python3_EXECUTABLE}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/tests/test_host_launch_timings_protocol.py")
|
||||
set_tests_properties(host_launch_timings_protocol_test PROPERTIES SKIP_RETURN_CODE 77)
|
||||
add_test(NAME tcp_command_index_check COMMAND "${Python3_EXECUTABLE}"
|
||||
"${PSXRECOMP_ROOT}/tools/gen_tcp_commands.py" --check)
|
||||
add_test(NAME wtrace_dump_test COMMAND ${CMAKE_COMMAND} -E env
|
||||
"CC=${CMAKE_C_COMPILER}" "${Python3_EXECUTABLE}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/tests/test_wtrace_dump.py")
|
||||
@@ -689,7 +701,12 @@ if(BUILD_TESTING)
|
||||
add_executable(native_wide_projection_test tests/test_native_wide_projection.c
|
||||
src/psx_video_timing.c
|
||||
src/gpu_timeline.c)
|
||||
foreach(_anchor_test ws_view_anchor ws_view_anchor_gpu native_wide_projection)
|
||||
add_executable(gpu_render_pass_provenance_test tests/test_gpu_render_pass_provenance.c
|
||||
src/ws_ui_group.c
|
||||
src/psx_video_timing.c
|
||||
src/gpu_timeline.c)
|
||||
foreach(_anchor_test ws_view_anchor ws_view_anchor_gpu native_wide_projection
|
||||
gpu_render_pass_provenance)
|
||||
target_include_directories(${_anchor_test}_test PRIVATE include src)
|
||||
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(${_anchor_test}_test PRIVATE -ffunction-sections -fdata-sections)
|
||||
@@ -704,6 +721,7 @@ if(BUILD_TESTING)
|
||||
add_test(NAME ws_view_anchor_test COMMAND ws_view_anchor_test)
|
||||
add_test(NAME ws_view_anchor_gpu_test COMMAND ws_view_anchor_gpu_test)
|
||||
add_test(NAME native_wide_projection_test COMMAND native_wide_projection_test)
|
||||
add_test(NAME gpu_render_pass_provenance_test COMMAND gpu_render_pass_provenance_test)
|
||||
|
||||
add_executable(ws_repeat_rect_test tests/test_ws_repeat_rect.c)
|
||||
target_include_directories(ws_repeat_rect_test PRIVATE include)
|
||||
@@ -970,6 +988,21 @@ if(BUILD_TESTING)
|
||||
endif()
|
||||
endif()
|
||||
add_test(NAME netplay_view_mods_test COMMAND netplay_view_mods_test)
|
||||
get_target_property(mod_runtime_sources mod_runtime_test SOURCES)
|
||||
add_executable(mod_runtime_cache_test ${mod_runtime_sources})
|
||||
target_include_directories(mod_runtime_cache_test PRIVATE
|
||||
include ../recompiler/include ../recompiler/lib/toml11)
|
||||
target_compile_definitions(mod_runtime_cache_test PRIVATE
|
||||
PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE=1)
|
||||
target_link_libraries(mod_runtime_cache_test PRIVATE chdr-static)
|
||||
if(WIN32)
|
||||
if(MSVC)
|
||||
target_link_options(mod_runtime_cache_test PRIVATE /STACK:16777216)
|
||||
else()
|
||||
target_link_options(mod_runtime_cache_test PRIVATE -Wl,--stack,16777216)
|
||||
endif()
|
||||
endif()
|
||||
add_test(NAME mod_runtime_cache_test COMMAND mod_runtime_cache_test)
|
||||
|
||||
add_executable(mod_resident_test
|
||||
tests/test_mod_resident.cpp
|
||||
|
||||
@@ -99,6 +99,17 @@ void gl_renderer_pass_service_presents(void);
|
||||
* presents past the frame's planned end (the next flip was late; the newest
|
||||
* image held), stale pass-cost estimates measured again. */
|
||||
void gl_renderer_pass_diag(uint64_t out[10]);
|
||||
/* Always-on snapshot of the last eligible planner call and lifetime count of
|
||||
* wanted plans refused with zero measured credit. Times are host milliseconds;
|
||||
* reads do not reset counters or invoke the planner. Backend refusals leave
|
||||
* this snapshot unchanged. */
|
||||
typedef struct GLRenderPassPlanDiag {
|
||||
uint64_t plans, frame, zero_credit_refusals;
|
||||
double idle_ms, present_ms, prior_pass_ms, present_reserve_ms, spare_ms;
|
||||
double frame_ms, cost_ms, budget_ms;
|
||||
uint32_t wanted, planned;
|
||||
} GLRenderPassPlanDiag;
|
||||
void gl_renderer_pass_plan_diag(GLRenderPassPlanDiag *out);
|
||||
/* Out-of-rect VRAM writes journaled and rolled back (lifetime count). */
|
||||
uint64_t gl_renderer_pass_journaled(void);
|
||||
/* Passes that reused the previous pass's VRAM backup (lifetime count). */
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <sstream>
|
||||
#include <cwchar>
|
||||
#if defined(_WIN32)
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
namespace PSXRecompV4 {
|
||||
// A failed identity is never a cache hit. Change time protects restored mtimes.
|
||||
inline bool host_file_identity(const std::filesystem::path& path, std::string& identity) {
|
||||
identity.clear();
|
||||
std::ostringstream out;
|
||||
#if defined(_WIN32)
|
||||
HANDLE file = CreateFileW(path.c_str(), FILE_READ_ATTRIBUTES,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
|
||||
OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) return false;
|
||||
struct HandleScope { HANDLE value; ~HandleScope() { CloseHandle(value); } } handle{file};
|
||||
WCHAR filesystem_name[32]{};
|
||||
// NTFS/ReFS expose change time and native file identity. For a remote
|
||||
// handle also require SMB3+, rather than assuming every NAS is unsupported.
|
||||
FILE_REMOTE_PROTOCOL_INFO remote{};
|
||||
const bool is_remote = GetFileInformationByHandleEx(file, FileRemoteProtocolInfo,
|
||||
&remote, sizeof remote) != FALSE;
|
||||
const DWORD protocol_error = is_remote ? ERROR_SUCCESS : GetLastError();
|
||||
bool local_handle = false;
|
||||
if (!is_remote) {
|
||||
// A failed protocol query proves nothing about locality. Resolve the
|
||||
// handle itself: mapped drives become UNC paths here and cannot take
|
||||
// the local fallback when a remote provider omits protocol metadata.
|
||||
WCHAR final_path[32768]{};
|
||||
const DWORD length = GetFinalPathNameByHandleW(file, final_path, 32768, FILE_NAME_NORMALIZED);
|
||||
if (length >= 7 && length < 32768 && final_path[0] == L'\\' &&
|
||||
final_path[1] == L'\\' && final_path[2] == L'?' && final_path[3] == L'\\' &&
|
||||
final_path[5] == L':' && final_path[6] == L'\\') {
|
||||
WCHAR drive_root[] = {final_path[4], L':', L'\\', L'\0'};
|
||||
const UINT drive_type = GetDriveTypeW(drive_root);
|
||||
local_handle = drive_type == DRIVE_FIXED || drive_type == DRIVE_REMOVABLE;
|
||||
}
|
||||
}
|
||||
const bool reliable_protocol = is_remote
|
||||
? remote.Protocol == 0x00020000 && remote.ProtocolMajorVersion >= 3 // WNNC_NET_LANMAN
|
||||
: local_handle && (protocol_error == ERROR_INVALID_PARAMETER || protocol_error == ERROR_NOT_SUPPORTED);
|
||||
const bool reliable_fs = reliable_protocol &&
|
||||
GetVolumeInformationByHandleW(file, nullptr, 0, nullptr, nullptr, nullptr,
|
||||
filesystem_name, 32) &&
|
||||
(wcscmp(filesystem_name, L"NTFS") == 0 || wcscmp(filesystem_name, L"ReFS") == 0);
|
||||
BY_HANDLE_FILE_INFORMATION info{};
|
||||
FILE_BASIC_INFO basic{};
|
||||
const bool ok = reliable_fs && GetFileInformationByHandle(file, &info) &&
|
||||
GetFileInformationByHandleEx(file, FileBasicInfo, &basic, sizeof basic) &&
|
||||
!(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) &&
|
||||
(info.nFileIndexHigh != 0 || info.nFileIndexLow != 0) &&
|
||||
info.dwVolumeSerialNumber != 0 && basic.ChangeTime.QuadPart != 0 &&
|
||||
basic.LastWriteTime.QuadPart != 0;
|
||||
if (!ok) return false;
|
||||
out << "win1:" << info.dwVolumeSerialNumber << ':' << info.nFileIndexHigh << ':'
|
||||
<< info.nFileIndexLow << ':' << info.nFileSizeHigh << ':' << info.nFileSizeLow
|
||||
<< ':' << basic.LastWriteTime.QuadPart << ':' << basic.ChangeTime.QuadPart;
|
||||
#else
|
||||
struct stat info{};
|
||||
if (stat(path.c_str(), &info) || !S_ISREG(info.st_mode) || info.st_ino == 0) return false;
|
||||
out << "posix1:" << info.st_dev << ':' << info.st_ino << ':' << info.st_size;
|
||||
#if defined(__APPLE__)
|
||||
out << ':' << info.st_mtimespec.tv_sec << ':' << info.st_mtimespec.tv_nsec
|
||||
<< ':' << info.st_ctimespec.tv_sec << ':' << info.st_ctimespec.tv_nsec;
|
||||
#else
|
||||
out << ':' << info.st_mtim.tv_sec << ':' << info.st_mtim.tv_nsec
|
||||
<< ':' << info.st_ctim.tv_sec << ':' << info.st_ctim.tv_nsec;
|
||||
#endif
|
||||
#endif
|
||||
identity = out.str();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* Always-on host diagnostics. Completed scopes survive startup for the debug
|
||||
* TCP reader. Nested scopes overlap: do not sum a parent and its children.
|
||||
* No guest clocks, filesystem paths, logging, or environment switches. */
|
||||
#define HOST_LAUNCH_TIMING_CAPACITY 256u
|
||||
#define HOST_LAUNCH_TIMING_LABEL 64u
|
||||
|
||||
typedef enum HostLaunchStage {
|
||||
HOST_LAUNCH_PROVIDER_COMMIT,
|
||||
HOST_LAUNCH_RUNTIME_COMMIT,
|
||||
HOST_LAUNCH_COMMIT,
|
||||
HOST_LAUNCH_DISC_HASH,
|
||||
HOST_LAUNCH_PREPARE_RESOURCES,
|
||||
HOST_LAUNCH_MEDIA_PROVIDER,
|
||||
HOST_LAUNCH_RESOLVE,
|
||||
HOST_LAUNCH_OVERLAY_VERIFY,
|
||||
HOST_LAUNCH_DERIVED_DISC,
|
||||
HOST_LAUNCH_SAVE_STATE,
|
||||
HOST_LAUNCH_BUILD_DISC_INDEX,
|
||||
HOST_LAUNCH_OVERLAY_WORKER,
|
||||
HOST_LAUNCH_OVERLAY_JOIN,
|
||||
HOST_LAUNCH_PLUGIN_ACTIVATION,
|
||||
HOST_LAUNCH_PLUGIN_CALLBACK,
|
||||
HOST_LAUNCH_PROVIDER_NETPLAY_COMMIT
|
||||
} HostLaunchStage;
|
||||
|
||||
typedef struct HostLaunchTimingEvent {
|
||||
uint64_t seq; /* completion order, starting at 1 */
|
||||
uint64_t scope_id; /* unique start identity, independent of seq */
|
||||
uint64_t parent_id; /* same-thread enclosing scope, or 0 */
|
||||
uint64_t start_us; /* steady-clock epoch, not guest time */
|
||||
uint64_t duration_us;
|
||||
uint32_t stage;
|
||||
uint32_t ok; /* explicit success; early return/throw = 0 */
|
||||
uint32_t labels_truncated;
|
||||
char package_id[HOST_LAUNCH_TIMING_LABEL];
|
||||
char feature_id[HOST_LAUNCH_TIMING_LABEL];
|
||||
} HostLaunchTimingEvent;
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
/* Newest min(cap, retained) completions, oldest first. Null/zero output is a
|
||||
* metadata query. total is lifetime completed count; overwritten excludes
|
||||
* additional entries omitted by a reader's smaller cap. Never drains/reset. */
|
||||
uint32_t host_launch_timing_snapshot(HostLaunchTimingEvent* out, uint32_t cap,
|
||||
uint64_t* total, uint64_t* overwritten);
|
||||
const char* host_launch_timing_stage_name(uint32_t stage);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
|
||||
namespace PSXRecompV4 {
|
||||
/* Header-only to keep package-manager fixtures independent of mod runtime.
|
||||
* The external-linkage inline accessors' local statics have one instance
|
||||
* across C++ translation units, not one ring per includer. */
|
||||
class HostLaunchTimingRing {
|
||||
std::mutex mutex_;
|
||||
std::array<HostLaunchTimingEvent, HOST_LAUNCH_TIMING_CAPACITY> events_{};
|
||||
uint64_t started_ = 0, completed_ = 0;
|
||||
public:
|
||||
uint64_t begin() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return ++started_;
|
||||
}
|
||||
void finish(HostLaunchTimingEvent event) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
event.seq = ++completed_;
|
||||
events_[(event.seq - 1) % events_.size()] = event;
|
||||
}
|
||||
uint32_t snapshot(HostLaunchTimingEvent* out, uint32_t cap,
|
||||
uint64_t* total, uint64_t* overwritten) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (total) *total = completed_;
|
||||
if (overwritten) *overwritten = completed_ > events_.size()
|
||||
? completed_ - events_.size() : 0;
|
||||
if (!out) return 0;
|
||||
const uint32_t n = static_cast<uint32_t>(std::min<uint64_t>(
|
||||
std::min<uint64_t>(cap, events_.size()), completed_));
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
const uint64_t seq = completed_ - n + i + 1;
|
||||
out[i] = events_[(seq - 1) % events_.size()];
|
||||
}
|
||||
return n;
|
||||
}
|
||||
};
|
||||
|
||||
inline HostLaunchTimingRing& host_launch_timing_ring() {
|
||||
static HostLaunchTimingRing ring;
|
||||
return ring;
|
||||
}
|
||||
|
||||
struct HostLaunchTimingParent { HostLaunchTimingRing* ring; uint64_t id; };
|
||||
inline HostLaunchTimingParent& host_launch_timing_parent() {
|
||||
static thread_local HostLaunchTimingParent parent{};
|
||||
return parent;
|
||||
}
|
||||
inline uint64_t host_launch_now_us() {
|
||||
return static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
}
|
||||
|
||||
class HostLaunchTimingScope {
|
||||
HostLaunchTimingRing& ring_;
|
||||
HostLaunchTimingEvent event_{};
|
||||
HostLaunchTimingParent previous_;
|
||||
void label(char* out, const char* text) {
|
||||
if (!text) return;
|
||||
const auto size = std::strlen(text);
|
||||
const auto n = std::min<size_t>(size, HOST_LAUNCH_TIMING_LABEL - 1u);
|
||||
std::memcpy(out, text, n);
|
||||
event_.labels_truncated |= size != n;
|
||||
}
|
||||
public:
|
||||
explicit HostLaunchTimingScope(HostLaunchStage stage,
|
||||
const char* package = nullptr, const char* feature = nullptr,
|
||||
HostLaunchTimingRing& ring = host_launch_timing_ring())
|
||||
: ring_(ring), previous_(host_launch_timing_parent()) {
|
||||
event_.scope_id = ring_.begin();
|
||||
event_.parent_id = previous_.ring == &ring_ ? previous_.id : 0;
|
||||
event_.stage = stage;
|
||||
label(event_.package_id, package);
|
||||
label(event_.feature_id, feature);
|
||||
event_.start_us = host_launch_now_us();
|
||||
host_launch_timing_parent() = {&ring_, event_.scope_id};
|
||||
}
|
||||
HostLaunchTimingScope(const HostLaunchTimingScope&) = delete;
|
||||
HostLaunchTimingScope& operator=(const HostLaunchTimingScope&) = delete;
|
||||
~HostLaunchTimingScope() {
|
||||
event_.duration_us = host_launch_now_us() - event_.start_us;
|
||||
host_launch_timing_parent() = previous_;
|
||||
ring_.finish(event_);
|
||||
}
|
||||
void success() { event_.ok = 1; }
|
||||
};
|
||||
} // namespace PSXRecompV4
|
||||
#endif
|
||||
@@ -318,7 +318,14 @@ struct ModPrepareContext {
|
||||
using ModMediaPreparer = std::function<bool(const ModPrepareContext&,
|
||||
std::map<std::string, std::filesystem::path>&, std::string&)>;
|
||||
// Implementations are linked trusted code. A manifest cannot execute a command.
|
||||
bool mod_register_media_preparer(const std::string& id, ModMediaPreparer callback);
|
||||
// Normally called synchronously during launch preparation. A title enabling
|
||||
// PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE also permits preboot offline preparation
|
||||
// on a launcher-owned worker. Every registered callback/service used by that
|
||||
// title must then support serialized worker calls without SDL/UI/GL or
|
||||
// main-thread affinity. Registration must finish before opening the launcher;
|
||||
// activation callbacks still run separately, before renderer initialization.
|
||||
bool mod_register_media_preparer(const std::string& id, ModMediaPreparer callback,
|
||||
ModMediaPreparer cache_probe = {});
|
||||
|
||||
struct ModFeatureSelection {
|
||||
bool enabled = false;
|
||||
@@ -453,7 +460,7 @@ public:
|
||||
bool prepare_resources(const std::string& game_id,
|
||||
const std::filesystem::path& disc_path,
|
||||
const std::filesystem::path& cache_root,
|
||||
std::string* error = nullptr);
|
||||
std::string* error = nullptr, bool cached_only = false);
|
||||
|
||||
bool install_archive(const std::filesystem::path& archive,
|
||||
std::string* installed_id = nullptr,
|
||||
|
||||
@@ -21,6 +21,14 @@ bool mod_runtime_initialize(const std::filesystem::path& root,
|
||||
bool mod_runtime_commit(const std::filesystem::path& disc_path = {},
|
||||
std::string* error = nullptr,
|
||||
bool save_selection = true);
|
||||
/* Prepare a preboot launcher selection; the audited runtime retains a ticket. */
|
||||
bool mod_runtime_prepare_for_launcher(const std::filesystem::path& disc_path,
|
||||
std::string* error = nullptr);
|
||||
|
||||
/* Audited preboot only: load and verify a plan using existing receipts.
|
||||
* A miss never invokes a media converter or computes a disc digest. */
|
||||
bool mod_runtime_try_prepare_cached(const std::filesystem::path& disc_path);
|
||||
|
||||
/* Prepare enabled media without installing a guest plan or saving choices. */
|
||||
bool mod_runtime_prepare_resources(const std::filesystem::path& disc_path,
|
||||
std::string* error = nullptr);
|
||||
|
||||
@@ -128,7 +128,8 @@ int render_pass_cost_note_plan(RenderPassCost *cost);
|
||||
|
||||
/* Next frame's pass budget from the last frame: the host time the presenter
|
||||
* spent idle-waiting plus the time passes used, scaled by `share` (0..1) and
|
||||
* clamped to [0, frame_length]. No history (both zero) -> share of the frame. */
|
||||
* clamped to [0, frame_length]. Both zero means no measured credit, including
|
||||
* startup: an unknown cost warms up only when positive credit is available. */
|
||||
double render_pass_budget(double idle_ticks, double pass_ticks,
|
||||
double frame_length, double share);
|
||||
|
||||
|
||||
@@ -82,6 +82,11 @@ else()
|
||||
option(PSX_DEBUG_TOOLS "Build with TCP debug server + heartbeat + per-block recording" ON)
|
||||
endif()
|
||||
|
||||
# A title may opt in only after auditing every trusted media preparer used by
|
||||
# its mod commit. Older recomp-ui pins ignore this capability and remain sync.
|
||||
option(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE
|
||||
"Title certifies preboot mod commit/preparers have no UI or thread affinity" OFF)
|
||||
|
||||
# PSX_STATIC_RUNTIME: produce a 100% self-contained MinGW exe.
|
||||
#
|
||||
# A default MinGW build dynamically imports three NON-system DLLs —
|
||||
@@ -2085,6 +2090,9 @@ function(psxrecomp_add_runtime_target target)
|
||||
if(PSX_MOD_DEVELOPER_CHANNEL)
|
||||
target_compile_definitions(${target} PRIVATE PSX_MOD_DEVELOPER_CHANNEL=1)
|
||||
endif()
|
||||
if(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
target_compile_definitions(${target} PRIVATE PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE=1)
|
||||
endif()
|
||||
if(has_game_dispatch)
|
||||
target_compile_definitions(${target} PRIVATE PSX_HAS_GAME_DISPATCH=1)
|
||||
endif()
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#endif
|
||||
#include <time.h>
|
||||
#include "debug_server.h"
|
||||
#include "host_launch_timing.h"
|
||||
#include "psx_video_timing.h"
|
||||
#include "psx_netplay.h"
|
||||
#include "psx_bss.h"
|
||||
@@ -8222,6 +8223,53 @@ static void handle_netplay_status(int id, const char *json)
|
||||
debug_server_send_line(out);
|
||||
}
|
||||
|
||||
/* Startup host work recorded before TCP was initialized; always-on, read-only.
|
||||
* Completion order is not start order. Parents include their children. */
|
||||
static void handle_host_launch_timings(int id, const char *json)
|
||||
{
|
||||
int requested = json_get_int(json, "count", HOST_LAUNCH_TIMING_CAPACITY);
|
||||
if (requested < 1) requested = 1;
|
||||
if (requested > (int)HOST_LAUNCH_TIMING_CAPACITY)
|
||||
requested = HOST_LAUNCH_TIMING_CAPACITY;
|
||||
HostLaunchTimingEvent *events = (HostLaunchTimingEvent *)malloc(
|
||||
(size_t)requested * sizeof(*events));
|
||||
if (!events) { send_err(id, "alloc failed"); return; }
|
||||
uint64_t total = 0, overwritten = 0;
|
||||
uint32_t n = host_launch_timing_snapshot(events, (uint32_t)requested,
|
||||
&total, &overwritten);
|
||||
/* Two labels may each expand sixfold when JSON-escaped. */
|
||||
size_t cap = 512u + (size_t)n * 1536u;
|
||||
char *buf = (char *)malloc(cap);
|
||||
if (!buf) { free(events); send_err(id, "alloc failed"); return; }
|
||||
size_t off = (size_t)snprintf(buf, cap,
|
||||
"{\"id\":%d,\"ok\":true,\"clock\":\"steady_us\","
|
||||
"\"nested_durations_overlap\":true,\"capacity\":%u,"
|
||||
"\"total\":%llu,\"overwritten\":%llu,\"entries\":[",
|
||||
id, HOST_LAUNCH_TIMING_CAPACITY, (unsigned long long)total,
|
||||
(unsigned long long)overwritten);
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
const HostLaunchTimingEvent *e = &events[i];
|
||||
char package[HOST_LAUNCH_TIMING_LABEL * 6u + 1u];
|
||||
char feature[HOST_LAUNCH_TIMING_LABEL * 6u + 1u];
|
||||
json_escape_string(package, sizeof(package), e->package_id);
|
||||
json_escape_string(feature, sizeof(feature), e->feature_id);
|
||||
off += (size_t)snprintf(buf + off, cap - off,
|
||||
"%s{\"seq\":%llu,\"scope_id\":%llu,\"parent_id\":%llu,"
|
||||
"\"stage\":\"%s\",\"start_us\":%llu,\"duration_us\":%llu,"
|
||||
"\"ok\":%u,\"package\":\"%s\",\"feature\":\"%s\","
|
||||
"\"labels_truncated\":%u}",
|
||||
i ? "," : "", (unsigned long long)e->seq,
|
||||
(unsigned long long)e->scope_id, (unsigned long long)e->parent_id,
|
||||
host_launch_timing_stage_name(e->stage),
|
||||
(unsigned long long)e->start_us, (unsigned long long)e->duration_us,
|
||||
e->ok, package, feature, e->labels_truncated);
|
||||
}
|
||||
snprintf(buf + off, cap - off, "]}");
|
||||
debug_server_send_line(buf);
|
||||
free(buf);
|
||||
free(events);
|
||||
}
|
||||
|
||||
static void handle_mod_counters(int id, const char *json)
|
||||
{
|
||||
extern int psx_mod_counters_snapshot(const char **names, uint64_t *counts,
|
||||
@@ -8504,9 +8552,11 @@ static void handle_render_pass_stats(int id, const char *json)
|
||||
{
|
||||
(void)json;
|
||||
RenderPassStats st;
|
||||
GLRenderPassPlanDiag admission;
|
||||
uint64_t gd[10], image_bytes = 0;
|
||||
uint32_t image_textures;
|
||||
char failure_json[2048];
|
||||
char admission_json[768];
|
||||
char abort_detail_json[sizeof st.last_abort_detail * 6 + 1];
|
||||
DirtyRamSpanFailure sf;
|
||||
render_pass_get_stats(&st);
|
||||
@@ -8540,6 +8590,18 @@ static void handle_render_pass_stats(int id, const char *json)
|
||||
b->fbo_status, b->gl_error_before, b->gl_error);
|
||||
}
|
||||
gl_renderer_pass_diag(gd);
|
||||
gl_renderer_pass_plan_diag(&admission);
|
||||
snprintf(admission_json, sizeof admission_json,
|
||||
"{\"plans\":%llu,\"frame\":%llu,\"zero_credit_refusals\":%llu,"
|
||||
"\"idle_ms\":%.3f,\"present_ms\":%.3f,\"prior_pass_ms\":%.3f,"
|
||||
"\"present_reserve_ms\":%.3f,\"spare_ms\":%.3f,\"frame_ms\":%.3f,"
|
||||
"\"cost_ms\":%.3f,\"budget_ms\":%.3f,\"wanted\":%u,\"planned\":%u}",
|
||||
(unsigned long long)admission.plans,
|
||||
(unsigned long long)admission.frame,
|
||||
(unsigned long long)admission.zero_credit_refusals,
|
||||
admission.idle_ms, admission.present_ms, admission.prior_pass_ms,
|
||||
admission.present_reserve_ms, admission.spare_ms, admission.frame_ms,
|
||||
admission.cost_ms, admission.budget_ms, admission.wanted, admission.planned);
|
||||
image_textures = gl_renderer_pass_image_textures(&image_bytes);
|
||||
send_fmt("{\"id\":%d,\"ok\":true,\"plans\":%llu,\"planned\":%llu,"
|
||||
"\"wanted\":%llu,\"refused\":%llu,\"passes\":%llu,"
|
||||
@@ -8564,7 +8626,7 @@ static void handle_render_pass_stats(int id, const char *json)
|
||||
"\"spans\":%llu,\"span_failures\":%llu,"
|
||||
"\"local_views\":%llu,\"local_attempts\":%llu,"
|
||||
"\"local_status\":%u,"
|
||||
"\"last_failure\":%s,\"last_abort_detail\":\"%s\","
|
||||
"\"admission\":%s,\"last_failure\":%s,\"last_abort_detail\":\"%s\","
|
||||
"\"span_fail\":{\"reason\":%u,\"pc\":\"0x%08X\",\"start\":\"0x%08X\","
|
||||
"\"stop\":\"0x%08X\",\"ra\":\"0x%08X\",\"after\":\"0x%08X\","
|
||||
"\"insns\":%llu}}",
|
||||
@@ -8601,7 +8663,7 @@ static void handle_render_pass_stats(int id, const char *json)
|
||||
(unsigned long long)st.local_views,
|
||||
(unsigned long long)st.local_attempts,
|
||||
psx_mod_render_local_view_status(),
|
||||
failure_json, abort_detail_json,
|
||||
admission_json, failure_json, abort_detail_json,
|
||||
(unsigned)sf.reason, (unsigned)sf.pc, (unsigned)sf.start_pc,
|
||||
(unsigned)sf.stop_pc, (unsigned)sf.ra, (unsigned)sf.after,
|
||||
(unsigned long long)sf.insns);
|
||||
@@ -15324,6 +15386,7 @@ static const CmdEntry s_commands[] = {
|
||||
{ "ws_hud_mode", handle_ws_hud_mode },
|
||||
{ "ws_tag_stats", handle_ws_tag_stats },
|
||||
{ "mod_counters", handle_mod_counters },
|
||||
{ "host_launch_timings", handle_host_launch_timings },
|
||||
{ "resident_status", handle_resident_status },
|
||||
{ "netplay_status", handle_netplay_status },
|
||||
{ "resident_events", handle_resident_events },
|
||||
|
||||
+110
-3
@@ -7476,10 +7476,110 @@ int gpu_snapshot_read(const uint8_t *p, uint32_t len) {
|
||||
}
|
||||
/* ---- Render-pass GPU checkpoint (render_pass.c) ---------------------------
|
||||
* The same register set as a savestate (gpu_snap_emit), restored WITHOUT
|
||||
* gpu_snapshot_read's side effects (widescreen scene history and HUD anchor
|
||||
* tags belong to the live frame, which a pass must not reset). After the
|
||||
* restore the renderer's mirrored draw state (area, offset, texture window,
|
||||
* gpu_snapshot_read's side effects. Packet provenance belongs to the RAM it
|
||||
* describes: a synthetic builder can rewrite and retag a canonical packet,
|
||||
* so rolling its RAM back must also restore its original tags, not clear them.
|
||||
* The renderer's mirrored draw state (area, offset, texture window,
|
||||
* mask bits, native-wide target) is re-synced exactly as GP0(E2..E6) would. */
|
||||
typedef struct {
|
||||
WsTag ws_tags[WS_TAG_BUCKETS];
|
||||
WsHudAnchorTag ws_hud_anchor_tags[WS_HUD_ANCHOR_TABLE_SIZE];
|
||||
WsHudAnchorTag ws_background_tags[WS_HUD_ANCHOR_TABLE_SIZE];
|
||||
WsHudAnchorTag ws_reveal_clear_tags[WS_HUD_ANCHOR_TABLE_SIZE];
|
||||
WsHudAnchorTag ws_screen_mask_tags[WS_HUD_ANCHOR_TABLE_SIZE];
|
||||
WsRadialScreenMaskTag ws_radial_screen_mask_tags[WS_RADIAL_MASK_TAG_COUNT];
|
||||
WsRepeatRectTag ws_repeat_rect_tags[WS_REPEAT_RECT_TAG_TABLE_SIZE];
|
||||
WsPrimitiveRole ws_primitive_roles[WS_ROLE_BUCKETS];
|
||||
WsUiPrepassItem ws_ui_prepass[WS_UI_PREPASS_MAX];
|
||||
WsUiPrepassNode ws_ui_prepass_nodes[WS_UI_PREPASS_NODE_MAX];
|
||||
uint32_t ws_ui_prepass_count, ws_ui_prepass_node_count;
|
||||
uint16_t ws_ui_prepass_rank;
|
||||
int ws_auto_ui_dense, ws_shared_ui_copy;
|
||||
uint32_t ws_background_tag_frame, ws_last_tag_stamp;
|
||||
int ws_background_tags_used;
|
||||
uint32_t s_bg_phase_frame;
|
||||
int s_bg_phase_over;
|
||||
/* Synthetic GTE/GP0 draws update these signals, and native-wide target
|
||||
* selection reads them even though no guest frame has elapsed. */
|
||||
uint32_t ws_gte_frame, ws_gte_count, ws_gte_prev_verts;
|
||||
uint32_t ws_last_gte_stamp, ws_last_world3d_stamp, ws_sust_world3d_stamp;
|
||||
uint32_t ws_ovh_frame, ws_ovh_count, ws_ovh_prev;
|
||||
uint32_t ws_last_ovh_stamp, ws_sust_ovh_stamp;
|
||||
WsSceneLatch ws_scene_latch;
|
||||
WsSceneHold s_ws_scene_hold;
|
||||
uint32_t s_ws_fmv_frame_cache;
|
||||
int s_ws_fmv_cached;
|
||||
} GpuPassPresentationState;
|
||||
static GpuPassPresentationState s_pass_presentation;
|
||||
|
||||
/* One transfer list keeps save and restore symmetric. The prepass arrays are
|
||||
* read only through their counts, so copy just their populated prefixes.
|
||||
* Counters/rings remain cumulative; configuration and callbacks are unchanged. */
|
||||
static void gpu_pass_provenance_transfer(int restore) {
|
||||
#define PASS_ARRAY(name) do { \
|
||||
if (restore) memcpy(name, s_pass_presentation.name, sizeof(name)); \
|
||||
else memcpy(s_pass_presentation.name, name, sizeof(name)); \
|
||||
} while (0)
|
||||
#define PASS_VALUE(name) do { \
|
||||
if (restore) name = s_pass_presentation.name; \
|
||||
else s_pass_presentation.name = name; \
|
||||
} while (0)
|
||||
PASS_ARRAY(ws_tags);
|
||||
PASS_ARRAY(ws_hud_anchor_tags);
|
||||
PASS_ARRAY(ws_background_tags);
|
||||
PASS_ARRAY(ws_reveal_clear_tags);
|
||||
PASS_ARRAY(ws_screen_mask_tags);
|
||||
PASS_ARRAY(ws_radial_screen_mask_tags);
|
||||
PASS_ARRAY(ws_repeat_rect_tags);
|
||||
PASS_ARRAY(ws_primitive_roles);
|
||||
PASS_VALUE(ws_ui_prepass_count);
|
||||
PASS_VALUE(ws_ui_prepass_node_count);
|
||||
PASS_VALUE(ws_ui_prepass_rank);
|
||||
PASS_VALUE(ws_auto_ui_dense);
|
||||
PASS_VALUE(ws_shared_ui_copy);
|
||||
PASS_VALUE(ws_background_tag_frame);
|
||||
PASS_VALUE(ws_background_tags_used);
|
||||
PASS_VALUE(ws_last_tag_stamp);
|
||||
PASS_VALUE(s_bg_phase_frame);
|
||||
PASS_VALUE(s_bg_phase_over);
|
||||
if (restore) {
|
||||
memcpy(ws_ui_prepass, s_pass_presentation.ws_ui_prepass,
|
||||
ws_ui_prepass_count * sizeof(ws_ui_prepass[0]));
|
||||
memcpy(ws_ui_prepass_nodes, s_pass_presentation.ws_ui_prepass_nodes,
|
||||
ws_ui_prepass_node_count * sizeof(ws_ui_prepass_nodes[0]));
|
||||
} else {
|
||||
memcpy(s_pass_presentation.ws_ui_prepass, ws_ui_prepass,
|
||||
ws_ui_prepass_count * sizeof(ws_ui_prepass[0]));
|
||||
memcpy(s_pass_presentation.ws_ui_prepass_nodes, ws_ui_prepass_nodes,
|
||||
ws_ui_prepass_node_count * sizeof(ws_ui_prepass_nodes[0]));
|
||||
}
|
||||
#undef PASS_ARRAY
|
||||
#undef PASS_VALUE
|
||||
}
|
||||
|
||||
static void gpu_pass_scene_transfer(int restore) {
|
||||
#define PASS_VALUE(name) do { \
|
||||
if (restore) name = s_pass_presentation.name; \
|
||||
else s_pass_presentation.name = name; \
|
||||
} while (0)
|
||||
PASS_VALUE(ws_gte_frame);
|
||||
PASS_VALUE(ws_gte_count);
|
||||
PASS_VALUE(ws_gte_prev_verts);
|
||||
PASS_VALUE(ws_last_gte_stamp);
|
||||
PASS_VALUE(ws_last_world3d_stamp);
|
||||
PASS_VALUE(ws_sust_world3d_stamp);
|
||||
PASS_VALUE(ws_ovh_frame);
|
||||
PASS_VALUE(ws_ovh_count);
|
||||
PASS_VALUE(ws_ovh_prev);
|
||||
PASS_VALUE(ws_last_ovh_stamp);
|
||||
PASS_VALUE(ws_sust_ovh_stamp);
|
||||
PASS_VALUE(ws_scene_latch);
|
||||
PASS_VALUE(s_ws_scene_hold);
|
||||
PASS_VALUE(s_ws_fmv_frame_cache);
|
||||
PASS_VALUE(s_ws_fmv_cached);
|
||||
#undef PASS_VALUE
|
||||
}
|
||||
|
||||
static uint8_t s_pass_regs[512];
|
||||
static uint32_t s_pass_regs_len;
|
||||
static uint32_t s_pass_poll_count;
|
||||
@@ -7499,6 +7599,8 @@ int gpu_pass_checkpoint_save(void) {
|
||||
s_pass_doff_max = g_doff_max_this;
|
||||
s_pass_doff_cnt = g_doff_cnt_this;
|
||||
s_pass_split_this = split_trace_this;
|
||||
gpu_pass_provenance_transfer(0);
|
||||
gpu_pass_scene_transfer(0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -7512,12 +7614,17 @@ void gpu_pass_checkpoint_restore(void) {
|
||||
g_doff_max_this = s_pass_doff_max;
|
||||
g_doff_cnt_this = s_pass_doff_cnt;
|
||||
split_trace_this = s_pass_split_this;
|
||||
gpu_pass_provenance_transfer(1);
|
||||
gpu_pass_scene_transfer(1);
|
||||
gr_set_texture_window(texture_window_value);
|
||||
gr_set_draw_area((int)draw_area_left, (int)draw_area_top,
|
||||
(int)draw_area_right, (int)draw_area_bottom);
|
||||
gr_set_draw_offset(draw_offset_x, draw_offset_y);
|
||||
gr_set_mask_bits((int)set_mask_bit, (int)check_mask_bit);
|
||||
ws_nw_sync_target();
|
||||
/* Target sync may classify the scene before render_pass.c restores RAM.
|
||||
* Do not retain history learned from the synthetic draw or that query. */
|
||||
gpu_pass_scene_transfer(1);
|
||||
s_pass_regs_len = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -6342,6 +6342,7 @@ static uint64_t s_present_ticks_accum = 0, s_present_ticks_last = 0;
|
||||
static double s_present_cost_ema = 0.0; /* host ticks per present */
|
||||
static uint32_t s_intervals_since_plan = 0;
|
||||
static int s_pass_budget_pct = -1;
|
||||
static GLRenderPassPlanDiag s_pass_plan_diag;
|
||||
|
||||
static int s_pass_verify = -1;
|
||||
static uint8_t *s_pv_hr = NULL, *s_pv_raw = NULL;
|
||||
@@ -6431,7 +6432,7 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks,
|
||||
uint32_t *alpha_q16, uint32_t max,
|
||||
uint32_t *wanted) {
|
||||
RenderPassPlanInput in;
|
||||
double freq, sp;
|
||||
double freq, sp, spare;
|
||||
uint32_t cap;
|
||||
int live;
|
||||
|
||||
@@ -6467,8 +6468,8 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks,
|
||||
{
|
||||
/* Presents beyond two per frame are traded for passes: a shed
|
||||
* present is coalesced, a shed pass is a missing motion sample. */
|
||||
double spare = (double)s_idle_ticks_last + (double)s_present_ticks_last -
|
||||
2.0 * s_present_cost_ema;
|
||||
spare = (double)s_idle_ticks_last + (double)s_present_ticks_last -
|
||||
2.0 * s_present_cost_ema;
|
||||
if (spare < 0.0) spare = 0.0;
|
||||
in.budget = render_pass_budget(spare, (double)s_pass_ticks_last,
|
||||
in.frame_length,
|
||||
@@ -6486,10 +6487,33 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks,
|
||||
in.pass_cost = 0.0;
|
||||
n = render_pass_plan_phases(&in, alpha_q16, NULL);
|
||||
}
|
||||
/* Record the actual input used, including a cost rewarm, without
|
||||
* making a debug query drive admission or reset its history. */
|
||||
{
|
||||
double ms = 1000.0 / freq;
|
||||
s_pass_plan_diag.plans++;
|
||||
s_pass_plan_diag.frame = s_frame_count;
|
||||
s_pass_plan_diag.idle_ms = (double)s_idle_ticks_last * ms;
|
||||
s_pass_plan_diag.present_ms = (double)s_present_ticks_last * ms;
|
||||
s_pass_plan_diag.prior_pass_ms = (double)s_pass_ticks_last * ms;
|
||||
s_pass_plan_diag.present_reserve_ms = 2.0 * s_present_cost_ema * ms;
|
||||
s_pass_plan_diag.spare_ms = spare * ms;
|
||||
s_pass_plan_diag.frame_ms = in.frame_length * ms;
|
||||
s_pass_plan_diag.cost_ms = in.pass_cost * ms;
|
||||
s_pass_plan_diag.budget_ms = in.budget * ms;
|
||||
s_pass_plan_diag.wanted = want;
|
||||
s_pass_plan_diag.planned = n;
|
||||
if (want && !n && !(in.budget > 0.0))
|
||||
s_pass_plan_diag.zero_credit_refusals++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
void gl_renderer_pass_plan_diag(GLRenderPassPlanDiag *out) {
|
||||
if (out) *out = s_pass_plan_diag;
|
||||
}
|
||||
|
||||
void gl_renderer_pass_note_cost(uint64_t ticks) {
|
||||
if (s_pass_cost_w != s_interp_w || s_pass_cost_h != s_interp_h) {
|
||||
memset(&s_pass_cost, 0, sizeof s_pass_cost);
|
||||
|
||||
+17
-1
@@ -106,6 +106,7 @@ extern "C" void psx_event_step_conservative_env_init(void);
|
||||
#include "mod_local_input_policy.h"
|
||||
#include "mod_session_baseline.h"
|
||||
#include "mod_runtime.h"
|
||||
#include "host_launch_timing.h"
|
||||
#include "mod_packages.h"
|
||||
#include "present_image_ring.h"
|
||||
#include "gpu_timeline.h"
|
||||
@@ -2640,7 +2641,8 @@ static void write_cached_path(const char* argv0, const char* filename,
|
||||
// Relative inside the game folder so a moved portable folder still works;
|
||||
// read_cached_path anchors relative paths on the exe directory.
|
||||
if (f.is_open())
|
||||
f << PSXRecompV4::relative_to_folder(path, exe_dir_from_argv(argv0)).generic_string() << "\n";
|
||||
f << PSXRecompV4::host_path_forward_slashes(
|
||||
PSXRecompV4::relative_to_folder(path, exe_dir_from_argv(argv0))) << "\n";
|
||||
}
|
||||
|
||||
/* Nobody is at the screen: never block on a modal dialog or a file picker. */
|
||||
@@ -15991,6 +15993,7 @@ int main(int argc, char** argv) {
|
||||
std::thread overlay_init_thread;
|
||||
std::exception_ptr overlay_init_exc;
|
||||
auto run_deferred_overlay_init = [&]() {
|
||||
PSXRecompV4::HostLaunchTimingScope timing(HOST_LAUNCH_OVERLAY_WORKER);
|
||||
std::filesystem::path exe_dir = exe_dir_from_argv(argv[0]);
|
||||
std::string cache_dir = (exe_dir / "cache").string();
|
||||
std::filesystem::path captures_path =
|
||||
@@ -16176,6 +16179,7 @@ int main(int argc, char** argv) {
|
||||
captures_path.string().c_str());
|
||||
}
|
||||
code_provider_init(cfg_backend, gcc_avail);
|
||||
timing.success();
|
||||
};
|
||||
|
||||
if (deferred_overlay_cache) {
|
||||
@@ -16685,6 +16689,12 @@ int main(int argc, char** argv) {
|
||||
SDL_Quit();
|
||||
return qrc;
|
||||
}
|
||||
#endif
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
// Once per offline preboot: verified receipts make the selected plan
|
||||
// ready before the launcher opens. A miss leaves preparation to UI.
|
||||
if (!net_cfg.enabled && !ls.netplay_launch.enabled && !rui_initial_disc.empty())
|
||||
PSXRecompV4::mod_runtime_try_prepare_cached(rui_initial_disc);
|
||||
#endif
|
||||
char rui_out_disc[1024] = {0};
|
||||
launcher_boot_timing_mark("host:before_run_window");
|
||||
@@ -17142,6 +17152,7 @@ int main(int argc, char** argv) {
|
||||
#endif
|
||||
|
||||
if (overlay_init_thread.joinable()) {
|
||||
PSXRecompV4::HostLaunchTimingScope timing(HOST_LAUNCH_OVERLAY_JOIN);
|
||||
overlay_init_thread.join();
|
||||
if (overlay_init_exc) {
|
||||
try {
|
||||
@@ -17152,6 +17163,7 @@ int main(int argc, char** argv) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
timing.success();
|
||||
}
|
||||
|
||||
if (game_config_path || disc_override_path || !resolved_disc.empty()) {
|
||||
@@ -17167,6 +17179,7 @@ int main(int argc, char** argv) {
|
||||
/* Netplay is vanilla unless the title opted into content negotiation
|
||||
* ([netplay] content_negotiation); either way the user's persisted
|
||||
* offline mod selection is left untouched. */
|
||||
PSXRecompV4::HostLaunchTimingScope timing(HOST_LAUNCH_RUNTIME_COMMIT);
|
||||
std::string mod_error;
|
||||
if (net_cfg.enabled) {
|
||||
if (!netplay_commit_mods(resolved_disc, &mod_error)) {
|
||||
@@ -17182,6 +17195,7 @@ int main(int argc, char** argv) {
|
||||
mod_error.c_str());
|
||||
return 1;
|
||||
}
|
||||
timing.success();
|
||||
}
|
||||
/* Session start: every session runs this after its mod commit or netplay
|
||||
* clear -- the first boot here, and the lobby rematch, which re-enters at
|
||||
@@ -19311,6 +19325,7 @@ soft_return_lobby:
|
||||
}
|
||||
}
|
||||
{
|
||||
PSXRecompV4::HostLaunchTimingScope timing(HOST_LAUNCH_RUNTIME_COMMIT);
|
||||
std::string mod_error;
|
||||
if (net_cfg.enabled) {
|
||||
if (!netplay_commit_mods(resolved_disc, &mod_error)) {
|
||||
@@ -19330,6 +19345,7 @@ soft_return_lobby:
|
||||
SDL_Quit();
|
||||
return 1;
|
||||
}
|
||||
timing.success();
|
||||
}
|
||||
/* `goto session_reboot` re-enters below the first-boot session
|
||||
* block, so run the same session start here, after the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "mod_packages.h"
|
||||
#include "host_launch_timing.h"
|
||||
|
||||
#include "crc32.h"
|
||||
#include "mod_plugins.h"
|
||||
@@ -33,8 +34,9 @@ std::map<std::string, ModBuiltinResolver>& builtin_resolvers() {
|
||||
return value;
|
||||
}
|
||||
|
||||
std::map<std::string, ModMediaPreparer>& media_preparers() {
|
||||
static std::map<std::string, ModMediaPreparer> value;
|
||||
struct MediaPreparer { ModMediaPreparer prepare, probe; };
|
||||
std::map<std::string, MediaPreparer>& media_preparers() {
|
||||
static std::map<std::string, MediaPreparer> value;
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1443,8 +1445,8 @@ std::string fingerprint_text(const std::string& text) {
|
||||
|
||||
} // namespace
|
||||
|
||||
bool mod_register_media_preparer(const std::string& id, ModMediaPreparer callback) {
|
||||
return valid_id(id) && callback && media_preparers().emplace(id, std::move(callback)).second;
|
||||
bool mod_register_media_preparer(const std::string& id, ModMediaPreparer callback, ModMediaPreparer cache_probe) {
|
||||
return valid_id(id) && callback && media_preparers().emplace(id, MediaPreparer{std::move(callback), std::move(cache_probe)}).second;
|
||||
}
|
||||
|
||||
bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver resolver) {
|
||||
@@ -3437,7 +3439,7 @@ std::string ModPackageManager::feature_option_value(
|
||||
}
|
||||
|
||||
bool ModPackageManager::prepare_resources(const std::string& game_id,
|
||||
const fs::path& disc_path, const fs::path& cache_root, std::string* error) {
|
||||
const fs::path& disc_path, const fs::path& cache_root, std::string* error, bool cached_only) {
|
||||
auto effective = effective_selections(nullptr, nullptr, nullptr);
|
||||
auto pending = selections_;
|
||||
try {
|
||||
@@ -3462,8 +3464,14 @@ bool ModPackageManager::prepare_resources(const std::string& game_id,
|
||||
}
|
||||
std::map<std::string, fs::path> outputs;
|
||||
std::string reason;
|
||||
if (!provider->second(context, outputs, reason))
|
||||
throw std::runtime_error(package->name + ": " + reason);
|
||||
{
|
||||
HostLaunchTimingScope timing(HOST_LAUNCH_MEDIA_PROVIDER,
|
||||
package->id.c_str(), feature.id.c_str());
|
||||
const auto& callback = cached_only ? provider->second.probe : provider->second.prepare;
|
||||
if (!callback || !callback(context, outputs, reason))
|
||||
throw std::runtime_error(package->name + ": " + reason);
|
||||
timing.success();
|
||||
}
|
||||
for (const auto& [name, path] : outputs) {
|
||||
const auto* resource = find_resource(*package, feature.id, name);
|
||||
if (!resource || resource->input_only || resource->sha256.empty())
|
||||
|
||||
+390
-42
@@ -1,7 +1,9 @@
|
||||
#include "mod_runtime.h"
|
||||
#include "host_launch_timing.h"
|
||||
#include "cpu_state.h"
|
||||
|
||||
#include "disc_path.h"
|
||||
#include "host_file_identity.h"
|
||||
#include "iso_reader.h"
|
||||
#include "mod_packages.h"
|
||||
#include "mod_plugins.h"
|
||||
@@ -14,6 +16,22 @@
|
||||
#include "cpu_state.h"
|
||||
#include "psx_lobby_client.h"
|
||||
|
||||
extern "C" uint32_t host_launch_timing_snapshot(HostLaunchTimingEvent* out,
|
||||
uint32_t cap, uint64_t* total, uint64_t* overwritten) {
|
||||
return PSXRecompV4::host_launch_timing_ring().snapshot(out, cap, total, overwritten);
|
||||
}
|
||||
|
||||
extern "C" const char* host_launch_timing_stage_name(uint32_t stage) {
|
||||
static const char* const names[] = {
|
||||
"provider_commit", "runtime_commit", "commit", "disc_hash",
|
||||
"prepare_resources", "media_provider", "resolve", "overlay_verify",
|
||||
"derived_disc", "save_state", "build_disc_index", "overlay_worker",
|
||||
"overlay_join", "plugin_activation", "plugin_callback",
|
||||
"provider_netplay_commit"
|
||||
};
|
||||
return stage < sizeof(names) / sizeof(names[0]) ? names[stage] : "unknown";
|
||||
}
|
||||
|
||||
#if defined(RECOMP_LAUNCHER)
|
||||
#include "recomp_launcher.h"
|
||||
#endif
|
||||
@@ -89,6 +107,14 @@ struct RuntimeMods {
|
||||
std::filesystem::path disc_path;
|
||||
std::filesystem::path effective_disc_path;
|
||||
uint32_t entry_phys = 0;
|
||||
uint64_t preparation_revision = 1;
|
||||
uint64_t ticket_revision = 0;
|
||||
std::map<std::filesystem::path, std::string> ticket_files;
|
||||
bool ticket_ready = false;
|
||||
bool catalog_stamped = false;
|
||||
std::map<std::filesystem::path, std::string> loaded_catalog;
|
||||
bool cached_prepare = false;
|
||||
bool provider_preparing = false;
|
||||
bool initialized = false;
|
||||
bool main_applied = false;
|
||||
bool disc_enabled = false;
|
||||
@@ -106,6 +132,96 @@ RuntimeMods& state() {
|
||||
return value;
|
||||
}
|
||||
|
||||
std::filesystem::path media_cache_root() {
|
||||
std::filesystem::path root;
|
||||
#if defined(_WIN32)
|
||||
if (const char* local = std::getenv("LOCALAPPDATA")) root = local;
|
||||
#else
|
||||
if (const char* cache = std::getenv("XDG_CACHE_HOME")) root = cache;
|
||||
else if (const char* home_dir = std::getenv("HOME")) root = std::filesystem::path(home_dir) / ".cache";
|
||||
#endif
|
||||
if (root.empty()) root = std::filesystem::temp_directory_path();
|
||||
return root / "psxrecomp/imports";
|
||||
}
|
||||
|
||||
bool dependency_files(RuntimeMods& s, const std::filesystem::path& disc,
|
||||
std::map<std::filesystem::path, std::string>& files) {
|
||||
files.clear();
|
||||
auto add = [&](const std::filesystem::path& path) {
|
||||
if (path.empty()) return true;
|
||||
std::string stamp;
|
||||
if (!host_file_identity(path, stamp)) return false;
|
||||
files[path] = std::move(stamp);
|
||||
return true;
|
||||
};
|
||||
if (!add(disc) || !add(resolve_disc_path(disc).data)) return false;
|
||||
std::error_code ec;
|
||||
size_t entries = 0;
|
||||
for (std::filesystem::recursive_directory_iterator it(s.manager.root(), ec), end;
|
||||
!ec && it != end; it.increment(ec)) {
|
||||
if (++entries > 4096) return false;
|
||||
if (it->is_regular_file(ec) && !add(it->path())) return false;
|
||||
}
|
||||
if (ec) return false;
|
||||
for (const auto& [id, versions] : s.manager.packages()) {
|
||||
(void)versions;
|
||||
const auto* package = s.manager.selected_package(id);
|
||||
if (!package) continue;
|
||||
for (const auto& resource : package->resources) {
|
||||
if (!s.manager.feature_enabled(id, resource.feature_id) &&
|
||||
!s.manager.feature_implicitly_enabled(id, resource.feature_id)) continue;
|
||||
const auto path = s.manager.feature_resource_path(id, resource.feature_id, resource.id);
|
||||
if (!add(path)) return false;
|
||||
}
|
||||
}
|
||||
return add(s.effective_disc_path);
|
||||
}
|
||||
|
||||
bool catalog_files(RuntimeMods& s, std::map<std::filesystem::path, std::string>& files) {
|
||||
files.clear();
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(s.manager.root(), ec)) return !ec;
|
||||
size_t entries = 0;
|
||||
for (std::filesystem::recursive_directory_iterator it(s.manager.root(), ec), end;
|
||||
!ec && it != end; it.increment(ec)) {
|
||||
if (++entries > 4096) return false;
|
||||
if (it->path().filename() != "manifest.toml") continue;
|
||||
std::string stamp;
|
||||
if (!host_file_identity(it->path(), stamp)) return false;
|
||||
files[it->path()] = std::move(stamp);
|
||||
}
|
||||
return !ec;
|
||||
}
|
||||
|
||||
bool prepared_ticket_matches(const std::filesystem::path& disc) {
|
||||
auto& s = state();
|
||||
if (!s.ticket_ready || s.main_applied || !mod_runtime_session_plan_fp().empty() ||
|
||||
disc != s.disc_path || s.ticket_revision != s.preparation_revision) return false;
|
||||
std::map<std::filesystem::path, std::string> files;
|
||||
if (!dependency_files(s, disc, files) || files != s.ticket_files) return false;
|
||||
auto manager = s.manager;
|
||||
if (!manager.prepare_resources(s.game_id, disc, media_cache_root(), nullptr, true)) return false;
|
||||
for (const auto& [id, versions] : s.manager.packages()) {
|
||||
(void)versions;
|
||||
const auto* package = s.manager.selected_package(id);
|
||||
if (!package) continue;
|
||||
for (const auto& resource : package->resources)
|
||||
if (manager.feature_resource_path(id, resource.feature_id, resource.id) !=
|
||||
s.manager.feature_resource_path(id, resource.feature_id, resource.id)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class ScopedFlag {
|
||||
bool& flag;
|
||||
const bool previous;
|
||||
public:
|
||||
explicit ScopedFlag(bool& value) : flag(value), previous(value) { flag = true; }
|
||||
~ScopedFlag() { flag = previous; }
|
||||
ScopedFlag(const ScopedFlag&) = delete;
|
||||
ScopedFlag& operator=(const ScopedFlag&) = delete;
|
||||
};
|
||||
|
||||
/* Guest calls made from an entry hook can deliver VBlank callbacks before
|
||||
* returning. Every callback owns its resource/completion context; restoring
|
||||
* only entry hooks would let VBlank erase the interrupted plugin or complete
|
||||
@@ -426,6 +542,78 @@ bool sha256_file(const std::filesystem::path& path, std::string& out,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool measured_disc_digest(const std::filesystem::path& path, std::string& digest, std::string* error) {
|
||||
HostLaunchTimingScope timing(HOST_LAUNCH_DISC_HASH);
|
||||
if (!path.empty()) psx_mod_counter_add("startup.disc_hash", 1);
|
||||
const bool ok = sha256_file(path, digest, error);
|
||||
if (ok) timing.success();
|
||||
return ok;
|
||||
}
|
||||
|
||||
std::string sha256_text(const std::string& value) {
|
||||
psx_sha256_ctx hash; psx_sha256_init(&hash);
|
||||
psx_sha256_update(&hash, reinterpret_cast<const uint8_t*>(value.data()), value.size());
|
||||
uint8_t bytes[32]; psx_sha256_final(&hash, bytes);
|
||||
std::ostringstream text;
|
||||
for (uint8_t byte : bytes) text << std::hex << std::setw(2) << std::setfill('0') << unsigned(byte);
|
||||
return text.str();
|
||||
}
|
||||
|
||||
// Offline only: semantics match sha256_file (CUE data / CHD raw sectors).
|
||||
bool offline_disc_digest(const std::filesystem::path& path, std::string& digest,
|
||||
std::string* error, bool cached_only) {
|
||||
digest.clear();
|
||||
if (path.empty()) return true;
|
||||
const auto resolved = resolve_disc_path(path);
|
||||
std::string stamp, mount_stamp;
|
||||
const bool reliable = host_file_identity(resolved.data, stamp) &&
|
||||
host_file_identity(resolved.mount, mount_stamp);
|
||||
const std::string key = "disc-sha-v2\n" + resolved.mount.generic_string() + "\n" +
|
||||
resolved.data.generic_string() + "\n" + stamp + "\n" + mount_stamp + "\n";
|
||||
const bool cacheable = reliable && key.size() <= 4096;
|
||||
const auto directory = media_cache_root() / "disc-digests-v2";
|
||||
const auto receipt = directory / (sha256_text(key) + ".txt");
|
||||
if (cacheable) {
|
||||
std::ifstream file(receipt, std::ios::binary);
|
||||
std::array<char, 4227> buffer{};
|
||||
file.read(buffer.data(), buffer.size());
|
||||
const std::string record(buffer.data(), size_t(file.gcount()));
|
||||
if (file.eof() && record.size() == key.size() + 130 &&
|
||||
record.compare(0, key.size(), key) == 0) {
|
||||
const std::string value = record.substr(key.size(), 64);
|
||||
const std::string body = key + value + "\n";
|
||||
if (std::all_of(value.begin(), value.end(), [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }) &&
|
||||
record == body + sha256_text(body) + "\n") {
|
||||
digest = value;
|
||||
psx_mod_counter_add("startup.disc_digest_hit", 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cached_only) return false;
|
||||
if (!measured_disc_digest(path, digest, error)) return false;
|
||||
std::string after, mount_after;
|
||||
if (!cacheable || !host_file_identity(resolved.data, after) || after != stamp ||
|
||||
!host_file_identity(resolved.mount, mount_after) || mount_after != mount_stamp) return true;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(directory, ec);
|
||||
if (ec) return true;
|
||||
#if defined(_WIN32)
|
||||
const auto temporary = receipt.string() + "." + std::to_string(GetCurrentProcessId()) + ".tmp";
|
||||
#else
|
||||
const auto temporary = receipt.string() + "." + std::to_string(getpid()) + ".tmp";
|
||||
#endif
|
||||
const std::string body = key + digest + "\n";
|
||||
{ std::ofstream file(temporary, std::ios::binary | std::ios::trunc);
|
||||
if (!(file << body << sha256_text(body) << '\n')) return true; }
|
||||
#if defined(_WIN32)
|
||||
MoveFileExW(std::filesystem::path(temporary).c_str(), receipt.c_str(), MOVEFILE_REPLACE_EXISTING);
|
||||
#else
|
||||
std::filesystem::rename(temporary, receipt, ec);
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
std::filesystem::path raw_image_path(const std::filesystem::path& path,
|
||||
std::string* error) {
|
||||
std::string extension = path.extension().string();
|
||||
@@ -1129,6 +1317,8 @@ int provider_version_get(void*, const char* package_id, int index,
|
||||
|
||||
template <typename Callback>
|
||||
int mutate(Callback callback) {
|
||||
state().ticket_ready = false;
|
||||
++state().preparation_revision;
|
||||
std::string error;
|
||||
if (!callback(error)) {
|
||||
set_error(error);
|
||||
@@ -1149,6 +1339,9 @@ int provider_install(void*, const char* path) {
|
||||
std::string id, version;
|
||||
if (!state().manager.install_archive(path, &id, &version, &error)) return false;
|
||||
if (!state().manager.scan(&error)) return false;
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
state().catalog_stamped = catalog_files(state(), state().loaded_catalog);
|
||||
#endif
|
||||
return state().manager.select_version(id, version, &error);
|
||||
});
|
||||
}
|
||||
@@ -1156,7 +1349,11 @@ int provider_install(void*, const char* path) {
|
||||
int provider_remove(void*, const char* id, const char* version) {
|
||||
if (!id || !version) return 0;
|
||||
return mutate([&](std::string& error) {
|
||||
return state().manager.remove_version(id, version, &error);
|
||||
if (!state().manager.remove_version(id, version, &error)) return false;
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
state().catalog_stamped = catalog_files(state(), state().loaded_catalog);
|
||||
#endif
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1182,17 +1379,21 @@ int provider_set_option(void*, const char* id, const char* option, const char* v
|
||||
}
|
||||
|
||||
int provider_commit(void*, const char* image_path) {
|
||||
HostLaunchTimingScope timing(HOST_LAUNCH_PROVIDER_COMMIT);
|
||||
std::string error;
|
||||
if (!mod_runtime_commit(image_path ? std::filesystem::path(image_path) :
|
||||
std::filesystem::path(), &error)) {
|
||||
const bool ok = mod_runtime_prepare_for_launcher(
|
||||
image_path ? std::filesystem::path(image_path) : std::filesystem::path(), &error);
|
||||
if (!ok) {
|
||||
set_error(error);
|
||||
return 0;
|
||||
}
|
||||
state().error.clear();
|
||||
timing.success();
|
||||
return 1;
|
||||
}
|
||||
|
||||
int provider_commit_netplay(void*, const char* image_path) {
|
||||
HostLaunchTimingScope timing(HOST_LAUNCH_PROVIDER_NETPLAY_COMMIT);
|
||||
std::string error;
|
||||
/* Without [netplay] content_negotiation every netplay session is vanilla. */
|
||||
if (!(mod_runtime_netplay_content_negotiation()
|
||||
@@ -1203,6 +1404,7 @@ int provider_commit_netplay(void*, const char* image_path) {
|
||||
return 0;
|
||||
}
|
||||
state().error.clear();
|
||||
timing.success();
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1261,6 +1463,8 @@ bool mod_runtime_initialize(const std::filesystem::path& root,
|
||||
const std::filesystem::path& exe_path,
|
||||
std::string* error) {
|
||||
RuntimeMods& s = state();
|
||||
s.ticket_ready = false;
|
||||
++s.preparation_revision;
|
||||
offline_manager_before_netplay.reset();
|
||||
mod_runtime_set_session_plan_fp({});
|
||||
s.manager.set_root({});
|
||||
@@ -1288,6 +1492,10 @@ bool mod_runtime_initialize(const std::filesystem::path& root,
|
||||
s.manager.set_root(root);
|
||||
s.game_id = game_id;
|
||||
s.entry_phys = game_entry_pc & 0x1FFFFFFFu;
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
std::map<std::filesystem::path, std::string> catalog_before;
|
||||
const bool catalog_before_stamped = catalog_files(s, catalog_before);
|
||||
#endif
|
||||
if (!s.manager.scan(&s.error) || !s.manager.load_state(&s.error)) {
|
||||
if (error) *error = s.error;
|
||||
return false;
|
||||
@@ -1348,11 +1556,17 @@ bool mod_runtime_initialize(const std::filesystem::path& root,
|
||||
s.exe_sha256.clear();
|
||||
s.error.clear();
|
||||
}
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
s.catalog_stamped = catalog_before_stamped && catalog_files(s, s.loaded_catalog) &&
|
||||
catalog_before == s.loaded_catalog;
|
||||
#endif
|
||||
s.initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool mod_runtime_clear_for_netplay(std::string* error) {
|
||||
state().ticket_ready = false;
|
||||
++state().preparation_revision;
|
||||
RuntimeMods& s = state();
|
||||
if (!s.initialized) {
|
||||
if (error) error->clear();
|
||||
@@ -1587,21 +1801,15 @@ static bool netplay_resources_verified(const ModResolution& plan,
|
||||
}
|
||||
|
||||
bool mod_runtime_prepare_resources(const std::filesystem::path& disc_path, std::string* error) {
|
||||
HostLaunchTimingScope timing(HOST_LAUNCH_PREPARE_RESOURCES);
|
||||
RuntimeMods& s = state();
|
||||
if (!s.initialized) return true;
|
||||
std::filesystem::path media_cache;
|
||||
#if defined(_WIN32)
|
||||
if (const char* local = std::getenv("LOCALAPPDATA")) media_cache = local;
|
||||
#else
|
||||
if (const char* cache = std::getenv("XDG_CACHE_HOME")) media_cache = cache;
|
||||
else if (const char* home_dir = std::getenv("HOME")) media_cache = std::filesystem::path(home_dir) / ".cache";
|
||||
#endif
|
||||
if (media_cache.empty()) media_cache = std::filesystem::temp_directory_path();
|
||||
media_cache /= "psxrecomp/imports";
|
||||
if (!s.manager.prepare_resources(s.game_id, disc_path, media_cache, &s.error)) {
|
||||
if (!s.initialized) { timing.success(); return true; }
|
||||
const auto media_cache = media_cache_root();
|
||||
if (!s.manager.prepare_resources(s.game_id, disc_path, media_cache, &s.error, s.cached_prepare)) {
|
||||
if (error) *error = s.error;
|
||||
return false;
|
||||
}
|
||||
timing.success();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1615,9 +1823,18 @@ static void note_disc(RuntimeMods& s, const std::filesystem::path& disc_path) {
|
||||
s.disc_sha256 = std::move(digest);
|
||||
}
|
||||
|
||||
/* Capture before resolution: a resolver may mutate a resource while running. */
|
||||
struct PreparedPlanInputs {
|
||||
std::filesystem::path disc_path;
|
||||
std::string disc_identity, data_identity;
|
||||
std::map<std::filesystem::path, std::string> dependencies;
|
||||
bool disc_stamped = false, stable = false;
|
||||
};
|
||||
|
||||
static bool install_plan(RuntimeMods& s, ModResolution plan,
|
||||
bool save_selection, bool verify_netplay,
|
||||
std::string* error) {
|
||||
std::string* error,
|
||||
const PreparedPlanInputs* inputs = nullptr) {
|
||||
s.validation = plan;
|
||||
/* A derived activation is not in state.toml, so name it: a player (or a
|
||||
* test) reading the log can see why a hidden feature is running. */
|
||||
@@ -1642,28 +1859,52 @@ static bool install_plan(RuntimeMods& s, ModResolution plan,
|
||||
if (error) *error = s.error;
|
||||
return false;
|
||||
}
|
||||
for (const ModResolution::Overlay& overlay : plan.overlays) {
|
||||
if (overlay.expected_sha256.empty()) continue;
|
||||
std::string actual;
|
||||
if (!sha256_disc_range(
|
||||
s.disc_path, overlay.target, overlay.location,
|
||||
overlay.payload.size(), actual, &s.error) ||
|
||||
actual != overlay.expected_sha256) {
|
||||
if (s.error.empty())
|
||||
s.error = overlay.package_id + "/" + overlay.feature_id +
|
||||
": stock overlay range checksum failed";
|
||||
{
|
||||
HostLaunchTimingScope overlay_timing(HOST_LAUNCH_OVERLAY_VERIFY);
|
||||
for (const ModResolution::Overlay& overlay : plan.overlays) {
|
||||
if (overlay.expected_sha256.empty()) continue;
|
||||
std::string actual;
|
||||
if (!sha256_disc_range(
|
||||
s.disc_path, overlay.target, overlay.location,
|
||||
overlay.payload.size(), actual, &s.error) ||
|
||||
actual != overlay.expected_sha256) {
|
||||
if (s.error.empty())
|
||||
s.error = overlay.package_id + "/" + overlay.feature_id +
|
||||
": stock overlay range checksum failed";
|
||||
if (error) *error = s.error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
overlay_timing.success();
|
||||
}
|
||||
std::filesystem::path effective_disc;
|
||||
{
|
||||
HostLaunchTimingScope derived_timing(HOST_LAUNCH_DERIVED_DISC);
|
||||
if (!materialize_derived_disc(s, plan, effective_disc, &s.error)) {
|
||||
if (error) *error = s.error;
|
||||
return false;
|
||||
}
|
||||
derived_timing.success();
|
||||
}
|
||||
std::filesystem::path effective_disc;
|
||||
if (!materialize_derived_disc(s, plan, effective_disc, &s.error)) {
|
||||
if (error) *error = s.error;
|
||||
return false;
|
||||
}
|
||||
if (save_selection && !s.manager.save_state(&s.error)) {
|
||||
if (error) *error = s.error;
|
||||
return false;
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
std::map<std::filesystem::path, std::string> after_resolve;
|
||||
std::string disc_after, data_after;
|
||||
const bool unchanged_disc = inputs && (inputs->disc_path.empty() ||
|
||||
(inputs->disc_stamped && host_file_identity(inputs->disc_path, disc_after) &&
|
||||
inputs->disc_identity == disc_after &&
|
||||
host_file_identity(resolve_disc_path(inputs->disc_path).data, data_after) &&
|
||||
inputs->data_identity == data_after));
|
||||
const bool unchanged_inputs = unchanged_disc && inputs->stable &&
|
||||
dependency_files(s, inputs->disc_path, after_resolve) &&
|
||||
inputs->dependencies == after_resolve;
|
||||
#endif
|
||||
if (save_selection) {
|
||||
HostLaunchTimingScope save_timing(HOST_LAUNCH_SAVE_STATE);
|
||||
if (!s.manager.save_state(&s.error)) {
|
||||
if (error) *error = s.error;
|
||||
return false;
|
||||
}
|
||||
save_timing.success();
|
||||
}
|
||||
/* Hooks follow activation, never a bare commit: a new plan runs none of
|
||||
* its function-entry hooks until mod_runtime_activate_plugins(). */
|
||||
@@ -1672,30 +1913,122 @@ static bool install_plan(RuntimeMods& s, ModResolution plan,
|
||||
s.audio_tracks.clear();
|
||||
s.extent_start = 0;
|
||||
s.plan = std::move(plan);
|
||||
build_disc_index(s);
|
||||
{
|
||||
HostLaunchTimingScope index_timing(HOST_LAUNCH_BUILD_DISC_INDEX);
|
||||
build_disc_index(s);
|
||||
index_timing.success();
|
||||
}
|
||||
s.effective_disc_path = std::move(effective_disc);
|
||||
s.main_applied = false;
|
||||
s.error.clear();
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
if (save_selection && (s.provider_preparing || s.cached_prepare) &&
|
||||
s.catalog_stamped && unchanged_inputs && session_plan_fp().empty()) {
|
||||
s.ticket_revision = s.preparation_revision;
|
||||
s.ticket_ready = dependency_files(s, inputs->disc_path, s.ticket_files);
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error,
|
||||
bool save_selection) {
|
||||
bool mod_runtime_commit(const std::filesystem::path& disc_path, std::string* error, bool save_selection) {
|
||||
HostLaunchTimingScope timing(HOST_LAUNCH_COMMIT);
|
||||
RuntimeMods& s = state();
|
||||
if (!s.initialized) return true;
|
||||
s.netplay_view_plan = false;
|
||||
if (disc_path != s.disc_path) {
|
||||
if (!s.initialized) { timing.success(); return true; }
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
std::map<std::filesystem::path, std::string> catalog;
|
||||
if (s.catalog_stamped && (!catalog_files(s, catalog) || catalog != s.loaded_catalog)) {
|
||||
s.ticket_ready = false;
|
||||
s.error = "Mod catalog changed after loading; reopen the launcher to rescan it.";
|
||||
if (error) *error = s.error;
|
||||
return false;
|
||||
}
|
||||
if (save_selection && prepared_ticket_matches(disc_path)) {
|
||||
if (!s.provider_preparing) s.ticket_ready = false;
|
||||
clear_function_entry_hooks();
|
||||
s.disc_extents.clear(); s.audio_tracks.clear(); s.extent_start = 0;
|
||||
s.main_applied = false;
|
||||
s.error.clear();
|
||||
psx_mod_counter_add("startup.plan_reuse", 1);
|
||||
timing.success();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
s.ticket_ready = false;
|
||||
PreparedPlanInputs inputs;
|
||||
inputs.disc_path = disc_path;
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
inputs.disc_stamped = disc_path.empty() ||
|
||||
(host_file_identity(disc_path, inputs.disc_identity) &&
|
||||
host_file_identity(resolve_disc_path(disc_path).data, inputs.data_identity));
|
||||
#endif
|
||||
#if !defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
if (disc_path != s.disc_path)
|
||||
#endif
|
||||
{
|
||||
std::string hash_error, digest;
|
||||
if (!sha256_file(disc_path, digest, &hash_error)) digest.clear();
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
const bool hashed = save_selection
|
||||
? offline_disc_digest(disc_path, digest, &hash_error, s.cached_prepare)
|
||||
: measured_disc_digest(disc_path, digest, &hash_error);
|
||||
#else
|
||||
const bool hashed = measured_disc_digest(disc_path, digest, &hash_error);
|
||||
#endif
|
||||
if (!hashed && s.cached_prepare) return false;
|
||||
if (!hashed) digest.clear();
|
||||
s.disc_path = disc_path;
|
||||
s.disc_sha256 = std::move(digest);
|
||||
}
|
||||
if (!mod_runtime_prepare_resources(disc_path, error)) return false;
|
||||
return install_plan(s, s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256),
|
||||
save_selection, !save_selection, error);
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
inputs.stable = dependency_files(s, disc_path, inputs.dependencies);
|
||||
#else
|
||||
inputs.stable = false;
|
||||
#endif
|
||||
ModResolution plan;
|
||||
{
|
||||
HostLaunchTimingScope resolve_timing(HOST_LAUNCH_RESOLVE);
|
||||
plan = s.manager.resolve(s.game_id, s.exe_sha256, s.disc_sha256);
|
||||
if (plan.ok) resolve_timing.success();
|
||||
}
|
||||
const bool ok = install_plan(s, std::move(plan), save_selection,
|
||||
!save_selection, error, &inputs);
|
||||
if (ok) timing.success();
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool mod_runtime_prepare_for_launcher(const std::filesystem::path& disc_path, std::string* error) {
|
||||
ScopedFlag flag(state().provider_preparing);
|
||||
return mod_runtime_commit(disc_path, error, true);
|
||||
}
|
||||
|
||||
bool mod_runtime_try_prepare_cached(const std::filesystem::path& disc_path) {
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
try {
|
||||
auto& s = state();
|
||||
if (!s.initialized || s.main_applied || !session_plan_fp().empty()) return false;
|
||||
auto probe = s.manager;
|
||||
if (!probe.prepare_resources(s.game_id, disc_path, media_cache_root(), nullptr, true)) return false;
|
||||
ScopedFlag flag(s.cached_prepare);
|
||||
const bool ok = mod_runtime_commit(disc_path, nullptr, true);
|
||||
if (ok && s.ticket_ready) psx_mod_counter_add("startup.preload_hit", 1);
|
||||
return ok && s.ticket_ready;
|
||||
} catch (...) {
|
||||
// Preload is best effort before the launcher exists. The ordinary
|
||||
// provider path reports full-preparation errors once UI is available.
|
||||
state().ticket_ready = false;
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
(void)disc_path;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void mod_runtime_set_session_plan_fp(const std::string& fp) {
|
||||
state().ticket_ready = false;
|
||||
++state().preparation_revision;
|
||||
session_plan_fp() = fp;
|
||||
}
|
||||
|
||||
@@ -1884,6 +2217,15 @@ const std::filesystem::path& mod_runtime_effective_disc_path() {
|
||||
|
||||
#if defined(RECOMP_LAUNCHER)
|
||||
const RecompLauncherCModProvider* mod_runtime_launcher_provider() {
|
||||
#if defined(RECOMP_LAUNCHER_HAS_WORKER_MOD_COMMIT) && defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
provider.commit_worker_safe = 1;
|
||||
#if defined(RECOMP_LAUNCHER_HAS_PREPARED_MOD_COMMIT)
|
||||
provider.try_commit = [](void*, const char* path) -> int {
|
||||
return prepared_ticket_matches(path ? std::filesystem::path(path) : std::filesystem::path()) ? 1 : 0;
|
||||
};
|
||||
provider.preparation_revision = [](void*) -> unsigned long long { return state().preparation_revision; };
|
||||
#endif
|
||||
#endif
|
||||
return &provider;
|
||||
}
|
||||
|
||||
@@ -2278,22 +2620,28 @@ bool mod_runtime_read_disc_file_sectors(const std::string& path, uint32_t max_by
|
||||
|
||||
extern "C" void mod_runtime_activate_plugins(void) {
|
||||
using namespace PSXRecompV4;
|
||||
HostLaunchTimingScope timing(HOST_LAUNCH_PLUGIN_ACTIVATION);
|
||||
RuntimeMods& s = state();
|
||||
s.ticket_ready = false;
|
||||
psx_projection_reset_session();
|
||||
gpu_ws_set_native_scene_predicate(nullptr);
|
||||
psx_ram_reset_size_request();
|
||||
gpu_hd_textures_shutdown();
|
||||
if (!s.initialized || !s.plan.ok) return;
|
||||
if (!s.initialized || !s.plan.ok) { timing.success(); return; }
|
||||
s.disc_extents.clear();
|
||||
s.audio_tracks.clear();
|
||||
s.extent_start = 0;
|
||||
s.activating = true;
|
||||
for (const ModResolution::Plugin& plugin : s.plan.plugins) {
|
||||
HostLaunchTimingScope plugin_timing(HOST_LAUNCH_PLUGIN_CALLBACK,
|
||||
plugin.package_id.c_str(), plugin.feature_id.c_str());
|
||||
PluginCallbackScope scope(s, &plugin);
|
||||
mod_invoke_activation_plugin(plugin.id);
|
||||
plugin_timing.success();
|
||||
}
|
||||
s.activating = false;
|
||||
build_function_entry_hooks(s);
|
||||
timing.success();
|
||||
}
|
||||
|
||||
extern "C" void mod_runtime_on_vblank(void) {
|
||||
|
||||
@@ -196,8 +196,9 @@ double render_pass_budget(double idle_ticks, double pass_ticks,
|
||||
if (!(frame_length > 0.0)) return 0.0;
|
||||
if (!(share > 0.0)) return 0.0;
|
||||
if (share > 1.0) share = 1.0;
|
||||
if (!(idle_ticks > 0.0) && !(pass_ticks > 0.0))
|
||||
return frame_length * share;
|
||||
/* Both zero also describes a mature, overloaded frame after a skipped
|
||||
* pass. Inventing a nominal allowance there alternates expensive replay
|
||||
* with shedding. Unknown costs may warm up when measured credit returns. */
|
||||
b = ((idle_ticks > 0.0 ? idle_ticks : 0.0) +
|
||||
(pass_ticks > 0.0 ? pass_ticks : 0.0)) * share;
|
||||
if (b > frame_length) b = frame_length;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#include "host_launch_timing.h"
|
||||
|
||||
/* Separate translation unit: catches accidentally per-includer ring/parent
|
||||
* state, which would hide media-provider work from the runtime TCP reader. */
|
||||
void host_launch_timing_fixture_nested() {
|
||||
PSXRecompV4::HostLaunchTimingScope scope(HOST_LAUNCH_MEDIA_PROVIDER,
|
||||
"fixture.package", "fixture.feature");
|
||||
scope.success();
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/* Exercise the real GPU checkpoint and GP0 provenance consumers without a GL
|
||||
* context. RAM rollback deliberately follows GPU restore, as render_pass.c
|
||||
* does: a later canonical DMA must find the proof for its restored words. */
|
||||
#define GPU_EXEC_REAL_UI_GROUP
|
||||
#define main gpu_packet_fixture_main
|
||||
#include "test_gpu_textured_dot_nw_shift_exec.c"
|
||||
#undef main
|
||||
|
||||
static const uint32_t command_addr = 0x10004u;
|
||||
static const uint32_t canonical[] = {
|
||||
0x28020304u, 0, 320, 240u << 16, (240u << 16) | 320u
|
||||
};
|
||||
|
||||
static void packet_to_ram(const uint32_t *words, uint32_t count) {
|
||||
memcpy(&test_ram[command_addr / 4u], words, count * sizeof(words[0]));
|
||||
}
|
||||
|
||||
static void packet_to_gp0(const uint32_t *words, uint32_t count) {
|
||||
gp0_cmd_source_addr = command_addr;
|
||||
gp0_words_needed = (int)count;
|
||||
memcpy(gp0_cmd_buf, words, count * sizeof(words[0]));
|
||||
}
|
||||
|
||||
static void start_scene(void) {
|
||||
reset_gpu_state_for_test();
|
||||
configure_native_wide_16_9();
|
||||
ws_hud_anchor_clear(ws_reveal_clear_tags, WS_HUD_ANCHOR_TABLE_SIZE);
|
||||
ws_hud_anchor_clear(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE);
|
||||
ws_radial_mask_clear(ws_radial_screen_mask_tags);
|
||||
ws_repeat_rect_tag_clear(ws_repeat_rect_tags);
|
||||
memset(ws_primitive_roles, 0, sizeof(ws_primitive_roles));
|
||||
ws_ui_prepass_count = ws_ui_prepass_node_count = 0;
|
||||
ws_ui_prepass_rank = 0xFFFFu;
|
||||
ws_reset_scene_history();
|
||||
ws_nw_phase_backdrop = 0;
|
||||
s_bg_phase_frame = UINT32_MAX;
|
||||
packet_to_ram(canonical, 5);
|
||||
packet_to_gp0(canonical, 5);
|
||||
}
|
||||
|
||||
static void test_retagged_canonical_packet(void) {
|
||||
start_scene();
|
||||
gpu_ws_tag_background_prim(command_addr - 4u);
|
||||
gpu_ws_tag_hud_prim(command_addr - 4u, -1);
|
||||
gpu_ws_tag_world_primitive(command_addr - 4u, 1);
|
||||
gpu_ws_tag_hud_primitive(command_addr - 4u, -1);
|
||||
const int32_t offset = ws_nw_offset();
|
||||
assert(offset > 0);
|
||||
GpuWsTagStats before, after;
|
||||
gpu_ws_get_tag_stats(NULL, &before);
|
||||
|
||||
/* Repeated in-between draws reuse the same packet address and guest frame. */
|
||||
for (uint32_t pass = 1; pass <= 3; ++pass) {
|
||||
uint32_t synthetic[5];
|
||||
memcpy(synthetic, canonical, sizeof(synthetic));
|
||||
synthetic[1] += pass;
|
||||
assert(gpu_pass_checkpoint_save());
|
||||
packet_to_ram(synthetic, 5);
|
||||
packet_to_gp0(synthetic, 5);
|
||||
gpu_ws_tag_background_prim(command_addr - 4u);
|
||||
gpu_ws_tag_hud_prim(command_addr - 4u, 1);
|
||||
gpu_ws_tag_world_primitive(command_addr - 4u, 0);
|
||||
gpu_ws_tag_hud_primitive(command_addr - 4u, 1);
|
||||
int32_t delta;
|
||||
assert(psx_ws_prim_in_backdrop() == 1);
|
||||
assert(ws_nw_explicit_hud_delta(&delta) && delta == offset);
|
||||
|
||||
gpu_pass_checkpoint_restore();
|
||||
packet_to_ram(canonical, 5);
|
||||
packet_to_gp0(canonical, 5);
|
||||
assert(psx_ws_prim_in_backdrop() == 1);
|
||||
assert(ws_nw_explicit_hud_delta(&delta) && delta == -offset);
|
||||
assert(ws_tagged_world_primitive());
|
||||
assert(ws_tagged_hud_edge() == -1);
|
||||
assert(gpu_ws_background_requires_full_composite());
|
||||
packet_to_gp0(synthetic, 5);
|
||||
assert(!ws_nw_explicit_background());
|
||||
assert(!ws_nw_explicit_hud_delta(&delta));
|
||||
packet_to_gp0(canonical, 5);
|
||||
}
|
||||
gpu_ws_get_tag_stats(NULL, &after);
|
||||
assert(after.tag_calls == before.tag_calls + 3);
|
||||
assert(after.hit >= before.hit + 6); /* Diagnostic activity is not rolled back. */
|
||||
}
|
||||
|
||||
static void test_pass_only_tags_and_phase(void) {
|
||||
/* No image is consumed: discarded/watchdog-aborted passes use this same
|
||||
* GPU restore. render_pass_abort_test covers the watchdog control flow
|
||||
* separately with a mocked GPU/presenter. */
|
||||
start_scene();
|
||||
ws_nw_phase_backdrop = 1;
|
||||
ws_bg_phase_note(0x28u);
|
||||
assert(psx_ws_prim_in_backdrop() == 1);
|
||||
assert(!gpu_ws_background_requires_full_composite());
|
||||
assert(gpu_pass_checkpoint_save());
|
||||
gpu_ws_tag_background_prim(command_addr - 4u);
|
||||
gpu_ws_tag_hud_prim(command_addr - 4u, 1);
|
||||
gpu_ws_tag_world_primitive(command_addr - 4u, 1);
|
||||
ws_bg_phase_note(0x30u);
|
||||
assert(gpu_ws_background_requires_full_composite());
|
||||
gpu_pass_checkpoint_restore();
|
||||
packet_to_ram(canonical, 5);
|
||||
packet_to_gp0(canonical, 5);
|
||||
assert(!ws_nw_explicit_background());
|
||||
assert(!ws_nw_explicit_hud_delta(NULL));
|
||||
assert(!gpu_ws_background_stretch_active());
|
||||
assert(!gpu_ws_background_requires_full_composite());
|
||||
assert(!ws_tagged_world_primitive());
|
||||
assert(psx_ws_prim_in_backdrop() == 1); /* Original early-backdrop phase. */
|
||||
}
|
||||
|
||||
static void test_hud_dot_draw(void) {
|
||||
start_scene();
|
||||
set_dot_packet(command_addr, 20, 20);
|
||||
gp0_words_needed = 3;
|
||||
uint32_t dot[3];
|
||||
memcpy(dot, gp0_cmd_buf, sizeof(dot));
|
||||
gpu_ws_tag_hud_prim(command_addr - 4u, -1);
|
||||
assert(gpu_pass_checkpoint_save());
|
||||
set_dot_packet(command_addr, 80, 20);
|
||||
gpu_ws_tag_hud_prim(command_addr - 4u, 1);
|
||||
exec_dot_and_expect(80 + ws_nw_offset(), 20);
|
||||
gpu_pass_checkpoint_restore();
|
||||
packet_to_ram(dot, 3);
|
||||
packet_to_gp0(dot, 3);
|
||||
exec_dot_and_expect(20 - ws_nw_offset(), 20);
|
||||
}
|
||||
|
||||
static void test_screen_mask_packet(void) {
|
||||
start_scene();
|
||||
gpu_ws_tag_screen_mask_quad(command_addr - 4u);
|
||||
gpu_ws_tag_radial_screen_mask_quad(command_addr - 4u, 2.f);
|
||||
assert(gpu_pass_checkpoint_save());
|
||||
uint32_t synthetic[5];
|
||||
memcpy(synthetic, canonical, sizeof(synthetic));
|
||||
synthetic[1] = 16;
|
||||
packet_to_ram(synthetic, 5);
|
||||
gpu_ws_tag_screen_mask_quad(command_addr - 4u);
|
||||
gpu_ws_tag_radial_screen_mask_quad(command_addr - 4u, 3.f);
|
||||
gpu_pass_checkpoint_restore();
|
||||
packet_to_ram(canonical, 5);
|
||||
packet_to_gp0(canonical, 5);
|
||||
assert(ws_hud_anchor_lookup(ws_screen_mask_tags, WS_HUD_ANCHOR_TABLE_SIZE,
|
||||
command_addr, gp0_cmd_buf, 5, (uint32_t)s_frame_count, NULL));
|
||||
int32_t x[4] = {0, 320, 0, 320}, y[4] = {0, 0, 240, 240};
|
||||
assert(ws_nw_radial_mask_transform(x, y));
|
||||
assert(x[0] == -160 && x[1] == 480); /* Canonical scale 2, not pass scale 3. */
|
||||
}
|
||||
|
||||
static void test_repeat_rect_packet(void) {
|
||||
start_scene();
|
||||
const uint32_t rect[] = {0x64808080u, 0, 0, (64u << 16) | 32u};
|
||||
uint32_t synthetic[4];
|
||||
memcpy(synthetic, rect, sizeof(synthetic));
|
||||
synthetic[1] = 8;
|
||||
packet_to_ram(rect, 4);
|
||||
packet_to_gp0(rect, 4);
|
||||
gpu_ws_tag_black_reveal_rect(command_addr - 4u);
|
||||
gpu_ws_tag_repeat_rect(command_addr - 4u, 128);
|
||||
assert(gpu_pass_checkpoint_save());
|
||||
packet_to_ram(synthetic, 4);
|
||||
gpu_ws_tag_black_reveal_rect(command_addr - 4u);
|
||||
gpu_ws_tag_repeat_rect(command_addr - 4u, 64);
|
||||
gpu_pass_checkpoint_restore();
|
||||
packet_to_ram(rect, 4);
|
||||
packet_to_gp0(rect, 4);
|
||||
assert(ws_hud_anchor_lookup(ws_reveal_clear_tags, WS_HUD_ANCHOR_TABLE_SIZE,
|
||||
command_addr, gp0_cmd_buf, 4, (uint32_t)s_frame_count, NULL));
|
||||
assert(ws_repeat_rect_tag_lookup(ws_repeat_rect_tags, command_addr,
|
||||
gp0_cmd_buf, 4, (uint32_t)s_frame_count) == 128);
|
||||
}
|
||||
|
||||
static void test_scene_evidence(void) {
|
||||
start_scene();
|
||||
ws_full_2d = 0;
|
||||
gpu_ws_set_gte_game_mode(1);
|
||||
assert(gpu_ws_present_native_43());
|
||||
assert(gpu_pass_checkpoint_save());
|
||||
psx_ws_note_gte_project(3);
|
||||
assert(!gpu_ws_present_native_43());
|
||||
gpu_pass_checkpoint_restore();
|
||||
packet_to_ram(canonical, 5);
|
||||
assert(gpu_ws_present_native_43());
|
||||
gpu_ws_set_gte_game_mode(0);
|
||||
}
|
||||
|
||||
static void test_sprite_anchor(void) {
|
||||
start_scene();
|
||||
/* Sprite-anchor coordinates are consumed by the projection/squash path;
|
||||
* native-wide uses the same table only for packet identity. */
|
||||
ws_mode = 1;
|
||||
ws_xnum = 3;
|
||||
ws_xden = 4;
|
||||
const uint32_t anchor_addr = 0x20000u;
|
||||
ws_anchor_addr = anchor_addr;
|
||||
CPUState cpu = {0};
|
||||
cpu.read_word = psx_read_word;
|
||||
cpu.gpr[4] = command_addr - 4u;
|
||||
test_ram[anchor_addr / 4u] = 40;
|
||||
psx_ws_sprite_tag(&cpu);
|
||||
assert(gpu_pass_checkpoint_save());
|
||||
test_ram[anchor_addr / 4u] = 80;
|
||||
psx_ws_sprite_tag(&cpu);
|
||||
gpu_pass_checkpoint_restore();
|
||||
test_ram[anchor_addr / 4u] = 40;
|
||||
int32_t anchor;
|
||||
assert(ws_tagged_anchor(&anchor) && anchor == 40);
|
||||
}
|
||||
|
||||
static void test_auto_ui_prepass(void) {
|
||||
start_scene();
|
||||
ws_mode = 1;
|
||||
ws_xnum = 3;
|
||||
ws_xden = 4;
|
||||
gpu_ws_set_auto_ui_squash(1);
|
||||
const uint32_t hud[] = {
|
||||
0x28802040u, pack_vertex(20, 20), pack_vertex(40, 20),
|
||||
pack_vertex(20, 32), pack_vertex(40, 32)
|
||||
};
|
||||
uint32_t synthetic[5];
|
||||
memcpy(synthetic, hud, sizeof(synthetic));
|
||||
synthetic[1] = pack_vertex(24, 20);
|
||||
synthetic[3] = pack_vertex(24, 32);
|
||||
test_ram[(command_addr - 4u) / 4u] = (5u << 24) | 0xFFFFFFu;
|
||||
const uint32_t ot_head = 0xFF00u;
|
||||
test_ram[ot_head / 4u] = command_addr - 4u; /* Empty OT entry establishes rank 0. */
|
||||
packet_to_ram(hud, 5);
|
||||
gpu_ws_prepass_linked_list(ot_head);
|
||||
packet_to_gp0(hud, 5);
|
||||
int32_t anchor;
|
||||
assert(ws_auto_ui_anchor(&anchor));
|
||||
assert(gpu_pass_checkpoint_save());
|
||||
packet_to_ram(synthetic, 5);
|
||||
gpu_ws_prepass_linked_list(ot_head);
|
||||
gpu_pass_checkpoint_restore();
|
||||
packet_to_ram(hud, 5);
|
||||
gpu_ws_validate_linked_list_node(command_addr - 4u, 5);
|
||||
packet_to_gp0(hud, 5);
|
||||
assert(ws_auto_ui_anchor(&anchor));
|
||||
packet_to_gp0(synthetic, 5);
|
||||
assert(!ws_auto_ui_anchor(&anchor));
|
||||
gpu_ws_set_auto_ui_squash(0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_retagged_canonical_packet();
|
||||
test_pass_only_tags_and_phase();
|
||||
test_hud_dot_draw();
|
||||
test_screen_mask_packet();
|
||||
test_repeat_rect_packet();
|
||||
test_sprite_anchor();
|
||||
test_auto_ui_prepass();
|
||||
test_scene_evidence();
|
||||
puts("gpu_render_pass_provenance: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#include "host_launch_timing.h"
|
||||
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <type_traits>
|
||||
|
||||
using namespace PSXRecompV4;
|
||||
void host_launch_timing_fixture_nested();
|
||||
static int failures;
|
||||
static void check(bool value, const char* message) {
|
||||
if (!value) { std::cerr << "FAIL: " << message << '\n'; ++failures; }
|
||||
}
|
||||
|
||||
int main() {
|
||||
static_assert(std::is_trivially_copyable<HostLaunchTimingEvent>::value,
|
||||
"C-facing snapshot must remain POD");
|
||||
HostLaunchTimingEvent events[HOST_LAUNCH_TIMING_CAPACITY]{};
|
||||
uint64_t total = 0, overwritten = 0;
|
||||
{
|
||||
HostLaunchTimingScope parent(HOST_LAUNCH_COMMIT);
|
||||
host_launch_timing_fixture_nested();
|
||||
parent.success();
|
||||
}
|
||||
auto n = host_launch_timing_ring().snapshot(events, 256, &total, &overwritten);
|
||||
check(n == 2 && total == 2 && overwritten == 0, "shared ring across translation units");
|
||||
check(events[0].parent_id == events[1].scope_id && events[1].parent_id == 0,
|
||||
"shared nested parent across translation units");
|
||||
check(events[0].scope_id > events[1].scope_id && events[0].seq < events[1].seq,
|
||||
"completion order differs from start order");
|
||||
check(events[0].start_us >= events[1].start_us &&
|
||||
events[0].start_us + events[0].duration_us <=
|
||||
events[1].start_us + events[1].duration_us, "child time inside parent");
|
||||
check(events[0].ok && events[1].ok &&
|
||||
std::string(events[0].package_id) == "fixture.package", "success and metadata retained");
|
||||
|
||||
HostLaunchTimingRing ring;
|
||||
try {
|
||||
HostLaunchTimingScope failed(HOST_LAUNCH_PREPARE_RESOURCES, nullptr, nullptr, ring);
|
||||
HostLaunchTimingScope child(HOST_LAUNCH_MEDIA_PROVIDER, nullptr, nullptr, ring);
|
||||
throw std::runtime_error("fixture failure");
|
||||
} catch (const std::runtime_error&) { }
|
||||
{
|
||||
const std::string long_label(100, 'x');
|
||||
HostLaunchTimingScope next(HOST_LAUNCH_RESOLVE, long_label.c_str(), "short", ring);
|
||||
next.success();
|
||||
}
|
||||
n = ring.snapshot(events, 256, &total, &overwritten);
|
||||
check(n == 3 && !events[0].ok && !events[1].ok && events[2].ok,
|
||||
"exception exits remain failures and next call succeeds");
|
||||
check(events[0].parent_id == events[1].scope_id && events[2].parent_id == 0,
|
||||
"exception restores enclosing context");
|
||||
check(events[2].labels_truncated && std::strlen(events[2].package_id) == 63 &&
|
||||
std::string(events[2].feature_id) == "short", "bounded labels report truncation");
|
||||
|
||||
for (unsigned i = 0; i < HOST_LAUNCH_TIMING_CAPACITY; ++i) {
|
||||
HostLaunchTimingScope scope(HOST_LAUNCH_DISC_HASH, nullptr, nullptr, ring);
|
||||
scope.success();
|
||||
}
|
||||
n = ring.snapshot(events, 256, &total, &overwritten);
|
||||
check(n == 256 && total == 259 && overwritten == 3, "wrap retains fixed capacity");
|
||||
for (uint32_t i = 1; i < n; ++i)
|
||||
check(events[i].seq == events[i - 1].seq + 1, "wrapped snapshot oldest first");
|
||||
n = ring.snapshot(events, 2, &total, &overwritten);
|
||||
check(n == 2 && events[0].seq == 258 && events[1].seq == 259,
|
||||
"small reader receives newest entries without draining");
|
||||
check(ring.snapshot(nullptr, 256, &total, &overwritten) == 0 && total == 259,
|
||||
"metadata query does not consume entries");
|
||||
|
||||
HostLaunchTimingRing concurrent;
|
||||
auto writer = [&]() {
|
||||
for (unsigned i = 0; i < 40; ++i) {
|
||||
HostLaunchTimingScope parent(HOST_LAUNCH_COMMIT, nullptr, nullptr, concurrent);
|
||||
HostLaunchTimingScope child(HOST_LAUNCH_RESOLVE, nullptr, nullptr, concurrent);
|
||||
child.success(); parent.success();
|
||||
}
|
||||
};
|
||||
std::thread a(writer), b(writer);
|
||||
a.join(); b.join();
|
||||
n = concurrent.snapshot(events, 256, &total, &overwritten);
|
||||
check(n == 160 && total == 160 && overwritten == 0, "concurrent completion count preserved");
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
check(events[i].seq == i + 1 && events[i].ok, "concurrent sequence publication");
|
||||
if (events[i].stage == HOST_LAUNCH_COMMIT)
|
||||
check(events[i].parent_id == 0, "thread-local roots do not inherit other writer");
|
||||
else {
|
||||
bool found = false;
|
||||
for (uint32_t j = i + 1; j < n; ++j)
|
||||
if (events[j].scope_id == events[i].parent_id &&
|
||||
events[j].stage == HOST_LAUNCH_COMMIT) found = true;
|
||||
check(found, "concurrent child belongs to a completed parent");
|
||||
}
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Execute the production TCP handler/helpers and real ring through the C ABI.
|
||||
|
||||
No server, game, SDL, or persistent log. Missing compilers are an explicit skip.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_wtrace_dump import matching_brace
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def function(source, name):
|
||||
match = re.search(r'^(?:static |extern "C" )[^;{}]+\b' + name +
|
||||
r'\([^;{}]*\)\s*\{', source, re.M)
|
||||
if not match:
|
||||
raise AssertionError(f"production function missing: {name}")
|
||||
return source[match.start():matching_brace(source, match.end() - 1) + 1]
|
||||
|
||||
|
||||
class HostLaunchProtocol(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cc, cxx = os.environ.get("CC", "cc"), os.environ.get("CXX", "c++")
|
||||
if not shutil.which(cc) or not shutil.which(cxx):
|
||||
raise unittest.SkipTest("set CC and CXX to the native compilers")
|
||||
cls.temp = tempfile.TemporaryDirectory()
|
||||
cls.addClassCleanup(cls.temp.cleanup)
|
||||
temp = Path(cls.temp.name)
|
||||
debug = (ROOT / "runtime/src/debug_server.c").read_text(encoding="utf-8")
|
||||
runtime = (ROOT / "runtime/src/mod_runtime.cpp").read_text(encoding="utf-8")
|
||||
harness = '''#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "host_launch_timing.h"
|
||||
void seed_host_launch_timing_fixture(int count);
|
||||
static void debug_server_send_line(const char *line) { puts(line); }
|
||||
static void send_err(int id, const char *error) { (void)id; (void)error; abort(); }
|
||||
'''
|
||||
for name in ("json_get_int", "json_escape_string", "handle_host_launch_timings"):
|
||||
harness += function(debug, name) + "\n"
|
||||
harness += '''int main(int argc, char **argv) {
|
||||
if (argc != 3) return 2;
|
||||
seed_host_launch_timing_fixture(atoi(argv[2]));
|
||||
handle_host_launch_timings(42, argv[1]);
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
bridge = '#include "host_launch_timing.h"\n'
|
||||
for name in ("host_launch_timing_snapshot", "host_launch_timing_stage_name"):
|
||||
bridge += function(runtime, name) + "\n"
|
||||
bridge += '''extern "C" void seed_host_launch_timing_fixture(int count) {
|
||||
char label[64];
|
||||
for (unsigned i = 0; i < 63; ++i) label[i] = i % 3 == 0 ? '"' : i % 3 == 1 ? '\\\\' : '\\1';
|
||||
label[63] = 0;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
PSXRecompV4::HostLaunchTimingScope scope(HOST_LAUNCH_MEDIA_PROVIDER, label, label);
|
||||
if (i % 2 == 0) scope.success();
|
||||
}
|
||||
}
|
||||
'''
|
||||
(temp / "handler.c").write_text(harness, encoding="utf-8")
|
||||
(temp / "bridge.cpp").write_text(bridge, encoding="utf-8")
|
||||
include = str(ROOT / "runtime/include")
|
||||
cls.exe = temp / ("protocol.exe" if os.name == "nt" else "protocol")
|
||||
for args in ([cc, "-std=c99", "-Wall", "-Wextra", "-Werror", "-I", include,
|
||||
"-c", str(temp / "handler.c"), "-o", str(temp / "handler.o")],
|
||||
[cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pthread",
|
||||
"-I", include, str(temp / "bridge.cpp"), str(temp / "handler.o"),
|
||||
"-o", str(cls.exe)]):
|
||||
subprocess.run(args, check=True, capture_output=True, text=True)
|
||||
|
||||
def query(self, count=None, seeded=300):
|
||||
request = {"cmd": "host_launch_timings"}
|
||||
if count is not None:
|
||||
request["count"] = count
|
||||
output = subprocess.check_output([str(self.exe), json.dumps(request), str(seeded)], text=True)
|
||||
return json.loads(output)
|
||||
|
||||
def test_wrapped_maximum_labels_and_default_count(self):
|
||||
result = self.query()
|
||||
self.assertEqual((result["capacity"], result["total"], result["overwritten"]), (256, 300, 44))
|
||||
self.assertTrue(result["nested_durations_overlap"])
|
||||
self.assertEqual([e["seq"] for e in result["entries"]], list(range(45, 301)))
|
||||
for e in result["entries"]:
|
||||
self.assertEqual(e["stage"], "media_provider")
|
||||
self.assertEqual(e["package"], '"\\\x01' * 21)
|
||||
self.assertEqual(e["feature"], e["package"])
|
||||
self.assertEqual(e["labels_truncated"], 0)
|
||||
self.assertEqual(e["ok"], int(e["seq"] % 2 == 1))
|
||||
|
||||
def test_count_clamping(self):
|
||||
for count, expected in ((0, 1), (-10, 1), (1, 1), (3, 3), (256, 256), (1000, 256)):
|
||||
result = self.query(count)
|
||||
self.assertEqual(len(result["entries"]), expected)
|
||||
self.assertEqual(result["entries"][-1]["seq"], 300)
|
||||
|
||||
def test_empty_ring(self):
|
||||
result = self.query(seeded=0)
|
||||
self.assertEqual((result["total"], result["overwritten"], result["entries"]), (0, 0, []))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
suite = unittest.defaultTestLoader.loadTestsFromTestCase(HostLaunchProtocol)
|
||||
result = unittest.TextTestRunner().run(suite)
|
||||
raise SystemExit(77 if result.skipped else 0 if result.wasSuccessful() else 1)
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "mod_runtime.h"
|
||||
#include "host_launch_timing.h"
|
||||
#include "host_file_identity.h"
|
||||
#include "mod_packages.h"
|
||||
#include "mod_plugins.h"
|
||||
#include "psx_sha256.h"
|
||||
@@ -11,6 +13,8 @@
|
||||
#include <array>
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <stdexcept>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
@@ -331,8 +335,77 @@ static std::string sha256_hex(const std::vector<uint8_t>& bytes) {
|
||||
return out;
|
||||
}
|
||||
|
||||
static uint64_t launch_timing_total() {
|
||||
uint64_t total = 0;
|
||||
host_launch_timing_snapshot(nullptr, 0, &total, nullptr);
|
||||
return total;
|
||||
}
|
||||
|
||||
static void check_commit_timing(uint64_t before, bool ok, bool hashed) {
|
||||
HostLaunchTimingEvent events[HOST_LAUNCH_TIMING_CAPACITY]{};
|
||||
const auto n = host_launch_timing_snapshot(events, HOST_LAUNCH_TIMING_CAPACITY,
|
||||
nullptr, nullptr);
|
||||
const HostLaunchTimingEvent* commit = nullptr;
|
||||
for (uint32_t i = 0; i < n; ++i)
|
||||
if (events[i].seq > before && events[i].stage == HOST_LAUNCH_COMMIT)
|
||||
commit = &events[i];
|
||||
check(commit && commit->ok == unsigned(ok), "real commit outcome retained by C snapshot");
|
||||
if (!commit) return;
|
||||
uint32_t stages = 0;
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
const auto& e = events[i];
|
||||
if (e.seq <= before || e.parent_id != commit->scope_id) continue;
|
||||
stages |= 1u << e.stage;
|
||||
check(e.start_us >= commit->start_us &&
|
||||
e.start_us + e.duration_us <= commit->start_us + commit->duration_us,
|
||||
"real commit child measurement nested inside total");
|
||||
}
|
||||
check(bool(stages & (1u << HOST_LAUNCH_DISC_HASH)) == hashed,
|
||||
"disc hash timing follows actual digest reuse");
|
||||
const uint32_t required = (1u << HOST_LAUNCH_PREPARE_RESOURCES) |
|
||||
(1u << HOST_LAUNCH_RESOLVE);
|
||||
check((stages & required) == required, "real preparation and resolution observed");
|
||||
if (ok) {
|
||||
const uint32_t final = (1u << HOST_LAUNCH_OVERLAY_VERIFY) |
|
||||
(1u << HOST_LAUNCH_DERIVED_DISC) | (1u << HOST_LAUNCH_SAVE_STATE) |
|
||||
(1u << HOST_LAUNCH_BUILD_DISC_INDEX);
|
||||
check((stages & final) == final, "real successful commit final stages observed");
|
||||
} else {
|
||||
check(!(stages & (1u << HOST_LAUNCH_BUILD_DISC_INDEX)),
|
||||
"rejected commit does not invent final-stage completion");
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" int psx_mod_counters_snapshot(const char**, uint64_t*, uint64_t*, int, uint64_t*);
|
||||
static uint64_t startup_counter(const char* requested) {
|
||||
const char* names[128]{}; uint64_t counts[128]{}, frames[128]{};
|
||||
const int count = psx_mod_counters_snapshot(names, counts, frames, 128, nullptr);
|
||||
for (int i = 0; i < count && i < 128; ++i)
|
||||
if (std::string(names[i]) == requested) return counts[i];
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main() {
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-cache-test";
|
||||
const auto isolated_cache = root / "test-cache";
|
||||
#if defined(_WIN32)
|
||||
check(_wputenv_s(L"LOCALAPPDATA", isolated_cache.c_str()) == 0,
|
||||
"isolate audited digest receipts from owner cache");
|
||||
#else
|
||||
check(setenv("XDG_CACHE_HOME", isolated_cache.c_str(), 1) == 0,
|
||||
"isolate audited digest receipts from owner cache");
|
||||
#endif
|
||||
#if defined(_WIN32)
|
||||
const char* configured_cache = std::getenv("LOCALAPPDATA");
|
||||
#else
|
||||
const char* configured_cache = std::getenv("XDG_CACHE_HOME");
|
||||
#endif
|
||||
check(configured_cache && fs::path(configured_cache) == isolated_cache,
|
||||
"CRT getenv observes the isolated audited cache");
|
||||
#else
|
||||
const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test";
|
||||
#endif
|
||||
std::error_code ec;
|
||||
fs::remove_all(root, ec);
|
||||
const std::vector<uint8_t> stock(8 * 2352, 0);
|
||||
@@ -568,8 +641,10 @@ int main() {
|
||||
check(PSXRecompV4::mod_runtime_initialize(
|
||||
root, "SLUS-RUNTIME", 0x80002000, {}, &error),
|
||||
error.c_str());
|
||||
const auto first_commit_timing = launch_timing_total();
|
||||
check(PSXRecompV4::mod_runtime_commit(cue_path, &error),
|
||||
"CUE and its data-track BIN must have the same mod target identity");
|
||||
check_commit_timing(first_commit_timing, true, true);
|
||||
/* Committed but not yet activated: no hook may run. */
|
||||
psx_mod_instruction(&entry_cpu, 0x80003004u, 0x90A30014u);
|
||||
check(instruction_hits == 0 && !g_psx_mod_instruction_hooks, "instruction hooks await activation");
|
||||
@@ -1205,7 +1280,13 @@ int main() {
|
||||
check(PSXRecompV4::mod_runtime_initialize(loose_root, "READER", 0, {}, &error), "loose resource initialize");
|
||||
check(PSXRecompV4::mod_runtime_commit(iso_path, &error), "unverified file remains usable offline");
|
||||
check(PSXRecompV4::mod_runtime_plan_fingerprint_portable().empty(), "unverified resource has no online fingerprint");
|
||||
const auto rejected_commit_timing = launch_timing_total();
|
||||
check(!PSXRecompV4::mod_runtime_commit(iso_path, &error, false), "online commit rejects unverified resource");
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
check_commit_timing(rejected_commit_timing, false, true);
|
||||
#else
|
||||
check_commit_timing(rejected_commit_timing, false, false);
|
||||
#endif
|
||||
check(!PSXRecompV4::mod_runtime_commit_for_direct_netplay(iso_path, &error), "LAN also refuses unverified resources");
|
||||
test_match_caps = {};
|
||||
test_match_caps.valid = 1;
|
||||
@@ -1469,6 +1550,202 @@ int main() {
|
||||
check(!psx_mod_function_entry(&game_cpu, 0x80005000u) &&
|
||||
game_netplay_filter_hits == 1,
|
||||
"game filter must disarm when netplay stops");
|
||||
// Reliable identity detects byte replacement even when size/mtime are restored.
|
||||
{
|
||||
const auto identity_path = root / "identity.bin";
|
||||
write_bytes(identity_path, {1, 2, 3, 4});
|
||||
const auto original_time = fs::last_write_time(identity_path);
|
||||
std::string before, after;
|
||||
const bool reliable = PSXRecompV4::host_file_identity(identity_path, before);
|
||||
write_bytes(identity_path, {4, 3, 2, 1});
|
||||
fs::last_write_time(identity_path, original_time);
|
||||
check(!reliable || (PSXRecompV4::host_file_identity(identity_path, after) && before != after),
|
||||
"file identity rejects same-size restored-mtime replacement");
|
||||
check(!PSXRecompV4::host_file_identity(root, after), "directories are never file cache identities");
|
||||
check(!PSXRecompV4::host_file_identity(root / "absent.bin", after), "missing identity is a cache miss");
|
||||
}
|
||||
#if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE)
|
||||
{
|
||||
const auto cached_root = root / "audited-cache";
|
||||
const auto output_path = root / "audited-output.z64";
|
||||
const auto input_path = root / "audited-input.bin";
|
||||
const auto disc = root / "audited-disc.iso";
|
||||
fs::copy_file(iso_path, disc, fs::copy_options::overwrite_existing);
|
||||
write_bytes(input_path, {1,2,3,4});
|
||||
std::string input_receipt, output_receipt;
|
||||
int prepare_calls = 0;
|
||||
bool throw_probe = false, mutate_on_resolve = false, throw_resolver = false;
|
||||
const std::string manifest =
|
||||
"format_version = 9\nid = \"cache.test\"\nversion = \"1.0.0\"\nname = \"Cache\"\nprepare = \"test.cache.prepare\"\nresolver = \"builtin:test.cache.resolve\"\n"
|
||||
"[[target]]\ngame_id = \"READER\"\n"
|
||||
"[[feature]]\nid = \"active\"\nname = \"Active\"\ndefault_enabled = true\n"
|
||||
"[[resource]]\nfeature = \"active\"\nid = \"source\"\nlabel = \"Source\"\ninput_only = true\nshared_source = \"cache.original\"\nrequired = true\n"
|
||||
"[[resource]]\nfeature = \"active\"\nid = \"rom\"\nlabel = \"ROM\"\nformat = \"n64-rom\"\nhidden = true\nrequired = true\nsize = 64\nsha256 = \"" + sha256_hex(rom) + "\"\n"
|
||||
"[[feature]]\nid = \"second\"\nname = \"Second\"\ndefault_enabled = false\n"
|
||||
"[[resource]]\nfeature = \"second\"\nid = \"rom\"\nlabel = \"ROM\"\nformat = \"n64-rom\"\nhidden = true\nrequired = true\nsize = 64\nsha256 = \"" + sha256_hex(rom) + "\"\n";
|
||||
check(PSXRecompV4::mod_register_builtin_resolver("test.cache.resolve",
|
||||
[&](const auto&, const auto&, const auto&, auto&, auto&) {
|
||||
if (throw_resolver) throw std::runtime_error("resolver fixture exception");
|
||||
if (mutate_on_resolve) write_bytes(input_path, {7,7,7,7});
|
||||
return true;
|
||||
}), "register resolver for mutation and unwind regression");
|
||||
const auto manifest_path = cached_root / "packages/cache.test/1.0.0/manifest.toml";
|
||||
write_text(manifest_path, manifest);
|
||||
check(PSXRecompV4::mod_register_media_preparer("test.cache.prepare",
|
||||
[&](const PSXRecompV4::ModPrepareContext&, std::map<std::string, fs::path>& outputs, std::string&) {
|
||||
++prepare_calls;
|
||||
write_bytes(output_path, rom);
|
||||
PSXRecompV4::host_file_identity(input_path, input_receipt);
|
||||
PSXRecompV4::host_file_identity(output_path, output_receipt);
|
||||
outputs["rom"] = output_path;
|
||||
return true;
|
||||
},
|
||||
[&](const PSXRecompV4::ModPrepareContext& context, std::map<std::string, fs::path>& outputs, std::string&) {
|
||||
if (throw_probe) throw std::runtime_error("probe fixture exception");
|
||||
std::string input, output;
|
||||
if (context.feature_id == "second" ||
|
||||
!PSXRecompV4::host_file_identity(input_path, input) || input != input_receipt ||
|
||||
!PSXRecompV4::host_file_identity(output_path, output) || output != output_receipt) return false;
|
||||
outputs["rom"] = output_path; return true;
|
||||
}), "register audited preparation and cheap identity probe");
|
||||
PSXRecompV4::ModPackageManager source_selection(cached_root);
|
||||
check(source_selection.scan(&error) &&
|
||||
source_selection.set_feature_resource_path("cache.test", "active", "source", input_path, &error) &&
|
||||
source_selection.save_state(&error), "persist shared input selection for audited fixture");
|
||||
PSXRecompV4::mod_runtime_end_netplay(); test_match_caps = {};
|
||||
check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error), "audited initialize");
|
||||
check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc) && prepare_calls == 0,
|
||||
"cold cached-only launch does not prepare missing receipt");
|
||||
check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && prepare_calls == 1,
|
||||
"cold launcher creates verified preparation");
|
||||
const auto reused = startup_counter("startup.plan_reuse");
|
||||
const auto hashed = startup_counter("startup.disc_hash");
|
||||
check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) &&
|
||||
PSXRecompV4::mod_runtime_commit(disc, &error) && prepare_calls == 1 &&
|
||||
startup_counter("startup.plan_reuse") == reused + 2 && startup_counter("startup.disc_hash") == hashed,
|
||||
"duplicate provider and main commit reuse a prepared plan without converter or hash");
|
||||
check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error) &&
|
||||
PSXRecompV4::mod_runtime_try_prepare_cached(disc) && prepare_calls == 1 &&
|
||||
startup_counter("startup.disc_hash") == hashed,
|
||||
"warm initialize preloads receipts with no preparer or raw hash");
|
||||
mod_runtime_activate_plugins();
|
||||
check(PSXRecompV4::mod_runtime_commit(disc, &error) && prepare_calls == 2,
|
||||
"activation invalidates the preboot ticket");
|
||||
check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error) &&
|
||||
PSXRecompV4::mod_runtime_try_prepare_cached(disc), "restore warm plan for source invalidation");
|
||||
const auto input_time = fs::last_write_time(input_path);
|
||||
write_bytes(input_path, {4,3,2,1}); fs::last_write_time(input_path, input_time);
|
||||
check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc), "external shared input replacement invalidates title receipt");
|
||||
check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && prepare_calls == 3,
|
||||
"changed source goes through full preparation");
|
||||
auto corrupt_rom = rom; corrupt_rom[32] ^= 1; write_bytes(output_path, corrupt_rom);
|
||||
// Even if the probe claims the file is current, the first warm resolve
|
||||
// must verify output bytes against the declared SHA.
|
||||
PSXRecompV4::host_file_identity(output_path, output_receipt);
|
||||
check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error) &&
|
||||
!PSXRecompV4::mod_runtime_try_prepare_cached(disc) && prepare_calls == 3,
|
||||
"warm output corruption fails immutable-byte verification");
|
||||
fs::remove(output_path);
|
||||
check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc) && prepare_calls == 3,
|
||||
"missing output fails without invoking converter");
|
||||
check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && prepare_calls == 4,
|
||||
"full preparation repairs missing output");
|
||||
throw_probe = true;
|
||||
check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc), "throwing cached probe fails safely");
|
||||
throw_probe = false;
|
||||
check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error), "cached flag restored after failure");
|
||||
// Probe results are transactional across multiple enabled features.
|
||||
PSXRecompV4::ModPackageManager transactional(cached_root);
|
||||
check(transactional.scan(&error) && transactional.load_state(&error) &&
|
||||
transactional.set_feature_enabled("cache.test", "second", true, &error) &&
|
||||
transactional.set_feature_resource_path("cache.test", "active", "rom", rom_path, &error), "enable second probe fixture");
|
||||
const auto original_output = transactional.feature_resource_path("cache.test", "active", "rom");
|
||||
check(!transactional.prepare_resources("READER", disc, root / "cache", &error, true) &&
|
||||
transactional.feature_resource_path("cache.test", "active", "rom") == original_output,
|
||||
"later cache miss discards earlier pending output selections");
|
||||
// A throw inside resolve must restore cached-only/provider modes.
|
||||
check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error), "invalidate ticket for resolver exception fixture");
|
||||
throw_resolver = true;
|
||||
check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc),
|
||||
"best-effort preload contains resolver exceptions before launcher UI");
|
||||
throw_resolver = false;
|
||||
fs::remove(output_path);
|
||||
const auto before_unwind = prepare_calls;
|
||||
check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && prepare_calls == before_unwind + 1,
|
||||
"cached-only mode restored after resolve exception");
|
||||
// A dependency changed during resolution must never publish readiness.
|
||||
check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error), "invalidate ticket for resolver mutation fixture");
|
||||
const auto before_mutation = prepare_calls;
|
||||
mutate_on_resolve = true;
|
||||
check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error), "full resolution can finish with concurrent input mutation");
|
||||
mutate_on_resolve = false;
|
||||
check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) && prepare_calls == before_mutation + 2,
|
||||
"unstable resolve never publishes a reusable ticket");
|
||||
// Altering a syntactically valid digest must fail its receipt checksum.
|
||||
std::filesystem::path cache_dir;
|
||||
#if defined(_WIN32)
|
||||
if (const char* local = std::getenv("LOCALAPPDATA")) cache_dir = local;
|
||||
#else
|
||||
if (const char* cache = std::getenv("XDG_CACHE_HOME")) cache_dir = cache;
|
||||
else if (const char* home_dir = std::getenv("HOME")) cache_dir = fs::path(home_dir) / ".cache";
|
||||
#endif
|
||||
if (cache_dir.empty()) cache_dir = fs::temp_directory_path();
|
||||
cache_dir /= "psxrecomp/imports/disc-digests-v2";
|
||||
bool corrupted_receipt = false;
|
||||
for (const auto& entry : fs::directory_iterator(cache_dir)) {
|
||||
std::ifstream saved(entry.path(), std::ios::binary);
|
||||
std::string receipt(std::istreambuf_iterator<char>(saved), {});
|
||||
if (receipt.find(disc.generic_string()) == std::string::npos || receipt.size() < 130) continue;
|
||||
const size_t digest_offset = receipt.size() - 130;
|
||||
receipt[digest_offset] = receipt[digest_offset] == '0' ? '1' : '0';
|
||||
write_text(entry.path(), receipt); corrupted_receipt = true;
|
||||
}
|
||||
check(corrupted_receipt && PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error) &&
|
||||
!PSXRecompV4::mod_runtime_try_prepare_cached(disc), "corrupt well-formed digest receipt misses warm startup");
|
||||
const auto before_corrupt_hash = startup_counter("startup.disc_hash");
|
||||
check(PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) &&
|
||||
startup_counter("startup.disc_hash") == before_corrupt_hash + 1,
|
||||
"full launch repairs corrupted digest with authoritative source hash");
|
||||
// Catalog changes cannot certify stale in-memory parsed manifests.
|
||||
write_text(manifest_path, manifest + "\n# external catalog mutation\n");
|
||||
check(!PSXRecompV4::mod_runtime_prepare_for_launcher(disc, &error) &&
|
||||
error.find("catalog changed") != std::string::npos,
|
||||
"changed manifest requires rescan instead of resealing stale package");
|
||||
check(PSXRecompV4::mod_runtime_initialize(cached_root, "READER", 0, {}, &error), "explicit initialize rescans changed catalog");
|
||||
const int before_netplay = prepare_calls;
|
||||
const auto net_hash = startup_counter("startup.disc_hash");
|
||||
check(PSXRecompV4::mod_runtime_commit_for_direct_netplay(disc, &error) &&
|
||||
prepare_calls == before_netplay + 1 && startup_counter("startup.disc_hash") > net_hash,
|
||||
"netplay ignores preboot ticket and hashes the source fully");
|
||||
check(!PSXRecompV4::mod_runtime_try_prepare_cached(disc), "active netplay never preloads an offline ticket");
|
||||
PSXRecompV4::mod_runtime_end_netplay();
|
||||
// Legacy preparers have no cheap receipt and must never claim warm readiness.
|
||||
const auto legacy_root = root / "legacy-cache";
|
||||
int legacy_calls = 0;
|
||||
check(PSXRecompV4::mod_register_media_preparer("test.legacy.prepare",
|
||||
[&](const PSXRecompV4::ModPrepareContext&, std::map<std::string, fs::path>& outputs, std::string&) {
|
||||
++legacy_calls; outputs["rom"] = output_path; return true;
|
||||
}), "register legacy preparer without probe");
|
||||
std::string legacy_manifest = manifest;
|
||||
legacy_manifest.replace(legacy_manifest.find("test.cache.prepare"), std::string("test.cache.prepare").size(), "test.legacy.prepare");
|
||||
write_text(legacy_root / "packages/cache.test/1.0.0/manifest.toml", legacy_manifest);
|
||||
check(PSXRecompV4::mod_runtime_initialize(legacy_root, "READER", 0, {}, &error) &&
|
||||
!PSXRecompV4::mod_runtime_try_prepare_cached(disc) && legacy_calls == 0,
|
||||
"legacy preparer always misses cached-only startup");
|
||||
}
|
||||
#else
|
||||
{
|
||||
const auto defaults = root / "default-hashing";
|
||||
fs::create_directories(defaults);
|
||||
check(PSXRecompV4::mod_runtime_initialize(defaults, "READER", 0, {}, &error), "default hashing fixture initialize");
|
||||
const auto hash_count = startup_counter("startup.disc_hash");
|
||||
check(PSXRecompV4::mod_runtime_commit(iso_path, &error) &&
|
||||
PSXRecompV4::mod_runtime_commit(iso_path, &error) &&
|
||||
startup_counter("startup.disc_hash") == hash_count + 1,
|
||||
"default runtime preserves hash-only-on-selected-path-change behavior");
|
||||
check(!PSXRecompV4::mod_runtime_try_prepare_cached(iso_path), "default runtime does not opt into cached startup");
|
||||
}
|
||||
#endif
|
||||
fs::remove_all(root, ec);
|
||||
if (failures) return 1;
|
||||
std::cout << "mod runtime tests passed\n";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Relative paths persisted in settings.toml and the bios.cfg / disc.cfg
|
||||
sidecars resolve from the executable directory, not the working directory.
|
||||
|
||||
Compiles the real adapters from main.cpp (normalize_disc_path_for_launch,
|
||||
resolve_persisted_disc_path, anchor_on_exe_dir) into a probe and runs it from
|
||||
an unrelated working directory. Also checks that every persisted-path consumer
|
||||
in main.cpp goes through an adapter. No game data is used.
|
||||
Compiles the real adapters and sidecar writer/reader from main.cpp, plus
|
||||
relative_to_folder from config_loader.cpp, into a probe and runs it from an
|
||||
unrelated working directory. Checks save/reopen as well as path consumers.
|
||||
No game data is used.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
@@ -46,6 +46,13 @@ def main():
|
||||
'anchor_on_exe_dir'):
|
||||
start = text.index('static std::filesystem::path ' + name + '(')
|
||||
adapters.append(text[start:text.index('\n}\n', start) + 2])
|
||||
config = (root.parent / 'recompiler/src/config_loader.cpp').read_text(encoding='utf-8')
|
||||
start = config.index('fs::path relative_to_folder(')
|
||||
relative_adapter = config[start:config.index('\n}\n', start) + 2]
|
||||
for declaration in ('static std::filesystem::path read_cached_path(',
|
||||
'static void write_cached_path('):
|
||||
start = text.index(declaration)
|
||||
adapters.append(text[start:text.index('\n}\n', start) + 2])
|
||||
with tempfile.TemporaryDirectory(prefix='persisted paths spaces ') as tmp:
|
||||
base = Path(tmp)
|
||||
product, cwd = base / 'moved product', base / 'unrelated cwd'
|
||||
@@ -58,22 +65,34 @@ def main():
|
||||
# The probe's exe_dir_from_argv() stands in for the real one: the
|
||||
# "argv0" it receives is the product directory itself.
|
||||
source.write_text('''#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include "host_path.h"
|
||||
#include "disc_path.h"
|
||||
namespace fs = std::filesystem;
|
||||
namespace PSXRecompV4 {
|
||||
''' + relative_adapter + '''
|
||||
}
|
||||
static std::filesystem::path exe_dir_from_argv(const char* dir) {
|
||||
return std::filesystem::path(dir);
|
||||
}
|
||||
static std::filesystem::path sidecar_cfg_path(const char* dir, const char* filename) {
|
||||
return exe_dir_from_argv(dir) / filename;
|
||||
}
|
||||
''' + '\n'.join(adapters) + '''
|
||||
int main(int argc, char** argv) {
|
||||
(void)argc;
|
||||
const std::string mode = argv[1];
|
||||
const std::filesystem::path path = argv[2];
|
||||
const std::filesystem::path resolved =
|
||||
std::filesystem::path resolved =
|
||||
mode == "persisted" ? resolve_persisted_disc_path(path, argv[3])
|
||||
: mode == "anchor" ? anchor_on_exe_dir(argv[3], path)
|
||||
: normalize_disc_path_for_launch(path);
|
||||
if (mode == "sidecar") {
|
||||
write_cached_path(argv[3], "disc.cfg", path);
|
||||
resolved = read_cached_path(argv[3], "disc.cfg");
|
||||
}
|
||||
// libstdc++'s generic_string() collapses a UNC "\\\\server" root to "/server";
|
||||
// print the native spelling with forward slashes so both C++ libraries agree.
|
||||
std::string out = resolved.string();
|
||||
@@ -109,17 +128,22 @@ int main(int argc, char** argv) {
|
||||
check('persisted', '', '')
|
||||
check('persisted', 'inputs/missing.chd', (product/'inputs/missing.chd').as_posix())
|
||||
check('cli', relative, (cwd/relative).as_posix())
|
||||
check('sidecar', product/relative, (product/relative).as_posix())
|
||||
check('sidecar', '', '')
|
||||
if os.name == 'nt':
|
||||
check('persisted', r'\\server\share\Some Game [Disc 1].chd',
|
||||
'//server/share/Some Game [Disc 1].chd')
|
||||
for spelling in (r'\\server\share\Some Game [Disc 1].chd',
|
||||
'//server/share/Some Game [Disc 1].chd'):
|
||||
check('persisted', spelling, '//server/share/Some Game [Disc 1].chd')
|
||||
check('sidecar', spelling, '//server/share/Some Game [Disc 1].chd')
|
||||
# [bios] path, bios.cfg and [memcard] dir/card1/card2
|
||||
check_same('anchor', '.', product)
|
||||
check_same('anchor', 'cards/card1.mcd', product/'cards/card1.mcd')
|
||||
check_same('anchor', 'bios/SCPH1001.BIN', product/'bios/SCPH1001.BIN')
|
||||
check('anchor', product/'cards', (product/'cards').as_posix())
|
||||
check('anchor', '', '')
|
||||
print('persisted paths: disc (relative, absolute, empty, missing, CLI cwd, '
|
||||
'Windows UNC) and BIOS/memory card (".", relative, absolute, empty) passed')
|
||||
print('persisted paths: disc consumers and sidecar save/reopen '
|
||||
'(relative, absolute, empty, missing, CLI cwd, Windows UNC) '
|
||||
'and BIOS/memory card (".", relative, absolute, empty) passed')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -170,13 +170,17 @@ static void test_gen_flip_matches(void) {
|
||||
}
|
||||
|
||||
static void test_budget_and_ema(void) {
|
||||
CHECK(fabs(render_pass_budget(0, 0, 100.0, 0.5) - 50.0) < 1e-9,
|
||||
"no history spends the share of the frame");
|
||||
CHECK(render_pass_budget(0, 0, 100.0, 0.5) == 0.0,
|
||||
"zero measured credit spends nothing, including startup");
|
||||
CHECK(fabs(render_pass_budget(20.0, 10.0, 100.0, 0.8) - 24.0) < 1e-9,
|
||||
"idle plus pass time, scaled");
|
||||
CHECK(render_pass_budget(500.0, 0.0, 100.0, 0.8) == 100.0,
|
||||
"never more than a frame");
|
||||
CHECK(render_pass_budget(10.0, 10.0, 0.0, 0.8) == 0.0, "no frame, no budget");
|
||||
CHECK(render_pass_budget(20.0, 10.0, 100.0, 2.0) == 30.0,
|
||||
"share above one remains clamped");
|
||||
CHECK(render_pass_budget(20.0, 10.0, 100.0, 0.0) == 0.0,
|
||||
"zero share spends nothing");
|
||||
CHECK(render_pass_ema(0.0, 4.0) == 4.0, "first sample seeds the average");
|
||||
CHECK(fabs(render_pass_ema(4.0, 8.0) - 5.0) < 1e-9, "quarter-weight update");
|
||||
CHECK(render_pass_ema(4.0, -1.0) == 4.0, "bad samples are ignored");
|
||||
@@ -292,6 +296,89 @@ static void test_budget_and_ema(void) {
|
||||
}
|
||||
}
|
||||
|
||||
static void test_repeated_zero_credit_plans(void) {
|
||||
RenderPassPlanInput in = {0};
|
||||
RenderPassCost cost = {0};
|
||||
uint32_t phases[RENDER_PASS_MAX_PHASES], wanted;
|
||||
unsigned rewarms = 0;
|
||||
double previous_pass = 0.0;
|
||||
/* Millisecond host ticks: a 50 ms frame, 30 ms warmed replay and 80% share.
|
||||
* Keep a real output grid inside each frame. With the former both-zero
|
||||
* fallback, these calls alternated budget40/admit1 and budget24/admit0,
|
||||
* each measured replay preventing a cost rewarm. */
|
||||
in.frame_length = 50.0;
|
||||
in.target_period = 1000.0 / 165.0;
|
||||
in.max = 1;
|
||||
for (unsigned i = 0; i < RENDER_PASS_COST_WARMUP; i++)
|
||||
render_pass_cost_add(&cost, 30.0, 0);
|
||||
for (unsigned i = 0; i < 20; i++) {
|
||||
uint32_t n;
|
||||
in.frame_start = 1000.0 + i * in.frame_length;
|
||||
in.next_deadline = in.frame_start + in.target_period;
|
||||
in.pass_cost = render_pass_cost_estimate(&cost);
|
||||
in.budget = render_pass_budget(0.0, previous_pass, in.frame_length, 0.8);
|
||||
n = render_pass_plan_phases(&in, phases, &wanted);
|
||||
if (wanted && render_pass_cost_note_plan(&cost)) {
|
||||
rewarms++;
|
||||
in.pass_cost = 0.0;
|
||||
n = render_pass_plan_phases(&in, phases, NULL);
|
||||
}
|
||||
CHECK(wanted > 0 && n == 0, "repeated zero credit never reinvents nominal budget");
|
||||
previous_pass = 0.0;
|
||||
if (n) {
|
||||
render_pass_cost_add(&cost, 30.0, 0);
|
||||
previous_pass = 30.0;
|
||||
}
|
||||
}
|
||||
CHECK(rewarms == 0, "the zero-credit regression is independent of rewarming");
|
||||
|
||||
/* A later stale-estimate rewarm still cannot buy its measuring pass from
|
||||
* zero credit. The cost stays unknown until measured idle returns. */
|
||||
for (unsigned i = 20; i < 80; i++) {
|
||||
uint32_t n;
|
||||
in.frame_start += in.frame_length;
|
||||
in.next_deadline = in.frame_start + in.target_period;
|
||||
in.pass_cost = render_pass_cost_estimate(&cost);
|
||||
in.budget = render_pass_budget(0.0, 0.0, in.frame_length, 0.8);
|
||||
n = render_pass_plan_phases(&in, phases, &wanted);
|
||||
if (wanted && render_pass_cost_note_plan(&cost)) {
|
||||
rewarms++;
|
||||
in.pass_cost = 0.0;
|
||||
n = render_pass_plan_phases(&in, phases, NULL);
|
||||
}
|
||||
CHECK(n == 0, "zero-credit rewarm does not fund a measuring pass");
|
||||
}
|
||||
CHECK(rewarms == 1, "one rewarm stays unknown until actual credit returns");
|
||||
|
||||
/* When genuine idle returns, the same unknown cost left by that rewarm
|
||||
* seeds normally and keeps replaying; do not reset the estimator here. */
|
||||
for (unsigned i = 0; i < RENDER_PASS_COST_WARMUP + 2u; i++) {
|
||||
uint32_t n;
|
||||
in.frame_start += in.frame_length;
|
||||
in.next_deadline = in.frame_start + in.target_period;
|
||||
in.pass_cost = render_pass_cost_estimate(&cost);
|
||||
in.budget = render_pass_budget(40.0, 0.0, 50.0, 0.8);
|
||||
n = render_pass_plan_phases(&in, phases, &wanted);
|
||||
CHECK(n == 1 && wanted > 0, "positive measured idle warms and admits replay");
|
||||
CHECK(!render_pass_cost_note_plan(&cost), "measured affordable replay never rewarms");
|
||||
if (n) render_pass_cost_add(&cost, 30.0, 0);
|
||||
}
|
||||
CHECK(render_pass_cost_estimate(&cost) == 30.0, "genuine idle seeds the measured cost");
|
||||
|
||||
/* Ordinary previous replay credit is still real input: 30*.8 cannot fund
|
||||
* another 30 ms replay, but it can fund a measured 10 ms replay. */
|
||||
in.pass_cost = render_pass_cost_estimate(&cost);
|
||||
in.budget = render_pass_budget(0.0, 30.0, 50.0, 0.8);
|
||||
CHECK(in.budget == 24.0 && render_pass_plan_phases(&in, phases, NULL) == 0,
|
||||
"prior pass credit is retained without reinvention after shedding");
|
||||
memset(&cost, 0, sizeof cost);
|
||||
for (unsigned i = 0; i < RENDER_PASS_COST_WARMUP; i++)
|
||||
render_pass_cost_add(&cost, 10.0, 0);
|
||||
in.pass_cost = render_pass_cost_estimate(&cost);
|
||||
CHECK(render_pass_plan_phases(&in, phases, NULL) == 1,
|
||||
"ordinary prior credit still funds an affordable replay");
|
||||
}
|
||||
|
||||
static void test_store_policy(void) {
|
||||
CHECK(render_pass_mmio_class(0x1F801810u, 0x28000000u, 4) == -1, "GP0 reaches the GPU");
|
||||
CHECK(render_pass_mmio_class(0x1F801814u, 0x04000002u, 4) == -1, "GP1 DMA mode allowed");
|
||||
@@ -335,6 +422,7 @@ int main(void) {
|
||||
test_gen_select_late();
|
||||
test_gen_flip_matches();
|
||||
test_budget_and_ema();
|
||||
test_repeated_zero_credit_plans();
|
||||
printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user