mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 10:19:47 -04:00
408 lines
22 KiB
C++
408 lines
22 KiB
C++
/* Verify the VBlank recorder/replayer against a model of the engine's frame
|
|
* loop: VBlank plugin, the guest frame (which reads the pad state latched in
|
|
* SIO), then the boundary sample (physical pad, or a controller source).
|
|
* Real alignment is checked separately by recording and replaying the actual
|
|
* game. No window, disc or BIOS is needed. */
|
|
#include "host_osd.h"
|
|
#include "memcard.h"
|
|
#include "mod_netplay.h"
|
|
#include "mod_plugins.h"
|
|
#include "mod_runtime.h"
|
|
#include "savestate.h"
|
|
#include "sio.h"
|
|
#include "cdrom.h"
|
|
#include "render_pass.h"
|
|
#include <array>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <map>
|
|
#include <string>
|
|
#include <cstdlib>
|
|
#include <memory>
|
|
|
|
static std::array<uint8_t,0x200000> memory;
|
|
static std::array<uint16_t,2> sio_buttons{0xFFFF,0xFFFF};
|
|
static std::array<std::array<uint8_t,4>,2> sio_sticks{};
|
|
static std::array<int,2> sio_analog{};
|
|
static std::array<int,2> sio_connected{1,0}, source_connected{1,1};
|
|
static std::array<PSXModControllerSource,2> sources{};
|
|
static std::map<std::string,unsigned> counters;
|
|
static std::string save_root,shown[2];
|
|
static std::string application_root;
|
|
static bool online;
|
|
static unsigned status_generation, status_pending;
|
|
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)(),(*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")) {recording_activation=cb;activation=activate_recording_plan;}
|
|
else if(!strcmp(id,"psxrecomp.diagnostics.osd"))osd_activation=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;}
|
|
int psx_mod_set_controller_source_connected(uint32_t seat,int connected) {source_connected.at(seat)=connected;return 1;}
|
|
void psx_mod_counter_add(const char *name,uint32_t n) {counters[name]+=n;}
|
|
uint16_t sio_get_pad_buttons_slot(int slot) {return sio_buttons.at(slot);}
|
|
int sio_get_pad_analog(int slot) {return sio_analog.at(slot);}
|
|
int sio_get_pad_connected(int slot) {return sio_connected.at(slot);}
|
|
void sio_set_pad_connected(int slot,int connected) {sio_connected.at(slot)=connected;}
|
|
void sio_get_pad_sticks(int slot,uint8_t out[4]) {memcpy(out,sio_sticks.at(slot).data(),4);}
|
|
void sio_set_pad_state_slot(int slot,uint16_t buttons) {sio_buttons.at(slot)=buttons;}
|
|
void sio_set_pad_sticks(int slot,uint8_t lx,uint8_t ly,uint8_t rx,uint8_t ry) {sio_sticks.at(slot)={lx,ly,rx,ry};}
|
|
int memcard_is_present(int slot) {return slot==0;}
|
|
int memcard_export_raw(int,uint8_t *out) {memset(out,0xAB,MEMCARD_SIZE);return 0;}
|
|
void host_osd_set_diagnostics(const char *a,const char *b) {shown[0]=a?a:"";shown[1]=b?b:"";}
|
|
void host_osd_push(const char*,int) {}
|
|
void cdrom_debug_snapshot(CDROMDebugState *out) {*out={};}
|
|
int cdrom_data_read_active(void) {return 0;}
|
|
int cdrom_xa_stream_active(void) {return 0;}
|
|
void render_pass_get_stats(RenderPassStats *out) {*out={};}
|
|
void savestate_status_json(char *buf,size_t cap) {
|
|
snprintf(buf,cap,"\"generation\":%u,\"pending\":%u,\"last_ok\":%d,\"last_op\":\"%s\",\"last_slot\":%d",
|
|
status_generation,status_pending,status_ok,status_op,status_slot);
|
|
}
|
|
int savestate_read_slot(int,uint8_t **data,size_t *size) {
|
|
if(slot_image.empty())return 0;
|
|
*data=(uint8_t*)malloc(slot_image.size());memcpy(*data,slot_image.data(),slot_image.size());
|
|
*size=slot_image.size();return 1;
|
|
}
|
|
namespace PSXRecompV4 {
|
|
std::string mod_runtime_plan_fingerprint_portable() {return plan_text;}
|
|
const std::string& mod_runtime_fingerprint() {static const std::string fp="fixture-plan";return fp;}
|
|
const std::filesystem::path& mod_runtime_root() {
|
|
static std::filesystem::path root;
|
|
root=std::filesystem::path(application_root)/"mods";return root;
|
|
}
|
|
}
|
|
namespace {std::filesystem::path application_directory() {return std::filesystem::u8path(application_root);}}
|
|
#define PSX_DIAGNOSTIC_TEST
|
|
#include "../src/playtest_diagnostics.cpp"
|
|
|
|
#define CHECK(x) do {if(!(x)) {fprintf(stderr,"line %d: %s\n",__LINE__,#x);return 1;}} while(0)
|
|
static void env(const char *name,const char *v) {
|
|
#ifdef _WIN32
|
|
_putenv_s(name,v);
|
|
#else
|
|
if(*v)setenv(name,v,1);else unsetenv(name);
|
|
#endif
|
|
}
|
|
|
|
// ---- Engine model -----------------------------------------------------------
|
|
struct Physical {uint16_t buttons[2];uint8_t sticks[2][4];int analog[2];int connected[2]={1,0};};
|
|
static Physical script(unsigned t) {
|
|
Physical p{{0xFFFF,0xFFFF},{{128,128,128,128},{128,128,128,128}},{0,0}};
|
|
if(t>=10 && t<40)p.buttons[0]=0xBFFF; // Cross held
|
|
if(t%7==3)p.buttons[0]&=(uint16_t)~0x0010; // Up taps
|
|
if(t>=45 && t<50)p.buttons[0]&=(uint16_t)~0x0006; // one held incident chord
|
|
if(t>=60 && t<90) {p.analog[0]=1;p.sticks[0][0]=uint8_t(t*3);p.sticks[0][1]=uint8_t(255-t);}
|
|
if(t>=100 && t<120)p.buttons[1]=0x7FFF; // P2 Square
|
|
p.connected[0] = !((t>=70 && t<80) || (t>=130 && t<145)); // analog + digital unplug/replug
|
|
p.connected[1] = t>=95 && t<125; // P2 plug/unplug
|
|
return p;
|
|
}
|
|
// Boundary sample: exactly the engine's rule. A source ANDs with the physical
|
|
// pad and supplies sticks/type; with no source the physical pad is used.
|
|
static void boundary_sample(const Physical& phys) {
|
|
for(unsigned seat=0;seat<2;++seat) {
|
|
uint16_t buttons=phys.buttons[seat];int analog=phys.analog[seat];
|
|
int connected=phys.connected[seat];
|
|
std::array<uint8_t,4> sticks;memcpy(sticks.data(),phys.sticks[seat],4);
|
|
if(sources[seat]) {
|
|
PSXModControllerState state{sizeof state,0xFFFF,128,128,128,128,1};
|
|
if(!sources[seat](&state)) state={sizeof state,0xFFFF,128,128,128,128,1};
|
|
buttons=(uint16_t)(state.buttons&buttons);analog=(int)state.analog;
|
|
connected=source_connected[seat];
|
|
sticks={uint8_t(state.lx),uint8_t(state.ly),uint8_t(state.rx),uint8_t(state.ry)};
|
|
if(!analog)sticks={128,128,128,128};
|
|
}
|
|
sio_buttons[seat]=buttons;sio_analog[seat]=analog;sio_sticks[seat]=sticks;
|
|
sio_connected[seat]=connected;
|
|
}
|
|
}
|
|
// The guest frame reads the pad state SIO held at the VBlank and mutates RAM.
|
|
static void guest_frame(unsigned t) {
|
|
uint32_t value;memcpy(&value,&memory[0x4000],4);
|
|
value=value*1664525u+1013904223u+sio_buttons[0]+(uint32_t(sio_buttons[1])<<7)+
|
|
sio_sticks[0][0]*31u+sio_sticks[0][1]*17u+t+
|
|
sio_connected[0]*71u+sio_connected[1]*97u;
|
|
memcpy(&memory[0x4000],&value,4);
|
|
memcpy(&memory[0x100000+((t*64u)&0xFFFFF)],&value,4); // a spread-out write
|
|
}
|
|
// One engine frame: VBlank plugin, guest, boundary. `tamper` fires after the
|
|
// VBlank of that frame to model a diverging guest.
|
|
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));
|
|
}
|
|
}
|
|
static void reset_machine() {
|
|
memory.fill(0);sio_buttons={0xFFFF,0xFFFF};sio_sticks={};sio_analog={};
|
|
sio_connected={1,0};source_connected={1,1};
|
|
for(auto& s:sio_sticks)s.fill(128);
|
|
}
|
|
static std::filesystem::path newest_session() {
|
|
std::filesystem::path file;
|
|
for(const auto& entry:std::filesystem::directory_iterator(std::filesystem::path(application_root)/"saves/diagnostics"))
|
|
if(std::filesystem::exists(entry.path()/"inputs.csv") && (file.empty() || entry.path()>file))file=entry.path();
|
|
return file;
|
|
}
|
|
static std::string slurp(const std::filesystem::path& p) {
|
|
std::ifstream in(p,std::ios::binary);return std::string((std::istreambuf_iterator<char>(in)),{});
|
|
}
|
|
|
|
int main(int argc,char **argv) {
|
|
CHECK(psx_playtest_diagnostic_application_directory()==
|
|
std::filesystem::canonical(std::filesystem::u8path(argv[0])).parent_path());
|
|
save_root=argc>1?argv[1]:"diagnostic-test-output";
|
|
save_root+="/"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
|
|
application_root=save_root;
|
|
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 && 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","");
|
|
// 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();
|
|
auto final_memory=std::make_unique<decltype(memory)>(memory);
|
|
recording.close();
|
|
telemetry.close();
|
|
auto session=newest_session();
|
|
CHECK(!session.empty());
|
|
auto text=slurp(session/"inputs.csv");
|
|
CHECK(text.rfind("# psxrecomp-input-v2\n# execution ",0)==0 && text.find("\n# plan fixture-plan\n# build ")!=std::string::npos);
|
|
size_t rows=0;for(size_t at=0;(at=text.find("\nN ",at))!=std::string::npos;++at)++rows;
|
|
CHECK(rows==200);
|
|
auto timings=slurp(session/"telemetry.csv");
|
|
CHECK(timings.rfind("sequence,host_elapsed_us,host_interval_us,",0)==0);
|
|
CHECK(std::count(timings.begin(),timings.end(),'\n')==201);
|
|
CHECK(incident==1); // holding the chord across five frames marks once
|
|
// Row 0 is the initial SIO state; row 11 shows Cross, which the model's
|
|
// script first sampled at the boundary after frame 10... recorded one
|
|
// VBlank later, exactly as the game sees it.
|
|
CHECK(text.find("\nN 11,"+std::to_string(0xBFFF&0xFFEF)+",")!=std::string::npos ||
|
|
text.find("\nN 11,"+std::to_string(0xBFFF)+",")!=std::string::npos);
|
|
CHECK(std::filesystem::file_size(session/"card1.mcd")==MEMCARD_SIZE);
|
|
CHECK(std::filesystem::exists(session/"card2.absent"));
|
|
CHECK(std::filesystem::exists(session/"mods-state.toml") && std::filesystem::exists(session/"settings.toml"));
|
|
CHECK(slurp(session/"session.json").find("\"netplay\": false")!=std::string::npos);
|
|
CHECK(slurp(session/"session.json").find("baseline")==std::string::npos);
|
|
{auto location=slurp(std::filesystem::path(save_root)/"diagnostics-location.txt");
|
|
CHECK(location.find(psx_playtest_diagnostic_utf8(std::filesystem::absolute(session).make_preferred()))!=std::string::npos &&
|
|
location.find("zip this entire session folder")!=std::string::npos);}
|
|
|
|
// ---- Replay with idle physical pads: zero divergence ----------------------
|
|
env("PSX_DIAGNOSTIC_REPLAY",(session/"inputs.csv").string().c_str());
|
|
reset_machine();activation();
|
|
CHECK(sources[0] && sources[1] && replaying && !replay_failed);
|
|
// Physical input is idle during replay; the recorded script must come from
|
|
// the sources alone.
|
|
for(unsigned t=0;t<200;++t) {
|
|
vblank();guest_frame(t);
|
|
boundary_sample(Physical{{0xFFFF,0xFFFF},{{128,128,128,128},{128,128,128,128}},{0,0}});
|
|
CHECK(!replay_failed);
|
|
}
|
|
CHECK(!replaying && !replay_failed && counters["psxrecomp.diagnostics.replay-complete"]==1);
|
|
CHECK(counters["psxrecomp.diagnostics.replayed-inputs"]==200);
|
|
CHECK(memory==*final_memory);
|
|
CHECK(!sources[0] && !sources[1]);
|
|
|
|
// ---- A diverging guest is reported at its first sequence ------------------
|
|
reset_machine();activation();
|
|
for(unsigned t=0;t<150 && !replay_failed;++t) {
|
|
vblank();if(t==70)memory[0x4000]^=1;guest_frame(t);
|
|
boundary_sample(Physical{{0xFFFF,0xFFFF},{{128,128,128,128},{128,128,128,128}},{0,0}});
|
|
}
|
|
// The digest samples a rotating 1/128th of RAM, so a corrupted word is
|
|
// caught within one full rotation of the divergence.
|
|
CHECK(replay_failed && !replaying && !sources[0] && sequence>70 && sequence<=71+StrideWords);
|
|
|
|
// ---- Identity mismatches, torn tails and netplay --------------------------
|
|
plan_text="different-plan";reset_machine();activation();
|
|
CHECK(replay_failed && !sources[0]);
|
|
plan_text="fixture-plan";
|
|
online=true;reset_machine();activation();CHECK(replay_failed);online=false;
|
|
{ // A hard-killed recording leaves a torn final row: replay verifies the rest.
|
|
auto torn=std::filesystem::path(save_root)/"torn.csv";
|
|
auto whole=slurp(session/"inputs.csv");
|
|
std::ofstream out(torn,std::ios::binary);out<<whole.substr(0,whole.size()-9);out.close();
|
|
env("PSX_DIAGNOSTIC_REPLAY",torn.string().c_str());
|
|
reset_machine();activation();
|
|
for(unsigned t=0;t<200 && replaying;++t) {
|
|
vblank();guest_frame(t);
|
|
boundary_sample(Physical{{0xFFFF,0xFFFF},{{128,128,128,128},{128,128,128,128}},{0,0}});
|
|
}
|
|
CHECK(!replaying && !replay_failed && counters["psxrecomp.diagnostics.replay-complete"]==2);
|
|
}
|
|
env("PSX_DIAGNOSTIC_REPLAY","");
|
|
|
|
// A developer may apply old inputs across a fix, but this must never
|
|
// increment the verified-replay counter or guess missing v1 presence.
|
|
#ifdef PSX_NO_DEBUG_TOOLS
|
|
env("PSX_DIAGNOSTIC_INPUT_ONLY","1");
|
|
reset_machine();activation();CHECK(replay_failed);
|
|
env("PSX_DIAGNOSTIC_INPUT_ONLY","");
|
|
#else
|
|
{
|
|
auto legacy=std::filesystem::path(save_root)/"legacy-route.csv";
|
|
std::istringstream in(slurp(session/"inputs.csv"));std::ofstream out(legacy);
|
|
std::string line;unsigned row=0;
|
|
while(std::getline(in,line)) {
|
|
if(row++==0)line="# psxrecomp-input-v1";
|
|
else if(line.rfind("N ",0)==0) {
|
|
line.resize(line.rfind(','));line.resize(line.rfind(','));
|
|
}
|
|
out<<line<<'\n';
|
|
}
|
|
out.close();
|
|
env("PSX_DIAGNOSTIC_REPLAY",legacy.string().c_str());
|
|
reset_machine();activation();CHECK(replay_failed); // strict stays strict
|
|
env("PSX_DIAGNOSTIC_INPUT_ONLY","1");
|
|
reset_machine();activation();CHECK(replay_failed); // presence required
|
|
env("PSX_DIAGNOSTIC_LEGACY_CONNECTED","10");
|
|
const auto verified=counters["psxrecomp.diagnostics.replay-complete"];
|
|
reset_machine();activation();CHECK(replaying && input_only && legacy_route);
|
|
for(unsigned t=0;t<200;++t) {
|
|
vblank();guest_frame(t);memory[0x4000]^=1; // deliberately different guest state
|
|
boundary_sample(Physical{{0xFFFF,0xFFFF},{{128,128,128,128},{128,128,128,128}},{0,0}});
|
|
CHECK(!replay_failed);
|
|
}
|
|
CHECK(!replaying && counters["psxrecomp.diagnostics.route-complete"]==1);
|
|
CHECK(counters["psxrecomp.diagnostics.replay-complete"]==verified);
|
|
env("PSX_DIAGNOSTIC_INPUT_ONLY","");
|
|
env("PSX_DIAGNOSTIC_LEGACY_CONNECTED","");
|
|
env("PSX_DIAGNOSTIC_REPLAY","");
|
|
}
|
|
#endif
|
|
|
|
// ---- Netplay: no rows, no overlay, no session ------------------------------
|
|
{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();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);
|
|
online=false;}
|
|
|
|
// ---- Slot load starts a new baseline segment --------------------------------
|
|
slot_image={0x50,0x53,0x54,0x0A,0xFE,0x01};
|
|
reset_machine();activation();osd_activation();run_frames(0,30);
|
|
status_generation=5;status_pending=0;status_ok=1;status_op="load";status_slot=3;
|
|
auto first_segment=newest_session();
|
|
memory[0x8000]=0x77;savestate(); // restore callback after the load
|
|
CHECK(sequence==0 && !started && !recording.is_open());
|
|
run_frames(30,80);recording.flush();
|
|
auto segment=newest_session();
|
|
CHECK(segment!=first_segment);
|
|
CHECK(slurp(segment/"baseline.pst")==std::string((const char*)slot_image.data(),slot_image.size()));
|
|
CHECK(slurp(segment/"session.json").find("\"baseline\": \"baseline.pst\"")!=std::string::npos);
|
|
CHECK(counters["psxrecomp.diagnostics.state-segments"]==1);
|
|
// A rewind restore (same status generation, no new slot load) cannot copy a slot.
|
|
savestate();recording.flush();
|
|
CHECK(counters["psxrecomp.diagnostics.state-segments"]==2);
|
|
run_frames(80,85);recording.flush();
|
|
auto rewound=newest_session();
|
|
CHECK(rewound!=segment && !std::filesystem::exists(rewound/"baseline.pst"));
|
|
CHECK(slurp(rewound/"session.json").find("\"baseline_unavailable\": true")!=std::string::npos);
|
|
recording.close();
|
|
|
|
// ---- Replay of the baseline segment -----------------------------------------
|
|
{auto baseline_file=segment/"baseline.pst";
|
|
env("PSX_DIAGNOSTIC_REPLAY",(segment/"inputs.csv").string().c_str());
|
|
env("PSX_DIAGNOSTIC_BASELINE",baseline_file.string().c_str());
|
|
reset_machine();activation();
|
|
// Rows of the normal boot must not be consumed before the baseline loads.
|
|
run_frames(0,5);
|
|
CHECK(!started && !replaying && !replay_failed && sequence==0);
|
|
status_generation=9;status_op="load";status_slot=0;memory[0x8000]=0x77;
|
|
// The model's guest state at restore is whatever it was at recording; the
|
|
// plugin only checks the loaded slot image and then verifies digests.
|
|
// The recorded segment's first row was sampled mid-frame (P1 had Cross
|
|
// held); a restored replay has no source sample before the first VBlank,
|
|
// so the plugin must seed SIO with that row.
|
|
sio_buttons={0xFFFF,0xFFFF};
|
|
savestate();
|
|
CHECK(baseline_loaded && replaying && !replay_failed);
|
|
CHECK(sio_buttons[0]==pending[ColButtons] && sio_buttons[1]==pending[ColButtons+1]);
|
|
CHECK(pending[ColButtons]==0xBFFF);
|
|
// A mismatching restored image is refused.
|
|
slot_image.back()^=1;reset_machine();activation();status_generation=11;savestate();
|
|
CHECK(replay_failed && !replaying);
|
|
slot_image.back()^=1;
|
|
env("PSX_DIAGNOSTIC_BASELINE","");env("PSX_DIAGNOSTIC_REPLAY","");}
|
|
|
|
// ---- An unwritable application directory uses a separate writable root ---
|
|
online=false;reset_machine();activation();osd_activation();
|
|
application_root+="/blocked-app";std::filesystem::create_directories(application_root);
|
|
{std::ofstream blocked(std::filesystem::path(application_root)/"saves");blocked<<"file, not a directory";}
|
|
vblank();recording.flush();
|
|
CHECK(!recording_failed && recording.is_open());
|
|
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);
|
|
// 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;
|
|
}
|