Drive movie event timelines from the selected video picture timestamp

This commit is contained in:
automata.
2026-11-11 14:00:56 -05:00
committed by chrg6
parent 2571e180c8
commit feeb504bf4
4 changed files with 134 additions and 7 deletions

View File

@@ -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 |

View File

@@ -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

View File

@@ -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) {

View File

@@ -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('<Q', image, at, target)
emulator = uc.Uc(uc.UC_ARCH_ARM64, uc.UC_MODE_ARM)
start = address & ~0xfff
emulator.mem_map(start, ((address + len(image) + 0xfff) & ~0xfff) - start)
pc = 0x100000
emulator.mem_map(pc, 0x1000)
regs = [getattr(arm, 'UC_ARM64_REG_X' + str(i)) for i in range(31)]
rng = random.Random(711)
samples = [-2**63, -1, 0, 1, 8_333_333, 8_333_334, 16_000_000, 17_000_000,
33_000_000, 154_000_000_000, 3_600_000_000_000]
samples += [rng.randrange(12 * 3600 * 10**9) for _ in range(256)]
# The selected 60 Hz video's millisecond-rounded PTS at different redraw rates.
# Pauses/repeated pictures and skipped pictures must follow PTS, never redraw count.
for rate in [30, 60, 120, 144, 240]:
samples += [((render * 60 // rate) * 1000 + 30) // 60 * 1_000_000 for render in range(rate * 2)]
samples += [1_000_000_000] * 100 + [0, 17_000_000] # hold, then a new movie
for i, sequence in enumerate(words):
native = getattr(dll, f'movie_tick_{i}')
native.argtypes = [ctypes.c_void_p]
native.restype = None
machine = struct.pack('<' + 'I' * len(sequence), *sequence)
emulator.mem_write(pc, machine)
for timestamp in samples:
struct.pack_into('<q', image, 0x30c0, timestamp)
ctypes.memmove(address, bytes(image), len(image))
emulator.mem_write(address, bytes(image))
before = [rng.getrandbits(64) for _ in regs]
before[20] = address
flags = rng.randrange(16) << 28
cpu = ctypes.create_string_buffer(1024 + 16)
state = (ctypes.addressof(cpu) + 15) & ~15
ctypes.memmove(state, struct.pack('<31Q', *before), 31 * 8)
ctypes.memmove(state + 272, struct.pack('<Q', flags), 8)
for reg, value in zip(regs, before):
emulator.reg_write(reg, value)
emulator.reg_write(arm.UC_ARM64_REG_NZCV, flags)
emulator.emu_start(pc, pc + len(machine))
native(state)
actual = struct.unpack('<31Q', ctypes.string_at(state, 31 * 8))
oracle = tuple(emulator.reg_read(reg) for reg in regs)
assert actual == oracle, (hex(sites[i]), timestamp, 'C/ARM mismatch')
expected = (max(timestamp, 0) * 60 + 500_000_000) // 1_000_000_000
assert actual[8] == expected, (timestamp, actual[8], expected)
assert all(actual[j] == before[j] for j in range(31) if j not in (8, 9))
assert emulator.reg_read(arm.UC_ARM64_REG_NZCV) == flags
assert struct.unpack('<Q', ctypes.string_at(state + 272, 8))[0] == flags
assert bytes(arena) == bytes(image) and bytes(emulator.mem_read(address, len(image))) == bytes(image)
print(f'Movie timeline: {len(samples) * 2} C/Unicorn comparisons passed across both patch sites')
if __name__ == '__main__':
main()