Merge final diagnostics lifecycle and packaging fixes for Gex

Preserve the qualified Gex fidelity history while incorporating independent
input display, clean recording-session boundaries and setup-host replay tools.
This commit is contained in:
Matthew Stanley
2026-10-17 13:14:46 -07:00
9 changed files with 128 additions and 24 deletions
+4 -1
View File
@@ -29,7 +29,10 @@ Create a title mod package with a recording feature whose plugin is
`game.diagnostics.osd`. The package controls their defaults. The helper also
covers an existing `psx-runtime-pgxp` sibling and stages `replay-diagnostics.py`
beside both executables. The bundled-release packager includes this script when
present; AppImage wrapping preserves it in the payload. No texture feature,
present, as does the setup-host kit packager; AppImage wrapping preserves it in
the payload. Input Display also works with Recording disabled, with no RAM
digest or file capture. Session changes and netplay suspension clear the display.
No texture feature,
texture package or other enhancement is enabled or exposed by this helper.
The implementation reads no title-specific addresses. `telemetry.csv` stores
+2
View File
@@ -595,6 +595,8 @@ private:
bool mod_register_builtin_resolver(const std::string& id, ModBuiltinResolver resolver);
void mod_clear_builtin_resolvers_for_tests();
bool mod_register_activation_plugin(const std::string& id, void (*callback)(void));
bool mod_register_session_reset_plugin(const std::string& id, void (*callback)(void));
void mod_reset_plugin_sessions();
bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void));
bool mod_register_savestate_plugin(const std::string& id, void (*callback)(void));
void mod_invoke_savestate_plugin(const std::string& id);
+5
View File
@@ -25,6 +25,11 @@ typedef int (*PSXModFunctionFilterCallback)(struct CPUState* cpu,
*/
int psx_mod_register_activation_plugin(const char* id,
PSXModActivationCallback callback);
/* Host-only cleanup before each plan activates, including plans that omit this
* plugin, and when netplay clears the offline plan. Runs with emulation stopped
* and without a current feature/resource context. Never mutate guest state. */
int psx_mod_register_session_reset_plugin(const char* id,
PSXModActivationCallback callback);
int psx_mod_register_vblank_plugin(const char* id,
PSXModVBlankCallback callback);
/* Runs after successful guest-state restore, before the restored PC resumes.
+22 -2
View File
@@ -43,6 +43,7 @@ std::map<std::string, MediaPreparer>& media_preparers() {
struct RegisteredPlugin {
void (*savestate)(void) = nullptr;
PSXModActivationCallback activation = nullptr;
PSXModActivationCallback session_reset = nullptr;
PSXModVBlankCallback vblank = nullptr;
/* Generated-function entry hooks, keyed by guest address. One id may
* observe several functions. */
@@ -1491,6 +1492,19 @@ bool mod_register_activation_plugin(const std::string& id, void (*callback)(void
return true;
}
bool mod_register_session_reset_plugin(const std::string& id, void (*callback)(void)) {
if (!valid_id(id) || !callback) return false;
RegisteredPlugin& plugin = registered_plugins()[id];
if (plugin.session_reset) return false;
plugin.session_reset = callback;
return true;
}
void mod_reset_plugin_sessions() {
for (const auto& entry : registered_plugins())
if (entry.second.session_reset) entry.second.session_reset();
}
bool mod_register_vblank_plugin(const std::string& id, void (*callback)(void)) {
if (!valid_id(id) || !callback) return false;
RegisteredPlugin& plugin = registered_plugins()[id];
@@ -1523,7 +1537,7 @@ bool mod_register_function_filter_plugin(const std::string& id, uint32_t address
bool mod_plugin_registered(const std::string& id) {
const auto found = registered_plugins().find(id);
return found != registered_plugins().end() &&
(found->second.activation || found->second.vblank || found->second.savestate ||
(found->second.activation || found->second.session_reset || found->second.vblank || found->second.savestate ||
!found->second.function_entries.empty() || !found->second.guest_functions.empty() ||
!found->second.instructions.empty());
}
@@ -1605,7 +1619,7 @@ void mod_clear_plugins_for_tests() {
std::vector<std::string> mod_registered_plugin_ids() {
std::vector<std::string> ids;
for (const auto& entry : registered_plugins())
if (entry.second.activation || entry.second.vblank ||
if (entry.second.activation || entry.second.session_reset || entry.second.vblank ||
!entry.second.function_entries.empty() || !entry.second.guest_functions.empty())
ids.push_back(entry.first);
return ids; /* std::map keeps them sorted */
@@ -4259,6 +4273,12 @@ extern "C" int psx_mod_register_activation_plugin(
PSXRecompV4::mod_register_activation_plugin(id, callback) ? 1 : 0;
}
extern "C" int psx_mod_register_session_reset_plugin(
const char* id, PSXModActivationCallback callback) {
return id &&
PSXRecompV4::mod_register_session_reset_plugin(id, callback) ? 1 : 0;
}
extern "C" int psx_mod_register_savestate_plugin(
const char* id, PSXModActivationCallback callback) {
return id &&
+2
View File
@@ -1599,6 +1599,7 @@ bool mod_runtime_initialize(const std::filesystem::path& root,
}
bool mod_runtime_clear_for_netplay(std::string* error) {
mod_reset_plugin_sessions();
psx_mod_netplay_set_active(0);
(void)psx_mod_netplay_set_options_identity("");
state().ticket_ready = false;
@@ -2844,6 +2845,7 @@ 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);
mod_reset_plugin_sessions();
RuntimeMods& s = state();
s.ticket_ready = false;
psx_projection_reset_session();
+29 -14
View File
@@ -65,6 +65,7 @@ std::ifstream replay;
Row pending{}; // replay row the next VBlank verifies
bool pending_valid;
uint32_t sequence;
uint32_t osd_sequence;
bool started, replaying, replay_failed, recording_failed, owns_sources, osd_enabled;
bool baseline_loaded;
bool input_only, legacy_route;
@@ -453,7 +454,7 @@ void restored() {
psx_mod_counter_add("psxrecomp.diagnostics.state-segments", 1);
}
std::string input_line(unsigned seat, uint16_t buttons) {
std::string input_line(unsigned seat, uint16_t buttons, uint32_t frame) {
static const char *names[] = {"Se", "L3", "R3", "St", "U", "R", "D", "L", "L2", "R2", "L1", "R1", "T", "O", "X", "[]"};
std::string text = "P" + std::to_string(seat + 1) + ":";
bool held = false;
@@ -461,17 +462,24 @@ std::string input_line(unsigned seat, uint16_t buttons) {
text += ' '; text += names[bit]; held = true;
}
if (!held) text += " -";
text += " F" + std::to_string(sequence);
text += " F" + std::to_string(frame);
if (replay_failed || recording_failed) text += " ERROR";
else if (replaying) text += input_only ? " ROUTE" : " PLAY";
else if (recording.is_open()) text += " REC";
return text;
}
void show(const Row& row) {
if (!osd_enabled) return;
auto first = input_line(0, (uint16_t)row[ColButtons]), second = input_line(1, (uint16_t)row[ColButtons + 1]);
void tick_osd() {
if (!osd_enabled || psx_mod_netplay_is_active()) {
host_osd_set_diagnostics(nullptr, nullptr);
return;
}
// Input Display is independent of Recording. Only sample the accepted
// button words; do not hash RAM, enqueue rows or create session files.
auto first = input_line(0, sio_get_pad_buttons_slot(0), osd_sequence);
auto second = input_line(1, sio_get_pad_buttons_slot(1), osd_sequence);
host_osd_set_diagnostics(first.c_str(), second.c_str());
++osd_sequence;
}
const char *column_name(unsigned column) {
@@ -513,7 +521,6 @@ void tick() {
verify(row);
if (replaying) {
psx_mod_counter_add(input_only ? "psxrecomp.diagnostics.route-inputs" : "psxrecomp.diagnostics.replayed-inputs", 1);
show(row);
const uint32_t verified = sequence + 1;
if (!next_row()) {
replay.close(); replaying = false; pending_valid = false;
@@ -533,7 +540,6 @@ void tick() {
recording_location(std::string("Recording FAILED: ") + recording.error_text());
fprintf(stderr, "psxrecomp.diagnostics: %s\n", recording.error_text());
}
show(row);
}
++sequence;
const uint64_t ns = (uint64_t)std::chrono::duration_cast<std::chrono::nanoseconds>(
@@ -543,13 +549,21 @@ void tick() {
if (ns > 1000000) ++stats.slow;
}
void activate() {
void reset_session() {
release_sources();
close_recording(); replay.close(); recording.clear(); replay.clear();
started = replaying = replay_failed = recording_failed = pending_valid = false;
baseline_loaded = pending_baseline_unavailable = false;
input_only = legacy_route = false;
legacy_connected = 4;
osd_enabled = false;
sequence = osd_sequence = 0; last_generation = 0;
pending_baseline.clear(); expected_baseline.clear();
replay_path.clear(); baseline_path.clear();
host_osd_set_diagnostics(nullptr, nullptr);
}
void activate() {
#ifndef PSX_NO_DEBUG_TOOLS
const char* route = getenv("PSX_DIAGNOSTIC_INPUT_ONLY");
input_only = route && !strcmp(route,"1");
@@ -564,9 +578,6 @@ void activate() {
started = true; fail("input-only routes require a developer build"); return;
}
#endif
osd_enabled = false;
sequence = 0; last_generation = 0;
pending_baseline.clear(); expected_baseline.clear();
const char *path = getenv("PSX_DIAGNOSTIC_REPLAY");
replay_path = path ? path : "";
const char *baseline = getenv("PSX_DIAGNOSTIC_BASELINE");
@@ -605,14 +616,18 @@ void activate() {
host_osd_set_diagnostics(nullptr, nullptr);
}
/* The input display is its own feature so it can be switched off while
* recording stays on. Activation order follows the manifest. */
void activate_osd() { osd_enabled = true; }
void activate_osd() {
osd_enabled = true;
osd_sequence = 0;
host_osd_set_diagnostics(nullptr, nullptr);
}
}
PSX_MOD_CONSTRUCTOR(psx_register_playtest_diagnostics) {
psx_mod_register_session_reset_plugin(PSX_DIAGNOSTIC_MOD_ID, reset_session);
psx_mod_register_activation_plugin(PSX_DIAGNOSTIC_MOD_ID, activate);
psx_mod_register_vblank_plugin(PSX_DIAGNOSTIC_MOD_ID, tick);
psx_mod_register_savestate_plugin(PSX_DIAGNOSTIC_MOD_ID, restored);
psx_mod_register_activation_plugin(PSX_DIAGNOSTIC_MOD_ID ".osd", activate_osd);
psx_mod_register_vblank_plugin(PSX_DIAGNOSTIC_MOD_ID ".osd", tick_osd);
}
+11
View File
@@ -14,6 +14,7 @@ namespace fs = std::filesystem;
using namespace PSXRecompV4;
static int failures;
static int session_resets;
static void check(bool value, const char* message) {
if (!value) {
@@ -321,6 +322,16 @@ int main() {
check(mod_register_vblank_plugin(
"test.vblank", +[]() {}),
"test plugin must register");
check(mod_register_session_reset_plugin(
"test.vblank", +[]() { ++session_resets; }),
"session cleanup must register independently of active features");
check(!mod_register_session_reset_plugin("test.vblank", +[]() {}),
"duplicate session cleanup must be rejected");
// No manifest or active plan exists yet: deselected plugins still clean
// up the host state left by the previous session.
mod_reset_plugin_sessions();
mod_reset_plugin_sessions();
check(session_resets == 2, "each session boundary must invoke registered cleanup");
write_text(root / "packages/plugin.mod/1.0.0/manifest.toml",
"format_version = 5\n"
"id = \"plugin.mod\"\n"
+50 -7
View File
@@ -37,17 +37,27 @@ static int status_ok=1,status_slot=-1;
static const char *status_op="save";
static std::vector<uint8_t> slot_image;
static std::string plan_text="fixture-plan";
static void (*activation)(),(*vblank)(),(*savestate)(),(*osd_activation)();
static void (*activation)(),(*vblank)(),(*savestate)(),(*osd_activation)(),(*osd_vblank)(),(*session_reset)();
static void (*recording_activation)();
static uint64_t ram_reads;
static void activate_recording_plan() {session_reset();recording_activation();}
uint32_t psx_mod_read_word(uint32_t a) {
++ram_reads;
uint32_t v;memcpy(&v,&memory[a&0x1FFFFC],4);return v;
}
int psx_mod_register_activation_plugin(const char *id,PSXModActivationCallback cb) {
if(!strcmp(id,"psxrecomp.diagnostics"))activation=cb;
if(!strcmp(id,"psxrecomp.diagnostics")) {recording_activation=cb;activation=activate_recording_plan;}
else if(!strcmp(id,"psxrecomp.diagnostics.osd"))osd_activation=cb;
return 1;
}
int psx_mod_register_vblank_plugin(const char*,PSXModVBlankCallback cb) {vblank=cb;return 1;}
int psx_mod_register_session_reset_plugin(const char*,PSXModActivationCallback cb) {session_reset=cb;return 1;}
int psx_mod_register_vblank_plugin(const char *id,PSXModVBlankCallback cb) {
if(!strcmp(id,"psxrecomp.diagnostics"))vblank=cb;
else if(!strcmp(id,"psxrecomp.diagnostics.osd"))osd_vblank=cb;
return 1;
}
int psx_mod_register_savestate_plugin(const char*,PSXModActivationCallback cb) {savestate=cb;return 1;}
int psx_mod_netplay_is_active(void) {return online;}
int psx_mod_set_controller_source(uint32_t seat,PSXModControllerSource source) {sources.at(seat)=source;source_connected.at(seat)=1;return 1;}
@@ -144,6 +154,7 @@ static void guest_frame(unsigned t) {
static void run_frames(unsigned from,unsigned to,int tamper=-1) {
for(unsigned t=from;t<to;++t) {
vblank();
if(osd_enabled)osd_vblank();
if((int)t==tamper)memory[0x80000]^=1;
guest_frame(t);boundary_sample(script(t));
}
@@ -172,13 +183,37 @@ int main(int argc,char **argv) {
std::filesystem::create_directories(PSXRecompV4::mod_runtime_root());
{std::ofstream state(PSXRecompV4::mod_runtime_root()/"state.toml");state<<"format_version = 2\n";}
{std::ofstream settings(std::filesystem::path(application_root)/"settings.toml");settings<<"[video]\n";}
CHECK(activation && vblank && savestate && osd_activation);
CHECK(activation && vblank && savestate && osd_activation && osd_vblank && session_reset);
// Display-only plans invoke their own callback: accepted input changes
// are visible without creating files, hashing RAM or taking pad ownership.
reset_machine();session_reset();osd_activation();
const auto display_reads=ram_reads;
sio_buttons[0]=0xBFFF;osd_vblank();
CHECK(shown[0].find(" X ")!=std::string::npos && shown[0].find("F0")!=std::string::npos);
CHECK(shown[0].find("REC")==std::string::npos && !recording.is_open() && !telemetry.is_open());
sio_buttons[0]=0xFFFF;sio_buttons[1]=0x7FFF;osd_vblank();
CHECK(shown[0].find(" X ")==std::string::npos && shown[1].find(" [] ")!=std::string::npos);
CHECK(shown[1].find("F1")!=std::string::npos && ram_reads==display_reads);
CHECK(!sources[0] && !sources[1] && sequence==0);
CHECK(!std::filesystem::exists(std::filesystem::path(application_root)/"saves/diagnostics"));
// Reactivation starts at F0; online suspension clears stale text and
// offline resumption does not count the suspended network frames.
osd_activation();CHECK(shown[0].empty());osd_vblank();
CHECK(shown[0].find("F0")!=std::string::npos);
online=true;osd_vblank();CHECK(shown[0].empty() && shown[1].empty());
online=false;osd_vblank();CHECK(shown[0].find("F1")!=std::string::npos);
session_reset();CHECK(shown[0].empty() && shown[1].empty() && !osd_enabled);
// ---- Record 200 frames of scripted two-seat input --------------------------
env("PSX_DIAGNOSTIC_REPLAY","");env("PSX_DIAGNOSTIC_BASELINE","");
reset_machine();activation();osd_activation();
// The two features may activate in either order after the engine reset.
reset_machine();session_reset();osd_activation();recording_activation();
CHECK(osd_enabled);
CHECK(!sources[0] && !sources[1]);
const auto recording_reads=ram_reads;
run_frames(0,200);
CHECK(ram_reads-recording_reads==200*DigestWords); // display never adds RAM sampling
CHECK(shown[0].find("REC")!=std::string::npos && shown[0].find("F199")!=std::string::npos);
recording.flush();
telemetry.flush();
@@ -297,7 +332,7 @@ int main(int argc,char **argv) {
{auto before=std::distance(std::filesystem::directory_iterator(std::filesystem::path(application_root)/"saves/diagnostics"),
std::filesystem::directory_iterator{});
online=true;reset_machine();activation();osd_activation();
for(unsigned t=0;t<5;++t)vblank();
for(unsigned t=0;t<5;++t) {vblank();osd_vblank();}
CHECK(!recording.is_open() && shown[0].empty() && sequence==0);
CHECK(std::distance(std::filesystem::directory_iterator(std::filesystem::path(application_root)/"saves/diagnostics"),
std::filesystem::directory_iterator{})==before);
@@ -359,6 +394,14 @@ int main(int argc,char **argv) {
auto location=slurp(std::filesystem::path(application_root)/"diagnostics-location.txt");
CHECK(location.find("Recording: ")!=std::string::npos);
CHECK(location.find("psxrecomp-diagnostics")!=std::string::npos);
close_recording();
// Play Again with Recording disabled must release the previous capture
// and clear its REC label before a new display-only plan starts.
session_reset();
CHECK(!recording.is_open() && !telemetry.is_open() && shown[0].empty());
const auto restarted_reads=ram_reads;
osd_activation();osd_vblank();
CHECK(shown[0].find("F0")!=std::string::npos && shown[0].find("REC")==std::string::npos);
CHECK(ram_reads==restarted_reads && !recording.is_open() && !sources[0]);
session_reset();
printf("diagnostic recording/replay boundary checks passed\n");return 0;
}
+3
View File
@@ -280,6 +280,9 @@ else
fi
EXE_BASENAME="$(basename "${EXE}")"
EXE_DIR="$(dirname "${EXE}")"
if [[ -f "${EXE_DIR}/replay-diagnostics.py" ]]; then
cp -a "${EXE_DIR}/replay-diagnostics.py" "${STAGE}/replay-diagnostics.py"
fi
# Windows: CMake may already have placed imported runtime DLLs (zlib1.dll)
# next to the host. Copy siblings into the stage before MinGW bundling —