From 700e1e2d82eff74a6e55c5ea14620d15f83943ab Mon Sep 17 00:00:00 2001 From: mchughalex <25671999+mchughalex@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:14:44 +0100 Subject: [PATCH] scene: FMV boundary flash = instant emulated yield (stale framebuffer); async video planes --- src/skate3_native_scene.cpp | 117 +++++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 22 deletions(-) diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index 1d8251b..a84ae83 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -2866,6 +2866,12 @@ void OnPhotoReplayUpdate() { // logos, any full-motion video). Same contract as the photo-replay // heartbeat above. static std::atomic g_movie_decode_last_ns{-1}; +// Last frame the 2D replay actually SUBSTITUTED a movie quad (ps_yuv2d). +// Consulted by YieldForMovie: while the native substitution is serving the +// video, the emulated yield must never engage; the emulated framebuffer +// is STALE under draw suppression, and presenting it flashed the previous +// video's last frame at every video boundary. +static std::atomic g_movie_native_last_ns{-1}; void OnMovieDecode() { g_movie_decode_last_ns.store( @@ -13817,18 +13823,37 @@ bool YieldForMovie() { PerfClock::now().time_since_epoch()) .count(); const bool active = now_ns - last_ns < 500'000'000; + // LAST-RESORT gating: yielding INSTANTLY at video start presented the + // emulated framebuffer, which under draw suppression still holds a stale + // frame: the flash of the PREVIOUS video at every video boundary + // (measured: yield, planes published +16 ms, substitution + // native +38 ms, a ~5-frame stale-frame window). The substitution path + // gets 400 ms from the first heartbeat to engage, and once it has drawn + // recently the yield stays off entirely (covers the video-END race the + // same way). Genuine native-FMV failure (fmv_native off, pso missing, + // plane decode failure) still reaches the emulated yield after the + // grace window. + static int64_t s_fresh_since = -1; + if (!active) { + s_fresh_since = -1; + } else if (s_fresh_since < 0) { + s_fresh_since = now_ns; + } + const int64_t native_ns = g_movie_native_last_ns.load(std::memory_order_relaxed); + const bool yield = active && now_ns - s_fresh_since >= 400'000'000 && + (native_ns < 0 || now_ns - native_ns > 1'000'000'000); static bool s_active = false; - if (active != s_active) { - s_active = active; - if (active) { + if (yield != s_active) { + s_active = yield; + if (yield) { REXLOG_INFO( "native-scene: FMV playing - yielding to emulated output " - "(MovieDecoder heartbeat)"); + "(MovieDecoder heartbeat; substitution did not engage)"); } else { REXLOG_INFO("native-scene: FMV ended - native output resumes"); } } - return active; + return yield; } bool YieldForPhotoEditor(uint8_t* base) { @@ -16285,7 +16310,22 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat if (regen < 2 || !EnsureHudTileRegen(context, base, fetch, regen, gt)) { EnsureGuestTextureFromWords(context, base, fetch, gt); } - g_pw_tex_decode.Add(perf_ns_since(hud_t0)); + const uint64_t decode_ns = perf_ns_since(hud_t0); + g_pw_tex_decode.Add(decode_ns); + // Attribution for residual render-thread stalls: anything still + // decoding inline for >3 ms should either move over the async + // threshold or explain itself here. + if (decode_ns > 3'000'000) { + static std::atomic s_slow{0}; + const uint32_t n = s_slow.fetch_add(1, std::memory_order_relaxed); + if (n < 24 || (n & 255u) == 0) { + REXLOG_INFO( + "native-scene: SLOW inline 2D decode {:.1f}ms {}x{} " + "fetch=[{:08X} {:08X} {:08X}] forced={} (n={})", + double(decode_ns) / 1e6, px_w, px_h, fetch[0], fetch[1], + fetch[2], force_inline ? 1 : 0, n); + } + } if (!gt.valid) { static std::unordered_set logged; if (logged.size() < 32 && logged.insert(key).second) { @@ -16662,8 +16702,17 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat bool movie_drawn = false; for (const Draw2d& d : scene_2d) { const uint32_t* yuv = nullptr; - if (movie_sub && d.composite_srv < 0) { - if (yuv_triple(d)) { + // Video-quad detection runs regardless of movie_sub: a quad whose + // own fetch slots form a YUV triple must NEVER draw through ps_main + // - that renders the raw Y plane (greyscale luma, or the PREVIOUS + // video's frame while the plane copies are still stale), the + // video-boundary flash class. If the planes can't resolve yet + // (async decode in flight, substitution off) the quad is SKIPPED; + // black under a starting video is what the real thing looks like. + bool video_quad = false; + if (d.composite_srv < 0 && yuv_triple(d)) { + video_quad = true; + if (movie_sub) { TripleCacheEntry* e = nullptr; for (int t = 0; t < triple_count && e == nullptr; ++t) { if (triple_cache[t].y_addr == d.fetch[1]) { @@ -16674,18 +16723,24 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat e = &triple_cache[triple_count++]; e->y_addr = d.fetch[1]; e->ok = true; + bool decode_failed = false; for (int p = 0; p < 3 && e->ok; ++p) { const uint32_t* w = d.fetch + p * 6; auto hot = find_words_texture(FetchWordsKey(w)); if (hot != g_r.tex_store.end()) { hot->second.recheck_frame = 0; // content-hot: per-frame probe } - const GuestTexture* t = - resolve_2d_texture(w, /*force_inline=*/true); - e->ok = t != &g_r.white && t->texture != nullptr; - e->slots[p] = t->srv_slot; + // Async-capable resolve: a content change serves the stale + // plane while the worker re-decodes (the per-frame inline + // re-decode of a 720p plane set cost ~3x4 ms on the render + // thread every content change: video judder + boot + // sluggishness); nullptr = first decode still in flight. + const GuestTexture* t = resolve_2d_texture(w); + e->ok = t != nullptr && t != &g_r.white && t->texture != nullptr; + decode_failed |= t == &g_r.white; + e->slots[p] = e->ok ? t->srv_slot : 0; } - if (!e->ok) { + if (decode_failed) { static std::atomic s_triple_failed{0}; if (s_triple_failed.fetch_add(1, std::memory_order_relaxed) < 8) { REXLOG_INFO( @@ -16699,21 +16754,39 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat yuv = e->slots; } } - if (yuv == nullptr && (d.flags & 0x2u) != 0 && d.src_stride == 24) { - yuv = fallback_yuv(); - if (yuv != nullptr) { - static std::atomic s_fb_logged{0}; - if (s_fb_logged.fetch_add(1, std::memory_order_relaxed) < 4) { - REXLOG_INFO( - "native-scene: FMV bracket fallback served a quad (no " - "readable YUV triple on it)"); - } + } + if (yuv == nullptr && movie_sub && d.composite_srv < 0 && + (d.flags & 0x2u) != 0 && d.src_stride == 24) { + // Bracketed movie quad without a readable triple: through ps_main + // it draws its c8 opaque-black cover, also a video quad. + video_quad = true; + yuv = fallback_yuv(); + if (yuv != nullptr) { + static std::atomic s_fb_logged{0}; + if (s_fb_logged.fetch_add(1, std::memory_order_relaxed) < 4) { + REXLOG_INFO( + "native-scene: FMV bracket fallback served a quad (no " + "readable YUV triple on it)"); } } } + if (video_quad && yuv == nullptr) { + static std::atomic s_vskip{0}; + const uint32_t n = s_vskip.fetch_add(1, std::memory_order_relaxed); + if (n < 8 || (n & 511u) == 0) { + REXLOG_INFO( + "native-scene: video quad skipped (planes not ready, " + "y={:08X}, movie_sub={}) (n={})", + d.fetch[1], movie_sub ? 1 : 0, n); + } + continue; + } emit_draw(d, yuv); movie_drawn |= yuv != nullptr; } + if (movie_drawn) { + g_movie_native_last_ns.store(movie_now_ns, std::memory_order_relaxed); + } if (movie_drawn) { static std::atomic s_movie_logged{false}; if (!s_movie_logged.exchange(true, std::memory_order_relaxed)) {