Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/internal/FAITHFUL_TIMING_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions runtime/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions tools/tasreplays/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ 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.
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
with `setup --movie "D:/TAS/spikestuffv3-tekken3-ps1.bk2"`; it must have the
Expand Down
8 changes: 8 additions & 0 deletions tools/tasreplays/run_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion tools/tasreplays/tekken3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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)
Expand All @@ -288,7 +291,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
Expand All @@ -309,6 +312,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()
Expand All @@ -320,6 +325,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)
Expand Down
12 changes: 12 additions & 0 deletions tools/tasreplays/test_tekken3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading