fix(mpeg): pace sceMpegGetPicture to the stream's frame rate#175
Draft
smmathews wants to merge 1 commit into
Draft
fix(mpeg): pace sceMpegGetPicture to the stream's frame rate#175smmathews wants to merge 1 commit into
smmathews wants to merge 1 commit into
Conversation
With the FFmpeg-backed MpegFfmpegDecoder, sceMpegGetPicture pops and returns the next decoded frame as fast as the guest asks for it. Decode is driven synchronously by the guest's demux/AddBs calls and host file I/O is instant, so a guest that feeds the elementary stream in a burst decodes far ahead of real time: the standard vsync-gated PS2 FMV pump (one GetPicture ~ one field of movie time) instead gets frames handed back near-instantly, so the movie runs at 2x+ or drains to a premature end-of-stream before its own present loop has shown more than the first frames. On hardware the rate is bounded by the disc-fed stream buffer; that throttle has no analogue here unless delivery is metered at the library boundary. Meter delivery at the sceMpegGetPicture seam without throttling the decoder (which stays as fast as possible, good for prefetch): the first time a handle serves a real decoded frame, capture a base vsync tick; latch the stream rate from AVCodecContext::framerate on the first valid, in-range read (re-reading on later frames until it is known, falling back to 30000/1001 meanwhile, and rejecting rates outside 10..120 fps as garbage (a plausibility gate, not a correctness one: an interlaced 59.94 field rate would pass, but PS2 FMVs are overwhelmingly 29.97/25)). Frame N is then due at base + floor(N * 60 * fpsDen / fpsNum), computed fresh from N every call so integer rounding never drifts across a long movie. While the current tick is before the due tick, park on the existing WaitForNextVSyncTick primitive. The meter is ahead-only and bounded: the park is a no-op unless the guest is running ahead of the movie's own timeline, so a normal or slow consumer is never throttled; and a single park is hard-capped at a few vblanks, so a pathological rate can never stall one GetPicture call. 60 is the emulator's fixed vsync-tick-worker rate (kVblankPeriod), not a display-refresh number, so a park's real duration is N * fpsDen / fpsNum seconds regardless of NTSC/PAL display mode -- PAL content is paced correctly by its own frame rate. kVblankPeriod is exported from Interrupt.h and a static_assert in MPEG.cpp pins this rate to it, so retuning the vsync worker can never silently miscalibrate pacing. The park holds no lock: the pacing slot is reserved under g_mpeg_stub_mutex and the mutex is released before the wait, so a multi-vblank park never blocks stream-feed callbacks or another handle's GetPicture. The park loop also re-checks runtime->isStopRequested() each iteration and breaks out, so it cannot spin during shutdown once the tick counter stops advancing. Pacing is per-handle and inert for any path that never serves a real decoded frame -- the duplicate-last-frame and blank/no-frame paths and any title that does not use the sceMpeg FMV path see no behavioral change. Pacing math is split into mpegFrameDueTick()/mpegFrameRateIsSane() and the park into mpegParkUntilDueTick() (internal header, not the guest syscall surface), pinned by a new regression suite: the due-tick sequence equals the exact drift-free rational; the rate clamp accepts real rates and rejects garbage; the park blocks the expected vblanks, releases promptly on stop, and is hard-capped against a pathological due tick; concurrent stub calls make progress during a park (the park holds no global mutex); and a real embedded MPEG-2 stream fed through sceMpegAddBs and drained via sceMpegGetPicture is delivered at the stream's encoded rate, with the duplicate-last-frame and no-frame paths both left unpaced. Full build clean; ps2x_tests 340/340.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
With the FFmpeg-backed
MpegFfmpegDecoder,sceMpegGetPicturepops and returns the nextdecoded frame every time the guest calls it. Decode is driven synchronously by the guest's
sceMpegDemuxPss/sceMpegAddBscalls, and host file I/O is instant, so a guest that feeds theelementary stream in a burst decodes far ahead of real time. The standard PS2 FMV pump — one
sceMpegGetPicturecall per vertical blank, a vsync-gated read/decode/present loop — then getsframes handed back near-instantly: the movie runs at ~2x or the elementary stream drains across
a handful of calls, reaching end-of-stream before the present loop has shown more than the first
frames.
Root cause
sceMpegGetPicturetracks frame order but has no notion of the movie's timeline. On realhardware the delivery rate is bounded by the disc/stream read feeding the demux ring buffer; the
IPU decodes faster than real time and the disc-fed buffer is the throttle. A host FFmpeg decoder
fed by instant I/O has no such throttle, so playback outruns the encoded rate.
Fix
Meter delivery at the
sceMpegGetPictureseam, without throttling the decoder (it stays as fastas possible — good for prefetch):
and latch the stream rate from
AVCodecContext::framerate(via a newMpegFfmpegDecoder::getFrameRate()) on the first valid, in-range read — re-reading onlater frames until it is known, falling back to
30000/1001meanwhile, and rejecting ratesoutside 10–120 fps as garbage. (That clamp is a plausibility gate, not a correctness one: an
interlaced field rate like 59.94 would pass and double-pace, but PS2 FMVs are overwhelmingly
29.97/25 fps.)
base + floor(N * 60 * fpsDen / fpsNum), computed fresh from N every call(not accumulated), so integer rounding never drifts across a long movie.
GetCurrentVSyncTick()is before the due tick, park on the existingWaitForNextVSyncTick(rdram, runtime)primitive (the same onesceGsSyncVuses).The meter is ahead-only and bounded.
due(N)is measured from a fixed base, so the park isa no-op whenever the guest calls at or below the encoded rate — a normal or slow consumer is
never throttled; only a consumer running ahead of the movie's own timeline is metered. A single
park is hard-capped at a few vblanks, so even a bogus stream rate can never stall one
GetPicturecall.The
60here is the emulator's fixed vsync-tick-worker rate (kVblankPeriod), not adisplay-refresh figure, so a park's real duration is
N * fpsDen / fpsNumseconds regardless ofthe guest's NTSC/PAL display mode — PAL content (e.g. 25fps) is paced correctly by its own frame
rate.
kVblankPeriodis exported fromInterrupt.hand astatic_assertinMPEG.cppfails thebuild if this rate ever drifts from it, so retuning the worker cannot silently miscalibrate pacing.
A second
static_assertcouples the single-park hard cap to the sanity floor —kMpegPacingMaxParkVsyncTicks * kMpegMinSaneFps >= kMpegPacingVsyncHz(8 × 10 ≥ 60) — so the capcan never be lowered below the widest per-frame spacing the slowest in-range stream
(
kMpegMinSaneFps= 10 fps) can demand, which would otherwise clip a legitimately slow stream'spark short.
The park holds no lock: the slot is reserved under
g_mpeg_stub_mutexand the mutex isreleased before the wait, so a multi-vblank park never blocks stream-feed callbacks or another
handle's
GetPicture. The loop re-checksruntime->isStopRequested()each iteration and breaksout, so it cannot spin (or wedge teardown) during shutdown once the tick counter stops advancing.
Scope
Pacing models a property of the sceMpeg boundary itself (host decode speed vs. the delivery rate
real hardware bounds by disc reads), not any one title's content. It is per-handle and only
arms when a real decoded frame is served — the duplicate-last-frame and blank/no-frame paths, and
any title that never uses the sceMpeg FMV path, are unaffected.
The cadence is a single constant frame rate latched once per handle, so a stream whose per-frame
duration is not constant — variable-frame-rate content, or 2:3 pulldown where the field cadence
alternates — is paced to its nominal rate rather than frame-exact (the 59.94-field case above is
one instance of this). PS2 FMVs are almost universally constant-rate 29.97/25, so this is a
theoretical rather than practical limitation.
The base vsync tick is captured once, on the first served frame, and never re-anchored: if a
consumer falls behind the movie's timeline mid-FMV (a long stall, say), every remaining due tick
is already in the past and pacing no-ops for the rest of that handle — delivery then runs at full
decode speed to catch up. That is the intended ahead-only, wall-clock-honest behavior (the movie
runs late rather than resyncing), not a defect. The concrete trigger is a mid-FMV pause or seek
that outlasts the buffered content: because the base tick never moves, resume finds every due tick
already past and hands the backlog back in a burst to catch up, where disc-throttled hardware would
instead re-feed at the encoded rate. The practical impact is narrow — the PS2 intro FMVs this
targets are overwhelmingly unpausable and unskippable, so that timeline break rarely arises.
Audio is delivered on its own, separate real-time clock; by advancing video at the stream's
encoded rate instead of as fast as the guest drains frames, this keeps video from outrunning that
independent audio timeline — an unpaced
sceMpegGetPicturehands frames back near-instantly andraces ahead of it. This is delivery pacing, not an A/V resynchronizer: it removes the runaway, it
does not correct drift between the two paths.
Files touched
ps2xRuntime/src/lib/Kernel/Stubs/MPEG.cpp—MpegFfmpegDecoder::getFrameRate(), per-handlepacing state on
MpegPlaybackState,mpegFrameDueTick()/mpegFrameRateIsSane()/mpegParkUntilDueTick(), and the reservation + park insceMpegGetPicture.ps2xRuntime/src/lib/Kernel/Stubs/MPEG_internal.h— internal declarations for the pacinghelpers (kept off the guest syscall surface in
MPEG.h) and a test-seam frame-rate accessor.ps2xRuntime/src/lib/Kernel/Syscalls/Interrupt.h(+Interrupt.cpp) — moves the authoritativekVblankPeriodfrom the.cppinto the (already-declared)interrupt_statenamespace in theheader, so pacing can
static_assertits 60Hz tick rate against a single source of truth. Nofunctional change to the interrupt worker.
ps2xTest/src/ps2_mpeg_pacing_tests.cpp(+mpeg_pacing_fixture_m2v.h,main.cpp,CMakeLists.txt) — regression suite and its embedded MPEG-2 test vector.Testing
New
PS2MpegPacingsuite:frames, div-by-zero-safe fallback).
against a pathological due tick.
g_mpeg_stub_mutexmake progress while a park runs (the parkholds no global mutex).
sceMpegAddBsand drained viasceMpegGetPictureasserts two things. First, the handle latches the fixture's true 25fpsdirectly from the decoder (not the
30000/1001fallback) — a jitter-free pin on thegetFrameRatepath, since the 25fps and fallback delivery-span floors (12 vs 10 vsync ticks)differ by less than vsync-worker jitter and span alone cannot separate them. Second, delivery is
metered to the encoded rate (the inter-frame vsync-tick span meets the encoded rate's drift-free
floor), which catches a wholesale unpaced regression. The duplicate-last-frame path (re-served
after the bounded no-frame wait) and the no-frame path are both verified to add no pacing park.
This adds the repository's first test fixture generated from a real encoded binary,
mpeg_pacing_fixture_m2v.h: a ~2.4 KBfully synthetic MPEG-2 elementary stream (16×16, 25 fps (PAL), all-intra
ffmpeg/lavfi testsrcoutput — no third-party content), embedded so the
getFrameRatelatch is exercised through thereal decode path with no external fixture files or network. The exact regeneration command is in
the header.
Full runtime build clean; full
ps2x_testssuite green.Known follow-up (not in this PR)
for very long FMVs).