diff --git a/CMakeLists.txt b/CMakeLists.txt index 23bd180..0e0fa19 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -117,6 +117,10 @@ if(RETRO_RUNTIME_TOOLS) runner/net_session.cpp runner/net_module.cpp ) + add_executable(retro-gpu-host-test tests/gpu_host_test.cpp runner/host_session.cpp) + target_include_directories(retro-gpu-host-test PRIVATE runner) + target_link_libraries(retro-gpu-host-test PRIVATE retro_core_support) + add_test(NAME gpu_host_contract COMMAND retro-gpu-host-test) # Netplay (runner/net_session.hpp): recomp-net's episode driver and UDP # transport, compiled in from a checkout when one is named. Without it the # runner answers `netplay 0` and refuses --net-*. diff --git a/corelink/core_link.cpp b/corelink/core_link.cpp index aeb5042..3366be3 100644 --- a/corelink/core_link.cpp +++ b/corelink/core_link.cpp @@ -119,6 +119,66 @@ void CoreLink::handle_packet(const std::vector& buf, identity_.draft_revision = m.draft_revision; identity_.engine_dirty = m.engine_dirty != 0; identity_.protocol_minor = std::min(m.protocol_minor, kProtocolMinor); + if (link_has_gpu_frames(identity_.protocol_minor)) { + GpuCapabilitiesMsg caps{}; + caps.h.type = Msg::GpuCapabilities; + caps.caps = spec_.gpu_capabilities; + caps.caps.struct_size = sizeof caps.caps; + send_msg(channel_, caps); + } + break; + } + case Msg::GpuRing: { + GpuRingMsg m{}; + if (!link_has_gpu_frames(identity_.protocol_minor) || !as_msg(buf, m) || + m.ring.struct_size < sizeof m.ring || !m.ring.generation || + !m.ring.image_count || m.ring.image_count > RCORE_GPU_MAX_IMAGES || + handles.size() != m.ring.image_count * 3u || gpu_events_.size() >= 32) { + exit_reason_ = "link: malformed GPU ring"; + stop(0); + return; + } + GpuEvent event; + event.kind = GpuEvent::Kind::Register; + event.ring = m.ring; + event.generation = m.ring.generation; + event.handles.swap(handles); + gpu_events_.push_back(std::move(event)); + break; + } + case Msg::GpuFrame: { + GpuFrameMsg m{}; + if (!link_has_gpu_frames(identity_.protocol_minor) || !as_msg(buf, m) || + m.frame.struct_size < sizeof m.frame || !m.frame.generation || + !m.frame.frame_number || m.frame.frame_number > granted_ || !handles.empty() || + m.frame.image_index >= RCORE_GPU_MAX_IMAGES || gpu_events_.size() >= 32) { + exit_reason_ = "link: malformed GPU frame"; + stop(0); + return; + } + { + GpuEvent event; + event.kind = GpuEvent::Kind::Frame; + event.frame = m.frame; + event.generation = m.frame.generation; + gpu_events_.push_back(std::move(event)); + } + break; + } + case Msg::GpuUnregister: { + GpuUnregisterMsg m{}; + if (!link_has_gpu_frames(identity_.protocol_minor) || !as_msg(buf, m) || + !m.generation || !handles.empty() || gpu_events_.size() >= 32) { + exit_reason_ = "link: malformed GPU retirement"; + stop(0); + return; + } + { + GpuEvent event; + event.kind = GpuEvent::Kind::Unregister; + event.generation = m.generation; + gpu_events_.push_back(std::move(event)); + } break; } case Msg::SaveRegions: { @@ -257,7 +317,18 @@ bool CoreLink::request_state(Msg type, const fs::path& path) { return true; } -bool CoreLink::request_save_state(const fs::path& path) { return request_state(Msg::SaveState, path); } +bool CoreLink::request_save_state(const fs::path& path, const std::vector& thumbnail) { + if (!can_request_state()) return false; + if (!thumbnail.empty() && link_has_gpu_frames(identity_.protocol_minor)) { + if (thumbnail.size() != kStateThumbBytes) return false; + StateThumbnailMsg msg{}; + msg.h.type = Msg::StateThumbnail; + msg.frame_number = done_; + std::copy(thumbnail.begin(), thumbnail.end(), msg.rgba); + if (!send_msg(channel_, msg)) return false; + } + return request_state(Msg::SaveState, path); +} bool CoreLink::request_load_state(const fs::path& path) { return request_state(Msg::LoadState, path); } bool CoreLink::accessory_data_supported() const { @@ -364,6 +435,30 @@ void CoreLink::on_ended(int code) { persist_saves(); // the hub's mappings survive the runner } +std::optional CoreLink::poll_gpu_event() { + if (gpu_events_.empty()) return std::nullopt; + GpuEvent event = std::move(gpu_events_.front()); + gpu_events_.pop_front(); + return event; +} + +bool CoreLink::gpu_status(std::uint64_t generation, std::uint32_t state, + std::uint32_t released, + const std::array& frames) { + if (!generation || !link_has_gpu_frames(identity_.protocol_minor) || + state_ == LinkState::Idle || state_ == LinkState::Ended || + (state != RCORE_GPU_RING_READY && state != RCORE_GPU_RING_REFUSED && + state != RCORE_GPU_RING_RETIRED) || + released >= (1u << RCORE_GPU_MAX_IMAGES)) return false; + GpuStatusMsg msg{}; + msg.h.type = Msg::GpuStatus; + msg.generation = generation; + msg.state = state; + msg.released = released; + std::copy(frames.begin(), frames.end(), std::begin(msg.frame_number)); + return send_msg(channel_, msg); +} + void CoreLink::stop(int grace_ms) { if (state_ == LinkState::Idle) return; if (process_.running()) { diff --git a/corelink/core_link.hpp b/corelink/core_link.hpp index f11dbac..cebebea 100644 --- a/corelink/core_link.hpp +++ b/corelink/core_link.hpp @@ -15,6 +15,7 @@ #include #include #include +#include #include namespace retro::corelink { @@ -32,6 +33,7 @@ struct LaunchSpec { fs::path session_dir; // runner.log, core.log, events.tsv, the core's cache bool gl = true; bool strict = false; + rcore_gpu_capabilities gpu_capabilities{}; // actual local import support, sampled once std::map options; std::optional load_state; // Seat N's Transfer Pak cartridge (argv --tpakN-rom), N = 1-4; empty for @@ -95,6 +97,30 @@ struct LinkEvent { std::string detail; }; +struct GpuEvent { + enum class Kind { Register, Frame, Unregister } kind = Kind::Register; + rcore_gpu_ring ring{}; + rcore_gpu_frame frame{}; + std::uint64_t generation = 0; + std::vector handles; + GpuEvent() = default; + GpuEvent(const GpuEvent&) = delete; + GpuEvent& operator=(const GpuEvent&) = delete; + GpuEvent(GpuEvent&& other) noexcept + : kind(other.kind), ring(other.ring), frame(other.frame), + generation(other.generation), handles(std::move(other.handles)) { other.handles.clear(); } + GpuEvent& operator=(GpuEvent&& other) noexcept { + if (this != &other) { + for (auto h : handles) close_handle(h); + kind = other.kind; ring = other.ring; frame = other.frame; + generation = other.generation; handles = std::move(other.handles); + other.handles.clear(); + } + return *this; + } + ~GpuEvent() { for (auto h : handles) close_handle(h); } +}; + enum class LinkState { Idle, // not started Starting, // spawned; identity, load and saves in progress @@ -143,7 +169,7 @@ class CoreLink { // CAP_SAVESTATE, a grant or another request still outstanding. bool states_supported() const; bool can_request_state() const { return states_supported() && can_grant(); } - bool request_save_state(const fs::path& path); + bool request_save_state(const fs::path& path, const std::vector& thumbnail = {}); bool request_load_state(const fs::path& path); bool state_pending() const { return state_pending_; } // The answer, once, when it has arrived. A runner that ends first @@ -168,6 +194,9 @@ class CoreLink { // The picture last taken (nullptr before the first one). const FrameInfo* frame_info() const; const std::uint8_t* frame_pixels() const; + std::optional poll_gpu_event(); + bool gpu_status(std::uint64_t generation, std::uint32_t state, std::uint32_t released, + const std::array& frames = {}); std::uint32_t audio_rate() const; // The core's stated frame rate; false while it has stated none. @@ -223,6 +252,7 @@ class CoreLink { StateResult state_request_; std::optional state_result_; std::deque notifies_; + std::deque gpu_events_; int exit_code_ = 0; std::string exit_reason_; }; diff --git a/corelink/link_protocol.hpp b/corelink/link_protocol.hpp index 10f51f1..eaba1aa 100644 --- a/corelink/link_protocol.hpp +++ b/corelink/link_protocol.hpp @@ -44,7 +44,11 @@ constexpr std::uint32_t kProtocolMajor = 2; // message: a 2.2 runner sends it at its 2.1 size to a 2.1 hub, and a 2.2 hub // reads a 2.1 runner's with both fields zero. // 2.3: Grant appends the available drawable pixel area (rcore rev 9). -constexpr std::uint32_t kProtocolMinor = 3; +// 2.4: optional core-owned GPU rings, native handle transfer and release events. +constexpr std::uint32_t kProtocolMinor = 4; +constexpr bool link_has_gpu_frames(std::uint32_t peer_minor) { + return kProtocolMajor > 2 || peer_minor >= 4; +} constexpr bool link_has_presentation_size(std::uint32_t peer_minor) { return kProtocolMajor > 2 || peer_minor >= 3; } @@ -147,6 +151,9 @@ enum class Msg : std::uint32_t { Exiting = 7, // the runner is stopping on purpose; the reason follows StateDone = 8, // 1.1: how the last SaveState / LoadState went AccessoryNotify = 9, // 2.1: a data accessory's bytes for the hub (accessory_notify) + GpuRing = 10, // 2.4: metadata plus 3 native handles per image + GpuFrame = 11, // accepted GPU picture, before its FrameDone + GpuUnregister = 12, // retire a generation after all GPU users finish // hub -> runner SavesFilled = 64, // every region filled from its file; the seats at power-on Grant = 65, // run exactly one frame, with these pads @@ -154,6 +161,9 @@ enum class Msg : std::uint32_t { SaveState = 67, // 1.1: serialize now, into an envelope at this path LoadState = 68, // 1.1: check the envelope at this path, then unserialize AccessoryData = 69, // 2.1: a data accessory's bytes for the core (accessory_poll) + GpuCapabilities = 70, // 2.4: once after Hello, before core init + GpuStatus = 71, // resource ownership events, not capabilities + StateThumbnail = 72, // 2.4: displayed image observer for the next save }; struct MsgHeader { @@ -161,6 +171,38 @@ struct MsgHeader { std::uint32_t size; // whole packet, header included }; +struct GpuCapabilitiesMsg { + MsgHeader h; + rcore_gpu_capabilities caps; +}; +struct GpuRingMsg { + MsgHeader h; + // Handle fields are zero on wire; channel handles own the references. + rcore_gpu_ring ring; +}; +struct GpuFrameMsg { + MsgHeader h; + rcore_gpu_frame frame; +}; +struct GpuUnregisterMsg { + MsgHeader h; + std::uint64_t generation; +}; +struct GpuStatusMsg { + MsgHeader h; + std::uint64_t generation; + std::uint32_t state; + std::uint32_t released; + // Match the submitted link-frame number; stale releases cannot free reuse. + std::uint64_t frame_number[RCORE_GPU_MAX_IMAGES]; +}; +constexpr std::size_t kStateThumbBytes = 160u * 120u * 4u; +struct StateThumbnailMsg { + MsgHeader h; + std::uint64_t frame_number; + std::uint8_t rgba[kStateThumbBytes]; +}; + struct HelloMsg { MsgHeader h; std::uint32_t protocol_major; @@ -282,8 +324,9 @@ struct AccessoryDataMsg { }; constexpr std::size_t kAccessoryMsgHeaderSize = offsetof(AccessoryDataMsg, data); -constexpr std::size_t kMaxMsgSize = sizeof(AccessoryDataMsg); // the largest message -static_assert(kMaxMsgSize >= sizeof(SaveRegionsMsg) && kMaxMsgSize >= sizeof(LogMsg) && +constexpr std::size_t kMaxMsgSize = sizeof(StateThumbnailMsg); // the largest message +static_assert(kMaxMsgSize >= sizeof(AccessoryDataMsg) && + kMaxMsgSize >= sizeof(SaveRegionsMsg) && kMaxMsgSize >= sizeof(LogMsg) && kMaxMsgSize >= sizeof(StateRequestMsg) && kMaxMsgSize >= sizeof(StateDoneMsg) && kMaxMsgSize >= sizeof(EventMsg), "kMaxMsgSize must hold every message"); diff --git a/corelink/transport.hpp b/corelink/transport.hpp index c0cf488..25567a8 100644 --- a/corelink/transport.hpp +++ b/corelink/transport.hpp @@ -35,6 +35,8 @@ constexpr NativeHandle kNoHandle = -1; #endif void close_handle(NativeHandle h); +// Retain an independent reference in this process; caller still owns h. +NativeHandle retain_handle(NativeHandle h); // A shared memory object and this process's mapping of it. struct SharedMemory { diff --git a/corelink/transport_posix.cpp b/corelink/transport_posix.cpp index 3c5de4b..12044a7 100644 --- a/corelink/transport_posix.cpp +++ b/corelink/transport_posix.cpp @@ -90,6 +90,10 @@ void close_handle(NativeHandle h) { if (h >= 0) ::close(h); } +NativeHandle retain_handle(NativeHandle h) { + return h == kNoHandle ? kNoHandle : ::fcntl(h, F_DUPFD_CLOEXEC, 0); +} + // ---- memory ------------------------------------------------------------------- bool create_shared(std::size_t size, const char* name, SharedMemory& out, std::string* error) { diff --git a/corelink/transport_win32.cpp b/corelink/transport_win32.cpp index b69ef6c..6aeedbe 100644 --- a/corelink/transport_win32.cpp +++ b/corelink/transport_win32.cpp @@ -209,6 +209,13 @@ void close_handle(NativeHandle h) { if (h != kNoHandle) CloseHandle(H(h)); } +NativeHandle retain_handle(NativeHandle h) { + HANDLE copy = nullptr; + if (!h || !DuplicateHandle(GetCurrentProcess(), H(h), GetCurrentProcess(), ©, + 0, FALSE, DUPLICATE_SAME_ACCESS)) return kNoHandle; + return N(copy); +} + // ---- memory ------------------------------------------------------------------- bool create_shared(std::size_t size, const char* name, SharedMemory& out, std::string* error) { diff --git a/docs/CORE_ABI.md b/docs/CORE_ABI.md index fe6eeeb..29c5488 100644 --- a/docs/CORE_ABI.md +++ b/docs/CORE_ABI.md @@ -12,6 +12,7 @@ only the shape of the contract and why. | Rev | Date | Change | |---|---|---| | 1 | 2026-09-23 | First draft. | +| 11 | 2026-10-19 | Optional `RCORE_CAP_GPU_FRAMES` and five appended host callbacks for a fixed actual-device offer, image-ring registration, resource status, shared-frame submission and retirement. CPU RGBA8 remains the fallback; no generated game ABI change. See [GPU_FRAMES.md](GPU_FRAMES.md). | | 10 | 2026-10-14 | Optional pre-init `execution_profiles()` capability/qualification query; appended init `execution_profile` index, valid with `RCORE_INIT_PROFILE_AGREED`. The runner exchanges offers/ACKs before init/guest boot, folds the common profile and canonical capability transcript into existing netplay identity, and keeps selection fixed through replay/reset/rollback. Old runners retain the core's conservative startup default. | | 9 | 2026-10-14 | Optional host `presentation_size(ctx, width, height)`: the available drawable physical pixel area before letterboxing, stable for a granted frame. Returns 0 and zeros when unavailable. Core checks host `struct_size` before reading the appended callback. Presentation policy only; never guest input or serialized simulation state. | | 8 | 2026-10-08 | `rcore_frame::game_frame`: the pictures the GAME has finished, as distinct from the frames the machine ran (an N64 game drawing at 30 fps runs 60 VI fields a second). 0 = the core cannot tell them apart. For a host's readout only (link 2.2 carries it to the hub; `OVERLAY.md`), never pacing or netplay. n64lle fills it from `n64_vi_swap_count()`, the game's VI_ORIGIN changes. | diff --git a/docs/CORE_LINK.md b/docs/CORE_LINK.md index 47f15ed..c91a395 100644 --- a/docs/CORE_LINK.md +++ b/docs/CORE_LINK.md @@ -13,6 +13,13 @@ protocol is the same on all three. ## Transport +**2.4 (2026-10-19):** optional GPU ring/frame/retirement messages 10–12, +startup capabilities 70, ownership status 71 and explicit save thumbnail 72. +The existing shared frame-region layout is unchanged. These messages require +minor 4; old peers retain CPU frames. Native handles travel through the +platform transport rather than as raw integers. See +[GPU_FRAMES.md](GPU_FRAMES.md) for setup, lifetime, completion and fallback. + | Channel | What | Owner | |---|---|---| | control | `SOCK_SEQPACKET` socketpair, one message per packet; the runner's end at fd 3 | — | diff --git a/docs/GPU_FRAMES.md b/docs/GPU_FRAMES.md new file mode 100644 index 0000000..24c94d6 --- /dev/null +++ b/docs/GPU_FRAMES.md @@ -0,0 +1,79 @@ +# Shared GPU frames + +rcore draft 11 and CoreLink 2.4 add optional separate-process presentation. +CPU RGBA8 frames remain the portable contract. A host or core without the +extension continues using them. This is not a simulation profile and does +not change build, content, configuration or rollback identity checks. + +The first implementation shares Windows Vulkan RGBA8 images with an OpenGL +frontend using opaque NT memory and binary semaphore handles. Other platforms +and APIs decline through the same interface. No process-local texture name +crosses the link. See the normative structures and comments in +`include/rcore/rcore.h`; the C table and Rust mirror retain their old prefixes. + +## Setup and ownership + +The frontend samples actual GL extensions, adapter UUID, LUID, node mask and +texture limits once per session. It sends `GpuCapabilities` after Hello and +before initialization. The runner copies this offer; core callbacks do not +poll the transport. Missing support, mismatched adapters, invalid geometry or +allocation sizes decline to CPU frames. OS version and GPU names are not +capability evidence. + +The core owns up to three dedicated images per generation. Each image has +ready and released binary semaphores. A registration describes immutable +geometry, format, adapter identity and allocations. The Windows transport +duplicates nine handles into the receiving process; raw producer handle values +are zeroed on the wire. Every layer keeps its own references before returning +from a callback. The receiver closes received handles after importing them. +At most two generations may exist during resize and retirement. + +For each image: + +1. The producer completes its ready signal before publishing the frame. A + queued signal alone cannot protect the frontend against producer death. +2. The frontend waits on ready on its GPU, copies the image into its owned + display texture, signals released, inserts a fence and flushes commands. +3. Only a completed frontend fence permits a CPU release acknowledgment. + The acknowledgment identifies the generation, image and submitted link + frame number. A stale acknowledgment cannot free a reused image. +4. Only then may the producer wait on released and reuse the image. When + no acknowledged image is available, it emits an ordinary CPU frame. + +The frontend consumes and releases obsolete images even when a newer CPU +picture is already displayed. Link frame numbers order presentation; guest +ticks may repeat during rollback. Retired generations are destroyed after +outstanding consumer fences complete. The frontend's display copy survives +producer exit. Shutdown may drain GPU work; normal release polling is +nonblocking. Import/copy/synchronization failure refuses the affected ring +and retains CPU delivery. Refusal and retirement do not renegotiate the +simulation profile or startup capabilities. + +## Explicit observers + +Steady shared frames do not carry pixel payloads through CPU shared memory. +Screenshots and save thumbnails are real pixel observers and may read back. +`StateThumbnail` carries exactly 160 by 120 RGBA bytes and their displayed +link frame number before a save request. The runner uses it only for the +matching GPU frame; CPU presentation keeps its existing thumbnail path. +Malformed or stale observer data must not replace a valid current picture. + +## Validation and limits + +`gpu_host_test` covers a fixed offer, old short tables, adapter mismatch, +invalid sizes/overflow, aliased handles, refusal and portable fallback. +The existing CoreLink and startup-profile tests continue to run. Runtime's +34-test Windows suite passes with the coordinated Launcher/engine candidate. + +Real consumer/producer evidence is recorded in n64lle's +`docs/evidence/DIRECT-GPU-PRESENTATION.md`: actual game pixels/state/PCM, +resize, slot exhaustion, stale releases, refusal and intentional producer +death. Hardware qualification is currently one NVIDIA Windows adapter. +Compilation on another target is not external-memory or netplay qualification. + +The completion rule follows +[Vulkan synchronization signal ordering](https://docs.vulkan.org/spec/latest/chapters/synchronization.html): +signals to binary semaphores in one submission are unordered with one +another; a submission fence completes after its semaphore signals. +Handle lifetime follows +[Khronos external objects on Windows](https://registry.khronos.org/OpenGL/extensions/EXT/EXT_external_objects_win32.txt). diff --git a/include/rcore/rcore.h b/include/rcore/rcore.h index fec1bd0..e576db1 100644 --- a/include/rcore/rcore.h +++ b/include/rcore/rcore.h @@ -48,7 +48,7 @@ extern "C" { #define RCORE_ABI_MAJOR 0u /* 0 = draft; the first implemented contract is 1 */ #define RCORE_ABI_MINOR 0u -#define RCORE_DRAFT_REVISION 10u /* draft-only counter; see docs/CORE_ABI.md */ +#define RCORE_DRAFT_REVISION 11u /* draft-only counter; see docs/CORE_ABI.md */ #if defined(_WIN32) # define RCORE_EXPORT __declspec(dllexport) @@ -91,6 +91,7 @@ typedef int32_t rcore_result; #define RCORE_CAP_GL_COMPUTE (1ull << 9) /* wants a lent GL 4.3+ context (gl_get_proc_address); must still run, in software, when none is lent */ #define RCORE_CAP_ACCESSORY_DATA (1ull << 10) /* accessory_poll / accessory_notify (rev 7) */ +#define RCORE_CAP_GPU_FRAMES (1ull << 11) /* optional shared images; CPU frames remain supported */ typedef struct rcore_core_info { uint32_t struct_size; @@ -137,6 +138,71 @@ typedef struct rcore_frame { uint64_t game_frame; } rcore_frame; +/* Rev 11: a core-owned export ring, optional on both sides. Capabilities are + * sampled once before load; release notifications below concern resource + * ownership, never a simulation profile or a capability renegotiation. + * Initially only Windows opaque NT memory/binary semaphore handles are named. + * Unsupported platforms/backends keep video_submit, including per-field + * declines. No process-local GL texture name crosses this interface. */ +#define RCORE_GPU_MAX_IMAGES 3u +#define RCORE_GPU_HANDLE_WIN32_NT (1u << 0) +#define RCORE_GPU_SYNC_BINARY (1u << 0) +#define RCORE_GPU_FORMAT_RGBA8 1u +#define RCORE_GPU_ORIGIN_TOP_LEFT (1u << 0) +#define RCORE_GPU_RING_PENDING 1u +#define RCORE_GPU_RING_READY 2u +#define RCORE_GPU_RING_REFUSED 3u +#define RCORE_GPU_RING_RETIRED 4u + +typedef struct rcore_gpu_capabilities { + uint32_t struct_size; + uint32_t handle_types; + uint32_t sync_types; + uint32_t max_images; + uint8_t device_uuid[16]; + uint8_t device_luid[8]; + uint32_t device_node_mask; + uint32_t max_width; + uint32_t max_height; + uint32_t _pad0; +} rcore_gpu_capabilities; + +typedef struct rcore_gpu_image { + uint32_t struct_size; + uint32_t _pad0; + uint64_t memory_handle; + uint64_t allocation_size; + uint64_t ready_semaphore; + uint64_t release_semaphore; +} rcore_gpu_image; + +typedef struct rcore_gpu_ring { + uint32_t struct_size; + uint32_t handle_type; + uint32_t sync_type; + uint32_t pixel_format; + uint32_t width; + uint32_t height; + uint32_t image_count; + uint32_t flags; + uint64_t generation; + uint8_t device_uuid[16]; + uint8_t device_luid[8]; + uint32_t device_node_mask; + uint32_t _pad0; + rcore_gpu_image images[RCORE_GPU_MAX_IMAGES]; +} rcore_gpu_ring; + +typedef struct rcore_gpu_frame { + uint32_t struct_size; + uint32_t image_index; + uint64_t generation; + uint32_t aspect_num; + uint32_t aspect_den; + uint64_t frame_number; + uint64_t game_frame; +} rcore_gpu_frame; + /* ------------------------------------------------------------------------ */ /* Input — the host sends a generic pad; the core maps it to its device. */ /* ------------------------------------------------------------------------ */ @@ -422,6 +488,34 @@ typedef struct rcore_host_api { * a granted frame, including its resim calls. Presentation policy only; * never guest input, session identity, or serialized simulation state. */ uint32_t (*presentation_size)(void* host_ctx, uint32_t* width, uint32_t* height); + /* --- appended in draft revision 11: optional shared GPU images --- */ + /* All five slots must exist, with non-NULL functions, to offer GPU frames. + * Capabilities return 1 with actual adapter identity/import support, or 0. + * Query once at setup. The caller sets out->struct_size. */ + uint32_t (*gpu_frames_capabilities)(void* host_ctx, rcore_gpu_capabilities* out); + /* Nonzero = registration queued. The host takes its own handle references + * and copies metadata BEFORE returning; caller retains its handles. The + * generation is nonzero, never reused in a session. Exact adapter/format, + * dimensions, structure sizes and handle counts must be validated. */ + uint32_t (*gpu_frames_register)(void* host_ctx, const rcore_gpu_ring* ring); + /* Resource events only: state plus a CONSUMED delta of released image bits. + * READY first releases all images. A submitted image cannot be reused + * until released again. Release acknowledges completed host GPU work, + * including its release semaphore signal; CPU polling never blocks. + * A pending/full/refused ring uses CPU frames for that field. */ + uint32_t (*gpu_frames_released)(void* host_ctx, uint64_t generation, uint32_t* released); + /* The producer completes the ready signal BEFORE this call: merely + * queueing it is insufficient if the producer dies before it executes. + * Consumer still waits on GPU for memory coherence, samples, + * then signals release semaphore. Nonzero accepts this field; zero means + * no image was accepted, so use CPU output and retire the failed ring. + * Never emit both accepted GPU output and CPU output for one field. */ + uint32_t (*video_submit_gpu)(void* host_ctx, const rcore_gpu_frame* frame); + /* Asynchronous retirement: stop using the ring, preserving the last image + * if needed, then report RETIRED. Older generations' messages cannot affect + * the replacement ring. The core keeps memory alive until retirement, + * except unload: the host's independent handle references outlive it. */ + void (*gpu_frames_unregister)(void* host_ctx, uint64_t generation); } rcore_host_api; /* ------------------------------------------------------------------------ */ diff --git a/runner/core_library.cpp b/runner/core_library.cpp index 812c957..ba499fa 100644 --- a/runner/core_library.cpp +++ b/runner/core_library.cpp @@ -37,6 +37,7 @@ constexpr CapName kCaps[] = { {RCORE_CAP_ACCESSORY_HOTPLUG, "accessory_hotplug"}, {RCORE_CAP_GL_COMPUTE, "gl_compute"}, {RCORE_CAP_ACCESSORY_DATA, "accessory_data"}, + {RCORE_CAP_GPU_FRAMES, "gpu_frames"}, }; } // namespace diff --git a/runner/host_session.cpp b/runner/host_session.cpp index d1db2f1..9731d67 100644 --- a/runner/host_session.cpp +++ b/runner/host_session.cpp @@ -32,6 +32,82 @@ HostSession::HostSession(const LoadedCore& core, Sink& sink) : core_(core), sink host_.presentation_size = h_presentation_size; } +void HostSession::offer_gpu_frames() { + if (gpu_offered_) return; + gpu_offered_ = true; + gpu_caps_ = {}; + gpu_caps_.struct_size = sizeof gpu_caps_; + if (!sink_.gpu_capabilities(gpu_caps_) || gpu_caps_.struct_size < sizeof gpu_caps_ || + !gpu_caps_.handle_types || + !gpu_caps_.sync_types || !gpu_caps_.max_images || + gpu_caps_.max_images > RCORE_GPU_MAX_IMAGES || !gpu_caps_.max_width || + !gpu_caps_.max_height) return; + host_.gpu_frames_capabilities = h_gpu_caps; + host_.gpu_frames_register = h_gpu_register; + host_.gpu_frames_released = h_gpu_released; + host_.video_submit_gpu = h_gpu_frame; + host_.gpu_frames_unregister = h_gpu_unregister; +} + +std::uint32_t HostSession::h_gpu_caps(void* ctx, rcore_gpu_capabilities* out) { + if (!out || out->struct_size < sizeof *out) return 0; + *out = self(ctx)->gpu_caps_; + return 1; +} + +std::uint32_t HostSession::h_gpu_register(void* ctx, const rcore_gpu_ring* ring) { + auto* s = self(ctx); + if (!ring || ring->struct_size < sizeof *ring || !ring->generation || + ring->handle_type != RCORE_GPU_HANDLE_WIN32_NT || + ring->sync_type != RCORE_GPU_SYNC_BINARY || + ring->pixel_format != RCORE_GPU_FORMAT_RGBA8 || + ring->flags != RCORE_GPU_ORIGIN_TOP_LEFT || + !(s->gpu_caps_.handle_types & ring->handle_type) || + !(s->gpu_caps_.sync_types & ring->sync_type) || + !ring->width || !ring->height || ring->width > s->gpu_caps_.max_width || + ring->height > s->gpu_caps_.max_height || !ring->image_count || + ring->image_count > s->gpu_caps_.max_images || + std::memcmp(ring->device_luid, s->gpu_caps_.device_luid, 8) || + std::memcmp(ring->device_uuid, s->gpu_caps_.device_uuid, 16) || + ring->device_node_mask != s->gpu_caps_.device_node_mask) return 0; + const std::uint64_t pixels = std::uint64_t(ring->width) * ring->height; + if (pixels > UINT64_MAX / 4) return 0; + for (std::uint32_t i = 0; i < ring->image_count; ++i) { + const auto& image = ring->images[i]; + if (image.struct_size < sizeof image || !image.memory_handle || + !image.ready_semaphore || !image.release_semaphore || + image.ready_semaphore == image.release_semaphore || + image.allocation_size < pixels * 4) return 0; + for (std::uint32_t j = 0; j < i; ++j) { + const auto& prior = ring->images[j]; + if (image.memory_handle == prior.memory_handle || + image.ready_semaphore == prior.ready_semaphore || + image.ready_semaphore == prior.release_semaphore || + image.release_semaphore == prior.ready_semaphore || + image.release_semaphore == prior.release_semaphore) return 0; + } + } + return s->sink_.gpu_register(*ring) ? 1u : 0u; +} + +std::uint32_t HostSession::h_gpu_released(void* ctx, std::uint64_t generation, + std::uint32_t* released) { + if (!released) return RCORE_GPU_RING_REFUSED; + *released = 0; + if (!generation) return RCORE_GPU_RING_REFUSED; + return self(ctx)->sink_.gpu_released(generation, *released); +} + +std::uint32_t HostSession::h_gpu_frame(void* ctx, const rcore_gpu_frame* frame) { + if (!frame || frame->struct_size < sizeof *frame || !frame->generation || + frame->image_index >= RCORE_GPU_MAX_IMAGES) return 0; + return self(ctx)->sink_.gpu_frame(*frame) ? 1u : 0u; +} + +void HostSession::h_gpu_unregister(void* ctx, std::uint64_t generation) { + if (generation) self(ctx)->sink_.gpu_unregister(generation); +} + std::uint32_t HostSession::h_presentation_size(void* ctx, std::uint32_t* width, std::uint32_t* height) { const auto* s = self(ctx); if (width) *width = s->presentation_width_; diff --git a/runner/host_session.hpp b/runner/host_session.hpp index 7363836..bcf3d65 100644 --- a/runner/host_session.hpp +++ b/runner/host_session.hpp @@ -36,6 +36,14 @@ class Sink { // its bytes, copied before this returns. Stderr headless, the hub over the // link. May arrive at any point of a frame. virtual void accessory_notify(std::uint32_t, std::uint32_t, const void*, std::size_t) {} + virtual bool gpu_capabilities(rcore_gpu_capabilities&) { return false; } + virtual bool gpu_register(const rcore_gpu_ring&) { return false; } + virtual std::uint32_t gpu_released(std::uint64_t, std::uint32_t& released) { + released = 0; + return RCORE_GPU_RING_REFUSED; + } + virtual bool gpu_frame(const rcore_gpu_frame&) { return false; } + virtual void gpu_unregister(std::uint64_t) {} }; struct SaveRegion { @@ -68,6 +76,10 @@ class HostSession { host_.gl_get_proc_address = proc ? h_gl_proc : nullptr; } + // After link negotiation, before init. Import support/adapter identity is + // sampled once; callbacks stay absent for CPU-only sinks and older hubs. + void offer_gpu_frames(); + // The host table the core is given. Stable for the session's lifetime. const rcore_host_api* host_api() const { return &host_; } void set_presentation_size(std::uint32_t width, std::uint32_t height) { @@ -120,6 +132,13 @@ class HostSession { } private: + rcore_gpu_capabilities gpu_caps_{}; + bool gpu_offered_ = false; + static std::uint32_t h_gpu_caps(void*, rcore_gpu_capabilities*); + static std::uint32_t h_gpu_register(void*, const rcore_gpu_ring*); + static std::uint32_t h_gpu_released(void*, std::uint64_t, std::uint32_t*); + static std::uint32_t h_gpu_frame(void*, const rcore_gpu_frame*); + static void h_gpu_unregister(void*, std::uint64_t); static HostSession* self(void* ctx) { return static_cast(ctx); } static void h_log(void*, std::uint32_t, const char*); diff --git a/runner/runner_link.cpp b/runner/runner_link.cpp index bc37a53..d9179e3 100644 --- a/runner/runner_link.cpp +++ b/runner/runner_link.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include namespace retro::runner { @@ -101,6 +102,113 @@ class LinkSink final : public Sink { : sock_(sock), shm_(shm), log_(out / "core.log"), events_(out / "events.tsv") { base_ = reinterpret_cast(shm); } + ~LinkSink() override { + for (auto& item : gpu_rings_) close_gpu_handles(item.second); + } + void set_gpu_capabilities(const rcore_gpu_capabilities& caps) { gpu_caps_ = caps; } + bool gpu_capabilities(rcore_gpu_capabilities& out) override { + out = gpu_caps_; + return out.handle_types && out.sync_types; + } + bool gpu_register(const rcore_gpu_ring& ring) override { + if (ring.generation <= gpu_last_generation_ || gpu_rings_.size() >= 2) return false; + GpuRing record; + record.count = ring.image_count; + GpuRingMsg msg{}; + msg.h.type = Msg::GpuRing; + msg.ring = ring; + for (std::uint32_t i = 0; i < ring.image_count; ++i) { + const auto& im = ring.images[i]; + for (auto value : {im.memory_handle, im.ready_semaphore, im.release_semaphore}) { + const auto own = retain_handle(static_cast(value)); + if (own == kNoHandle) { close_gpu_handles(record); return false; } + record.handles.push_back(own); + } + msg.ring.images[i].memory_handle = 0; + msg.ring.images[i].ready_semaphore = 0; + msg.ring.images[i].release_semaphore = 0; + } + if (!send_msg(sock_, msg, record.handles.data(), record.handles.size())) { + close_gpu_handles(record); + return false; + } + gpu_last_generation_ = ring.generation; + gpu_rings_.emplace(ring.generation, std::move(record)); + return true; + } + std::uint32_t gpu_released(std::uint64_t generation, std::uint32_t& released) override { + auto it = gpu_rings_.find(generation); + if (it == gpu_rings_.end()) { released = 0; return RCORE_GPU_RING_REFUSED; } + auto& ring = it->second; + released = ring.released; + ring.released = 0; + const auto state = ring.state; + if (state == RCORE_GPU_RING_RETIRED) { + close_gpu_handles(ring); + gpu_rings_.erase(it); + } + return state; + } + bool gpu_frame(const rcore_gpu_frame& f) override { + if (!frame_number) return false; // pre-grant pictures retain CPU output + auto it = gpu_rings_.find(f.generation); + if (it == gpu_rings_.end()) return false; + auto& ring = it->second; + if (ring.state != RCORE_GPU_RING_READY || ring.retiring || f.image_index >= ring.count || + !(ring.free & (1u << f.image_index))) return false; + GpuFrameMsg msg{}; + msg.h.type = Msg::GpuFrame; + msg.frame = f; + msg.frame.frame_number = frame_number; + if (!send_msg(sock_, msg)) return false; + ring.free &= ~(1u << f.image_index); + ring.in_flight[f.image_index] = frame_number; + last_game_frame = f.game_frame; + last_gpu_ = true; + return true; + } + void gpu_unregister(std::uint64_t generation) override { + auto it = gpu_rings_.find(generation); + if (it == gpu_rings_.end() || it->second.retiring) return; + it->second.retiring = true; + GpuUnregisterMsg msg{}; + msg.h.type = Msg::GpuUnregister; + msg.generation = generation; + send_msg(sock_, msg); + } + bool take_gpu_status(const std::vector& packet) { + Msg type{}; + if (!packet_type(packet, type) || type != Msg::GpuStatus) return false; + GpuStatusMsg msg{}; + if (!as_msg(packet, msg)) return true; + auto it = gpu_rings_.find(msg.generation); + if (it == gpu_rings_.end()) return true; // retired generation + auto& ring = it->second; + if (msg.state == RCORE_GPU_RING_REFUSED) { + ring.state = RCORE_GPU_RING_REFUSED; + ring.free = ring.released = 0; + close_gpu_handles(ring); + } else if (msg.state == RCORE_GPU_RING_READY && ring.state == RCORE_GPU_RING_PENDING) { + ring.state = RCORE_GPU_RING_READY; + ring.free = ring.released = (1u << ring.count) - 1; + close_gpu_handles(ring); // hub now owns independent references + } else { + for (std::uint32_t i = 0; i < ring.count; ++i) { + if ((msg.released & (1u << i)) && ring.in_flight[i] && + ring.in_flight[i] == msg.frame_number[i]) { + ring.in_flight[i] = 0; + ring.free |= 1u << i; + ring.released |= 1u << i; + } + } + if (msg.state == RCORE_GPU_RING_RETIRED && ring.retiring && + std::all_of(ring.in_flight.begin(), ring.in_flight.end(), [](auto v) { return v == 0; })) { + ring.state = RCORE_GPU_RING_RETIRED; + close_gpu_handles(ring); + } + } + return true; + } void log(std::uint32_t level, const char* msg) override { log_ << level << '\t' << msg << '\n'; @@ -134,6 +242,7 @@ class LinkSink final : public Sink { } void frame(const rcore_frame& f) override { + last_gpu_ = false; if (f.pixel_format != RCORE_PIXEL_RGBA8 || f.width > kMaxFrameWidth || f.height > kMaxFrameHeight) { ++bad_frames; // reported at the end of the frame, not mid-core @@ -166,6 +275,8 @@ class LinkSink final : public Sink { // slot is the middle or the hub's front: nobody writes it until the runner // publishes again, which it is not doing while it answers a request. std::vector thumbnail() const { + if (host_thumb_frame_ == frame_number) return host_thumbnail_; + if (last_gpu_) return {}; // a CPU slot from an earlier field is stale if (published_ < 0) return {}; const FrameInfo& info = shm_->slot[published_]; return state::make_thumbnail(base_ + shm_->frames_offset + @@ -173,6 +284,15 @@ class LinkSink final : public Sink { info.width, info.height, info.stride); } + void take_thumbnail(const std::vector& packet) { + static_assert(state::kThumbWidth * state::kThumbHeight * 4 == kStateThumbBytes); + StateThumbnailMsg msg{}; + if (as_msg(packet, msg) && msg.frame_number == frame_number) { + host_thumbnail_.assign(std::begin(msg.rgba), std::end(msg.rgba)); + host_thumb_frame_ = frame_number; + } + } + void audio(const std::int16_t* samples, std::uint32_t n) override { const std::uint64_t cap = shm_->audio_capacity; const std::uint64_t w = shm_->audio_write.load(std::memory_order_relaxed); @@ -223,6 +343,23 @@ class LinkSink final : public Sink { std::uint32_t rate_num = 0, rate_den = 0; private: + bool last_gpu_ = false; + std::uint64_t host_thumb_frame_ = UINT64_MAX; + std::vector host_thumbnail_; + struct GpuRing { + std::uint32_t state = RCORE_GPU_RING_PENDING; + std::uint32_t count = 0, free = 0, released = 0; + bool retiring = false; + std::array in_flight{}; + std::vector handles; + }; + static void close_gpu_handles(GpuRing& ring) { + for (auto h : ring.handles) close_handle(h); + ring.handles.clear(); + } + std::map gpu_rings_; + rcore_gpu_capabilities gpu_caps_{}; + std::uint64_t gpu_last_generation_ = 0; Channel& sock_; SharedHeader* shm_; std::uint8_t* base_; @@ -292,6 +429,16 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li LinkSink sink(sock, shm, a.out); HostSession session(core, sink); + if (link_has_gpu_frames(session_minor)) { + std::vector packet; + if (recv_packet(sock, packet, nullptr, -1) != RecvResult::Packet) return 0; + GpuCapabilitiesMsg offered{}; + Msg type{}; + if (!packet_type(packet, type) || type != Msg::GpuCapabilities || !as_msg(packet, offered)) + return exiting(sock, 2, "link: missing GPU capability setup"); + sink.set_gpu_capabilities(offered.caps); + session.offer_gpu_frames(); + } for (const DataAccessory& d : a.data_accessories) session.bind_data_accessory(d.seat, d.slot); // 2.1: a hub's AccessoryData, queued for the next granted frame. One // arriving for a (seat, slot) nothing is plugged into is dropped and said @@ -383,6 +530,7 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li if (rr != RecvResult::Packet) return 0; // the hub went away before playing Msg t{}; packet_type(buf, t); + if (sink.take_gpu_status(buf)) continue; SavesFilledMsg filled{}; if (t == Msg::SavesFilled && as_msg(buf, filled)) { std::copy(std::begin(filled.pads), std::end(filled.pads), std::begin(sink.pads)); @@ -461,7 +609,12 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li if (rr != RecvResult::Packet) break; // hub gone: stop quietly Msg t{}; packet_type(buf, t); + if (sink.take_gpu_status(buf)) continue; if (t == Msg::Quit) break; + if (t == Msg::StateThumbnail && link_has_gpu_frames(session_minor)) { + sink.take_thumbnail(buf); + continue; + } if (t == Msg::AccessoryData) { take_accessory_data(buf); continue; @@ -549,6 +702,7 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li if (recv_packet(sock, peek, nullptr, 0) == RecvResult::Packet) { Msg pt{}; packet_type(peek, pt); + if (sink.take_gpu_status(peek)) continue; if (pt == Msg::Quit) { quit = true; break; diff --git a/runner/runner_main.cpp b/runner/runner_main.cpp index 3ad0245..408ad25 100644 --- a/runner/runner_main.cpp +++ b/runner/runner_main.cpp @@ -104,6 +104,10 @@ namespace { constexpr std::uint32_t kScriptCLeft = 1u << 28, kScriptCRight = 1u << 29, kScriptCUp = 1u << 30, kScriptCDown = 1u << 31; constexpr std::uint32_t kScriptStick = kScriptCLeft | kScriptCRight | kScriptCUp | kScriptCDown; +// Scripted directions drive the left analog stick; C directions drive the right. +constexpr std::uint32_t kScriptLeft = 1u << 20, kScriptRight = 1u << 21, + kScriptUp = 1u << 22, kScriptDown = 1u << 23; +constexpr std::uint32_t kScriptLeftStick = kScriptLeft | kScriptRight | kScriptUp | kScriptDown; std::uint32_t script_buttons(const std::string& s) { std::uint32_t b = 0; @@ -123,6 +127,10 @@ std::uint32_t script_buttons(const std::string& s) { else if (tok == "ddown") b |= RCORE_PAD_DPAD_DOWN; else if (tok == "dleft") b |= RCORE_PAD_DPAD_LEFT; else if (tok == "dright") b |= RCORE_PAD_DPAD_RIGHT; + else if (tok == "left") b |= kScriptLeft; + else if (tok == "right") b |= kScriptRight; + else if (tok == "up") b |= kScriptUp; + else if (tok == "down") b |= kScriptDown; else if (tok == "cleft") b |= kScriptCLeft; else if (tok == "cright") b |= kScriptCRight; else if (tok == "cup") b |= kScriptCUp; @@ -203,7 +211,9 @@ class HeadlessSink final : public Sink { for (const auto& [at, buttons] : script_) { if (at <= *frame_) held = buttons; } - pad.buttons = held & ~kScriptStick; + pad.buttons = held & ~(kScriptStick | kScriptLeftStick); + pad.axes[RCORE_AXIS_LX] = (held & kScriptLeft) ? -32767 : (held & kScriptRight) ? 32767 : 0; + pad.axes[RCORE_AXIS_LY] = (held & kScriptUp) ? 32767 : (held & kScriptDown) ? -32767 : 0; pad.axes[RCORE_AXIS_RX] = (held & kScriptCLeft) ? -32767 : (held & kScriptCRight) ? 32767 : 0; pad.axes[RCORE_AXIS_RY] = (held & kScriptCUp) ? 32767 : (held & kScriptCDown) ? -32767 : 0; } @@ -805,7 +815,7 @@ int main(int argc, char** argv) { // The local pad for the next frames: the script, on seat 0's terms. rcore_pad local{}; local.struct_size = sizeof local; - frame = live + 1; + frame = static_cast(ns.tick()) + 1; sink.input(0, local); ns.stage_local(local); const NetSession::Admit a = ns.poll(); @@ -831,15 +841,26 @@ int main(int argc, char** argv) { } ns.finish(a); if (core.api->state_hash) tick_hash[ns.tick()] = core.api->state_hash(); - if (a == NetSession::Admit::Live && ++live >= target && !quiescing) { - ns.request_quiesce(); - quiescing = true; + if (a == NetSession::Admit::Live) { + ++live; + // Count completed simulation ticks; live admissions can repeat + // ticks discarded by a rollback and overstate actual progress. + if (ns.tick() >= target && !quiescing) { + ns.request_quiesce(); + quiescing = true; + } } if (ns.drained()) break; } const double secs = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); const NetStats st = ns.stats(); + { + std::ofstream hashes(out / "confirmed-hashes.tsv"); + for (const auto& [tick, hash] : tick_hash) { + if (tick <= st.confirmed_through) hashes << tick << '\t' << std::hex << hash << std::dec << '\n'; + } + } // A frame both peers ran and the digest chain confirmed. const std::uint32_t at_tick = target > 20 ? static_cast(target - 20) : 0; const auto hit = tick_hash.find(at_tick); diff --git a/tests/gpu_host_test.cpp b/tests/gpu_host_test.cpp new file mode 100644 index 0000000..3a52af2 --- /dev/null +++ b/tests/gpu_host_test.cpp @@ -0,0 +1,110 @@ +#include "host_session.hpp" +#include +#include + +using namespace retro::runner; +void check(bool ok, const char* what) { + if (!ok) { std::cerr << "FAIL " << what << '\n'; std::exit(1); } +} +struct TestSink : Sink { + bool available = false; + unsigned samples = 0, registrations = 0, pictures = 0, retires = 0; + rcore_gpu_capabilities caps{}; + void log(std::uint32_t, const char*) override {} + void event(const rcore_event&) override {} + void frame(const rcore_frame&) override {} + void audio(const std::int16_t*, std::uint32_t) override {} + void audio_rate(std::uint32_t) override {} + void input(std::uint32_t, rcore_pad&) override {} + bool gpu_capabilities(rcore_gpu_capabilities& out) override { + ++samples; out = caps; return available; + } + bool gpu_register(const rcore_gpu_ring&) override { ++registrations; return true; } + std::uint32_t gpu_released(std::uint64_t, std::uint32_t& mask) override { + mask = 2; return RCORE_GPU_RING_READY; + } + bool gpu_frame(const rcore_gpu_frame&) override { ++pictures; return true; } + void gpu_unregister(std::uint64_t) override { ++retires; } +}; +int main() { + LoadedCore core; + TestSink absent; + HostSession cpu(core, absent); + cpu.offer_gpu_frames(); + absent.available = true; + cpu.offer_gpu_frames(); + check(absent.samples == 1 && !cpu.host_api()->gpu_frames_capabilities && + !cpu.host_api()->video_submit_gpu, "unsupported host remains CPU for session"); + + TestSink sink; + sink.available = true; + sink.caps.struct_size = sizeof sink.caps; + sink.caps.handle_types = RCORE_GPU_HANDLE_WIN32_NT; + sink.caps.sync_types = RCORE_GPU_SYNC_BINARY; + sink.caps.max_images = 3; + sink.caps.max_width = sink.caps.max_height = UINT32_MAX; + sink.caps.device_uuid[0] = 7; + sink.caps.device_luid[0] = 9; + sink.caps.device_node_mask = 1; + HostSession gpu(core, sink); + gpu.offer_gpu_frames(); + const auto* h = gpu.host_api(); + check(h->gpu_frames_capabilities && h->gpu_frames_register && h->gpu_frames_released && + h->video_submit_gpu && h->gpu_frames_unregister, "complete optional API"); + sink.caps.device_uuid[0] = 55; + gpu.offer_gpu_frames(); + rcore_gpu_capabilities sampled{}; + sampled.struct_size = sizeof sampled; + check(h->gpu_frames_capabilities(h->host_ctx, &sampled) && sampled.device_uuid[0] == 7 && + sink.samples == 1, "actual capabilities sampled once and fixed"); + --sampled.struct_size; + check(!h->gpu_frames_capabilities(h->host_ctx, &sampled), "short capability output refused"); + + rcore_gpu_ring ring{}; + ring.struct_size = sizeof ring; + ring.generation = 42; + ring.handle_type = RCORE_GPU_HANDLE_WIN32_NT; + ring.sync_type = RCORE_GPU_SYNC_BINARY; + ring.pixel_format = RCORE_GPU_FORMAT_RGBA8; + ring.flags = RCORE_GPU_ORIGIN_TOP_LEFT; + ring.width = 8; ring.height = 4; ring.image_count = 2; + ring.device_uuid[0] = 7; ring.device_luid[0] = 9; ring.device_node_mask = 1; + for (unsigned i = 0; i < ring.image_count; ++i) { + ring.images[i].struct_size = sizeof(rcore_gpu_image); + ring.images[i].allocation_size = 128; + ring.images[i].memory_handle = 100 + i * 3; + ring.images[i].ready_semaphore = 101 + i * 3; + ring.images[i].release_semaphore = 102 + i * 3; + } + check(h->gpu_frames_register(h->host_ctx, &ring) == 1, "valid ring reaches sink"); + auto reject = [&](rcore_gpu_ring bad, const char* why) { + const auto before = sink.registrations; + check(!h->gpu_frames_register(h->host_ctx, &bad) && sink.registrations == before, why); + }; + auto bad = ring; bad.device_uuid[0] ^= 1; reject(bad, "UUID mismatch"); + bad = ring; bad.device_luid[0] ^= 1; reject(bad, "LUID mismatch"); + bad = ring; bad.device_node_mask = 2; reject(bad, "node mismatch"); + bad = ring; bad.image_count = 4; reject(bad, "too many images"); + bad = ring; bad.width = 0; reject(bad, "empty geometry"); + bad = ring; bad.generation = 0; reject(bad, "zero generation"); + bad = ring; bad.images[0].allocation_size = 127; reject(bad, "short image allocation"); + bad = ring; bad.images[0].ready_semaphore = bad.images[0].release_semaphore; reject(bad, "same binary semaphore"); + bad = ring; bad.images[1].memory_handle = bad.images[0].memory_handle; reject(bad, "duplicate image handle"); + bad = ring; bad.images[1].ready_semaphore = bad.images[0].release_semaphore; reject(bad, "cross-image semaphore alias"); + bad = ring; bad.width = bad.height = UINT32_MAX; + bad.images[0].allocation_size = bad.images[1].allocation_size = UINT64_MAX; + reject(bad, "pixel byte-count overflow"); + std::uint32_t released = 88; + check(h->gpu_frames_released(h->host_ctx, 0, &released) == RCORE_GPU_RING_REFUSED && released == 0, + "invalid release is empty"); + check(h->gpu_frames_released(h->host_ctx, 42, &released) == RCORE_GPU_RING_READY && released == 2, + "ownership event forwarded"); + rcore_gpu_frame frame{}; + frame.struct_size = sizeof frame; frame.generation = 42; + check(h->video_submit_gpu(h->host_ctx, &frame), "GPU submission accepted"); + frame.image_index = 3; + check(!h->video_submit_gpu(h->host_ctx, &frame) && sink.pictures == 1, "invalid slot refused"); + h->gpu_frames_unregister(h->host_ctx, 0); h->gpu_frames_unregister(h->host_ctx, 42); + check(sink.retires == 1, "one retirement forwarded"); + std::cout << "GPU host contract: fixed offer, adapter and geometry refusal, CPU fallback PASS\n"; +}