From 9e32bbd9560268c8866c442d28f4e6ffa0d4c631 Mon Sep 17 00:00:00 2001 From: Gregory Lirent Date: Sat, 20 Jun 2026 13:20:28 +0300 Subject: [PATCH] feat: add host-side vgpu-perception library Read-only consumer of the vgpu region: discovers it by structural invariants, samples frames and reads cursor/geometry/status under seqlock, and builds (never writes) the control frame. Built as a separate host CMake project; the memory-model dependency source path is supplied via -DLIBVMIE_PATH at configure time. --- include/vgpu_perception.h | 221 +++++++++++++++ src/perception/CMakeLists.txt | 40 +++ src/perception/control.c | 35 +++ src/perception/discover.c | 114 ++++++++ src/perception/include/perception-internal.h | 139 ++++++++++ src/perception/sample.c | 200 ++++++++++++++ src/perception/test/test_perception.c | 270 +++++++++++++++++++ 7 files changed, 1019 insertions(+) create mode 100644 include/vgpu_perception.h create mode 100644 src/perception/CMakeLists.txt create mode 100644 src/perception/control.c create mode 100644 src/perception/discover.c create mode 100644 src/perception/include/perception-internal.h create mode 100644 src/perception/sample.c create mode 100644 src/perception/test/test_perception.c diff --git a/include/vgpu_perception.h b/include/vgpu_perception.h new file mode 100644 index 0000000..0f29b0b --- /dev/null +++ b/include/vgpu_perception.h @@ -0,0 +1,221 @@ +#ifndef VGPU_PERCEPTION_H +#define VGPU_PERCEPTION_H + +/* vgpu_perception.h — host-side, read-only perception over the vgpu region. + * + * A pure functional core that builds vgpu semantics ON TOP OF a guest + * address-space root handed in by the caller. It only PERCEIVES: it discovers + * the region by structural invariants, samples frames and reads cursor / + * geometry / lifecycle, and returns SNAPSHOTS (POD values). It never owns + * coherence, never opens RW guest memory, never decides control or behavioural + * timing, never emits events upward. + * + * What this core does NOT do (by design — those belong to the caller): + * - It does NOT own the vmie_mem / coherent address-space root: (m, kcr3) are + * BORROWED. The core never opens or closes a vmie_mem; the caller opens it + * for the current guest address-space mapping and closes it when that + * mapping goes stale. + * - It does NOT sleep / poll / spawn threads / arm timers: the two-phase + * liveness handshake is two calls; the WAIT between them is the caller's. + * - It does NOT transport frames. Frame transport is the caller's concern; + * the core is a PULL source — the caller takes desc+bytes from + * vgpup_sample_frame and routes them. No sink callback here. + * - It does NOT write control. vgpup_build_control_write only BUILDS the + * desired frame + offsets; the actual write is performed elsewhere, by a + * component that holds read-write access to the region. + * + * Ownership convention: + * - vmie_mem* m, uintptr_t kcr3 — BORROWED. The caller owns their lifecycle + * (tied to the address-space mapping). The core only reads through them. + * - vgpup_region* — heap-owned by the core (small private state). Create with + * vgpup_open, release with vgpup_close. Closing it does NOT touch (m, kcr3). + * + * Conventions (mirror memmodel.h): + * - kcr3 is the System address space CR3 (the region is a pinned device + * shared-section visible as a kernel VA). A "GVA" is a 64-bit guest VA. + * - All guest reads go through gva_read into a local copy; no borrowed + * pointer into guest memory ever escapes a seqlock window or this API. + * - Integer returns: 0 success / negative failure for deterministic calls. + * Lossy read calls (sample/cursor/geometry) are tristate: 1 = consistent + * snapshot produced, 0 = no fresh data / writer kept it busy past the retry + * limit / would not fit (a SKIP, never an error — do not block), <0 = a + * hard memory-read error (page gone / mapping stale — the caller re-discovers). + * + * Example (the caller drives the two-phase liveness and the read loop): + * + * // caller already opened a RO vmie_mem for the current address-space mapping: + * vmie_mem* m = caller_mem; // BORROWED by the core + * uintptr_t kcr3 = caller_kcr3; // System AS + * + * vgpup_region* r = vgpup_open(m, kcr3); // phase 1: candidate + hb0 + * if (!r) { return; } // no region under this AS + * + * // phase 2 is the caller's: it waits >= VGPU_HEARTBEAT_PERIOD_MS, then + * uint64_t region_gva, hb0; + * vgpup_discover_candidate(m, kcr3, ®ion_gva, &hb0); // (or reuse open's) + * // ... the caller sleeps here, NOT the core ... + * int alive = vgpup_confirm_alive(m, kcr3, region_gva, hb0); + * + * // sampling (lossy pull): + * static uint8_t buf[VGPU_SLOT_STRIDE]; + * vgpup_frame_info fi; + * if (vgpup_sample_frame(r, m, kcr3, buf, sizeof buf, &fi) == 1) { + * // route fi.desc + buf[0..fi.bytes) to the chosen transport + * } + * + * vgpup_close(r); // frees core state only; (m, kcr3) stay with the caller + */ + +#include +#include + +#include "vgpu_stream.h" /* region ABI: producer/control types, slot geometry */ +#include "memmodel.h" /* vmie_mem, gva_* (BORROWED access primitives) */ + +/* Opaque found vgpu region under (vmie_mem, kcr3). Heap-owned by the core; holds + * only small private state (region GVA, last frame_id, last run_epoch). It does + * NOT own (m, kcr3) — those are passed back in on every read. */ +typedef struct vgpup_region vgpup_region; + +/* ---- handle / lifecycle (the core does NOT own vmie_mem) ------------------ */ + +/* Phase-1 discover + bind: find the region by structural invariants, snapshot + * hb0, and build a handle. (m, kcr3) are BORROWED — the core reads them but + * never closes them. Returns a heap-owned vgpup_region*, or NULL if no region + * is found under this address space. Liveness is NOT yet proven: the caller must + * call vgpup_confirm_alive after waiting >= VGPU_HEARTBEAT_PERIOD_MS. Sampling + * before confirmation is allowed (lossy); "producer alive" is true only after a + * positive confirm. */ +vgpup_region* vgpup_open(vmie_mem* m, uintptr_t kcr3); + +/* Release ONLY the core state. Does NOT touch (m, kcr3) — the caller closes + * those (their lifetime is the caller's). Safe on NULL. */ +void vgpup_close(vgpup_region* r); + +/* ---- two-phase discovery (the WAIT belongs to the caller) ----------------- */ + +/* Phase 1: find a candidate by structural invariants (no liveness). On success + * writes the region base GVA (== producer-block GVA) to *out_region_gva and the + * heartbeat snapshot to *out_hb0, and returns 0. Returns <0 if no candidate is + * found or a read fails. Pure; does NOT wait. */ +int vgpup_discover_candidate(vmie_mem* m, uintptr_t kcr3, + uint64_t* out_region_gva, uint64_t* out_hb0); + +/* Phase 2: confirm liveness. The caller calls this >= VGPU_HEARTBEAT_PERIOD_MS + * after phase 1. Re-reads heartbeat at region_gva and returns 1 if it advanced + * (alive producer), 0 if it did not tick (dead / not the region), <0 on a read + * error. Pure; does NOT wait — the inter-phase delay is the caller's. */ +int vgpup_confirm_alive(vmie_mem* m, uintptr_t kcr3, + uint64_t region_gva, uint64_t hb0); + +/* ---- snapshots (POD values; read under their seqlock discipline) ---------- */ + +/* Snapshot of the last published frame's descriptor (read under seq[slot]). */ +typedef struct { + uint32_t width, height, stride, format; + uint64_t frame_id; + uint64_t timestamp_ns; +} vgpup_frame_desc; + +/* Result of a frame sample: the descriptor plus the count of bytes copied into + * the caller's buffer (== height*stride, tight). */ +typedef struct { + vgpup_frame_desc desc; + size_t bytes; +} vgpup_frame_info; + +/* Cursor snapshot (read under the cursor_seq acquire gate). seq lets the caller + * tell "cursor idle" from "producer stopped reporting". */ +typedef struct { + uint32_t seq; /* cursor_seq observed for this snapshot */ + uint32_t visible; /* 1 = shown, 0 = hidden */ + int32_t x, y; /* unpacked from cursor_pos (signed) */ + uint16_t hot_x, hot_y; /* unpacked from cursor_hotspot */ + uint16_t glyph_w, glyph_h; /* unpacked from cursor_glyph */ + uint32_t id; /* VGPU_CURSOR_ID_* */ +} vgpup_cursor; + +/* Display-geometry snapshot (read under the geom_seq seqlock). */ +typedef struct { + int32_t virt_x, virt_y; + uint32_t virt_w, virt_h; + int32_t cap_x, cap_y; + uint32_t dpi, refresh_mhz; +} vgpup_geometry; + +/* Lifecycle / status snapshot (cold line; single naturally-aligned atomic + * fields, no seqlock — "fresh enough" by the lossy contract). */ +typedef struct { + uint64_t heartbeat; + uint32_t run_epoch; + uint32_t status; /* VGPU_ST_* */ + uint32_t backend; /* VGPU_BK_* */ + uint32_t error_code; + uint32_t applied_fps; + uint32_t supported_formats; + uint32_t ctrl_ack; + uint32_t full_frame_ack; + uint64_t content_change_ns; +} vgpup_status; + +/* ---- read API (lossy; seqlock discipline lives inside) -------------------- */ + +/* Sample the latest frame. Seqlock-reads latest/seq[slot]/desc, copies the slot + * bytes out of the RING via gva_read, then re-checks seq[slot] in one window. + * dst is the caller's buffer, cap its capacity. Returns 1 = a fresh frame was + * copied (info filled), 0 = no new frame / writer busy past the retry limit / + * frame would not fit cap (lossy SKIP, not an error), <0 = a memory-read error. + * "Fresh" dedups by frame_id: a frame_id <= the last sampled one returns 0. */ +int vgpup_sample_frame(vgpup_region* r, vmie_mem* m, uintptr_t kcr3, + uint8_t* dst, size_t cap, vgpup_frame_info* info); + +/* Read the cursor under the cursor_seq acquire gate. 1 = consistent snapshot, + * 0 = writer busy past the retry limit, <0 = read error. */ +int vgpup_read_cursor(vgpup_region* r, vmie_mem* m, uintptr_t kcr3, + vgpup_cursor* out); + +/* Read display geometry under the geom_seq seqlock. Returns as read_cursor. */ +int vgpup_read_geometry(vgpup_region* r, vmie_mem* m, uintptr_t kcr3, + vgpup_geometry* out); + +/* Read the cold-line status/lifecycle. 0 = success, <0 = read error. The single + * atomic fields carry no seqlock; the snapshot is "fresh enough" (lossy). */ +int vgpup_read_status(vgpup_region* r, vmie_mem* m, uintptr_t kcr3, + vgpup_status* out); + +/* The run_epoch from the last vgpup_read_status — a session-break detector for + * the caller while kcr3 stays live. The core only reports the raw value; it + * holds no reset policy (what to reset is the caller's decision). */ +uint32_t vgpup_run_epoch(const vgpup_region* r); + +/* ---- control-write — SEAM ONLY (this never writes) ------------------------ */ + +/* Desired control-block value (host-RW fields). The caller builds it and later + * forwards it to the writer; the actual gva_write is performed elsewhere, by the + * component that holds read-write access to the region. */ +typedef struct { + uint32_t desired_state; /* VGPU_CMD_* */ + uint32_t target_fps; /* 0 = producer default */ + uint32_t draw_cursor; /* 0/1 */ + uint32_t full_frame_req; /* edge counter (caller bumps vs the previous) */ +} vgpup_control_intent; + +/* Build a control frame WITHOUT writing: fill a vgpu_control_t image from `in`, + * and report the control-block GVA plus the offset/length of the significant + * field range, so an external read-write writer can perform an atomic write + * under the ctrl_gen seqlock. This NEVER touches guest memory (the RO fd would + * not allow it anyway). ctrl_gen is left zero here: the writer owns it under the + * seqlock. The significant range is desired_state .. full_frame_req; + * consumer_tick/attached carry separate heartbeat/intent semantics and are NOT + * part of this intent. + * out_frame — filled vgpu_control_t (significant fields from `in`) + * out_ctrl_gva — control-block GVA (region base + VGPU_CONTROL_OFFSET) + * out_off — offset of the first significant field (offsetof desired_state) + * out_len — length of the significant range (through full_frame_req) + * Returns 0 on success, <0 if r is NULL. The write itself is performed + * elsewhere; there is no live gva_write here and there must not be. */ +int vgpup_build_control_write(vgpup_region* r, const vgpup_control_intent* in, + vgpu_control_t* out_frame, uint64_t* out_ctrl_gva, + uint32_t* out_off, uint32_t* out_len); + +#endif /* VGPU_PERCEPTION_H */ diff --git a/src/perception/CMakeLists.txt b/src/perception/CMakeLists.txt new file mode 100644 index 0000000..4359303 --- /dev/null +++ b/src/perception/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.18) # add_subdirectory of vmie (needs find_program REQUIRED) +project(vgpu-perception C) # standalone host project, native gcc + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) + +# vmie is our own library — reference its SOURCES by path, never a third_party copy. +# LIBVMIE_PATH is supplied from OUTSIDE at configure time → no private path in the tree. +set(LIBVMIE_PATH "" CACHE PATH "Path to the vmie library source tree (host memory model)") +if(NOT LIBVMIE_PATH) + message(FATAL_ERROR "vgpu-perception: set -DLIBVMIE_PATH=/path/to/vmie/sources") +endif() + +# Build vmie's static lib from its own sources. EXCLUDE_FROM_ALL: only the `vmie` +# target we link is built (not vmie's CLI/scan demos or its guest exe). memmodel.h +# arrives transitively via the vmie target's PUBLIC include dir — no manual include, +# no vendored header to keep in sync. +add_subdirectory(${LIBVMIE_PATH} ${CMAKE_BINARY_DIR}/vmie-build EXCLUDE_FROM_ALL) + +set(REPO ${CMAKE_CURRENT_SOURCE_DIR}/../..) # repo root (this lives in src/perception) + +add_library(vgpu-perception STATIC + discover.c + sample.c + control.c) +target_include_directories(vgpu-perception + PUBLIC ${REPO}/include # vgpu_perception.h, vgpu_stream.h + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) # perception-internal.h +target_link_libraries(vgpu-perception PUBLIC vmie) # memmodel.h include comes transitively +target_compile_options(vgpu-perception PRIVATE -O2 -Wall -Wextra) + +# table-driven test: invariant predicates + flat sampling smoke +enable_testing() +add_executable(vgpu-perception-test test/test_perception.c) +target_include_directories(vgpu-perception-test + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) # memmodel.h via vgpu-perception -> vmie (transitive) +target_link_libraries(vgpu-perception-test PRIVATE vgpu-perception) +target_compile_options(vgpu-perception-test PRIVATE -O2 -Wall -Wextra) +add_test(NAME vgpu-perception-test COMMAND vgpu-perception-test) diff --git a/src/perception/control.c b/src/perception/control.c new file mode 100644 index 0000000..ba2c782 --- /dev/null +++ b/src/perception/control.c @@ -0,0 +1,35 @@ +/* control.c — control-write SEAM ONLY (this never writes guest memory). + * + * The actual write is performed elsewhere, by a component that holds read-write + * access to the region; this only builds the desired vgpu_control_t image from + * the intent and computes the GVA + offset/length of the significant field range + * for that atomic write under the ctrl_gen seqlock. There is no gva_write here + * and there must not be — the source is a RO fd that would fault on a store anyway. + */ + +#include "perception-internal.h" + +int vgpup_build_control_write(vgpup_region* r, const vgpup_control_intent* in, + vgpu_control_t* out_frame, uint64_t* out_ctrl_gva, + uint32_t* out_off, uint32_t* out_len) +{ + if (!r || !in || !out_frame || !out_ctrl_gva || !out_off || !out_len) { return -1; } + + /* Fill the desired control image. ctrl_gen stays 0: the writer owns it under + * the seqlock. consumer_tick/attached carry separate heartbeat/intent + * semantics and are not part of this intent. */ + memset(out_frame, 0, sizeof *out_frame); + out_frame->desired_state = in->desired_state; + out_frame->target_fps = in->target_fps; + out_frame->draw_cursor = in->draw_cursor; + out_frame->full_frame_req = in->full_frame_req; + + *out_ctrl_gva = r->ctrl_gva; /* region base + VGPU_CONTROL_OFFSET (cached) */ + + /* Significant range: desired_state .. full_frame_req (contiguous in the ABI), + * i.e. offsetof(desired_state) through the end of full_frame_req. */ + *out_off = (uint32_t)offsetof(vgpu_control_t, desired_state); + *out_len = (uint32_t)(offsetof(vgpu_control_t, full_frame_req) + sizeof(uint32_t) + - offsetof(vgpu_control_t, desired_state)); + return 0; +} diff --git a/src/perception/discover.c b/src/perception/discover.c new file mode 100644 index 0000000..eb03203 --- /dev/null +++ b/src/perception/discover.c @@ -0,0 +1,114 @@ +/* discover.c — region discovery by structural invariants (NO magic) + handle. + * + * The region is a pinned device shared-section projected into the System address + * space under kcr3, so we scan the KERNEL canonical half [KERN_MIN, ~0]. We find + * a contiguous readable run >= VGPU_REGION_BYTES (the region is GVA-contiguous, + * possibly spread across adjacent same-protection runs), read the producer block + * at its base, and accept it iff the whole structural-invariant table holds. + * + * There is NO magic field in the ABI and the owner forbids inventing one. The + * discriminator is the invariant table plus two-phase heartbeat liveness — and + * the inter-phase WAIT is the caller's (the core never sleeps). + */ + +#include + +#include "perception-internal.h" + +/* How many region records to ask for when probing the System AS. The kernel half + * has few large same-protection runs; this is generous for the shared-section. */ +#define VGPUP_MAX_REGIONS 256 + +/* Read the producer block at `region_gva` into *out (one gva_read of the whole + * block). 0 on success, <0 on read error. */ +static int read_producer_block(vmie_mem* m, uintptr_t kcr3, uint64_t region_gva, + vgpu_producer_t* out) +{ + return gva_read(m, kcr3, (uintptr_t)region_gva, out, sizeof *out) < 0 ? -1 : 0; +} + +/* Walk the region runs in the kernel half and, for each contiguous readable span + * of >= VGPU_REGION_BYTES, test the producer block at the span base against the + * invariant table. On the first accepted candidate, write its base GVA + the + * heartbeat snapshot and return 0. Returns <0 if none is found / a read fails. + * + * Adjacent same-protection runs are coalesced: gva_regions reports VA-contiguous + * runs, but a region can land as one run or as touching neighbours, so we extend + * a running span while the next run starts exactly where the current one ends. */ +int vgpup_discover_candidate(vmie_mem* m, uintptr_t kcr3, + uint64_t* out_region_gva, uint64_t* out_hb0) +{ + vregion runs[VGPUP_MAX_REGIONS]; + int n, i; + + if (!m || !out_region_gva || !out_hb0) { return -1; } + + n = gva_regions(m, kcr3, KERN_MIN, ~0ull, VR_R, runs, VGPUP_MAX_REGIONS); + if (n < 0) { return -1; } + if (n > VGPUP_MAX_REGIONS) { n = VGPUP_MAX_REGIONS; } /* truncated; probe what we got */ + + for (i = 0; i < n; ++i) { + uint64_t span_base = runs[i].va; + uint64_t span_len = runs[i].len; + int j = i; + + /* coalesce adjacent readable runs into one contiguous span */ + while (j + 1 < n && runs[j + 1].va == runs[j].va + runs[j].len) { + span_len += runs[j + 1].len; + ++j; + } + + if (span_len >= VGPU_REGION_BYTES) { + vgpu_producer_t p; + if (read_producer_block(m, kcr3, span_base, &p) == 0 && + vgpup_invariants_hold(&p)) { + *out_region_gva = span_base; + *out_hb0 = p.heartbeat; + return 0; + } + } + } + return -1; +} + +/* Phase 2: re-read heartbeat at region_gva and report whether it advanced. The + * caller must have waited >= VGPU_HEARTBEAT_PERIOD_MS since phase 1. */ +int vgpup_confirm_alive(vmie_mem* m, uintptr_t kcr3, + uint64_t region_gva, uint64_t hb0) +{ + uint64_t hb_now; + if (!m) { return -1; } + if (gva_read(m, kcr3, (uintptr_t)region_gva + offsetof(vgpu_producer_t, heartbeat), + &hb_now, sizeof hb_now) < 0) { + return -1; + } + return (hb_now - hb0) > 0u ? 1 : 0; +} + +vgpup_region* vgpup_open(vmie_mem* m, uintptr_t kcr3) +{ + uint64_t region_gva = 0, hb0 = 0; + vgpup_region* r; + + if (vgpup_discover_candidate(m, kcr3, ®ion_gva, &hb0) != 0) { return NULL; } + + r = (vgpup_region*)calloc(1, sizeof *r); + if (!r) { return NULL; } + + r->region_gva = region_gva; + r->ctrl_gva = region_gva + VGPU_CONTROL_OFFSET; + r->ring_gva = region_gva + VGPU_RING_OFFSET; + r->last_frame_id = 0; + r->run_epoch = 0; + return r; +} + +void vgpup_close(vgpup_region* r) +{ + free(r); /* core state only; (m, kcr3) belong to the caller */ +} + +uint32_t vgpup_run_epoch(const vgpup_region* r) +{ + return r ? r->run_epoch : 0u; +} diff --git a/src/perception/include/perception-internal.h b/src/perception/include/perception-internal.h new file mode 100644 index 0000000..d56eabf --- /dev/null +++ b/src/perception/include/perception-internal.h @@ -0,0 +1,139 @@ +#ifndef VGPU_PERCEPTION_INTERNAL_H +#define VGPU_PERCEPTION_INTERNAL_H + +/* perception-internal.h — private consumer-side helpers (NOT a public surface). + * + * Holds the core's private state type, the consumer-side seqlock read discipline + * (the mirror of the producer's atomic-shim accessors, but an independent body — + * we read into local copies via gva_read, never sharing producer code), the + * structural-invariant validator table used by discovery, and the bit unpackers + * for the packed cursor fields. Included only by the perception TUs. + * + * Consumer seqlock discipline: every guest read goes through gva_read into a + * local copy, so the compiler cannot reorder a data read across the seq read — + * each gva_read is an opaque call. We still bump the seq read into its own + * gva_read and treat odd seq / changed seq as "writer in flight → retry". + */ + +#include +#include +#include + +#include "vgpu_stream.h" +#include "memmodel.h" +#include "vgpu_perception.h" + +/* Bounded seqlock retry. Producer windows are short (a single slot publish), so + * a small count suffices; spinning longer would be a behavioural timing choice + * (control's job), which does not belong in the sensor. Exhausted → lossy skip. */ +#define VGPUP_SEQLOCK_RETRIES 8u + +/* Private core state. Owns nothing of the address space — only where the region + * lives and the last-seen monotonic markers for dedup / session-break. */ +struct vgpup_region { + uint64_t region_gva; /* producer-block GVA == region base */ + uint64_t ctrl_gva; /* region_gva + VGPU_CONTROL_OFFSET (cached) */ + uint64_t ring_gva; /* region_gva + VGPU_RING_OFFSET (cached) */ + uint64_t last_frame_id; /* dedup: only frames with a greater id are "fresh" */ + uint32_t run_epoch; /* last run_epoch seen via vgpup_read_status */ +}; + +/* ---- seqlock primitives -------------------------------------------------- */ + +static inline int vgpup_seq_is_writing(uint32_t seq) { return (seq & 1u) != 0u; } + +/* Read one 32-bit seq field at `gva` into *out. 0 on success, <0 on read error. */ +static inline int vgpup_read_seq(vmie_mem* m, uintptr_t kcr3, uint64_t gva, + uint32_t* out) +{ + return gva_read(m, kcr3, (uintptr_t)gva, out, sizeof *out) < 0 ? -1 : 0; +} + +/* ---- packed-field unpackers (cursor line) -------------------------------- */ + +static inline int32_t vgpup_cursor_x(uint64_t pos) { return (int32_t)(uint32_t)(pos & 0xFFFFFFFFu); } +static inline int32_t vgpup_cursor_y(uint64_t pos) { return (int32_t)(uint32_t)(pos >> 32); } +static inline uint16_t vgpup_lo16(uint32_t v) { return (uint16_t)(v & 0xFFFFu); } +static inline uint16_t vgpup_hi16(uint32_t v) { return (uint16_t)(v >> 16); } + +/* ---- structural-invariant validator (discovery, BY TABLE — no magic) ------ + * + * Discovery has no magic field in the ABI (the owner forbids one). The + * discriminator is the conjunction of structural invariants derived from the + * ABI bounds in vgpu_stream.h, plus the two-phase heartbeat liveness handled by + * the caller. The predicates run cheap→costly with early exit; each takes a + * decoded producer-block snapshot and returns 1 (holds) / 0 (rejects). */ + +typedef int (*vgpup_inv_fn)(const vgpu_producer_t* p); + +/* Is `latest` a valid slot index, or the legitimate "no frame yet" sentinel? + * latest == NONE is NOT a rejection (a freshly-started region has no frame). */ +static inline int vgpup_inv_latest_in_range(const vgpu_producer_t* p) +{ + return p->latest == VGPU_LATEST_NONE || p->latest < VGPU_SLOT_COUNT; +} + +/* If a frame is published, its slot seq must be even (stable, not mid-write). */ +static inline int vgpup_inv_latest_seq_stable(const vgpu_producer_t* p) +{ + if (p->latest == VGPU_LATEST_NONE) { return 1; } + return !vgpup_seq_is_writing(p->seq[p->latest]); +} + +/* If a frame is published, its descriptor must be a tight BGRA frame within the + * ABI dimension bounds. */ +static inline int vgpup_inv_latest_desc_valid(const vgpu_producer_t* p) +{ + const vgpu_desc_t* d; + if (p->latest == VGPU_LATEST_NONE) { return 1; } + d = &p->desc[p->latest]; + if (d->format != VGPU_FMT_BGRA8888) { return 0; } + if (d->width == 0u || d->width > VGPU_MAX_WIDTH) { return 0; } + if (d->height == 0u || d->height > VGPU_MAX_HEIGHT) { return 0; } + if (d->stride != d->width * 4u) { return 0; } + return 1; +} + +/* Cold-line status enum must be in the ABI range. */ +static inline int vgpup_inv_status_in_range(const vgpu_producer_t* p) +{ + return p->status <= VGPU_ST_ERROR; +} + +/* Cold-line backend enum must be in the ABI range. */ +static inline int vgpup_inv_backend_in_range(const vgpu_producer_t* p) +{ + return p->backend <= VGPU_BK_GDI; +} + +/* The producer must advertise the one wire format we consume. */ +static inline int vgpup_inv_supports_bgra(const vgpu_producer_t* p) +{ + return (p->supported_formats & (1u << VGPU_FMT_BGRA8888)) != 0u; +} + +/* The invariant table, cheap→costly. A candidate is accepted (phase 1) iff + * every predicate holds; the table is the single discriminator, no scattered + * ifs and no hardcoded numbers (all bounds come from vgpu_stream.h). */ +static const vgpup_inv_fn VGPUP_INVARIANTS[] = { + vgpup_inv_latest_in_range, + vgpup_inv_status_in_range, + vgpup_inv_backend_in_range, + vgpup_inv_supports_bgra, + vgpup_inv_latest_seq_stable, + vgpup_inv_latest_desc_valid, +}; +#define VGPUP_INVARIANT_COUNT (sizeof(VGPUP_INVARIANTS) / sizeof(VGPUP_INVARIANTS[0])) + +/* Run the whole invariant table over a decoded producer-block snapshot. + * Returns 1 if every predicate holds, 0 on the first rejection. */ +static inline int vgpup_invariants_hold(const vgpu_producer_t* p) +{ + size_t i; + for (i = 0; i < VGPUP_INVARIANT_COUNT; ++i) { + if (!VGPUP_INVARIANTS[i](p)) { return 0; } + } + return 1; +} + +#endif /* VGPU_PERCEPTION_INTERNAL_H */ diff --git a/src/perception/sample.c b/src/perception/sample.c new file mode 100644 index 0000000..e59b091 --- /dev/null +++ b/src/perception/sample.c @@ -0,0 +1,200 @@ +/* sample.c — consumer seqlock reads: frame sampling, cursor, geometry, status. + * + * Every guest read goes through gva_read into a local copy; we never hold a + * gva_ptr across a seqlock window (it is borrowed and not atomic for re-check). + * The discipline is the mirror of the producer's publish order in atomic-shim.h, + * but an independent body — this is consumer code, not shared producer code. + * + * Lossy by contract: when a writer keeps a window busy past VGPUP_SEQLOCK_RETRIES + * we return 0 (skip), never block. Blocking longer would be behavioural timing + * (control's concern), which has no place in the sensor. + */ + +#include "perception-internal.h" + +/* Read one cold-line / packed field at producer offset `off` into dst. */ +static int read_field(vmie_mem* m, uintptr_t kcr3, uint64_t region_gva, + size_t off, void* dst, size_t n) +{ + return gva_read(m, kcr3, (uintptr_t)region_gva + off, dst, n) < 0 ? -1 : 0; +} + +int vgpup_sample_frame(vgpup_region* r, vmie_mem* m, uintptr_t kcr3, + uint8_t* dst, size_t cap, vgpup_frame_info* info) +{ + unsigned attempt; + + if (!r || !m || !dst || !info) { return -1; } + + for (attempt = 0; attempt < VGPUP_SEQLOCK_RETRIES; ++attempt) { + uint32_t latest = 0, seq_before = 0, seq_after = 0; + vgpu_desc_t d; + uint64_t slot_gva, seq_gva, desc_gva; + size_t frame_bytes; + + /* latest (acquire-equivalent: its own read) */ + if (read_field(m, kcr3, r->region_gva, + offsetof(vgpu_producer_t, latest), &latest, sizeof latest) < 0) { + return -1; + } + if (latest == VGPU_LATEST_NONE || latest >= VGPU_SLOT_COUNT) { return 0; } + + seq_gva = r->region_gva + offsetof(vgpu_producer_t, seq) + (uint64_t)latest * sizeof(uint32_t); + desc_gva = r->region_gva + offsetof(vgpu_producer_t, desc) + (uint64_t)latest * sizeof(vgpu_desc_t); + + if (vgpup_read_seq(m, kcr3, seq_gva, &seq_before) < 0) { return -1; } + if (vgpup_seq_is_writing(seq_before)) { continue; } /* writer in slot */ + + if (gva_read(m, kcr3, (uintptr_t)desc_gva, &d, sizeof d) < 0) { return -1; } + + /* dedup by frame_id: nothing newer than what we already sampled */ + if (d.frame_id <= r->last_frame_id) { return 0; } + + /* descriptor sanity within the read window (tight BGRA, bounded dims) */ + if (d.format != VGPU_FMT_BGRA8888 || d.stride != d.width * 4u || + d.width == 0u || d.width > VGPU_MAX_WIDTH || + d.height == 0u || d.height > VGPU_MAX_HEIGHT) { + continue; /* likely a torn read; retry */ + } + + frame_bytes = (size_t)d.height * d.stride; + if (frame_bytes > VGPU_SLOT_STRIDE) { return 0; } /* impossible-large → skip */ + if (frame_bytes > cap) { return 0; } /* would not fit → lossy drop */ + + slot_gva = r->ring_gva + (uint64_t)latest * VGPU_SLOT_STRIDE; + if (gva_read(m, kcr3, (uintptr_t)slot_gva, dst, frame_bytes) < 0) { return -1; } + + /* re-check the slot seq: unchanged and still even → snapshot consistent */ + if (vgpup_read_seq(m, kcr3, seq_gva, &seq_after) < 0) { return -1; } + if (seq_after != seq_before || vgpup_seq_is_writing(seq_after)) { + continue; /* the slot was rewritten under us — retry */ + } + + info->desc.width = d.width; + info->desc.height = d.height; + info->desc.stride = d.stride; + info->desc.format = d.format; + info->desc.frame_id = d.frame_id; + info->desc.timestamp_ns = d.timestamp_ns; + info->bytes = frame_bytes; + + r->last_frame_id = d.frame_id; + return 1; + } + return 0; /* writer kept the slot busy past the retry limit — skip */ +} + +int vgpup_read_cursor(vgpup_region* r, vmie_mem* m, uintptr_t kcr3, + vgpup_cursor* out) +{ + unsigned attempt; + + if (!r || !m || !out) { return -1; } + + /* The producer bumps cursor_seq LAST (acquire), so we read the cursor line + * first and gate on cursor_seq being even and unchanged across the window. */ + for (attempt = 0; attempt < VGPUP_SEQLOCK_RETRIES; ++attempt) { + uint32_t seq_before = 0, seq_after = 0; + uint32_t visible = 0, hotspot = 0, glyph = 0, id = 0; + uint64_t pos = 0; + + if (vgpup_read_seq(m, kcr3, r->region_gva + offsetof(vgpu_producer_t, cursor_seq), + &seq_before) < 0) { return -1; } + if (vgpup_seq_is_writing(seq_before)) { continue; } + + if (read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, cursor_visible), &visible, sizeof visible) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, cursor_pos), &pos, sizeof pos) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, cursor_hotspot), &hotspot, sizeof hotspot) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, cursor_glyph), &glyph, sizeof glyph) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, cursor_id), &id, sizeof id) < 0) { + return -1; + } + + if (vgpup_read_seq(m, kcr3, r->region_gva + offsetof(vgpu_producer_t, cursor_seq), + &seq_after) < 0) { return -1; } + if (seq_after != seq_before || vgpup_seq_is_writing(seq_after)) { continue; } + + out->seq = seq_after; + out->visible = visible; + out->x = vgpup_cursor_x(pos); + out->y = vgpup_cursor_y(pos); + out->hot_x = vgpup_lo16(hotspot); + out->hot_y = vgpup_hi16(hotspot); + out->glyph_w = vgpup_lo16(glyph); + out->glyph_h = vgpup_hi16(glyph); + out->id = id; + return 1; + } + return 0; +} + +int vgpup_read_geometry(vgpup_region* r, vmie_mem* m, uintptr_t kcr3, + vgpup_geometry* out) +{ + unsigned attempt; + + if (!r || !m || !out) { return -1; } + + for (attempt = 0; attempt < VGPUP_SEQLOCK_RETRIES; ++attempt) { + uint32_t seq_before = 0, seq_after = 0; + int32_t virt_x = 0, virt_y = 0, cap_x = 0, cap_y = 0; + uint32_t virt_w = 0, virt_h = 0, dpi = 0, refresh_mhz = 0; + + if (vgpup_read_seq(m, kcr3, r->region_gva + offsetof(vgpu_producer_t, geom_seq), + &seq_before) < 0) { return -1; } + if (vgpup_seq_is_writing(seq_before)) { continue; } + + if (read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, virt_x), &virt_x, sizeof virt_x) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, virt_y), &virt_y, sizeof virt_y) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, virt_w), &virt_w, sizeof virt_w) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, virt_h), &virt_h, sizeof virt_h) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, cap_x), &cap_x, sizeof cap_x) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, cap_y), &cap_y, sizeof cap_y) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, dpi), &dpi, sizeof dpi) < 0 || + read_field(m, kcr3, r->region_gva, offsetof(vgpu_producer_t, refresh_mhz), &refresh_mhz, sizeof refresh_mhz) < 0) { + return -1; + } + + if (vgpup_read_seq(m, kcr3, r->region_gva + offsetof(vgpu_producer_t, geom_seq), + &seq_after) < 0) { return -1; } + if (seq_after != seq_before || vgpup_seq_is_writing(seq_after)) { continue; } + + out->virt_x = virt_x; + out->virt_y = virt_y; + out->virt_w = virt_w; + out->virt_h = virt_h; + out->cap_x = cap_x; + out->cap_y = cap_y; + out->dpi = dpi; + out->refresh_mhz = refresh_mhz; + return 1; + } + return 0; +} + +int vgpup_read_status(vgpup_region* r, vmie_mem* m, uintptr_t kcr3, + vgpup_status* out) +{ + vgpu_producer_t p; + + if (!r || !m || !out) { return -1; } + + /* Cold line: single naturally-aligned atomic fields with no seqlock. Read + * the whole producer block once and pick the cold fields — "fresh enough" + * by the lossy contract. */ + if (gva_read(m, kcr3, (uintptr_t)r->region_gva, &p, sizeof p) < 0) { return -1; } + + out->heartbeat = p.heartbeat; + out->run_epoch = p.run_epoch; + out->status = p.status; + out->backend = p.backend; + out->error_code = p.error_code; + out->applied_fps = p.applied_fps; + out->supported_formats = p.supported_formats; + out->ctrl_ack = p.ctrl_ack; + out->full_frame_ack = p.full_frame_ack; + out->content_change_ns = p.content_change_ns; + + r->run_epoch = p.run_epoch; /* feed the session-break detector */ + return 0; +} diff --git a/src/perception/test/test_perception.c b/src/perception/test/test_perception.c new file mode 100644 index 0000000..986d024 --- /dev/null +++ b/src/perception/test/test_perception.c @@ -0,0 +1,270 @@ +/* test_perception.c — table-driven invariant predicates + flat sampling smoke. + * + * Two layers: + * 1) Invariant predicates as a TABLE of cases over a synthesized producer + * block (pure, no vmie): valid / latest==NONE / torn odd seq / non-BGRA / + * stride!=width*4 / dims out of range — each asserts accept-vs-reject. + * 2) Flat sampling smoke: lay out a real region per vgpu_stream.h in a memfd, + * build a minimal x86-64 identity page table (2 MiB large pages) that maps + * the region at a kernel VA, open it RO via vmie_mem_from_ro_fd, and run the + * real gva_*-driven discovery + frame/cursor/geometry/status reads and a + * two-phase heartbeat liveness check under that cr3. (cr3 0 over a flat + * image cannot translate — gva_* needs real page tables — so we synthesize + * them; this exercises the actual translation path the caller will use.) + * + * Exit 0 on all-pass; nonzero on the first failure. + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +#include "perception-internal.h" + +static int g_fail; + +#define CHECK(cond, msg) do { \ + if (!(cond)) { fprintf(stderr, "FAIL: %s (%s:%d)\n", (msg), __FILE__, __LINE__); ++g_fail; } \ +} while (0) + +/* ---- layer 1: invariant predicate table ---------------------------------- */ + +/* Build a baseline VALID producer block (one published BGRA frame in slot 0). */ +static void make_valid_producer(vgpu_producer_t* p) +{ + memset(p, 0, sizeof *p); + p->latest = 0; + p->frame_id = 1; + p->seq[0] = 2; /* even = stable */ + p->desc[0].width = 1920; + p->desc[0].height = 1080; + p->desc[0].stride = 1920 * 4; + p->desc[0].format = VGPU_FMT_BGRA8888; + p->desc[0].frame_id = 1; + p->status = VGPU_ST_CAPTURING; + p->backend = VGPU_BK_DDA; + p->supported_formats = (1u << VGPU_FMT_BGRA8888); + p->heartbeat = 42; +} + +typedef struct { + const char* name; + void (*mutate)(vgpu_producer_t*); + int expect; /* expected vgpup_invariants_hold result */ +} inv_case; + +static void mut_none(vgpu_producer_t* p) { (void)p; } +static void mut_latest_none(vgpu_producer_t* p) { p->latest = VGPU_LATEST_NONE; } +static void mut_latest_oob(vgpu_producer_t* p) { p->latest = VGPU_SLOT_COUNT; } +static void mut_seq_odd(vgpu_producer_t* p) { p->seq[0] = 3; } +static void mut_not_bgra(vgpu_producer_t* p) { p->desc[0].format = 7; } +static void mut_bad_stride(vgpu_producer_t* p) { p->desc[0].stride = 1920 * 4 + 1; } +static void mut_width_zero(vgpu_producer_t* p) { p->desc[0].width = 0; } +static void mut_width_huge(vgpu_producer_t* p) { p->desc[0].width = VGPU_MAX_WIDTH + 1; } +static void mut_height_huge(vgpu_producer_t* p) { p->desc[0].height = VGPU_MAX_HEIGHT + 1; } +static void mut_status_oob(vgpu_producer_t* p) { p->status = VGPU_ST_ERROR + 1; } +static void mut_backend_oob(vgpu_producer_t* p) { p->backend = VGPU_BK_GDI + 1; } +static void mut_no_bgra_support(vgpu_producer_t* p) { p->supported_formats = 0; } + +static const inv_case INV_CASES[] = { + { "valid", mut_none, 1 }, + { "latest==NONE", mut_latest_none, 1 }, /* no frame yet, still valid */ + { "latest out of range", mut_latest_oob, 0 }, + { "torn odd seq", mut_seq_odd, 0 }, + { "non-BGRA format", mut_not_bgra, 0 }, + { "stride != width*4", mut_bad_stride, 0 }, + { "width == 0", mut_width_zero, 0 }, + { "width too large", mut_width_huge, 0 }, + { "height too large", mut_height_huge, 0 }, + { "status out of range", mut_status_oob, 0 }, + { "backend out of range", mut_backend_oob, 0 }, + { "BGRA not supported", mut_no_bgra_support, 0 }, +}; + +static void run_invariant_table(void) +{ + size_t i; + for (i = 0; i < sizeof(INV_CASES) / sizeof(INV_CASES[0]); ++i) { + vgpu_producer_t p; + int got; + make_valid_producer(&p); + INV_CASES[i].mutate(&p); + got = vgpup_invariants_hold(&p); + CHECK(got == INV_CASES[i].expect, INV_CASES[i].name); + } +} + +/* ---- layer 2: flat sampling smoke over a real RO vmie_mem ----------------- */ + +/* x86-64 paging entry flags for the synthetic identity table. */ +#define PTE_P 0x1u /* present */ +#define PTE_RW 0x2u /* writable */ +#define PTE_PS 0x80u /* page size (2 MiB leaf at PD level) */ +#define LARGE_PAGE (2ull * 1024 * 1024) + +/* Build a minimal identity page table mapping [0, span) of the image at kernel + * VA `base` using 2 MiB large pages, with the PML4/PDPT/PD pages laid out right + * after the region in the same image. Returns the cr3 (PML4 GPA). The mapped VA + * range fits one PD (covers up to 1 GiB), which is plenty for the region. */ +static uint64_t build_identity_table(uint8_t* img, uint64_t region_bytes, + uint64_t base, uint64_t span) +{ + const uint64_t pml4_gpa = region_bytes; /* one page each, after region */ + const uint64_t pdpt_gpa = region_bytes + 0x1000; + const uint64_t pd_gpa = region_bytes + 0x2000; + uint64_t* pml4 = (uint64_t*)(img + pml4_gpa); + uint64_t* pdpt = (uint64_t*)(img + pdpt_gpa); + uint64_t* pd = (uint64_t*)(img + pd_gpa); + const unsigned pml4i = (unsigned)((base >> 39) & 0x1ffu); + const unsigned pdpti = (unsigned)((base >> 30) & 0x1ffu); + const unsigned pdi0 = (unsigned)((base >> 21) & 0x1ffu); + uint64_t mapped = 0; + unsigned k = 0; + + pml4[pml4i] = pdpt_gpa | PTE_P | PTE_RW; + pdpt[pdpti] = pd_gpa | PTE_P | PTE_RW; + while (mapped < span) { + pd[pdi0 + k] = mapped | PTE_P | PTE_RW | PTE_PS; /* VA base+k*2M → GPA mapped */ + mapped += LARGE_PAGE; + ++k; + } + return pml4_gpa; +} + +static void run_flat_smoke(void) +{ + const uint64_t region_bytes = VGPU_REGION_BYTES; + /* region rounded up to a 2 MiB boundary for the large-page identity map */ + const uint64_t mapped_span = (region_bytes + LARGE_PAGE - 1) & ~(LARGE_PAGE - 1); + const size_t total_bytes = (size_t)region_bytes + 0x3000; /* + PML4/PDPT/PD */ + const uint64_t base_va = KERN_MIN; /* kernel VA */ + const uint32_t w = 64, h = 32; + const size_t frame_bytes = (size_t)w * h * 4u; + int fd; + uint8_t* img; + uint64_t cr3; + vmie_mem* m; + vgpu_producer_t p; + uint8_t marker; + + fd = memfd_create("vgpu-region", 0); + CHECK(fd >= 0, "memfd_create"); + if (fd < 0) { return; } + if (ftruncate(fd, (off_t)total_bytes) != 0) { CHECK(0, "ftruncate"); close(fd); return; } + + img = mmap(NULL, total_bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + CHECK(img != MAP_FAILED, "mmap"); + if (img == MAP_FAILED) { close(fd); return; } + + /* lay out a valid producer block with one BGRA frame in slot 0 (at GPA 0) */ + make_valid_producer(&p); + p.desc[0].width = w; + p.desc[0].height = h; + p.desc[0].stride = w * 4u; + memcpy(img + VGPU_PRODUCER_OFFSET, &p, sizeof p); + + /* fill the slot-0 frame bytes in the RING with a recognizable marker */ + marker = 0xA5; + memset(img + VGPU_RING_OFFSET + 0 * VGPU_SLOT_STRIDE, marker, frame_bytes); + + /* synthesize an identity table mapping the region at base_va, then open RO */ + cr3 = build_identity_table(img, region_bytes, base_va, mapped_span); + m = vmie_mem_from_ro_fd(fd, total_bytes); + CHECK(m != NULL, "vmie_mem_from_ro_fd"); + if (!m) { munmap(img, total_bytes); close(fd); return; } + + /* discovery: candidate found at the kernel VA with hb0 == 42 */ + { + uint64_t rgva = 0xdead, hb0 = 0; + int rc = vgpup_discover_candidate(m, cr3, &rgva, &hb0); + CHECK(rc == 0, "discover_candidate rc"); + CHECK(rgva == base_va, "discover_candidate region gva"); + CHECK(hb0 == 42, "discover_candidate hb0"); + + /* two-phase liveness: not alive until heartbeat advances */ + CHECK(vgpup_confirm_alive(m, cr3, rgva, hb0) == 0, "confirm not-yet-alive"); + { uint64_t hb = 43; memcpy(img + offsetof(vgpu_producer_t, heartbeat), &hb, sizeof hb); } + CHECK(vgpup_confirm_alive(m, cr3, rgva, hb0) == 1, "confirm alive after tick"); + } + + /* open handle + read API */ + { + vgpup_region* r = vgpup_open(m, cr3); + CHECK(r != NULL, "vgpup_open"); + if (r) { + uint8_t* dst = malloc(frame_bytes); + vgpup_frame_info fi; + vgpup_cursor cur; + vgpup_geometry geo; + vgpup_status st; + int rc; + + CHECK(dst != NULL, "malloc dst"); + + rc = vgpup_sample_frame(r, m, cr3, dst, frame_bytes, &fi); + CHECK(rc == 1, "sample_frame fresh"); + if (rc == 1) { + CHECK(fi.desc.width == w && fi.desc.height == h, "sample dims"); + CHECK(fi.bytes == frame_bytes, "sample bytes"); + CHECK(dst[0] == marker && dst[frame_bytes - 1] == marker, "sample content"); + } + + /* same frame_id → no fresh frame (dedup) */ + CHECK(vgpup_sample_frame(r, m, cr3, dst, frame_bytes, &fi) == 0, "sample dedup"); + + /* too-small buffer → lossy drop (0), not error */ + CHECK(vgpup_sample_frame(r, m, cr3, dst, 1, &fi) == 0, "sample tiny-cap"); + + CHECK(vgpup_read_cursor(r, m, cr3, &cur) == 1, "read_cursor"); + CHECK(vgpup_read_geometry(r, m, cr3, &geo) == 1, "read_geometry"); + CHECK(vgpup_read_status(r, m, cr3, &st) == 0, "read_status"); + CHECK(st.status == VGPU_ST_CAPTURING, "status value"); + CHECK(st.heartbeat == 43, "status heartbeat"); + CHECK(vgpup_run_epoch(r) == st.run_epoch, "run_epoch accessor"); + + free(dst); + vgpup_close(r); + } + } + + /* control-write seam: builds frame + offsets, writes nothing */ + { + vgpup_region* r = vgpup_open(m, cr3); + if (r) { + vgpup_control_intent in = { VGPU_CMD_RUN, 60, 1, 7 }; + vgpu_control_t frame; + uint64_t ctrl_gva = 0; + uint32_t off = 0, len = 0; + int rc = vgpup_build_control_write(r, &in, &frame, &ctrl_gva, &off, &len); + CHECK(rc == 0, "build_control_write rc"); + CHECK(frame.desired_state == VGPU_CMD_RUN, "control desired_state"); + CHECK(frame.target_fps == 60, "control target_fps"); + CHECK(frame.full_frame_req == 7, "control full_frame_req"); + CHECK(frame.ctrl_gen == 0, "control ctrl_gen untouched"); + CHECK(ctrl_gva == base_va + VGPU_CONTROL_OFFSET, "control gva"); + CHECK(off == offsetof(vgpu_control_t, desired_state), "control off"); + CHECK(len == offsetof(vgpu_control_t, full_frame_req) + sizeof(uint32_t) + - offsetof(vgpu_control_t, desired_state), "control len"); + vgpup_close(r); + } + } + + vmie_mem_close(m); /* the TEST owns vmie_mem here (it is the caller) */ + munmap(img, total_bytes); + close(fd); +} + +int main(void) +{ + run_invariant_table(); + run_flat_smoke(); + if (g_fail) { + fprintf(stderr, "%d check(s) failed\n", g_fail); + return 1; + } + printf("all checks passed\n"); + return 0; +}