diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c700b9..926fbf3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -505,6 +505,7 @@ if(RETCOMM_HAVE_SDL3 AND RETCOMM_IMGUI_READY) # A core running in the hub's window (Retro-Runtime docs/CORE_LINK.md), on # every OS the link has a transport for: Linux, macOS, Windows. target_sources(retro-hub PRIVATE src/hub/hub_play.cpp src/hub/hub_states.cpp src/hub/hub_picture.cpp + src/hub/hub_gpu_frames.cpp src/hub/hub_core_settings.cpp src/hub/hub_title.cpp src/hub/hub_update.cpp src/hub/hub_vru.cpp src/hub/hub_vru_mic.cpp) # The VRU Microphone's recognizer, Vosk (third_party/vosk/NOTICE.md): never diff --git a/docs/GPU_PRESENTATION.md b/docs/GPU_PRESENTATION.md new file mode 100644 index 0000000..d157497 --- /dev/null +++ b/docs/GPU_PRESENTATION.md @@ -0,0 +1,45 @@ +# Shared GPU presentation + +The Launcher can consume optional rcore draft 11 / CoreLink 2.4 shared images +from a separate runner process. The first path is a Windows Vulkan core with +an OpenGL frontend on the same actual adapter. It imports a ring once and +copies each accepted image on the GPU into the existing `Picture` texture. +Filtering, sharp bilinear, mipmaps, screenshots and the overlay continue +through `Picture`. Ordinary supported frames avoid CPU readback, pixel IPC +and texture upload. Explicit screenshots and save thumbnails may read back. + +Actual UUID/LUID/node, extensions and limits are sampled once before core +initialization. Three slots and at most two generations bound resources. +The frontend waits ready, copies, signals released and flushes a completion +fence. It acknowledges a slot only once that fence completes. Link grant +numbers order pictures across rollback and CPU/GPU transitions; an obsolete +GPU frame still returns ownership. Failed imports or copies refuse that ring +and preserve CPU frames. Session teardown drains pending local GPU work and +releases imports before destroying the context. A new session samples its +own offer; there is no mid-match capability renegotiation. + +The core must complete its ready signal before publishing a frame. This +prevents producer death after acceptance from stranding the frontend's GPU +wait. The frontend owns the final display copy, so it survives runner exit. +The precise ABI and handle lifetime contract is +[`Retro-Runtime/docs/GPU_FRAMES.md`](../third_party/Retro-Runtime/docs/GPU_FRAMES.md). +The Runtime pin in this change contains that coordinated implementation. + +On other platforms, older Runtime/core versions, unsupported devices or +backends, and temporarily unavailable slots, the existing CPU frame path +continues to work. The supported OpenGL core renderer also keeps its existing +implementation. GPU transport does not alter guest memory or netplay policy. +In particular, n64lle's full-pixel-digest titles retain their portable online +renderer selection; this transport is not permission to omit guest pixels +from hashes or accept an unqualified graphics profile. + +Private qualification uses the real `GpuFrames`, `Picture`, ImGui renderer +and separate CoreLink runner, rather than substituting a process-local texture +name. It checks every displayed picture, full canonical state and audio on a +selected Smash route, plus refusal, stale acknowledgments, slot exhaustion, +recovery and producer death. It also retains the existing enhanced first-frame +restore limitation: stale future pixels are invalidated, then native pixels +are displayed until enhanced history is rebuilt. Detailed source hashes, +completed-work timings, failures and platform limits are in n64lle's +`docs/evidence/DIRECT-GPU-PRESENTATION.md`. Hardware qualification is currently +one Windows NVIDIA adapter; cross-platform builds are not runtime proof. diff --git a/src/hub/hub_gpu_frames.cpp b/src/hub/hub_gpu_frames.cpp new file mode 100644 index 0000000..cec63f9 --- /dev/null +++ b/src/hub/hub_gpu_frames.cpp @@ -0,0 +1,275 @@ +#include "hub/hub_gpu_frames.hpp" +#include "hub/hub_picture.hpp" +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include "hub/hub_gl.hpp" +#include +#endif + +namespace retcomm::hub { +using retro::corelink::CoreLink; +using retro::corelink::GpuEvent; + +struct GpuFrames::Impl { + rcore_gpu_capabilities caps{}; + bool sampled = false; + Stats stats; +#if defined(_WIN32) + PFNGLGETSTRINGIPROC GetStringi = nullptr; + PFNGLGETUNSIGNEDBYTEVEXTPROC GetUnsignedBytevEXT = nullptr; + PFNGLGETUNSIGNEDBYTEI_VEXTPROC GetUnsignedBytei_vEXT = nullptr; + PFNGLCREATEMEMORYOBJECTSEXTPROC CreateMemoryObjectsEXT = nullptr; + PFNGLDELETEMEMORYOBJECTSEXTPROC DeleteMemoryObjectsEXT = nullptr; + PFNGLMEMORYOBJECTPARAMETERIVEXTPROC MemoryObjectParameterivEXT = nullptr; + PFNGLIMPORTMEMORYWIN32HANDLEEXTPROC ImportMemoryWin32HandleEXT = nullptr; + PFNGLTEXSTORAGEMEM2DEXTPROC TexStorageMem2DEXT = nullptr; + PFNGLGENSEMAPHORESEXTPROC GenSemaphoresEXT = nullptr; + PFNGLDELETESEMAPHORESEXTPROC DeleteSemaphoresEXT = nullptr; + PFNGLIMPORTSEMAPHOREWIN32HANDLEEXTPROC ImportSemaphoreWin32HandleEXT = nullptr; + PFNGLWAITSEMAPHOREEXTPROC WaitSemaphoreEXT = nullptr; + PFNGLSIGNALSEMAPHOREEXTPROC SignalSemaphoreEXT = nullptr; + PFNGLFENCESYNCPROC FenceSync = nullptr; + PFNGLCLIENTWAITSYNCPROC ClientWaitSync = nullptr; + PFNGLDELETESYNCPROC DeleteSync = nullptr; + struct Image { + GLuint memory = 0, texture = 0, sem[2]{}; + GLsync fence = nullptr; + std::uint64_t frame = 0; + }; + struct Ring { + rcore_gpu_ring desc{}; + Image images[RCORE_GPU_MAX_IMAGES]{}; + bool retiring = false; + }; + std::map rings; + + bool load() { + bool ok = true; + auto get = [&](auto& f, const char* name) { + f = reinterpret_cast>(SDL_GL_GetProcAddress(name)); + ok = ok && f != nullptr; + }; +#define GPU_GL(name) get(name, "gl" #name) + GPU_GL(GetStringi); GPU_GL(GetUnsignedBytevEXT); GPU_GL(GetUnsignedBytei_vEXT); + GPU_GL(CreateMemoryObjectsEXT); GPU_GL(DeleteMemoryObjectsEXT); + GPU_GL(MemoryObjectParameterivEXT); GPU_GL(ImportMemoryWin32HandleEXT); + GPU_GL(TexStorageMem2DEXT); GPU_GL(GenSemaphoresEXT); GPU_GL(DeleteSemaphoresEXT); + GPU_GL(ImportSemaphoreWin32HandleEXT); GPU_GL(WaitSemaphoreEXT); + GPU_GL(SignalSemaphoreEXT); GPU_GL(FenceSync); GPU_GL(ClientWaitSync); GPU_GL(DeleteSync); +#undef GPU_GL + if (!ok) return false; + GLint count = 0; + glGetIntegerv(GL_NUM_EXTENSIONS, &count); + for (const char* wanted : {"GL_EXT_memory_object", "GL_EXT_memory_object_win32", + "GL_EXT_semaphore", "GL_EXT_semaphore_win32"}) { + bool found = false; + for (GLint i = 0; i < count; ++i) + if (!std::strcmp(reinterpret_cast(GetStringi(GL_EXTENSIONS, i)), wanted)) found = true; + if (!found) return false; + } + return true; + } + + void destroy(Ring& ring) { + for (auto& im : ring.images) { + if (im.fence) DeleteSync(im.fence); + if (im.texture) glDeleteTextures(1, &im.texture); + if (im.sem[0] || im.sem[1]) DeleteSemaphoresEXT(2, im.sem); + if (im.memory) DeleteMemoryObjectsEXT(1, &im.memory); + im = {}; + } + } + + bool import(Ring& ring, const GpuEvent& e) { + const auto& d = e.ring; + if (!caps.handle_types || rings.size() >= 2 || !d.generation || rings.count(d.generation) || + d.struct_size != sizeof(d) || d.handle_type != RCORE_GPU_HANDLE_WIN32_NT || + d.sync_type != RCORE_GPU_SYNC_BINARY || d.pixel_format != RCORE_GPU_FORMAT_RGBA8 || + d.flags != RCORE_GPU_ORIGIN_TOP_LEFT || !d.width || !d.height || + d.width > caps.max_width || d.height > caps.max_height || !d.image_count || + d.image_count > RCORE_GPU_MAX_IMAGES || e.handles.size() != d.image_count * 3 || + std::memcmp(d.device_uuid, caps.device_uuid, 16) || + std::memcmp(d.device_luid, caps.device_luid, 8) || + d.device_node_mask != caps.device_node_mask) return false; + ring.desc = d; + // NT imports retain their own reference; GpuEvent closes the received + // handles after this call. No inherited raw producer handle is used. + for (unsigned i = 0; i < d.image_count; ++i) { + if (d.images[i].allocation_size < std::uint64_t(d.width) * d.height * 4) return false; + auto& im = ring.images[i]; + CreateMemoryObjectsEXT(1, &im.memory); + const GLint dedicated = GL_TRUE; + MemoryObjectParameterivEXT(im.memory, GL_DEDICATED_MEMORY_OBJECT_EXT, &dedicated); + ImportMemoryWin32HandleEXT(im.memory, d.images[i].allocation_size, + GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, + reinterpret_cast(e.handles[i * 3])); + glGenTextures(1, &im.texture); + glBindTexture(GL_TEXTURE_2D, im.texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_TILING_EXT, GL_OPTIMAL_TILING_EXT); + TexStorageMem2DEXT(GL_TEXTURE_2D, 1, GL_RGBA8, d.width, d.height, im.memory, 0); + GenSemaphoresEXT(2, im.sem); + ImportSemaphoreWin32HandleEXT(im.sem[0], GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, + reinterpret_cast(e.handles[i * 3 + 1])); + ImportSemaphoreWin32HandleEXT(im.sem[1], GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, + reinterpret_cast(e.handles[i * 3 + 2])); + if (glGetError() != GL_NO_ERROR) return false; + } + return true; + } + + void collect(CoreLink& link) { + for (auto it = rings.begin(); it != rings.end();) { + auto& ring = it->second; + std::uint32_t released = 0; + std::array frames{}; + bool busy = false; + for (unsigned i = 0; i < ring.desc.image_count; ++i) { + auto& im = ring.images[i]; + if (!im.fence) continue; + const GLenum status = ClientWaitSync(im.fence, 0, 0); + if (status == GL_ALREADY_SIGNALED || status == GL_CONDITION_SATISFIED) { + DeleteSync(im.fence); + im.fence = nullptr; + released |= 1u << i; + frames[i] = im.frame; + ++stats.releases; + } else { + busy = true; + if (status == GL_WAIT_FAILED) + link.gpu_status(it->first, RCORE_GPU_RING_REFUSED, 0); + } + } + if (released) link.gpu_status(it->first, RCORE_GPU_RING_READY, released, frames); + if (ring.retiring && !busy) { + destroy(ring); + link.gpu_status(it->first, RCORE_GPU_RING_RETIRED, 0); + it = rings.erase(it); + } else ++it; + } + } +#endif +}; + +GpuFrames::GpuFrames() : impl_(std::make_unique()) {} +GpuFrames::~GpuFrames() = default; +GpuFrames::Stats GpuFrames::stats() const { return impl_->stats; } + +rcore_gpu_capabilities GpuFrames::capabilities() const { + auto& s = *impl_; + if (s.sampled) return s.caps; + s.sampled = true; + s.stats = {}; + s.caps.struct_size = sizeof(s.caps); +#if defined(_WIN32) + if (!s.load()) return s.caps; + s.GetUnsignedBytevEXT(GL_DEVICE_LUID_EXT, s.caps.device_luid); + s.GetUnsignedBytei_vEXT(GL_DEVICE_UUID_EXT, 0, s.caps.device_uuid); + GLint node = 0, size = 0; + glGetIntegerv(GL_DEVICE_NODE_MASK_EXT, &node); + glGetIntegerv(GL_MAX_TEXTURE_SIZE, &size); + if (glGetError() != GL_NO_ERROR || node <= 0 || size <= 0) return s.caps; + s.caps.device_node_mask = node; + s.caps.max_width = std::min(size, retro::corelink::kMaxFrameWidth); + s.caps.max_height = std::min(size, retro::corelink::kMaxFrameHeight); + s.caps.max_images = RCORE_GPU_MAX_IMAGES; + s.caps.handle_types = RCORE_GPU_HANDLE_WIN32_NT; + s.caps.sync_types = RCORE_GPU_SYNC_BINARY; +#endif + return s.caps; +} + +std::uint64_t GpuFrames::pump(CoreLink& link, Picture& picture, std::uint64_t newest) { +#if defined(_WIN32) + auto& s = *impl_; + s.collect(link); + while (auto event = link.poll_gpu_event()) { + const auto& e = *event; + if (e.kind == GpuEvent::Kind::Register) { + Impl::Ring ring; + if (s.import(ring, e)) { + ++s.stats.imports; + s.rings.emplace(e.ring.generation, ring); + link.gpu_status(e.ring.generation, RCORE_GPU_RING_READY, 0); + } else { + ++s.stats.refused; + s.destroy(ring); + SDL_Log("retro-hub: GPU ring import refused; using CPU frames"); + link.gpu_status(e.ring.generation, RCORE_GPU_RING_REFUSED, 0); + } + } else if (e.kind == GpuEvent::Kind::Unregister) { + auto found = s.rings.find(e.generation); + if (found != s.rings.end()) found->second.retiring = true; + else link.gpu_status(e.generation, RCORE_GPU_RING_RETIRED, 0); + } else { + const auto& f = e.frame; + auto found = s.rings.find(f.generation); + if (found == s.rings.end() || f.image_index >= found->second.desc.image_count) { + link.gpu_status(f.generation, RCORE_GPU_RING_REFUSED, 0); + continue; + } + auto& ring = found->second; + auto& im = ring.images[f.image_index]; + if (im.fence || ring.retiring) { + link.gpu_status(f.generation, RCORE_GPU_RING_REFUSED, 0); + continue; + } + const GLenum layout = GL_LAYOUT_GENERAL_EXT; + s.WaitSemaphoreEXT(im.sem[0], 0, nullptr, 1, &im.texture, &layout); + // Even an obsolete picture consumes ready and returns release. + if (f.frame_number > newest) { + if (picture.copy_texture(im.texture, ring.desc.width, + ring.desc.height, f.aspect_num, f.aspect_den)) { + newest = f.frame_number; + ++s.stats.pictures; + } else { + ++s.stats.refused; + link.gpu_status(f.generation, RCORE_GPU_RING_REFUSED, 0); + } + } + s.SignalSemaphoreEXT(im.sem[1], 0, nullptr, 1, &im.texture, &layout); + im.fence = s.FenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + im.frame = f.frame_number; + glFlush(); + if (!im.fence || glGetError() != GL_NO_ERROR) + link.gpu_status(f.generation, RCORE_GPU_RING_REFUSED, 0); + } + } + s.collect(link); +#else + (void)picture; + while (auto e = link.poll_gpu_event()) + link.gpu_status(e->kind == GpuEvent::Kind::Register ? e->ring.generation : e->generation, + RCORE_GPU_RING_REFUSED, 0); +#endif + return newest; +} + +void GpuFrames::release(CoreLink& link) { +#if defined(_WIN32) + auto& s = *impl_; + if (!s.rings.empty()) { + // Shutdown only. Steady state uses nonblocking fence queries. + glFinish(); + s.collect(link); + for (auto& [id, ring] : s.rings) s.destroy(ring); + s.rings.clear(); + } +#else + (void)link; +#endif + impl_->sampled = false; + impl_->caps = {}; +} +} diff --git a/src/hub/hub_gpu_frames.hpp b/src/hub/hub_gpu_frames.hpp new file mode 100644 index 0000000..36936ce --- /dev/null +++ b/src/hub/hub_gpu_frames.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "core_link.hpp" +#include + +namespace retcomm::hub { +class Picture; + +// Presentation only. Capabilities are sampled with the actual GL context +// current, once before runner initialization; ownership events never change it. +class GpuFrames { +public: + struct Stats { std::uint64_t imports = 0, pictures = 0, releases = 0, refused = 0; }; + GpuFrames(); + ~GpuFrames(); + GpuFrames(const GpuFrames&) = delete; + GpuFrames& operator=(const GpuFrames&) = delete; + rcore_gpu_capabilities capabilities() const; + Stats stats() const; + std::uint64_t pump(retro::corelink::CoreLink&, Picture&, std::uint64_t newest); + // The GL context must remain current until release returns. + void release(retro::corelink::CoreLink&); +private: + struct Impl; + std::unique_ptr impl_; +}; +} diff --git a/src/hub/hub_picture.cpp b/src/hub/hub_picture.cpp index 5094fc2..d763b9e 100644 --- a/src/hub/hub_picture.cpp +++ b/src/hub/hub_picture.cpp @@ -129,6 +129,62 @@ void Picture::set_minified(bool on) { mipmapped_ = on; } +bool Picture::copy_texture(unsigned int texture, std::uint32_t w, std::uint32_t h, + std::uint32_t aspect_num, std::uint32_t aspect_den) { + const auto& g = gl3(); + if (!g.blit || !texture || !w || !h) return false; + if (!tex_ || w != w_ || h != h_) upload(nullptr, w, h, aspect_num, aspect_den); + if (!read_fb_) g.GenFramebuffers(1, &read_fb_); + if (!draw_fb_) g.GenFramebuffers(1, &draw_fb_); + GLint read = 0, draw = 0; + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read); + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw); + const bool scissor = glIsEnabled(GL_SCISSOR_TEST); + if (scissor) glDisable(GL_SCISSOR_TEST); + g.BindFramebuffer(GL_READ_FRAMEBUFFER, read_fb_); + g.FramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + g.BindFramebuffer(GL_DRAW_FRAMEBUFFER, draw_fb_); + g.FramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex_, 0); + const bool ok = g.CheckFramebufferStatus(GL_READ_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE && + g.CheckFramebufferStatus(GL_DRAW_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE; + if (ok) g.BlitFramebuffer(0, 0, GLint(w), GLint(h), 0, 0, GLint(w), GLint(h), + GL_COLOR_BUFFER_BIT, GL_NEAREST); + g.BindFramebuffer(GL_READ_FRAMEBUFFER, GLuint(read)); + g.BindFramebuffer(GL_DRAW_FRAMEBUFFER, GLuint(draw)); + if (scissor) glEnable(GL_SCISSOR_TEST); + if (!ok) return false; + aspect_num_ = aspect_num; + aspect_den_ = aspect_den; + ++rev_; + if (mipmapped_) { + glBindTexture(GL_TEXTURE_2D, tex_); + g.GenerateMipmap(GL_TEXTURE_2D); + } + return true; +} + +std::vector Picture::snapshot(std::uint32_t& width, std::uint32_t& height) { + width = height = 0; + const auto& g = gl3(); + if (!tex_ || !g.blit) return {}; + if (!read_fb_) g.GenFramebuffers(1, &read_fb_); + GLint read = 0, alignment = 0; + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read); + glGetIntegerv(GL_PACK_ALIGNMENT, &alignment); + g.BindFramebuffer(GL_READ_FRAMEBUFFER, read_fb_); + g.FramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex_, 0); + std::vector pixels; + if (g.CheckFramebufferStatus(GL_READ_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE) { + pixels.resize(std::size_t(w_) * h_ * 4); + glPixelStorei(GL_PACK_ALIGNMENT, 4); + glReadPixels(0, 0, GLsizei(w_), GLsizei(h_), GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + width = w_; height = h_; + } + g.BindFramebuffer(GL_READ_FRAMEBUFFER, GLuint(read)); + glPixelStorei(GL_PACK_ALIGNMENT, alignment); + return pixels; +} + // tex_ blown up kx by ky with nearest, into pre_: redone per new frame or size. // 0 when it cannot be (the caller draws tex_ instead). unsigned int Picture::prescaled(std::uint32_t kx, std::uint32_t ky) { @@ -142,8 +198,8 @@ unsigned int Picture::prescaled(std::uint32_t kx, std::uint32_t ky) { glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - g.GenFramebuffers(1, &read_fb_); - g.GenFramebuffers(1, &draw_fb_); + if (!read_fb_) g.GenFramebuffers(1, &read_fb_); + if (!draw_fb_) g.GenFramebuffers(1, &draw_fb_); } if (pw != pre_w_ || ph != pre_h_) { glBindTexture(GL_TEXTURE_2D, pre_); diff --git a/src/hub/hub_picture.hpp b/src/hub/hub_picture.hpp index 983b161..ada6319 100644 --- a/src/hub/hub_picture.hpp +++ b/src/hub/hub_picture.hpp @@ -33,6 +33,7 @@ #include "imgui.h" #include +#include namespace retcomm::hub { @@ -65,6 +66,11 @@ class Picture { // current. void upload(const void* rgba, std::uint32_t w, std::uint32_t h, std::uint32_t aspect_num, std::uint32_t aspect_den); + // GPU copy from a same-context RGBA8 texture. No host pixel transfer. + bool copy_texture(unsigned int texture, std::uint32_t w, std::uint32_t h, + std::uint32_t aspect_num, std::uint32_t aspect_den); + // Explicit screenshot/thumbnail observer; never called by steady presentation. + std::vector snapshot(std::uint32_t& width, std::uint32_t& height); bool empty() const { return tex_ == 0; } // Into `dl` (inside an ImGui frame), letterboxed on the display, tinted. diff --git a/src/hub/hub_play.cpp b/src/hub/hub_play.cpp index cceea76..7241783 100644 --- a/src/hub/hub_play.cpp +++ b/src/hub/hub_play.cpp @@ -5,6 +5,7 @@ #include "hub/hub_gl.hpp" #include "runner_probe.hpp" // Retro-Runtime: probe_runner +#include "state_envelope.hpp" #include #include @@ -111,6 +112,7 @@ PlaySession::~PlaySession() { shutdown(); } bool PlaySession::start(const PlayArgs& args, const fs::path& runner, const fs::path& session_dir, const fs::path& save_dir, std::string* error) { args_ = args; + picture_frame_ = 0; prefs_ = args.prefs; osd_.set_fps_visible(prefs_.show_fps); picture_.set_style(args.picture); @@ -126,6 +128,7 @@ bool PlaySession::start(const PlayArgs& args, const fs::path& runner, const fs:: const fs::path& owner = args.package.empty() ? args.core : args.package; spec.title_dir = args.title_dir.empty() ? owner.parent_path() : args.title_dir; spec.session_dir = session_dir; + spec.gpu_capabilities = gpu_frames_.capabilities(); spec.save_dir = save_dir; spec.gl = args.gl && runner_lends_gl(runner); if (args.gl && !spec.gl) @@ -350,7 +353,16 @@ void PlaySession::service_states() { states_.finish(false, save, "the game stopped"); state_request_ = Req{}; } else if (link_.can_request_state()) { - const bool sent = save ? link_.request_save_state(state_request_.path) + std::vector thumbnail; + if (save && corelink::link_has_gpu_frames(link_.identity().protocol_minor)) { + upload_frame(); + if (gpu_frames_.stats().pictures) { + std::uint32_t w = 0, h = 0; + const auto pixels = picture_.snapshot(w, h); + if (!pixels.empty()) thumbnail = retro::state::make_thumbnail(pixels.data(), w, h, w * 4); + } + } + const bool sent = save ? link_.request_save_state(state_request_.path, thumbnail) : link_.request_load_state(state_request_.path); if (!sent) states_.finish(false, save, "the link could not send the request"); state_request_ = Req{}; @@ -563,10 +575,16 @@ void PlaySession::end_turbo_stretch() { } void PlaySession::upload_frame() { - if (!link_.take_frame()) return; - const corelink::FrameInfo* f = link_.frame_info(); - if (!f || !f->width || !f->height) return; - picture_.upload(link_.frame_pixels(), f->width, f->height, f->aspect_num, f->aspect_den); + const bool cpu = link_.take_frame(); + const auto* f = cpu ? link_.frame_info() : nullptr; + // The two transports share the monotonically increasing grant number. + // Consume and release every GPU picture, but never replace a newer one. + const auto cutoff = f ? std::max(picture_frame_, f->frame_number) : picture_frame_; + picture_frame_ = gpu_frames_.pump(link_, picture_, cutoff); + if (f && f->width && f->height && f->frame_number >= picture_frame_) { + picture_.upload(link_.frame_pixels(), f->width, f->height, f->aspect_num, f->aspect_den); + picture_frame_ = f->frame_number; + } } void PlaySession::tick(SDL_Window* window) { @@ -1094,6 +1112,14 @@ void PlaySession::shutdown() { if (turbo_running_) set_turbo_running(false); stop_vru(); states_.release(); + gpu_frames_.release(link_); + const auto gpu_stats = gpu_frames_.stats(); + if (gpu_stats.imports || gpu_stats.refused) + SDL_Log("retro-hub: GPU presentation imports=%llu pictures=%llu releases=%llu refused=%llu", + static_cast(gpu_stats.imports), + static_cast(gpu_stats.pictures), + static_cast(gpu_stats.releases), + static_cast(gpu_stats.refused)); if (link_.state() != corelink::LinkState::Idle) link_.stop(); if (audio_) { SDL_DestroyAudioStream(audio_); diff --git a/src/hub/hub_play.hpp b/src/hub/hub_play.hpp index 0290e15..529cda0 100644 --- a/src/hub/hub_play.hpp +++ b/src/hub/hub_play.hpp @@ -24,6 +24,7 @@ #include "hub/hub_accessory_link.hpp" #include "hub/hub_core_settings.hpp" #include "hub/hub_picture.hpp" +#include "hub/hub_gpu_frames.hpp" #include "hub/hub_vru.hpp" #include "hub/hub_vru_mic.hpp" #include "overlay.hpp" // Retro-Runtime: retro_overlay @@ -163,6 +164,8 @@ class PlaySession { // The core's frame, scaled as args_.picture says. Picture picture_; + GpuFrames gpu_frames_; + std::uint64_t picture_frame_ = 0; // Audio: an SDL stream fed from the link's ring; its fill level paces // grants (the core states no frame rate; its audio clock stands in). diff --git a/third_party/Retro-Runtime b/third_party/Retro-Runtime index 2374599..5e419d0 160000 --- a/third_party/Retro-Runtime +++ b/third_party/Retro-Runtime @@ -1 +1 @@ -Subproject commit 23745993cfbee3d132d1fe5724c89eacfb3f93fb +Subproject commit 5e419d0edab8c710fde303fe533c400974fed853