diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 42abc0da..90fee787 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -789,6 +789,11 @@ if(BUILD_TESTING) add_test(NAME tas_replay_recipe COMMAND ${Python3_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/../tools/tasreplays/test_tekken3.py") + # The Python driver compiles test_source_ram_page_probe.c at O0/O2. + add_test(NAME tas_ram_long_route + COMMAND ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_source_ram_page_probe.py" + --cc ${CMAKE_C_COMPILER}) endif() endif() diff --git a/runtime/include/source_ram_page_probe.h b/runtime/include/source_ram_page_probe.h index 7ac6569d..e4c8fbba 100644 --- a/runtime/include/source_ram_page_probe.h +++ b/runtime/include/source_ram_page_probe.h @@ -17,17 +17,31 @@ static uint64_t source_ram_page_hash(const uint8_t *bytes, unsigned count) { static void source_ram_page_probe(unsigned frame, uint64_t cycle) { static int initialized, enabled; static FILE *stream; - static unsigned snapshots[32], snapshot_count; + static unsigned snapshots[32], snapshot_count, max_frames = 20000; if (!initialized) { initialized = 1; const char *setting = getenv("PSX_SOURCE_RAM_PAGE_PROBE"); enabled = setting && strcmp(setting, "1") == 0; + const char *limit = getenv("PSX_SOURCE_RAM_MAX_FRAMES"); + if (enabled && limit) { + /* A declared route may exceed the historical short-TAS bound. + * Parse without strtoul overflow or accepting signs/whitespace. */ + unsigned value = 0; + if (!*limit) abort(); + for (const char *p = limit; *p; ++p) { + if (*p < '0' || *p > '9' || value > 100000) abort(); + value = value * 10 + (unsigned)(*p - '0'); + if (value > 1000000) abort(); + } + if (!value) abort(); + max_frames = value; + } const char *cursor = getenv("PSX_SOURCE_RAM_SNAPSHOT_FRAMES"); while (enabled && cursor && *cursor) { if (*cursor < '0' || *cursor > '9') abort(); char *end; unsigned long value = strtoul(cursor, &end, 10); - if (end == cursor || value < 1 || value > 20000 || snapshot_count == 32 || + if (end == cursor || value < 1 || value > max_frames || snapshot_count == 32 || (*end && *end != ',')) abort(); for (unsigned i = 0; i < snapshot_count; ++i) if (snapshots[i] == value) abort(); @@ -37,7 +51,7 @@ static void source_ram_page_probe(unsigned frame, uint64_t cycle) { } } if (!enabled) return; - if (frame < 1 || frame > 20000) abort(); + if (frame < 1 || frame > max_frames) abort(); extern uint8_t *memory_get_ram_ptr(void); const uint8_t *ram = memory_get_ram_ptr(); char path[4096]; diff --git a/runtime/tests/test_source_ram_page_probe.c b/runtime/tests/test_source_ram_page_probe.c new file mode 100644 index 00000000..ca57f1cf --- /dev/null +++ b/runtime/tests/test_source_ram_page_probe.c @@ -0,0 +1,14 @@ +/* Exercise the production diagnostic with synthetic RAM, including long movies. */ +#include "source_ram_page_probe.h" +#include +static uint8_t test_ram[2097152]; +uint8_t *memory_get_ram_ptr(void) { return test_ram; } +static void expected_abort(int ignored) { (void)ignored; exit(4); } +int main(int argc, char **argv) { + signal(SIGABRT, expected_abort); + if (argc != 2) return 2; + test_ram[0] = 0x34; + test_ram[2097151] = 0xab; + source_ram_page_probe((unsigned)strtoul(argv[1], NULL, 10), UINT64_C(40500000000)); + return 0; +} diff --git a/runtime/tests/test_source_ram_page_probe.py b/runtime/tests/test_source_ram_page_probe.py new file mode 100644 index 00000000..42b9d156 --- /dev/null +++ b/runtime/tests/test_source_ram_page_probe.py @@ -0,0 +1,60 @@ +"""Long-route coverage and fail-closed bounds using the actual RAM observer.""" +import argparse +import os +from pathlib import Path +import subprocess +import tempfile + +def fnv(data): + h = 14695981039346656037 + for byte in data: + h = ((h ^ byte) * 1099511628211) & 0xffffffffffffffff + return f'{h:016X}' + +def main(): + p = argparse.ArgumentParser() + p.add_argument('--cc', default='gcc') + a = p.parse_args() + here = Path(__file__).resolve().parent + ram = bytearray(2097152) + ram[0], ram[-1] = 0x34, 0xab + zero = fnv(bytes(4096)) + expected = [fnv(ram[:4096])] + [zero]*510 + [fnv(ram[-4096:])] + cases = [('legacy',None,20000,None,0), + ('legacy_limit',None,20001,None,4), + ('pepsiman','71806',71806,'71806',0), + ('re1','227202',227202,'227202',0), + ('bound','71806',71807,None,4), + ('snapshot_bound','71806',1,'71807',4), + ('duplicate','71806',1,'71806,71806',4), + ('zero','0',1,None,4), ('negative','-1',1,None,4), + ('junk','71806x',1,None,4), ('overflow','999999999999999999999',1,None,4), + ('space',' 71806',1,None,4), ('over_cap','1000001',1,None,4)] + with tempfile.TemporaryDirectory() as temporary: + root=Path(temporary) + for opt in ['-O0','-O2']: + exe=root/('probe'+opt+('.exe' if os.name=='nt' else '')) + subprocess.run([a.cc,'-std=c11',opt,'-Wall','-Wextra','-Werror', + '-I'+str(here.parent/'include'),str(here/'test_source_ram_page_probe.c'), + '-o',str(exe)],check=True) + for name,limit,frame,snapshot,code in cases: + out=root/(name+opt) + out.mkdir() + env={k:v for k,v in os.environ.items() if not k.startswith('PSX_')} + env.update(PSX_SOURCE_RAM_PAGE_PROBE='1',PSX_INPUT_ROUTE_CAPTURE_DIR=str(out)) + if limit is not None: env['PSX_SOURCE_RAM_MAX_FRAMES']=limit + if snapshot: env['PSX_SOURCE_RAM_SNAPSHOT_FRAMES']=snapshot + result=subprocess.run([str(exe),str(frame)],env=env,capture_output=True,timeout=20) + assert result.returncode==code,(opt,name,result.returncode,code,result.stderr) + if code==0: + rows=(out/'ram-pages.tsv').read_text().splitlines() + assert len(rows)==3,(name,len(rows)) + row=rows[2].split('\t') + assert row[:2]==[str(frame),'40500000000'] and row[2:]==expected,name + if snapshot: + assert (out/f'ram-frame-{frame:06d}.bin').read_bytes()==ram,name + else: + assert not (out/'ram-pages.tsv').exists(),name + print(f'PASS: {len(cases)*2} production RAM observer cases; all 512 pages, clocks and late raw snapshots') + +if __name__=='__main__': main() diff --git a/tools/tasreplays/run_native.py b/tools/tasreplays/run_native.py index bc29bf71..a5c1a099 100644 --- a/tools/tasreplays/run_native.py +++ b/tools/tasreplays/run_native.py @@ -149,8 +149,8 @@ def main(): raise ValueError('RAM page probe requires source raster frontend boundaries') if args.ram_snapshot_frame and (not args.ram_page_probe or len(args.ram_snapshot_frame)>32 or len(set(args.ram_snapshot_frame))!=len(args.ram_snapshot_frame) or - any(f<1 or f>20000 for f in args.ram_snapshot_frame)): - raise ValueError('RAM snapshots require page probe and1..32 unique frames in1..20000') + any(f<1 for f in args.ram_snapshot_frame)): + raise ValueError('RAM snapshots require page probe and 1..32 unique positive frames') if args.timer2_model!="default" and args.timer1_model!="octoshock-2.2.2": raise ValueError("timer2 model requires source timer1 model") if args.timer1_model!="default" and args.field_model!="octoshock-2.2.2-ntsc-raster": @@ -187,6 +187,13 @@ def main(): if clock_tape: paths['cd_source_clock_tape'] = Path(clock_tape['path']) identity = route_identity(paths["route"]) + # The completion hook exits before the final return observer. Bind every + # enabled RAM capture to the exact declared number of observed returns. + ram_returns = identity['frames'] + args.neutral_tail - 1 + if args.ram_page_probe and not 1 <= ram_returns <= 1000000: + raise ValueError('RAM capture requires 1..1000000 declared completed returns') + if any(frame > ram_returns for frame in args.ram_snapshot_frame): + raise ValueError('RAM snapshot exceeds the declared completed-return boundary') update_profile = None update_contexts = None context_values = None @@ -328,6 +335,7 @@ p2_mode = "digital" selected_env['PSX_SOURCE_CPU_RETURN_PROBE']='1' if args.ram_page_probe: selected_env['PSX_SOURCE_RAM_PAGE_PROBE']='1' + selected_env['PSX_SOURCE_RAM_MAX_FRAMES']=str(ram_returns) if args.ram_snapshot_frame: selected_env['PSX_SOURCE_RAM_SNAPSHOT_FRAMES']=','.join(map(str,args.ram_snapshot_frame)) if args.update_predictor != "gate":