From 29b3bce695a33d52b30178c71d9253d7f1e55f22 Mon Sep 17 00:00:00 2001 From: Pegasus Date: Wed, 9 Sep 2026 11:18:55 +0300 Subject: [PATCH 1/2] Expose host fast-forward speeds for verified TAS replay --- docs/internal/FAITHFUL_TIMING_PLAN.md | 8 ++++++++ runtime/src/main.cpp | 13 ++++++++++--- tools/tasreplays/README.md | 12 ++++++++++++ tools/tasreplays/run_native.py | 8 ++++++++ tools/tasreplays/tekken3.py | 6 +++++- 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index 48e53cec0..ecb4b5cb1 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -213,6 +213,14 @@ on a fixed region -> next. ## 5. Status / Log (update every session) +- **2026-09-09 (TAS host-speed validation):** The published Tekken 3 cold-boot + route reproduces the 8.80 victory with 7,974 unchanged inputs and 8,399 exact + RAM/clock returns. Added replay `--speed` control using the existing host + fast-forward path, including 32x/64x caps and an uncapped mode. Guest timing, + code generation and controller routing are unchanged. Full visible 4x, 8x, + 32x and uncapped checks use the same fingerprint gate; results belong in the + speed-validation PR. Requested caps and measured throughput are separate. + - **2026-07-28 (per-game host audio cushion — implemented, parser validated):** Added `[audio] buffer_ms` as a runtime-only developer setting with a guarded 30–500 ms range. The compatibility default remains 180 ms, preserving the diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 83ee54eb5..4050de957 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -608,7 +608,7 @@ static int manual_fast_forward_multiplier(void) { value = -1; } else { int parsed = std::atoi(e); - if (parsed >= 2 && parsed <= 16) + if (parsed >= 2 && parsed <= 64) value = parsed; } } @@ -7182,13 +7182,20 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { bool turbo_load_paced = false; /* Manual fast-forward: bounded by default so the game visibly advances and - * audio is less hostile. PSX_FAST_FORWARD_SPEED=2..16 changes the cap; + * audio is less hostile. PSX_FAST_FORWARD_SPEED=2..64 changes the cap; * PSX_FAST_FORWARD_SPEED=max restores the old unbounded simulation rate. */ { const Uint8* keys = SDL_GetKeyboardState(NULL); static int turbo_skip = 0; static int turbo_was_down = 0; - if (host_keymap_down(HOST_KEYMAP_TURBO, keys, (int)SDL_GetModState())) { + /* Automated replay uses the same host pacing/presentation path as + * holding the fast-forward key. Guest clocks and input are unchanged. */ + static const bool startup_fast_forward = [] { + const char *e = std::getenv("PSX_FAST_FORWARD"); + return e && std::strcmp(e, "1") == 0; + }(); + if (startup_fast_forward || + host_keymap_down(HOST_KEYMAP_TURBO, keys, (int)SDL_GetModState())) { const int mult = manual_fast_forward_multiplier(); const int present_every = (mult < 0) ? 4 : (mult <= 4 ? 2 : 4); manual_turbo_active = true; diff --git a/tools/tasreplays/README.md b/tools/tasreplays/README.md index b6fc35bee..41d1024b9 100644 --- a/tools/tasreplays/README.md +++ b/tools/tasreplays/README.md @@ -44,6 +44,18 @@ For unattended verification: python tools/tasreplays/tekken3.py run --headless ``` +To fast-forward a visible replay, use `run --speed 4`, `--speed 8`, +`--speed 32`, or `--speed max`. Supported caps are 1, 2, 4, 8, 16, 32 and 64; +`max` removes the wall-clock cap. These use the same host pacing and +presentation path as holding the fast-forward key. Every original input and +RAM/clock checkpoint is still verified. The cap is a ceiling: CPU, rendering +and capture costs can keep the achieved rate below it. Headless playback is +already uncapped, so combining `--headless` with a speed override is rejected. + +The standalone runtime accepts `PSX_FAST_FORWARD=1` to hold fast-forward from +startup and `PSX_FAST_FORWARD_SPEED=2..64|max` to choose its cap. The replay +command sets these explicitly and records them in each run's manifest. + Use `--timeout 3600` on a slower machine, or `setup --jobs 4` to reduce peak compiler memory. An original `.bk2` or its TASVideos download ZIP can be supplied with `setup --movie "D:/TAS/spikestuffv3-tekken3-ps1.bk2"`; it must have the diff --git a/tools/tasreplays/run_native.py b/tools/tasreplays/run_native.py index 4eb5088fb..bc29bf71f 100644 --- a/tools/tasreplays/run_native.py +++ b/tools/tasreplays/run_native.py @@ -68,6 +68,8 @@ def main(): parser.add_argument("--" + name, required=True, type=Path) parser.add_argument("--timeout", type=float, default=900) parser.add_argument("--show", action="store_true") + parser.add_argument("--speed", choices=("1", "2", "4", "8", "16", "32", "64", "max"), default="1", + help="visible replay fast-forward cap; headless runs are always uncapped") parser.add_argument("--renderer", choices=("software", "opengl"), default="software", help="rendering backend; window visibility is independent") parser.add_argument("--fast-boot", action="store_true") @@ -141,6 +143,8 @@ def main(): parser.add_argument("--precise-slice", choices=("off", "on"), default="off", help="Experimental existing instruction-boundary IRQ slicer; RAM BIOS guard requires the qualified candidate emitter") args = parser.parse_args() + if args.speed != "1" and not args.show: + parser.error('--speed requires --show; headless playback is already uncapped') if args.ram_page_probe and args.field_model!='octoshock-2.2.2-ntsc-raster': 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 @@ -274,6 +278,9 @@ def main(): "PSX_INPUT_ROUTE_TRACE": "1" if args.sio_trace else "0", "PSX_INPUT_ROUTE_CAPTURE_EVERY": str(args.checkpoint_every), "PSX_BIOS_HLE": "1" if args.hle else "0"} + if args.speed != "1": + selected_env["PSX_FAST_FORWARD"] = "1" + selected_env["PSX_FAST_FORWARD_SPEED"] = args.speed if refresh_guard is not None: selected_env["PSX_INPUT_UPDATE_REFRESH_GUARD"]=','.join(map(str,refresh_guard)) if args.cd_read_start_model != "default": @@ -351,6 +358,7 @@ def main(): env.update(selected_env) write_json(run / "manifest.json", { "schema": "psx-native-tas-run-v1", "command": command, + "requested_speed": args.speed if args.show else "headless-uncapped", "update_profile":update_profile, "update_contexts":update_contexts, "update_profile_sha256":digest(args.update_profile) if update_profile else None, diff --git a/tools/tasreplays/tekken3.py b/tools/tasreplays/tekken3.py index 4a646a873..a9fdefb91 100644 --- a/tools/tasreplays/tekken3.py +++ b/tools/tasreplays/tekken3.py @@ -288,7 +288,7 @@ def run(args) -> None: '--exe', info['executable'], '--game', info['game'], '--disc', info['disc'], '--bios', info['bios'], '--route', info['route'], '--cd-source-clock-tape', info['tape'], '--neutral-tail', '426', '--timeout', str(args.timeout), '--checkpoint-every', '300', - '--renderer', 'software', *PROFILE] + '--renderer', 'software', '--speed', args.speed, *PROFILE] if not args.headless: argv.append('--show') # The runtime writes settings beside its executable; the runner creates an @@ -309,6 +309,8 @@ def main(): prepare.add_argument('--jobs', type=int, default=min(12, os.cpu_count() or 1)) play = sub.add_parser('run', help='play through the victory and compare every RAM/clock checkpoint') play.add_argument('--headless', action='store_true') + play.add_argument('--speed', choices=('1', '2', '4', '8', '16', '32', '64', 'max'), default='1', + help='visible replay speed cap; actual speed depends on the host') play.add_argument('--timeout', type=int, default=1800, help='host seconds; increase for a slower machine') play.add_argument('--output', type=Path, help='new run directory; defaults to a unique build subdirectory') args = parser.parse_args() @@ -320,6 +322,8 @@ def main(): else: if args.timeout < 1: raise ValueError('--timeout must be positive') + if args.headless and args.speed != '1': + raise ValueError('--speed requires visible playback; --headless is already uncapped') run(args) except (ValueError, RuntimeError, OSError, KeyError) as error: print(f'ERROR: {error}', file=sys.stderr) From 9bad25a016e8e588c3a7bec18fecd0be9f1a49a7 Mon Sep 17 00:00:00 2001 From: Pegasus Date: Wed, 9 Sep 2026 11:39:10 +0300 Subject: [PATCH 2/2] Reject speed overrides until an older player is rebuilt --- tools/tasreplays/README.md | 3 +++ tools/tasreplays/tekken3.py | 3 +++ tools/tasreplays/test_tekken3.py | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/tools/tasreplays/README.md b/tools/tasreplays/README.md index 41d1024b9..5bdd3f1fb 100644 --- a/tools/tasreplays/README.md +++ b/tools/tasreplays/README.md @@ -55,6 +55,9 @@ already uncapped, so combining `--headless` with a speed override is rejected. The standalone runtime accepts `PSX_FAST_FORWARD=1` to hold fast-forward from startup and `PSX_FAST_FORWARD_SPEED=2..64|max` to choose its cap. The replay command sets these explicitly and records them in each run's manifest. +After updating an older checkout, rerun `setup` before selecting a speed. +The runner rejects older setup receipts so a player cannot silently ignore +the requested fast-forward control. Use `--timeout 3600` on a slower machine, or `setup --jobs 4` to reduce peak compiler memory. An original `.bk2` or its TASVideos download ZIP can be supplied diff --git a/tools/tasreplays/tekken3.py b/tools/tasreplays/tekken3.py index a9fdefb91..2f8ca1fdd 100644 --- a/tools/tasreplays/tekken3.py +++ b/tools/tasreplays/tekken3.py @@ -238,6 +238,7 @@ def q(path): '-DCMAKE_DISABLE_FIND_PACKAGE_ZLIB=TRUE'], PROJECT / 'configure-native.log') command(['cmake', '--build', NATIVE, '--parallel', args.jobs], PROJECT / 'build-native.log') build_info = {'schema': 'psx-tas-setup-v1', 'disc': str(cue), 'bios': str(media / 'SCPH1001.BIN'), + 'replay_speed_control': 1, 'game': str(game), 'route': str(route), 'tape': str(tape), 'executable': str(NATIVE / 'Tekken3-TAS.exe'), 'executable_sha256': digest(NATIVE / 'Tekken3-TAS.exe'), @@ -277,6 +278,8 @@ def run(args) -> None: if not setup_path.exists(): raise ValueError('Run the setup command with your disc and SCPH1001 BIOS first.') info = json.loads(setup_path.read_text()) + if args.speed != '1' and info.get('replay_speed_control') != 1: + raise ValueError('Rerun setup to build a player with replay speed control before using --speed.') require_hash(Path(info['executable']), info['executable_sha256']) require_hash(Path(info['bios']), BIOS_SHA) require_hash(Path(info['tape']), TAPE_SHA) diff --git a/tools/tasreplays/test_tekken3.py b/tools/tasreplays/test_tekken3.py index 9e77f8427..3dbd51905 100644 --- a/tools/tasreplays/test_tekken3.py +++ b/tools/tasreplays/test_tekken3.py @@ -3,12 +3,24 @@ import hashlib from pathlib import Path import tempfile +from types import SimpleNamespace import unittest from unittest.mock import patch import tekken3 class RecipeTests(unittest.TestCase): + def test_old_setup_cannot_silently_ignore_speed_override(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + # An older receipt has no capability marker. Reject before asset + # access or launching a player that would ignore the new switch. + (root / 'setup.json').write_text('{}', encoding='utf-8') + with patch.object(tekken3, 'PROJECT', root): + for speed in ('4', '8', '32', 'max'): + with self.subTest(speed=speed), self.assertRaisesRegex(ValueError, 'Rerun setup'): + tekken3.run(SimpleNamespace(speed=speed)) + def test_renamed_tracks_keep_qualified_layout(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary)