diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index f6c1af6e..8ce32949 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -96,6 +96,18 @@ jobs: # First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code). run: cargo clippy -p punktfunk-host --features nvenc,amf-qsv -- -D warnings + - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer) + shell: pwsh + # Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games + # like Doom use HDR on the virtual display). Lint here so a regression fails CI instead of + # silently shipping the host without the layer (pack-host-installer.ps1 builds it non-fatally). + # Windows-only FFI (user32 + the vk_layer loader glue) → can't be linted on the Linux CI. + run: | + Push-Location packaging/windows/pf-vkhdr-layer + cargo fmt --check; if ($LASTEXITCODE) { throw "pf-vkhdr-layer rustfmt" } + cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" } + Pop-Location + - name: Ensure Inno Setup shell: pwsh run: | diff --git a/api/openapi.json b/api/openapi.json new file mode 100644 index 00000000..fed50b71 --- /dev/null +++ b/api/openapi.json @@ -0,0 +1,1701 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "punktfunk management API", + "description": "Control-plane API for managing a punktfunk streaming host: host capabilities, runtime status, paired clients, the pairing PIN flow, and session control. Authentication: HTTP bearer token, enforced on every route except `/api/v1/health` when the host is started with a management token (mandatory for non-loopback binds).", + "contact": { + "name": "unom" + }, + "license": { + "name": "MIT OR Apache-2.0", + "identifier": "MIT OR Apache-2.0" + }, + "version": "0.0.1" + }, + "paths": { + "/api/v1/clients": { + "get": { + "tags": [ + "clients" + ], + "summary": "List paired clients", + "operationId": "listPairedClients", + "responses": { + "200": { + "description": "All certificate-pinned clients", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PairedClient" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/clients/{fingerprint}": { + "delete": { + "tags": [ + "clients" + ], + "summary": "Unpair a client", + "description": "Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer\ndoes not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.", + "operationId": "unpairClient", + "parameters": [ + { + "name": "fingerprint", + "in": "path", + "description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Client unpaired" + }, + "400": { + "description": "Malformed fingerprint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No paired client with that fingerprint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/compositors": { + "get": { + "tags": [ + "host" + ], + "summary": "Available compositor backends", + "description": "Lists every backend the host knows how to drive, flags which are usable right now, and marks\nthe one an unspecified (`Auto`) client request resolves to. Clients pass an `id` to their\n`--compositor` flag (or `PUNKTFUNK_COMPOSITOR_*` over the C ABI) to request it.", + "operationId": "listCompositors", + "responses": { + "200": { + "description": "Compositor backends with availability + the auto-detected default", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AvailableCompositor" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/health": { + "get": { + "tags": [ + "host" + ], + "summary": "Liveness probe", + "description": "Always available without authentication.", + "operationId": "getHealth", + "responses": { + "200": { + "description": "Host is up", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + } + }, + "security": [ + {} + ] + } + }, + "/api/v1/host": { + "get": { + "tags": [ + "host" + ], + "summary": "Host identity and capabilities", + "operationId": "getHostInfo", + "responses": { + "200": { + "description": "Host identity, versions, codecs, and port map", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostInfo" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/library": { + "get": { + "tags": [ + "library" + ], + "summary": "List the game library", + "description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles).", + "operationId": "getLibrary", + "responses": { + "200": { + "description": "Unified library across all stores", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GameEntry" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/library/custom": { + "post": { + "tags": [ + "library" + ], + "summary": "Add a custom library entry", + "description": "Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied\nartwork URLs. The host assigns a stable id, returned in the body.", + "operationId": "createCustomGame", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Entry created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomEntry" + } + } + } + }, + "400": { + "description": "Empty title", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/library/custom/{id}": { + "put": { + "tags": [ + "library" + ], + "summary": "Update a custom library entry", + "operationId": "updateCustomGame", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The custom entry id (without the `custom:` prefix)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Entry updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomEntry" + } + } + } + }, + "400": { + "description": "Empty title", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No custom entry with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "library" + ], + "summary": "Delete a custom library entry", + "operationId": "deleteCustomGame", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The custom entry id (without the `custom:` prefix)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Entry deleted" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No custom entry with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/clients": { + "get": { + "tags": [ + "native" + ], + "summary": "List native paired clients", + "operationId": "listNativeClients", + "responses": { + "200": { + "description": "Paired native clients", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NativeClient" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/clients/{fingerprint}": { + "delete": { + "tags": [ + "native" + ], + "summary": "Unpair a native client", + "description": "Removes a punktfunk/1 client from the native trust store by fingerprint.", + "operationId": "unpairNativeClient", + "parameters": [ + { + "name": "fingerprint", + "in": "path", + "description": "Hex SHA-256 of the client certificate (case-insensitive)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Client unpaired" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No paired native client with that fingerprint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/pair": { + "get": { + "tags": [ + "native" + ], + "summary": "Native pairing status", + "description": "The native (punktfunk/1) pairing window. Poll while armed to show the PIN + countdown.\n`enabled: false` means this host runs GameStream only (no `--native`).", + "operationId": "getNativePairing", + "responses": { + "200": { + "description": "Native pairing status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NativePairStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "native" + ], + "summary": "Disarm native pairing", + "description": "Closes the pairing window immediately (no new ceremonies accepted).", + "operationId": "disarmNativePairing", + "responses": { + "204": { + "description": "Pairing disarmed" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/pair/arm": { + "post": { + "tags": [ + "native" + ], + "summary": "Arm native pairing", + "description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list.", + "operationId": "armNativePairing", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArmNativePairing" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Pairing armed; the response carries the PIN to display", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NativePairStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not available in this process", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/pending": { + "get": { + "tags": [ + "native" + ], + "summary": "List devices awaiting pairing approval", + "description": "Unpaired devices that tried to connect while the host requires pairing. Approve one to pair\nit without a PIN (delegated approval); entries expire after ~10 minutes.", + "operationId": "listPendingDevices", + "responses": { + "200": { + "description": "Devices awaiting approval (empty when none, or when the native host is not enabled)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PendingDevice" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/pending/{id}/approve": { + "post": { + "tags": [ + "native" + ], + "summary": "Approve a pending device", + "description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.", + "operationId": "approvePendingDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Pending-request id from the pending list", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApprovePending" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Device paired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NativeClient" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No pending request with that id (expired?)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the trust store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/native/pending/{id}/deny": { + "post": { + "tags": [ + "native" + ], + "summary": "Deny a pending device", + "description": "Drops the request. Not a blocklist — the device's next attempt knocks again.", + "operationId": "denyPendingDevice", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Pending-request id from the pending list", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "Request dropped" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No pending request with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Native host not enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/pair": { + "get": { + "tags": [ + "pairing" + ], + "summary": "Pairing-flow status", + "description": "Poll this to know when to prompt the user for the PIN Moonlight displays.", + "operationId": "getPairingStatus", + "responses": { + "200": { + "description": "Whether a pairing handshake is waiting for a PIN", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairingStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/pair/pin": { + "post": { + "tags": [ + "pairing" + ], + "summary": "Submit the pairing PIN", + "description": "Delivers the PIN the Moonlight client is displaying, completing the out-of-band half\nof the pairing handshake.", + "operationId": "submitPairingPin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitPin" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "PIN delivered to the waiting handshake" + }, + "400": { + "description": "Malformed PIN or unparseable JSON body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "No pairing handshake is waiting for a PIN", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "415": { + "description": "Body is not application/json", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "422": { + "description": "JSON body does not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/session": { + "delete": { + "tags": [ + "session" + ], + "summary": "Stop the active session", + "description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.", + "operationId": "stopSession", + "responses": { + "204": { + "description": "Session stopped (or none was active)" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/session/idr": { + "post": { + "tags": [ + "session" + ], + "summary": "Force a keyframe", + "description": "Asks the encoder for an IDR frame on the active video stream (what a client requests\nafter unrecoverable loss — exposed for debugging).", + "operationId": "requestIdr", + "responses": { + "202": { + "description": "Keyframe requested" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "No active video stream", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/status": { + "get": { + "tags": [ + "host" + ], + "summary": "Live host status", + "operationId": "getStatus", + "responses": { + "200": { + "description": "Streaming/pairing state and the active session, if any", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuntimeStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApiCodec": { + "type": "string", + "description": "Video codec identifier.", + "enum": [ + "h264", + "h265", + "av1" + ] + }, + "ApiError": { + "type": "object", + "description": "Error envelope for every non-2xx response.", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + "ApprovePending": { + "type": "object", + "description": "Approve-pending-device request body. Send `{}` to keep the device's own name.", + "properties": { + "name": { + "type": [ + "string", + "null" + ], + "description": "Operator-chosen label for the device (defaults to the name it knocked with).", + "example": "Living Room TV" + } + } + }, + "ArmNativePairing": { + "type": "object", + "description": "Arm-native-pairing request body.", + "properties": { + "ttl_secs": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Window length in seconds (default 120; clamped to 15–600).", + "example": 120, + "minimum": 0 + } + } + }, + "Artwork": { + "type": "object", + "description": "Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).", + "properties": { + "header": { + "type": [ + "string", + "null" + ], + "description": "Horizontal header (Steam `header.jpg`) — the universal fallback." + }, + "hero": { + "type": [ + "string", + "null" + ], + "description": "Wide background (Steam `library_hero.jpg`)." + }, + "logo": { + "type": [ + "string", + "null" + ], + "description": "Transparent title logo (Steam `logo.png`)." + }, + "portrait": { + "type": [ + "string", + "null" + ], + "description": "Vertical capsule / poster (Steam `library_600x900.jpg`). Best for a grid." + } + } + }, + "AvailableCompositor": { + "type": "object", + "description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.", + "required": [ + "id", + "label", + "available", + "default" + ], + "properties": { + "available": { + "type": "boolean", + "description": "Usable on this host right now: the live session's own compositor, or gamescope wherever\nits binary is installed." + }, + "default": { + "type": "boolean", + "description": "True for the backend an `Auto` (unspecified) request resolves to right now." + }, + "id": { + "type": "string", + "description": "Stable identifier (`\"kwin\"` | `\"wlroots\"` | `\"mutter\"` | `\"gamescope\"`) — pass this to a\nclient's `--compositor` flag." + }, + "label": { + "type": "string", + "description": "Human-readable label for UIs." + } + } + }, + "CustomEntry": { + "type": "object", + "description": "A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API\nreturns and the web console edits.", + "required": [ + "id", + "title" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "id": { + "type": "string", + "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec" + } + ] + }, + "title": { + "type": "string" + } + } + }, + "CustomInput": { + "type": "object", + "description": "Request body to create or replace a custom entry (no `id` — the host owns it).", + "required": [ + "title" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec" + } + ] + }, + "title": { + "type": "string" + } + } + }, + "GameEntry": { + "type": "object", + "description": "One title in the unified library, regardless of which store it came from.", + "required": [ + "id", + "store", + "title", + "art" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "id": { + "type": "string", + "description": "Stable, store-qualified id: `steam:` or `custom:`.", + "example": "steam:570" + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec", + "description": "How the host would launch it, when known." + } + ] + }, + "store": { + "type": "string", + "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.", + "example": "steam" + }, + "title": { + "type": "string" + } + } + }, + "Health": { + "type": "object", + "description": "Liveness + version probe.", + "required": [ + "status", + "version", + "abi_version" + ], + "properties": { + "abi_version": { + "type": "integer", + "format": "int32", + "description": "`punktfunk-core` C ABI version.", + "minimum": 0 + }, + "status": { + "type": "string", + "description": "Always `\"ok\"` when the host responds.", + "example": "ok" + }, + "version": { + "type": "string", + "description": "`punktfunk-host` crate version." + } + } + }, + "HostInfo": { + "type": "object", + "description": "Host identity and advertised capabilities (static for the life of the process).", + "required": [ + "hostname", + "uniqueid", + "local_ip", + "version", + "abi_version", + "app_version", + "gfe_version", + "codecs", + "ports" + ], + "properties": { + "abi_version": { + "type": "integer", + "format": "int32", + "description": "`punktfunk-core` C ABI version.", + "minimum": 0 + }, + "app_version": { + "type": "string", + "description": "GameStream host version advertised to Moonlight clients." + }, + "codecs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiCodec" + }, + "description": "Codecs the host can encode (NVENC)." + }, + "gfe_version": { + "type": "string", + "description": "GFE version advertised to Moonlight clients." + }, + "hostname": { + "type": "string" + }, + "local_ip": { + "type": "string", + "description": "Best-effort primary LAN IP." + }, + "ports": { + "$ref": "#/components/schemas/PortMap" + }, + "uniqueid": { + "type": "string", + "description": "Stable per-host id (persisted across restarts), matched on pairing." + }, + "version": { + "type": "string", + "description": "`punktfunk-host` crate version." + } + } + }, + "LaunchSpec": { + "type": "object", + "description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/`;\n`command` → run `` nested in a gamescope session.", + "required": [ + "kind", + "value" + ], + "properties": { + "kind": { + "type": "string", + "description": "`\"steam_appid\"` or `\"command\"`.", + "example": "steam_appid" + }, + "value": { + "type": "string", + "description": "The appid (for `steam_appid`) or the shell command (for `command`)." + } + } + }, + "NativeClient": { + "type": "object", + "description": "A paired native (punktfunk/1) client.", + "required": [ + "name", + "fingerprint" + ], + "properties": { + "fingerprint": { + "type": "string", + "description": "Hex SHA-256 of the client certificate — its stable id here." + }, + "name": { + "type": "string", + "description": "The name the client supplied when pairing.", + "example": "Living Room iPad" + } + } + }, + "NativePairStatus": { + "type": "object", + "description": "Native (punktfunk/1) pairing status. Unlike GameStream, the **host** mints the PIN (the SPAKE2\nceremony needs it client-side first), so the console **displays** `pin` for the user to enter on\ntheir device — armed on demand for a short window.", + "required": [ + "enabled", + "armed", + "paired_clients" + ], + "properties": { + "armed": { + "type": "boolean", + "description": "True while a pairing window is open." + }, + "enabled": { + "type": "boolean", + "description": "Whether the native host is running (the unified host started with `--native`)." + }, + "expires_in_secs": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag).", + "minimum": 0 + }, + "paired_clients": { + "type": "integer", + "format": "int32", + "description": "Number of paired native clients.", + "minimum": 0 + }, + "pin": { + "type": [ + "string", + "null" + ], + "description": "The PIN to display while armed (null when disarmed).", + "example": "1234" + } + } + }, + "PairedClient": { + "type": "object", + "description": "A paired (certificate-pinned) Moonlight client.", + "required": [ + "fingerprint" + ], + "properties": { + "fingerprint": { + "type": "string", + "description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.", + "example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + }, + "not_after_unix": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Certificate validity end (unix seconds)." + }, + "not_before_unix": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Certificate validity start (unix seconds)." + }, + "subject": { + "type": [ + "string", + "null" + ], + "description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses." + } + } + }, + "PairingStatus": { + "type": "object", + "description": "Pairing-flow status.", + "required": [ + "pin_pending" + ], + "properties": { + "pin_pending": { + "type": "boolean", + "description": "True while a pairing handshake is parked waiting for the user's PIN." + } + } + }, + "PendingDevice": { + "type": "object", + "description": "An unpaired device that tried to connect while the host requires pairing — awaiting\n**delegated approval** (approve it here instead of fetching the host PIN out of band).", + "required": [ + "id", + "name", + "fingerprint", + "age_secs" + ], + "properties": { + "age_secs": { + "type": "integer", + "format": "int64", + "description": "Seconds since the device last knocked.", + "minimum": 0 + }, + "fingerprint": { + "type": "string", + "description": "Hex SHA-256 of the device's certificate — what approval pins." + }, + "id": { + "type": "integer", + "format": "int32", + "description": "Id to address approve/deny (per-process; entries expire after ~10 minutes).", + "minimum": 0 + }, + "name": { + "type": "string", + "description": "Best-effort device label (the client's own name, else fingerprint-derived).", + "example": "Enrico's MacBook" + } + } + }, + "PortMap": { + "type": "object", + "description": "Every port a client integration may need (Moonlight derives the stream ports from the\nHTTP base; a control pane should not have to).", + "required": [ + "mgmt", + "http", + "https", + "rtsp", + "video", + "control", + "audio" + ], + "properties": { + "audio": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "control": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "http": { + "type": "integer", + "format": "int32", + "description": "nvhttp plain HTTP (serverinfo, pairing).", + "minimum": 0 + }, + "https": { + "type": "integer", + "format": "int32", + "description": "nvhttp mutual-TLS HTTPS (post-pairing).", + "minimum": 0 + }, + "mgmt": { + "type": "integer", + "format": "int32", + "description": "This management API.", + "minimum": 0 + }, + "rtsp": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "video": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "RuntimeStatus": { + "type": "object", + "description": "Live host status (changes as clients launch/end sessions).", + "required": [ + "video_streaming", + "audio_streaming", + "pin_pending", + "paired_clients" + ], + "properties": { + "audio_streaming": { + "type": "boolean", + "description": "True while the audio stream thread is running." + }, + "paired_clients": { + "type": "integer", + "format": "int32", + "description": "Number of pinned (paired) client certificates.", + "minimum": 0 + }, + "pin_pending": { + "type": "boolean", + "description": "True while a pairing handshake is parked waiting for the user's PIN\n(submit it via `POST /api/v1/pair/pin`)." + }, + "session": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SessionInfo", + "description": "The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop)." + } + ] + }, + "stream": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StreamInfo", + "description": "The RTSP-negotiated stream parameters (present once a client has completed ANNOUNCE)." + } + ] + }, + "video_streaming": { + "type": "boolean", + "description": "True while the video stream thread is running." + } + } + }, + "SessionInfo": { + "type": "object", + "description": "Client-requested launch parameters (key material is never exposed here).", + "required": [ + "width", + "height", + "fps" + ], + "properties": { + "fps": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "StreamInfo": { + "type": "object", + "description": "RTSP-negotiated stream parameters.", + "required": [ + "width", + "height", + "fps", + "bitrate_kbps", + "packet_size", + "min_fec", + "codec" + ], + "properties": { + "bitrate_kbps": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "codec": { + "$ref": "#/components/schemas/ApiCodec" + }, + "fps": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "height": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "min_fec": { + "type": "integer", + "format": "int32", + "description": "Client's parity floor per FEC block (`minRequiredFecPackets`).", + "minimum": 0 + }, + "packet_size": { + "type": "integer", + "format": "int32", + "description": "Video payload size per packet (bytes).", + "minimum": 0 + }, + "width": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "SubmitPin": { + "type": "object", + "description": "The PIN Moonlight displays during pairing.", + "required": [ + "pin" + ], + "properties": { + "pin": { + "type": "string", + "description": "1–16 ASCII digits (Moonlight shows 4).", + "example": "1234" + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer" + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "host", + "description": "Host identity, capabilities, and liveness" + }, + { + "name": "clients", + "description": "Paired Moonlight client management" + }, + { + "name": "pairing", + "description": "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)" + }, + { + "name": "native", + "description": "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices" + }, + { + "name": "session", + "description": "Active streaming session control" + }, + { + "name": "library", + "description": "Game library: installed-store titles (Steam) plus user-curated custom entries" + } + ] +} diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 index 705e6fe6..41f5baeb 100644 --- a/packaging/windows/pack-host-installer.ps1 +++ b/packaging/windows/pack-host-installer.ps1 @@ -211,6 +211,38 @@ if ($WebDir -and (Test-Path $WebDir) -and $BunExe -and (Test-Path $BunExe)) { } else { Write-Host "no -WebDir/-BunExe -> installer built WITHOUT the web console" } +# --- build + stage the HDR Vulkan layer (pf-vkhdr-layer) -------------------------------------- +# A tiny always-on Vulkan implicit layer (cdylib) that advertises HDR10/scRGB surface formats on the +# virtual display so Vulkan games (Doom: The Dark Ages, etc.) can enable HDR while streaming — the +# NVIDIA/AMD ICDs hide HDR formats on an indirect display even though they accept+present a forced HDR +# swapchain there. Self-gated on the display's actual advanced-color state, so it's a no-op on SDR. +# Standalone crate (own [workspace]); built here and registered by the installer. Skipped if cargo +# is unavailable or the build fails -> installer is produced WITHOUT the layer (non-fatal). +$layerSrc = Join-Path $here 'pf-vkhdr-layer' +if (Test-Path (Join-Path $layerSrc 'Cargo.toml')) { + $layerTarget = Join-Path $OutDir 'vklayer-target' + Write-Host "==> building pf-vkhdr-layer (cdylib)" + $prevTarget = $env:CARGO_TARGET_DIR + $env:CARGO_TARGET_DIR = $layerTarget + Push-Location $layerSrc + & cargo build --release + $layerExit = $LASTEXITCODE + Pop-Location + if ($prevTarget) { $env:CARGO_TARGET_DIR = $prevTarget } else { Remove-Item Env:\CARGO_TARGET_DIR -ErrorAction SilentlyContinue } + $layerDll = Join-Path $layerTarget 'release\pf_vkhdr_layer.dll' + if ($layerExit -eq 0 -and (Test-Path $layerDll)) { + $layerStage = Join-Path $OutDir 'vklayer' + New-Item -ItemType Directory -Force -Path $layerStage | Out-Null + Copy-Item $layerDll (Join-Path $layerStage 'pf_vkhdr_layer.dll') -Force + Copy-Item (Join-Path $layerSrc 'pf_vkhdr_layer.json') (Join-Path $layerStage 'pf_vkhdr_layer.json') -Force + Sign-File (Join-Path $layerStage 'pf_vkhdr_layer.dll') + $defines += "/DVkLayerDir=$layerStage" + Write-Host "==> staged pf-vkhdr-layer -> $layerStage" + } + else { Write-Warning "pf-vkhdr-layer build failed ($layerExit) — installer built WITHOUT the HDR Vulkan layer" } +} +else { Write-Host "no pf-vkhdr-layer crate -> installer built WITHOUT the HDR Vulkan layer" } + # --- build the installer (from the non-redirected copy under C:\t) ----------------------------- Write-Host "==> ISCC $($defines -join ' ') $issLocal" & $iscc @defines $issLocal diff --git a/packaging/windows/pf-vkhdr-layer/Cargo.toml b/packaging/windows/pf-vkhdr-layer/Cargo.toml new file mode 100644 index 00000000..e6876a4e --- /dev/null +++ b/packaging/windows/pf-vkhdr-layer/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "pf-vkhdr-layer" +version = "0.1.0" +edition = "2021" +description = "punktfunk Vulkan implicit layer: inject HDR10/scRGB surface formats on the virtual display so Vulkan games (id Tech, etc.) detect HDR over an IddCx virtual display" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +name = "pf_vkhdr_layer" +crate-type = ["cdylib"] + +[dependencies] +ash = "=0.38.0+1.3.281" + +[profile.release] +opt-level = 2 +panic = "abort" +strip = true + +# Standalone package (not a member of the main punktfunk cargo workspace) — it's a Windows-only +# cdylib built on its own by pack-host-installer.ps1, like the UMDF driver crates next to it. +[workspace] diff --git a/packaging/windows/pf-vkhdr-layer/README.md b/packaging/windows/pf-vkhdr-layer/README.md new file mode 100644 index 00000000..f94e14a8 --- /dev/null +++ b/packaging/windows/pf-vkhdr-layer/README.md @@ -0,0 +1,70 @@ +# pf-vkhdr-layer — HDR Vulkan layer for the virtual display + +A tiny Vulkan **implicit layer** (`VK_LAYER_PUNKTFUNK_hdr_inject`) that lets **Vulkan games enable +HDR while streaming over the punktfunk virtual display**. + +## The problem it solves + +On Windows, NVIDIA/AMD Vulkan ICDs do **not** advertise any HDR color space +(`VK_COLOR_SPACE_HDR10_ST2084_EXT`, `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT`) for a surface that +lives on an **IddCx indirect / virtual display** — even when Windows "Use HDR" is on and the desktop +is composited at 10-bit. So Vulkan games (Doom: The Dark Ages and the rest of id Tech, Indiana Jones +and the Great Circle, …) query `vkGetPhysicalDeviceSurfaceFormatsKHR`, find no HDR color space, and +refuse HDR ("This device does not support HDR"). D3D11/D3D12 HDR works on the very same display +because the OS compositor drives it — only the **Vulkan WSI enumeration** is gated. + +This was long believed unfixable from outside the GPU driver (the Apollo/Sunshine/Virtual-Display- +Driver communities all concluded "it's in Windows's kernel"). It isn't: an on-box experiment proved +the ICD happily **accepts and presents a *forced* HDR swapchain** on that exact virtual-display +surface (`vkCreateSwapchainKHR` + `vkSetHdrMetadataEXT` + present all succeed) — it simply won't +*advertise* the format. So the whole fix is to add the HDR surface formats to the enumeration the +game queries; once the game requests that swapchain, the ICD honors it. **Validated live: Doom: The +Dark Ages enables HDR over the virtual display with this layer.** + +## What it does + +- Intercepts `vkGetPhysicalDeviceSurfaceFormatsKHR` / `...2KHR`, calls down to the ICD, and appends + `{A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT}` + `{R16G16B16A16_SFLOAT, EXTENDED_SRGB_LINEAR_EXT}` + (deduped — a no-op on real HDR monitors that already list them). +- **Self-gates**: it only injects when the surface's monitor actually has Windows advanced-color + (HDR) *enabled* right now (checked via `DisplayConfigGetDeviceInfo` / `GET_ADVANCED_COLOR_INFO`). + So it does **nothing** on SDR sessions/displays — no washed-out "SDR-in-HDR". It tracks + `VkSurfaceKHR → HWND` by intercepting `vkCreateWin32SurfaceKHR`. +- Everything else is pass-through dispatch chaining (instance + device). + +It is shipped as an **always-on** implicit layer (loads via the registry, so it works regardless of +how a game is launched — including via an already-running Steam, which env-based scoping can't +guarantee). Because of the self-gate it is inert outside HDR streaming. + +## Controls + +| Variable | Effect | +|---|---| +| `DISABLE_PF_VKHDR=1` | Loader-standard off-switch — disables the whole layer for that process. | +| `PF_VKHDR_EXCLUDE=foo.exe,bar.exe` | Extra exe basenames to skip (in addition to a small built-in kernel-anti-cheat default list: `cs2.exe`, `rainbowsix.exe`, …). | +| `PF_VKHDR_LOG=1` | Write a debug log to `%TEMP%\pf_vkhdr_layer.log`. | + +## Build / install + +Standalone crate (own `[workspace]`), Windows-only `cdylib`: + +```sh +cargo build --release # -> target/release/pf_vkhdr_layer.dll +``` + +The host installer (`packaging/windows/pack-host-installer.ps1` → `punktfunk-host.iss`) builds it, +lays `pf_vkhdr_layer.dll` + `pf_vkhdr_layer.json` into `{app}\vklayer`, and registers it under +`HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers` (opt-out task "Install the HDR Vulkan layer"). + +Manual dev install: drop the DLL + JSON in one directory and add a `REG_DWORD` value named after the +JSON's full path (data `0`) under `HKLM\SOFTWARE\Khronos\Vulkan\ImplicitLayers` (or `HKCU\...`). +Confirm with `vulkaninfo` (Surface section) — `HDR10_ST2084_EXT` should appear when the display has +HDR enabled. + +## Notes + +- x64 only (the Windows host is x64 only). +- Anti-cheat: the layer is benign (it only *adds* surface formats; it never touches rendering, + memory, or input) and is signed, but because it's always-on it is *present* in every Vulkan + process. The built-in exclude list + `PF_VKHDR_EXCLUDE` + `DISABLE_PF_VKHDR` cover kernel-anti- + cheat titles you'd rather it stay out of. diff --git a/packaging/windows/pf-vkhdr-layer/pf_vkhdr_layer.json b/packaging/windows/pf-vkhdr-layer/pf_vkhdr_layer.json new file mode 100644 index 00000000..caeab69d --- /dev/null +++ b/packaging/windows/pf-vkhdr-layer/pf_vkhdr_layer.json @@ -0,0 +1,17 @@ +{ + "file_format_version": "1.2.0", + "layer": { + "name": "VK_LAYER_PUNKTFUNK_hdr_inject", + "type": "GLOBAL", + "library_path": ".\\pf_vkhdr_layer.dll", + "api_version": "1.4.280", + "implementation_version": "1", + "description": "punktfunk: advertise HDR10/scRGB Vulkan surface formats on the virtual display (NVIDIA/AMD ICDs hide them on indirect displays even though the ICD accepts + presents a forced HDR swapchain).", + "functions": { + "vkNegotiateLoaderLayerInterfaceVersion": "vkNegotiateLoaderLayerInterfaceVersion" + }, + "disable_environment": { + "DISABLE_PF_VKHDR": "1" + } + } +} diff --git a/packaging/windows/pf-vkhdr-layer/src/lib.rs b/packaging/windows/pf-vkhdr-layer/src/lib.rs new file mode 100644 index 00000000..92ae1f34 --- /dev/null +++ b/packaging/windows/pf-vkhdr-layer/src/lib.rs @@ -0,0 +1,841 @@ +//! punktfunk Vulkan implicit layer — `VK_LAYER_PUNKTFUNK_hdr_inject`. +//! +//! ## Why +//! On Windows, NVIDIA/AMD Vulkan ICDs do **not** advertise any HDR color space +//! (`HDR10_ST2084_EXT`, `EXTENDED_SRGB_LINEAR_EXT`) for a surface on an IddCx *indirect / virtual* +//! display — even when Windows "Use HDR" is enabled and the desktop is composited at 10-bit. So +//! Vulkan games (Doom: The Dark Ages and the rest of id Tech, Indiana Jones, …) query +//! `vkGetPhysicalDeviceSurfaceFormatsKHR`, find no HDR color space, and refuse HDR +//! ("This device does not support HDR"). DX11/DX12 HDR works on the same display because the OS +//! compositor drives it; only the Vulkan WSI *enumeration* is gated. An on-box spike proved the ICD +//! *accepts and presents* a forced HDR swapchain on that exact surface — it just won't *advertise* +//! the format. So the entire fix is to append the HDR surface formats to the enumeration the game +//! queries; once the game asks for that swapchain, the ICD honors it. +//! +//! ## What this layer does +//! Intercepts `vkGetPhysicalDeviceSurfaceFormatsKHR` / `...2KHR`, calls down to the ICD, and appends +//! `{A2B10G10R10_UNORM_PACK32, HDR10_ST2084_EXT}` and `{R16G16B16A16_SFLOAT, EXTENDED_SRGB_LINEAR_EXT}` +//! (deduped). **Self-gating:** it only injects when the surface's monitor actually has Windows +//! advanced-color (HDR) *enabled* — so it is a complete no-op on SDR sessions and on real monitors +//! (which already advertise HDR, and dedup drops the duplicate). It tracks `VkSurfaceKHR -> HWND` by +//! intercepting `vkCreateWin32SurfaceKHR`. Everything else is pass-through dispatch chaining. +//! +//! Off-switches: the loader-standard `DISABLE_PF_VKHDR=1` (disables the whole layer), and +//! `PF_VKHDR_EXCLUDE` (comma/semicolon list of exe basenames to skip — defaults include known +//! kernel-anti-cheat titles). `PF_VKHDR_LOG=1` enables a debug log in `%TEMP%\pf_vkhdr_layer.log`. + +#![allow(non_snake_case)] +#![allow(clippy::missing_safety_doc)] +#![allow(clippy::too_many_arguments)] +// HWND / HMONITOR etc. deliberately mirror the Win32 names. +#![allow(clippy::upper_case_acronyms)] + +use ash::vk; +use ash::vk::Handle; +use std::collections::HashMap; +use std::ffi::{c_char, c_void, CStr}; +use std::ptr; +use std::sync::{Mutex, OnceLock}; + +// ---- Vulkan loader<->layer glue (vk_layer.h; not in the core registry / ash) ---- +// The loader's private create-info sTypes squat on 47/48 (NOT the 1000211xxx range). +const LOADER_INSTANCE_CREATE_INFO: i32 = 47; +const LOADER_DEVICE_CREATE_INFO: i32 = 48; +const VK_LAYER_LINK_INFO: i32 = 0; +const LAYER_NEGOTIATE_INTERFACE_STRUCT: i32 = 1; + +type PfnGipa = vk::PFN_vkGetInstanceProcAddr; +type PfnGdpa = vk::PFN_vkGetDeviceProcAddr; +type PfnGpdpa = unsafe extern "system" fn(vk::Instance, *const c_char) -> vk::PFN_vkVoidFunction; + +#[repr(C)] +struct BaseIn { + s_type: vk::StructureType, + p_next: *const c_void, +} +#[repr(C)] +struct LayerInstanceLink { + p_next: *mut LayerInstanceLink, + next_gipa: PfnGipa, + next_gpdpa: Option, +} +#[repr(C)] +struct LayerInstanceCreateInfo { + s_type: vk::StructureType, + p_next: *const c_void, + function: i32, + u: *mut LayerInstanceLink, +} +#[repr(C)] +struct LayerDeviceLink { + p_next: *mut LayerDeviceLink, + next_gipa: PfnGipa, + next_gdpa: PfnGdpa, +} +#[repr(C)] +struct LayerDeviceCreateInfo { + s_type: vk::StructureType, + p_next: *const c_void, + function: i32, + u: *mut LayerDeviceLink, +} +#[repr(C)] +pub struct NegotiateLayerInterface { + s_type: i32, + p_next: *mut c_void, + loader_layer_interface_version: u32, + pfn_gipa: Option, + pfn_gdpa: Option, + pfn_gpdpa: Option, +} + +// raw mirror of VkSurfaceFormat2KHR (avoid ash lifetime generics in fn-pointer types) +#[repr(C)] +#[derive(Clone, Copy)] +struct SurfaceFormat2Raw { + s_type: vk::StructureType, + p_next: *mut c_void, + surface_format: vk::SurfaceFormatKHR, +} + +// ---- ICD function-pointer typedefs we call down to (raw pointers, no lifetimes) ---- +type FnCreateInstance = + unsafe extern "system" fn(*const c_void, *const c_void, *mut vk::Instance) -> vk::Result; +type FnDestroyInstance = unsafe extern "system" fn(vk::Instance, *const c_void); +type FnCreateDevice = unsafe extern "system" fn( + vk::PhysicalDevice, + *const c_void, + *const c_void, + *mut vk::Device, +) -> vk::Result; +type FnGetSurfFmts = unsafe extern "system" fn( + vk::PhysicalDevice, + vk::SurfaceKHR, + *mut u32, + *mut vk::SurfaceFormatKHR, +) -> vk::Result; +type FnGetSurfFmts2 = unsafe extern "system" fn( + vk::PhysicalDevice, + *const c_void, + *mut u32, + *mut c_void, +) -> vk::Result; +type FnCreateWin32Surface = unsafe extern "system" fn( + vk::Instance, + *const c_void, + *const c_void, + *mut vk::SurfaceKHR, +) -> vk::Result; +type FnDestroySurface = unsafe extern "system" fn(vk::Instance, vk::SurfaceKHR, *const c_void); + +struct InstanceData { + instance: vk::Instance, + next_gipa: PfnGipa, + next_gpdpa: Option, + destroy_instance: Option, + get_surface_formats: Option, + get_surface_formats2: Option, + create_win32_surface: Option, + destroy_surface: Option, +} +unsafe impl Send for InstanceData {} + +struct DeviceData { + next_gdpa: PfnGdpa, +} +unsafe impl Send for DeviceData {} + +fn instances() -> &'static Mutex> { + static M: OnceLock>> = OnceLock::new(); + M.get_or_init(|| Mutex::new(HashMap::new())) +} +fn devices() -> &'static Mutex> { + static M: OnceLock>> = OnceLock::new(); + M.get_or_init(|| Mutex::new(HashMap::new())) +} +/// VkSurfaceKHR handle -> the HWND it was created from (so we can resolve its monitor's HDR state). +fn surface_hwnds() -> &'static Mutex> { + static M: OnceLock>> = OnceLock::new(); + M.get_or_init(|| Mutex::new(HashMap::new())) +} + +// dispatch key = first pointer word of a dispatchable handle (loader dispatch table ptr). +#[inline] +unsafe fn key(raw: u64) -> usize { + *(raw as usize as *const usize) +} +#[inline] +unsafe fn as_pfn(p: *const c_void) -> vk::PFN_vkVoidFunction { + Some(std::mem::transmute::< + *const c_void, + unsafe extern "system" fn(), + >(p)) +} +#[inline] +unsafe fn resolve(gipa: PfnGipa, inst: vk::Instance, name: &CStr) -> Option { + gipa(inst, name.as_ptr()).map(|f| std::mem::transmute_copy::<_, T>(&f)) +} + +fn log(msg: &str) { + if std::env::var_os("PF_VKHDR_LOG").is_none() { + return; + } + use std::io::Write; + let mut path = std::env::temp_dir(); + path.push("pf_vkhdr_layer.log"); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = writeln!(f, "{msg}"); + } +} + +fn hdr_extra() -> [vk::SurfaceFormatKHR; 2] { + [ + vk::SurfaceFormatKHR { + format: vk::Format::A2B10G10R10_UNORM_PACK32, + color_space: vk::ColorSpaceKHR::HDR10_ST2084_EXT, + }, + vk::SurfaceFormatKHR { + format: vk::Format::R16G16B16A16_SFLOAT, + color_space: vk::ColorSpaceKHR::EXTENDED_SRGB_LINEAR_EXT, + }, + ] +} + +/// `false` if this process is on the anti-cheat exclude list (built-in + `PF_VKHDR_EXCLUDE`). +/// Computed once per process. +fn injection_allowed_for_process() -> bool { + static C: OnceLock = OnceLock::new(); + *C.get_or_init(|| { + let exe = std::env::current_exe() + .ok() + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_lowercase())) + .unwrap_or_default(); + if exe.is_empty() { + return true; + } + // Conservative default skip-list for kernel-level anti-cheat titles. Users can extend or + // clear via PF_VKHDR_EXCLUDE. (HDR injection is benign, but we err toward not being present + // in these process' WSI path at all.) + const DENY: &[&str] = &[ + "cs2.exe", + "rainbowsix.exe", + "rainbowsixgame.exe", + "r5apex.exe", + ]; + let mut denied = false; + for d in DENY { + if *d == exe { + denied = true; + } + } + if let Ok(extra) = std::env::var("PF_VKHDR_EXCLUDE") { + for e in extra.split([',', ';']) { + if e.trim().to_lowercase() == exe { + denied = true; + } + } + } + if denied { + log(&format!("injection disabled for excluded process: {exe}")); + } + !denied + }) +} + +// ---- Win32 / DisplayConfig: is the surface's monitor HDR-enabled right now? ---- +mod hdr { + use std::ffi::c_void; + pub type HWND = isize; + pub type HMONITOR = isize; + + #[repr(C)] + #[derive(Clone, Copy)] + pub struct Rect { + pub l: i32, + pub t: i32, + pub r: i32, + pub b: i32, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct MonitorInfoExW { + pub cb: u32, + pub rc_monitor: Rect, + pub rc_work: Rect, + pub flags: u32, + pub sz_device: [u16; 32], + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct Luid { + pub low: u32, + pub high: i32, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct Rational { + pub num: u32, + pub den: u32, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct Source { + pub adapter: Luid, + pub id: u32, + pub mode_idx: u32, + pub status: u32, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct Target { + pub adapter: Luid, + pub id: u32, + pub mode_idx: u32, + pub tech: i32, + pub rotation: i32, + pub scaling: i32, + pub refresh: Rational, + pub scanline: i32, + pub available: i32, + pub status: u32, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct PathInfo { + pub src: Source, + pub tgt: Target, + pub flags: u32, + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct ModeInfo { + pub _b: [u8; 64], + } + #[repr(C)] + #[derive(Clone, Copy)] + pub struct Header { + pub typ: i32, + pub size: u32, + pub adapter: Luid, + pub id: u32, + } + #[repr(C)] + pub struct AdvInfo { + pub header: Header, + pub value: u32, + pub enc: i32, + pub bpc: i32, + } + #[repr(C)] + pub struct SourceName { + pub header: Header, + pub gdi: [u16; 32], + } + + #[link(name = "user32")] + extern "system" { + fn MonitorFromWindow(h: HWND, flags: u32) -> HMONITOR; + fn GetMonitorInfoW(h: HMONITOR, mi: *mut MonitorInfoExW) -> i32; + fn GetDisplayConfigBufferSizes(flags: u32, np: *mut u32, nm: *mut u32) -> i32; + fn QueryDisplayConfig( + flags: u32, + np: *mut u32, + pa: *mut PathInfo, + nm: *mut u32, + ma: *mut ModeInfo, + topo: *mut c_void, + ) -> i32; + fn DisplayConfigGetDeviceInfo(p: *mut c_void) -> i32; + } + const QDC_ONLY_ACTIVE_PATHS: u32 = 2; + const GET_SOURCE_NAME: i32 = 1; + const GET_ADVANCED_COLOR_INFO: i32 = 9; + const MONITOR_DEFAULTTONEAREST: u32 = 2; + + unsafe fn active_paths() -> Vec { + let (mut np, mut nm) = (0u32, 0u32); + if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm) != 0 || np == 0 { + return Vec::new(); + } + let mut pa: Vec = vec![std::mem::zeroed(); np as usize]; + let mut ma: Vec = vec![std::mem::zeroed(); nm as usize]; + if QueryDisplayConfig( + QDC_ONLY_ACTIVE_PATHS, + &mut np, + pa.as_mut_ptr(), + &mut nm, + ma.as_mut_ptr(), + std::ptr::null_mut(), + ) != 0 + { + return Vec::new(); + } + pa.truncate(np as usize); + pa + } + + unsafe fn target_hdr_enabled(p: &PathInfo) -> bool { + let mut ai: AdvInfo = std::mem::zeroed(); + ai.header.typ = GET_ADVANCED_COLOR_INFO; + ai.header.size = std::mem::size_of::() as u32; + ai.header.adapter = p.tgt.adapter; + ai.header.id = p.tgt.id; + if DisplayConfigGetDeviceInfo(&mut ai as *mut _ as *mut c_void) != 0 { + return false; + } + // value bitfield: bit0 advancedColorSupported, bit1 advancedColorEnabled. + (ai.value & 0b10) != 0 + } + + unsafe fn source_gdi(p: &PathInfo) -> [u16; 32] { + let mut sn: SourceName = std::mem::zeroed(); + sn.header.typ = GET_SOURCE_NAME; + sn.header.size = std::mem::size_of::() as u32; + sn.header.adapter = p.src.adapter; + sn.header.id = p.src.id; + let _ = DisplayConfigGetDeviceInfo(&mut sn as *mut _ as *mut c_void); + sn.gdi + } + + /// Is HDR (Windows advanced color) currently enabled on the display this surface lives on? + /// `hwnd == 0`/unknown falls back to "any active display has HDR enabled". + pub unsafe fn enabled_for(hwnd: HWND) -> bool { + let paths = active_paths(); + if hwnd != 0 { + let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + let mut mi: MonitorInfoExW = std::mem::zeroed(); + mi.cb = std::mem::size_of::() as u32; + if GetMonitorInfoW(mon, &mut mi) != 0 { + for p in &paths { + if source_gdi(p) == mi.sz_device { + return target_hdr_enabled(p); + } + } + } + } + paths.iter().any(|p| target_hdr_enabled(p)) + } +} + +/// Should we inject HDR formats for this surface right now? +unsafe fn should_inject(surface: vk::SurfaceKHR) -> bool { + if !injection_allowed_for_process() { + return false; + } + let hwnd = surface_hwnds() + .lock() + .ok() + .and_then(|m| m.get(&surface.as_raw()).copied()) + .unwrap_or(0); + hdr::enabled_for(hwnd) +} + +// ---- entry point ---- + +#[no_mangle] +pub unsafe extern "system" fn vkNegotiateLoaderLayerInterfaceVersion( + p: *mut NegotiateLayerInterface, +) -> vk::Result { + if p.is_null() { + return vk::Result::ERROR_INITIALIZATION_FAILED; + } + let s = &mut *p; + if s.s_type != LAYER_NEGOTIATE_INTERFACE_STRUCT { + return vk::Result::ERROR_INITIALIZATION_FAILED; + } + if s.loader_layer_interface_version > 2 { + s.loader_layer_interface_version = 2; + } + s.pfn_gipa = Some(layer_gipa); + s.pfn_gdpa = Some(layer_gdpa); + s.pfn_gpdpa = Some(layer_gpdpa); + log("negotiate: VK_LAYER_PUNKTFUNK_hdr_inject active (v2)"); + vk::Result::SUCCESS +} + +// ---- proc-addr dispatch ---- + +unsafe extern "system" fn layer_gipa( + instance: vk::Instance, + p_name: *const c_char, +) -> vk::PFN_vkVoidFunction { + if p_name.is_null() { + return None; + } + match CStr::from_ptr(p_name).to_bytes() { + b"vkGetInstanceProcAddr" => as_pfn(layer_gipa as *const c_void), + b"vkGetDeviceProcAddr" => as_pfn(layer_gdpa as *const c_void), + b"vkCreateInstance" => as_pfn(create_instance as *const c_void), + b"vkDestroyInstance" => as_pfn(destroy_instance as *const c_void), + b"vkCreateDevice" => as_pfn(create_device as *const c_void), + b"vkGetPhysicalDeviceSurfaceFormatsKHR" => as_pfn(get_surface_formats as *const c_void), + b"vkGetPhysicalDeviceSurfaceFormats2KHR" => as_pfn(get_surface_formats2 as *const c_void), + b"vkCreateWin32SurfaceKHR" => as_pfn(create_win32_surface as *const c_void), + b"vkDestroySurfaceKHR" => as_pfn(destroy_surface as *const c_void), + _ => { + if instance == vk::Instance::null() { + return None; + } + let next = { + let g = instances().lock().ok()?; + g.get(&key(instance.as_raw())).map(|d| d.next_gipa) + }; + next.and_then(|gipa| gipa(instance, p_name)) + } + } +} + +unsafe extern "system" fn layer_gpdpa( + instance: vk::Instance, + p_name: *const c_char, +) -> vk::PFN_vkVoidFunction { + if p_name.is_null() { + return None; + } + match CStr::from_ptr(p_name).to_bytes() { + b"vkGetPhysicalDeviceSurfaceFormatsKHR" => as_pfn(get_surface_formats as *const c_void), + b"vkGetPhysicalDeviceSurfaceFormats2KHR" => as_pfn(get_surface_formats2 as *const c_void), + _ => { + if instance == vk::Instance::null() { + return None; + } + let next = { + let g = instances().lock().ok()?; + g.get(&key(instance.as_raw())).and_then(|d| d.next_gpdpa) + }; + next.and_then(|gpdpa| gpdpa(instance, p_name)) + } + } +} + +unsafe extern "system" fn layer_gdpa( + device: vk::Device, + p_name: *const c_char, +) -> vk::PFN_vkVoidFunction { + if p_name.is_null() { + return None; + } + if CStr::from_ptr(p_name).to_bytes() == b"vkGetDeviceProcAddr" { + return as_pfn(layer_gdpa as *const c_void); + } + if device == vk::Device::null() { + return None; + } + let next = { + let g = match devices().lock() { + Ok(g) => g, + Err(_) => return None, + }; + g.get(&key(device.as_raw())).map(|d| d.next_gdpa) + }; + next.and_then(|gdpa| gdpa(device, p_name)) +} + +// ---- instance chain ---- + +unsafe fn find_instance_link(p_ci: *const c_void) -> *mut LayerInstanceCreateInfo { + let mut node = (*(p_ci as *const BaseIn)).p_next as *const BaseIn; + while !node.is_null() { + if (*node).s_type.as_raw() == LOADER_INSTANCE_CREATE_INFO { + let lci = node as *mut LayerInstanceCreateInfo; + if (*lci).function == VK_LAYER_LINK_INFO { + return lci; + } + } + node = (*node).p_next as *const BaseIn; + } + ptr::null_mut() +} + +unsafe extern "system" fn create_instance( + p_ci: *const c_void, + p_alloc: *const c_void, + p_inst: *mut vk::Instance, +) -> vk::Result { + let lci = find_instance_link(p_ci); + if lci.is_null() { + return vk::Result::ERROR_INITIALIZATION_FAILED; + } + let link = (*lci).u; + if link.is_null() { + return vk::Result::ERROR_INITIALIZATION_FAILED; + } + let next_gipa = (*link).next_gipa; + let next_gpdpa = (*link).next_gpdpa; + (*lci).u = (*link).p_next; + + let create: FnCreateInstance = + match resolve(next_gipa, vk::Instance::null(), c"vkCreateInstance") { + Some(f) => f, + None => return vk::Result::ERROR_INITIALIZATION_FAILED, + }; + let res = create(p_ci, p_alloc, p_inst); + if res != vk::Result::SUCCESS { + return res; + } + let inst = *p_inst; + let data = InstanceData { + instance: inst, + next_gipa, + next_gpdpa, + destroy_instance: resolve(next_gipa, inst, c"vkDestroyInstance"), + get_surface_formats: resolve(next_gipa, inst, c"vkGetPhysicalDeviceSurfaceFormatsKHR"), + get_surface_formats2: resolve(next_gipa, inst, c"vkGetPhysicalDeviceSurfaceFormats2KHR"), + create_win32_surface: resolve(next_gipa, inst, c"vkCreateWin32SurfaceKHR"), + destroy_surface: resolve(next_gipa, inst, c"vkDestroySurfaceKHR"), + }; + if let Ok(mut g) = instances().lock() { + g.insert(key(inst.as_raw()), data); + } + log("create_instance: hooked"); + vk::Result::SUCCESS +} + +unsafe extern "system" fn destroy_instance(inst: vk::Instance, p_alloc: *const c_void) { + if inst == vk::Instance::null() { + return; + } + let data = instances() + .lock() + .ok() + .and_then(|mut g| g.remove(&key(inst.as_raw()))); + if let Some(d) = data { + if let Some(f) = d.destroy_instance { + f(inst, p_alloc); + } + } +} + +// ---- device chain (pass-through; keeps device-level dispatch working) ---- + +unsafe fn find_device_link(p_ci: *const c_void) -> *mut LayerDeviceCreateInfo { + let mut node = (*(p_ci as *const BaseIn)).p_next as *const BaseIn; + while !node.is_null() { + if (*node).s_type.as_raw() == LOADER_DEVICE_CREATE_INFO { + let lci = node as *mut LayerDeviceCreateInfo; + if (*lci).function == VK_LAYER_LINK_INFO { + return lci; + } + } + node = (*node).p_next as *const BaseIn; + } + ptr::null_mut() +} + +unsafe extern "system" fn create_device( + pdev: vk::PhysicalDevice, + p_ci: *const c_void, + p_alloc: *const c_void, + p_dev: *mut vk::Device, +) -> vk::Result { + let lci = find_device_link(p_ci); + if lci.is_null() { + return vk::Result::ERROR_INITIALIZATION_FAILED; + } + let link = (*lci).u; + if link.is_null() { + return vk::Result::ERROR_INITIALIZATION_FAILED; + } + let next_gipa = (*link).next_gipa; + let next_gdpa = (*link).next_gdpa; + (*lci).u = (*link).p_next; + + let inst = instances() + .lock() + .ok() + .and_then(|g| g.get(&key(pdev.as_raw())).map(|d| d.instance)) + .unwrap_or(vk::Instance::null()); + + let create: FnCreateDevice = match resolve(next_gipa, inst, c"vkCreateDevice") { + Some(f) => f, + None => return vk::Result::ERROR_INITIALIZATION_FAILED, + }; + let res = create(pdev, p_ci, p_alloc, p_dev); + if res != vk::Result::SUCCESS { + return res; + } + let dev = *p_dev; + if let Ok(mut g) = devices().lock() { + g.insert(key(dev.as_raw()), DeviceData { next_gdpa }); + } + vk::Result::SUCCESS +} + +// ---- surface tracking (so we can resolve a surface's monitor) ---- + +unsafe extern "system" fn create_win32_surface( + inst: vk::Instance, + p_ci: *const c_void, + p_alloc: *const c_void, + p_surface: *mut vk::SurfaceKHR, +) -> vk::Result { + let down = instances().lock().ok().and_then(|g| { + g.get(&key(inst.as_raw())) + .and_then(|d| d.create_win32_surface) + }); + let down = match down { + Some(f) => f, + None => return vk::Result::ERROR_EXTENSION_NOT_PRESENT, + }; + let res = down(inst, p_ci, p_alloc, p_surface); + if res == vk::Result::SUCCESS { + // VkWin32SurfaceCreateInfoKHR: sType@0, pNext@8, flags@16, hinstance@24, hwnd@32 + let hwnd = *((p_ci as *const u8).add(32) as *const isize); + if let Ok(mut m) = surface_hwnds().lock() { + m.insert((*p_surface).as_raw(), hwnd); + } + } + res +} + +unsafe extern "system" fn destroy_surface( + inst: vk::Instance, + surface: vk::SurfaceKHR, + p_alloc: *const c_void, +) { + if let Ok(mut m) = surface_hwnds().lock() { + m.remove(&surface.as_raw()); + } + let down = instances() + .lock() + .ok() + .and_then(|g| g.get(&key(inst.as_raw())).and_then(|d| d.destroy_surface)); + if let Some(f) = down { + f(inst, surface, p_alloc); + } +} + +// ---- the actual fix: append HDR surface formats (self-gated on display HDR state) ---- + +unsafe extern "system" fn get_surface_formats( + pdev: vk::PhysicalDevice, + surface: vk::SurfaceKHR, + p_count: *mut u32, + p_formats: *mut vk::SurfaceFormatKHR, +) -> vk::Result { + let down = instances().lock().ok().and_then(|g| { + g.get(&key(pdev.as_raw())) + .and_then(|d| d.get_surface_formats) + }); + let down = match down { + Some(f) => f, + None => return vk::Result::ERROR_INITIALIZATION_FAILED, + }; + + let mut n = 0u32; + let r = down(pdev, surface, &mut n, ptr::null_mut()); + if r != vk::Result::SUCCESS { + return r; + } + let mut real = vec![vk::SurfaceFormatKHR::default(); n as usize]; + if n > 0 { + let r = down(pdev, surface, &mut n, real.as_mut_ptr()); + if r != vk::Result::SUCCESS { + return r; + } + } + real.truncate(n as usize); + + let mut aug = real; + if !aug.is_empty() && should_inject(surface) { + for e in hdr_extra() { + if !aug + .iter() + .any(|x| x.format == e.format && x.color_space == e.color_space) + { + aug.push(e); + } + } + } + + if p_formats.is_null() { + *p_count = aug.len() as u32; + return vk::Result::SUCCESS; + } + let m = (*p_count as usize).min(aug.len()); + ptr::copy_nonoverlapping(aug.as_ptr(), p_formats, m); + *p_count = m as u32; + if m < aug.len() { + vk::Result::INCOMPLETE + } else { + vk::Result::SUCCESS + } +} + +unsafe extern "system" fn get_surface_formats2( + pdev: vk::PhysicalDevice, + p_info: *const c_void, + p_count: *mut u32, + p_formats: *mut c_void, +) -> vk::Result { + let down = instances().lock().ok().and_then(|g| { + g.get(&key(pdev.as_raw())) + .and_then(|d| d.get_surface_formats2) + }); + let down = match down { + Some(f) => f, + None => return vk::Result::ERROR_INITIALIZATION_FAILED, + }; + + let mut n = 0u32; + let r = down(pdev, p_info, &mut n, ptr::null_mut()); + if r != vk::Result::SUCCESS { + return r; + } + let mut real: Vec = (0..n) + .map(|_| SurfaceFormat2Raw { + s_type: vk::StructureType::SURFACE_FORMAT_2_KHR, + p_next: ptr::null_mut(), + surface_format: vk::SurfaceFormatKHR::default(), + }) + .collect(); + if n > 0 { + let r = down(pdev, p_info, &mut n, real.as_mut_ptr() as *mut c_void); + if r != vk::Result::SUCCESS { + return r; + } + } + real.truncate(n as usize); + + // VkPhysicalDeviceSurfaceInfo2KHR: sType@0, pNext@8, surface@16 + let surface = vk::SurfaceKHR::from_raw(*((p_info as *const u8).add(16) as *const u64)); + + let mut extras: Vec = Vec::new(); + if !real.is_empty() && should_inject(surface) { + for e in hdr_extra() { + if !real.iter().any(|x| { + x.surface_format.format == e.format && x.surface_format.color_space == e.color_space + }) { + extras.push(e); + } + } + } + let total = real.len() + extras.len(); + + if p_formats.is_null() { + *p_count = total as u32; + return vk::Result::SUCCESS; + } + let m = (*p_count as usize).min(total); + let out = p_formats as *mut SurfaceFormat2Raw; + for i in 0..m { + let sf = if i < real.len() { + real[i].surface_format + } else { + extras[i - real.len()] + }; + let dst = out.add(i); + (*dst).s_type = vk::StructureType::SURFACE_FORMAT_2_KHR; + (*dst).surface_format = sf; + } + *p_count = m as u32; + if m < total { + vk::Result::INCOMPLETE + } else { + vk::Result::SUCCESS + } +} diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index 1b7edc2a..18a1bbd3 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -56,6 +56,13 @@ #define WithWeb #endif #endif +; VkLayerDir (the staged pf-vkhdr-layer: pf_vkhdr_layer.dll + .json) is optional — present when the +; HDR Vulkan layer was built. It lets Vulkan games (Doom: The Dark Ages, etc.) enable HDR over the +; virtual display (the ICD won't advertise HDR there; the layer injects the surface formats, self- +; gated on the display's actual HDR state). +#ifdef VkLayerDir + #define WithVkLayer +#endif [Setup] AppId={{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93} @@ -89,6 +96,9 @@ Name: "installdriver"; Description: "Install the pf-vdisplay virtual display dri #ifdef WithGamepad Name: "installgamepad"; Description: "Install the virtual gamepad drivers (DualSense / DualShock 4 / Xbox 360 — no ViGEmBus needed)" #endif +#ifdef WithVkLayer +Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan games like Doom use HDR on the virtual display)" +#endif Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)" [Files] @@ -119,6 +129,22 @@ Source: "{#StageDir}\*"; DestDir: "{tmp}\pfvdisplay"; Flags: deleteafterinstall ; The vendored UMDF gamepad drivers + install-gamepad-drivers.ps1, extracted to {tmp}, removed after. Source: "{#GamepadStageDir}\*"; DestDir: "{tmp}\gamepad"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installgamepad #endif +#ifdef WithVkLayer +; The HDR Vulkan implicit layer (cdylib + its JSON manifest) laid into {app}\vklayer and registered +; below. The manifest's library_path is ".\pf_vkhdr_layer.dll" (relative to the JSON), so the two +; must live in the same directory. +Source: "{#VkLayerDir}\pf_vkhdr_layer.dll"; DestDir: "{app}\vklayer"; Flags: ignoreversion; Tasks: installhdrlayer +Source: "{#VkLayerDir}\pf_vkhdr_layer.json"; DestDir: "{app}\vklayer"; Flags: ignoreversion; Tasks: installhdrlayer +#endif + +[Registry] +#ifdef WithVkLayer +; Register the HDR Vulkan implicit layer system-wide. The 64-bit Vulkan loader reads +; HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers; the value NAME is the manifest path and the DWORD +; DATA is 0 (= enabled). uninsdeletevalue removes just this value on uninstall. The layer is inert +; unless the target display has HDR enabled, and honors DISABLE_PF_VKHDR=1 as a global off-switch. +Root: HKLM64; Subkey: "SOFTWARE\Khronos\Vulkan\ImplicitLayers"; ValueType: dword; ValueName: "{app}\vklayer\pf_vkhdr_layer.json"; ValueData: 0; Flags: uninsdeletevalue; Tasks: installhdrlayer +#endif [Run] #ifdef WithDriver