diff --git a/docs/building.md b/docs/building.md index c78ab1b4..a3952b4d 100644 --- a/docs/building.md +++ b/docs/building.md @@ -672,6 +672,7 @@ change passes these, all run with `.venv`: | `tests\diff\func.py` | whole functions as the generator emits them, loops, jump tables and calls included (calls go to a stub that checks the registers at the call) | yes | | `tests\flags_check.py` | the function-local flag helpers of `runtime\cpu.h` against its packed ones | no | | `tests\ds3_check.py` | the DualShock 3 report decoding and output report of `runtime\input_ds3.c` | no | +| `tests\movie_timeline_check.py --cc PATH` | both 1.1.1 movie timestamp patches against Unicorn and lifted C, with repeated/skipped pictures, rounded timestamps and preserved registers/flags | no | | `tests\gpa_check.py` | the device address table of `runtime\vk_gpa.c` as a shader reads it: mapping, mirrors and twins, overlapping imports, and blocks given back and taken again while pool addresses climb (no GPU; any Python) | no | | `tests\excl_mt.py` | the exclusive monitor helpers under contention and ABA, on several threads | no | | `tests\jumptables_check.py` | `recomp/jumptables.py` against a Unicorn oracle; must report 0 disagreements, on 1.4.3 too | yes | diff --git a/recomp/patches-v111.txt b/recomp/patches-v111.txt index 3b2423e9..8414acdf 100644 --- a/recomp/patches-v111.txt +++ b/recomp/patches-v111.txt @@ -78,6 +78,17 @@ site osolver main 38afd10 bd440d02 1e2a1002 # the same three words in the seco site osolver main 38afd1c 1f010800 1f218800 site osolver main 38afd20 1e380001 1e280001 +# Movie-synchronised event timelines: the movie layer advances its counter at +760 on each draw of a ready +# picture. main+0x3b3ec10 reads that counter for the event dispatcher, so rendering a picture twice also +# advances voices and overlays twice. Both ready paths reach these sites only after main+0x3b5ce80 checks +# the player and its video surface. Use the timestamp of the surface's current picture: main+0x3b61ea0 reads +# it from the selected non-EOS VideoFrame when switching the image buffers, before marking the surface ready. +# x8 = layer->controller->player->surface->timestamp in ns. Clamp negative timestamps to zero and convert to +# the nearest 60 Hz timeline tick (WebM timestamps can be rounded to milliseconds). The original add of one +# counts the first picture as frame one. x9 is scratch on both paths, and the replacement preserves flags. +site ostep main 3b5c7c4 b942fa88 f9400a88 f9400108 f940f508 f9406108 8aa8fd08 d2800789 9b097d08 528ca009 72a3b9a9 8b090108 d37ff929 9ac90908 +site ostep main 3b5c908 b942fa88 f9400a88 f9400108 f940f508 f9406108 8aa8fd08 d2800789 9b097d08 528ca009 72a3b9a9 8b090108 d37ff929 9ac90908 + # Pick-up window count: the window that shows a pick-up above the character (its update is main+0xa67e50) moves the # number it shows (+0x360) to the new total (+0x35c) by trunc(frames per update * max(1, difference / 50)) a frame, and # closes only once the two are equal. Below one frame per update that is 0: above 60 fps, and at 60 while the step runs diff --git a/runtime/frame_rate.c b/runtime/frame_rate.c index 6bfcffd0..bdd3a205 100644 --- a/runtime/frame_rate.c +++ b/runtime/frame_rate.c @@ -193,13 +193,11 @@ static void frame_limit_live(void) { else rt_log("frame rate: limit at the monitor's refresh rate (60 until the window measures it)"); } -// NX2NAVI_MOVIE_FPS (default 60; 0 = off): the game's frame rate while a movie plays (ov_movie2.c ov_movie_live). A -// movie's event counts the game's updates as 60 Hz frames whatever the step: at 144 fps the intro's narration lines -// started 2.4 times too early, each over the one before, and its closing fade to black came at 65 s of the movie's -// 154, hiding the whole dream. The movies are 60 fps, so the cap shows the same pictures. It is the rate in both -// directions: under a lower limit (40 fps, or 60 shown with frame generation, which is 30 of the game's) the movie ran -// at that fraction of its speed and ended many seconds behind its sound, so the game is paced at the movie's rate -// while one plays, above the limit too. +// NX2NAVI_MOVIE_FPS (default 60; 0 = off): the game's frame rate while a movie plays (ov_movie2.c ov_movie_live). +// Movie-synchronised events use the selected picture's timestamp with the 1.1.1 ostep patches enabled; redrawing +// a picture no longer advances that timeline. Keep the 60 Hz pacing policy for the 60 fps movies and for builds +// without those patches. It applies in both directions: a lower gameplay limit must not slow the movie's updates +// relative to its audio. A player can turn this pacing override off independently of the timestamp patches. static int movie_cap_hz(void) { static int hz = -1; if (hz < 0) { diff --git a/tests/movie_timeline_check.py b/tests/movie_timeline_check.py new file mode 100644 index 00000000..5686f965 --- /dev/null +++ b/tests/movie_timeline_check.py @@ -0,0 +1,117 @@ +"""Compare both movie timestamp patches with Unicorn and their lifted C. + +Requires Unicorn and a C compiler (--cc or CC). No game files or GPU are used. +Generated harness files stay in build/test/movie_timeline. +""" +import argparse +import ctypes +import os +from pathlib import Path +import random +import struct +import subprocess +import sys + +import unicorn as uc +from unicorn import arm64_const as arm + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / 'recomp')] +from lifter import Lifter +from patches import load +from tools.check_content import GENERATED_MARKER + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cc', default=os.environ.get('CC', 'clang')) + args = parser.parse_args() + table = load(ROOT / 'recomp/patches-v111.txt', [('main', 0x7000000000, 0x4ea0000)]) + sites = [0x3b5c7c4, 0x3b5c908] + words = [] + for site in sites: + alternatives = table.sites['main'][site] + assert len(alternatives) == 1 + key, replacement, original = alternatives[0] + assert table.sets[key] == ('ostep', 'NX2NAVI_OCARINA_REALTIME', 1) + assert original == 0xb942fa88 + words.append(replacement) + assert words[0] == words[1] + + work = ROOT / 'build/test/movie_timeline' + work.mkdir(parents=True, exist_ok=True) + code = '// ' + GENERATED_MARKER + '\n#include "cpu.h"\n' + export = '__declspec(dllexport)' if os.name == 'nt' else '' + for i, sequence in enumerate(words): + code += f'{export} void movie_tick_{i}(Cpu *c) {{\n' + for j, word in enumerate(sequence): + instruction = Lifter(0, simd='c').lift(word, sites[i] + j * 4) + assert instruction.kind == 'op' + code += instruction.code + '\n' + code += '}\n' + source = work / 'harness.c' + library = work / ('harness.dll' if os.name == 'nt' else 'harness.so') + source.write_text(code) + subprocess.run([args.cc, '-shared', '-O2', '-fno-strict-aliasing', '-I', str(ROOT / 'runtime'), + *([] if os.name == 'nt' else ['-fPIC']), str(source), '-o', str(library)], check=True) + dll = ctypes.CDLL(str(library)) + + # One synthetic layer -> controller -> player -> video surface chain for both engines. + arena = ctypes.create_string_buffer(0x4000) + address = ctypes.addressof(arena) + image = bytearray(0x4000) + for at, target in [(0x10, address + 0x1000), (0x1000, address + 0x2000), + (0x21e8, address + 0x3000)]: + struct.pack_into('