Add optional desktop mirror and native ARM64 release packaging

This commit is contained in:
InstanceBrick committed 2026-10-16 15:43:07 -04:00
1 parent 7dc4f55642
commit bc223c4273
23 files changed
+806 -127

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
name: Steam Frame ARM64
on:
push:
branches: [steam-frame]
tags: ['steam-frame-*']
pull_request:
branches: [steam-frame]
workflow_dispatch:
inputs:
publish_release:
description: Publish a release for an existing steam-frame-* tag
type: boolean
default: false
release_tag:
description: Existing tag to build when publishing (leave empty for artifact only)
type: string
default: ''
permissions:
contents: read
concurrency:
group: steam-frame-arm64-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
IMAGE: localhost/nightcap-vr-arm64:frame-foveated
ASSET: nightcap-steam-frame-arm64.oci.tar.zst
jobs:
source-checks:
runs-on: ubuntu-24.04-arm
timeout-minutes: 10
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_release && inputs.release_tag || github.ref }}
- name: Check Native settings and package scripts
run: |
for script in build-frame-nightcap.sh packaging/steam-frame/verify_image.sh; do
bash -n "$script"
done
python3 - <<'PY'
import ast
from pathlib import Path
paths = list(Path('tools/steam_frame').glob('*.py'))
paths.append(Path('tools/steam_frame/nightcap-vr-arm64'))
for path in paths:
ast.parse(path.read_text(), filename=str(path))
PY
python3 -m unittest discover -s tests -p 'steam_frame_*_test.py'
node tests/steamvr_video_controls_test.js
build:
if: github.event_name != 'pull_request'
needs: source-checks
runs-on: ubuntu-24.04-arm
timeout-minutes: 90
outputs:
release_tag: ${{ steps.release-target.outputs.tag }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
submodules: recursive
persist-credentials: false
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_release && inputs.release_tag || github.ref }}
- name: Validate release target
id: release-target
env:
EVENT: ${{ github.event_name }}
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
PUBLISH: ${{ inputs.publish_release }}
REQUESTED_TAG: ${{ inputs.release_tag }}
run: |
tag=''
if [[ "$EVENT" == push && "$REF_TYPE" == tag ]]; then
tag="$REF_NAME"
elif [[ "$EVENT" == workflow_dispatch && "$PUBLISH" == true ]]; then
tag="$REQUESTED_TAG"
fi
if [[ -n "$tag" || "$PUBLISH" == true ]]; then
[[ "$tag" =~ ^steam-frame-[A-Za-z0-9][A-Za-z0-9._-]*$ ]]
[[ "$(git rev-parse "refs/tags/$tag^{commit}")" == "$(git rev-parse HEAD)" ]]
fi
printf 'tag=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- name: Install container tools
run: |
test "$(uname -m)" = aarch64
sudo apt-get update
sudo apt-get install --no-install-recommends -y podman zstd
- name: Build Native ARM64 image
run: |
sudo podman build --arch arm64 --jobs 1 \
--label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \
--label "org.opencontainers.image.revision=$(git rev-parse HEAD)" \
--label org.opencontainers.image.title='Nightcap Steam Frame ARM64' \
-f packaging/steam-frame/Containerfile -t "$IMAGE" .
- name: Run mirror and runtime configuration regressions
run: |
sudo podman run --rm --entrypoint /bin/bash "$IMAGE" -lc '
set -euo pipefail
cmake -S /work -B /work/build-frame-arm64 -DBUILD_TESTING=ON
cmake --build /work/build-frame-arm64 -j3 \
--target desktop_mirror_test runtime_config_test runtime_config_file_test
/work/build-frame-arm64/tests/desktop_mirror_test
/work/build-frame-arm64/tests/runtime_config_test
/work/build-frame-arm64/tests/runtime_config_file_test
'
- name: Check payload-free image
run: |
mkdir -p dist
sudo bash packaging/steam-frame/verify_image.sh "$IMAGE" > dist/image-check.json
cat dist/image-check.json
- name: Export image and checksums
run: |
sudo podman save --format oci-archive "$IMAGE" | zstd -T2 -6 -o "dist/$ASSET"
test "$(stat -c %s "dist/$ASSET")" -lt 2147483648
python3 - <<'PY'
import json, os, pathlib, subprocess, tarfile
archive = pathlib.Path('dist') / os.environ['ASSET']
process = subprocess.Popen(['zstd', '-dc', str(archive)], stdout=subprocess.PIPE)
index = None
with tarfile.open(fileobj=process.stdout, mode='r|') as stream:
for member in stream:
if member.name == 'index.json':
index = json.load(stream.extractfile(member))
while process.stdout.read(1024 * 1024):
pass
if process.wait() != 0:
raise SystemExit('Compressed OCI archive integrity check failed')
manifests = index.get('manifests', []) if index else []
if (len(manifests) != 1 or manifests[0].get('annotations', {}).get(
'org.opencontainers.image.ref.name') != os.environ['IMAGE']):
raise SystemExit('OCI archive must restore the normal launcher image tag')
print('Compressed OCI archive and launcher tag verified')
PY
cd dist
sha256sum "$ASSET" > "$ASSET.sha256"
- name: Write build metadata
env:
RELEASE_TAG: ${{ steps.release-target.outputs.tag }}
run: |
python3 - <<'PY'
import json, os, pathlib, subprocess
commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
image_id = subprocess.check_output(['sudo', 'podman', 'image', 'inspect', '--format', '{{.Id}}', os.environ['IMAGE']], text=True).strip()
metadata = {
'source': 'https://github.com/' + os.environ['GITHUB_REPOSITORY'],
'commit': commit, 'release_tag': os.environ['RELEASE_TAG'] or None,
'platform': 'linux/arm64', 'image_tag': os.environ['IMAGE'], 'image_id': image_id,
'archive': os.environ['ASSET'], 'upscaling': 'Native', 'fsr3_compiled': False,
'default_eye_size': [2160, 2160], 'default_refresh_hz': 72,
'default_antialiasing': 'SMAA High',
'checks': ['Native single-sample policy', 'refresh-rate policy', 'desktop mirror policy', 'video controls',
'mirror command recording and runtime configuration',
'AArch64 ELF and dynamic dependencies', 'payload and license packaging',
'compressed OCI integrity and launcher tag'],
'runtime_testing': 'CI packaging checks do not start SteamVR or Roblox',
'licenses': 'Source notices and third-party notices are included under /work in the image',
}
pathlib.Path('dist/build-info.json').write_text(json.dumps(metadata, indent=2) + '\n')
PY
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: nightcap-steam-frame-arm64
path: dist/
if-no-files-found: error
compression-level: 0
retention-days: 14
release:
if: needs.build.outputs.release_tag != ''
needs: build
runs-on: ubuntu-24.04-arm
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: nightcap-steam-frame-arm64
path: dist
- name: Publish checked ARM64 package
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ needs.build.outputs.release_tag }}
run: |
cd dist
sha256sum --check "$ASSET.sha256"
printf '%s\n' \
'Native ARM64 Steam Frame image and packaging-check metadata.' \
'Roblox, SteamVR and host graphics drivers are downloaded or provided separately.' \
'Verify the SHA256, then import with: zstd -dc nightcap-steam-frame-arm64.oci.tar.zst | podman load' \
'See docs/INSTALL.md in this tag for launcher and SteamVR setup.' > release-notes.md
gh release create "$RELEASE_TAG" --verify-tag \
--title "Steam Frame ARM64 $RELEASE_TAG" --notes-file release-notes.md \
"$ASSET" "$ASSET.sha256" image-check.json build-info.json
+8 -7
View File
@@ -23,8 +23,8 @@ experience and its graphics settings; 72 FPS is the target.
- Roblox's internal frame-rate cap follows the selected headset refresh rate.
- Renderer and session handling across menu, game and game-to-game transitions.
- Native microphone capture bridge for Roblox voice chat.
- One **Nightcap Roblox VR** Steam entry with the original Nightcap icon and a
hidden desktop mirror.
- One **Nightcap Roblox VR** Steam entry with the original Nightcap icon and an
optional desktop mirror. The mirror is off by default on Steam Frame.
- Simple **Foveated Rendering**, **Gaze sharp radius** and **Anti Aliasing**
controls in SteamVR's Video settings.
- Optional in-headset performance overlay with actual game FPS, headset Hz,
@@ -32,10 +32,11 @@ experience and its graphics settings; 72 FPS is the target.
## Install
Build and install on the Frame using the [installation guide](docs/INSTALL.md).
The repository contains source code, build instructions and integration tools.
The Roblox client is downloaded separately on the device; no Roblox binaries,
account data or prebuilt runtime images are distributed here.
Install the native ARM64 build from [Releases](https://github.com/InstanceBrick/nightcap/releases),
or build it on the Frame using the [installation guide](docs/INSTALL.md).
The ARM64 package contains Nightcap and its open-source runtime dependencies.
Roblox is downloaded separately on the device. Roblox binaries, account data,
SteamVR files and the Frame's graphics drivers are not included in the package.
Current ARM64 client support: **2.742.1059 (3222)** and **2.736.1408 (2998)**.
@@ -45,7 +46,7 @@ Current ARM64 client support: **2.742.1059 (3222)** and **2.736.1408 (2998)**.
## About this fork
This Steam Frame edition is based on the `vr` branch of
The `steam-frame` branch is the Steam Frame edition of the `vr` branch of
[CoderDayton/nightcap](https://github.com/CoderDayton/nightcap), itself a fork of
[komaruworld/mocktail](https://github.com/komaruworld/mocktail). It adds the native
ARM64 VR bridge and Frame rendering, input, audio and Steam integration changes.
+4
View File
@@ -61,6 +61,10 @@ graphics:
# enabled: true/false still works as on/off; set one or the other.
# Standalone OpenXR test scene: mocktail-vr-probe --scene.
# mode: off
# Boolean (default: true): also show the left-eye VR view in the desktop
# window. Restart Nightcap after changing this setting. Steam Frame
# uses its Video settings Desktop Mirror control, off by default.
# desktop_mirror: true
# String (default: fxaa): smooth jagged edges in the headset. Roblox draws
# the VR eyes without anti-aliasing; fxaa filters each eye on its way to the
# headset. The Steam Frame profile uses its separate gaze AA control.
+1
View File
@@ -7,6 +7,7 @@ graphics:
vr:
enabled: true
desktop_mirror: false
frame_rate_limit: unlimited
audio:
+21 -39
View File
@@ -3,10 +3,10 @@
The [installation guide](INSTALL.md) covers a complete source build on Steam
Frame. The container recipe uses Fedora 44 ARM64 with CMake, Ninja, SDL3,
OpenXR, Vulkan, WebKitGTK and Nightcap's other native dependencies.
FSR 3.1 shaders are generated during the build. Fedora's `glslang` package
provides `glslangValidator`, and `spirv-tools` provides `spirv-val` for shader
validation; both are installed by the Frame container recipe. An existing
development image needs these packages before rebuilding this branch.
The Frame build enables OpenXR and the ARM64 bridge, disables runtime
diagnostics, and uses native rendering. Fedora's `glslang` and `spirv-tools`
packages are included for shader generation and validation. The ARM64 workflow
builds the same container recipe and checks the package before export.
## Upstream source
@@ -82,38 +82,21 @@ Shader sources and their generated SPIR-V header are included. Regenerating the
AA shaders with `tools/compile_gaze_aa.py` additionally needs `glslangValidator`.
The SMAA sources retain their upstream license in `src/vr/smaa/`.
## FSR shaders
## Native rendering
The FSR 1 reference headers and MIT license are in `src/vr/fsr`. The Vulkan
fragment shader uses FP32 EASU around the gaze with a smooth transition to
bilinear scaling in the periphery. SMAA runs at output resolution; RCAS then
sharpens only the gaze disk. sRGB inputs use a raw copy to an encoded UNORM
image so EASU avoids repeated gamma conversions. The original eye provenance,
copy command submission and completion fence are retained.
The shipping Frame profile uses native rendering with gaze anti-aliasing.
MSAA and upscaling controls are absent from the Frame Video settings. The build
sets `MOCKTAIL_ENABLE_FSR3=OFF`, so the FidelityFX SDK is not linked into the
Frame runtime. Reference upscaler sources and their licenses remain in the tree.
After changing the shader, regenerate the checked-in SPIR-V with:
To regenerate the gaze AA shaders after changing them:
```sh
python3 tools/compile_fsr.py .
python3 tools/compile_gaze_aa.py .
```
This requires `glslangValidator`. Build `fsr_upscaler_gpu_test` with testing
enabled for offscreen checks of source replacement, repeated frames, SMAA
ordering, gaze boundaries and alpha. Set `NIGHTCAP_AA_VALIDATE=1` to also enable
the Vulkan validation layer and synchronization checks.
The optional temporal pass is `src/vr/temporal.frag`, with rotational
reprojection in `include/mocktail/vr/temporal_reprojection.h`. It runs after
SMAA and before RCAS, reuses the gaze scratch region and copies only that region
to a per-eye unsharpened history. It adds no queue submission or frame fence.
The shader regeneration command above also rebuilds `temporal_spv.h`.
FSR 3.1 uses the MIT-licensed AMD FidelityFX SDK in `third_party/fidelityfx`.
Its gaze-region path receives matching native scene depth, projection jitter
and motion vectors. AMD optical flow supplies image motion when native motion
is unavailable. Each eye keeps its own temporal history; the rest of the image
uses inexpensive spatial scaling. SDK shader generation and standalone GPU
checks are described in the [FidelityFX build notes](../third_party/fidelityfx/README.md).
This requires `glslangValidator`. The SMAA sources retain their upstream license
in `src/vr/smaa/`.
## Foveation tile calibration
@@ -125,7 +108,7 @@ in `include/mocktail/vr/foveation_tile_bounds.h` applies only to this identity:
- Driver name `turnip Mesa driver`, info `Mesa 26.3.0-devel (git-abc426bb62)`.
- Driver UUID `586b9fa4514cb88b3c11a968ef4d62e4`; measured ELF build ID
`b7e5995d0b202f06e81cd5a0f809ec1b744a0cb8`.
- A 2160 × 2160 framebuffer, one layer/subpass, single-sample
- A 2160 × 2160 framebuffer, one layer/subpass, single-sample
`R8G8B8A8_UNORM` color and `D32_SFLOAT` depth, plus the density attachment.
Offsets, resolves, extra attachments, multiview and feedback/input attachments
retain the conservative guard.
@@ -137,11 +120,11 @@ The divisor routine at `0xa9e300` returns 1 for FDM. CPU-only creation of 72
render-pass/imageless-framebuffer combinations, covering FDM on/off and every
standard color/depth load/store combination, produced identical budgets:
356352 pixels for `FULL`, 350208 for `AVOID_CCU`. Their base tiles are
480 × 736 and 768 × 448 respectively. FDM therefore selects 768 × 448
480 × 736 and 768 × 448 respectively. FDM therefore selects 768 × 448
independently of shaders and load/store operations for this tuple.
The GPU qualification compared a high-frequency full-density reference against
the production map helper with its 68 × 68 map and 768 × 448 guard. Sweep both
the production map helper with its 68 × 68 map and 768 × 448 guard. Sweep both
gaze axes through `{-1, -.75, -.4, 0, .4, .75, 1}`, radii
`{.08, .15, .22, .35}`, and all three active profiles. All 588 cases preserved
every pixel inside the gaze disk exactly, with 588 valid fragment-invocation
@@ -167,20 +150,19 @@ Allocation, deallocation and the process-memory limit checks retain their normal
behavior.
Gaze density generation reuses per-eye pixel and half-float buffers across
frames. The density values and gaze protection are unchanged. At a 1:1 render
and output size with matching channel formats, FSR copies the encoded source
pixels directly instead of reconstructing pixels that already exist. Gaze AA,
optional temporal stabilization and RCAS keep their existing order.
frames. The density values and gaze protection are unchanged. At a 1:1 render and output size, the native path avoids unnecessary
reconstruction, temporal resource tracking and upscaler shader dispatches.
Gaze anti-aliasing runs on the composed eye image.
The Vulkan eye copy is submitted on the OpenXR binding queue before releasing
its swapchain images, so SteamVR can enqueue composition immediately. When the
guest device requested exactly one unprotected queue, completion is deferred
until the next use of its AA/FSR resources. GPU queue ordering keeps subsequent guest
until the next use of its eye-copy and anti-aliasing resources. GPU queue ordering keeps subsequent guest
commands behind the copy. The upstream submission ring tracks command buffers, fences and source/destination
images. Presentation dependencies are consumed on the GPU and replaced with a
per-swapchain-image semaphore, so the CPU does not wait for the current render.
AA descriptors and temporal history wait only for previous eye-copy users;
AA resources wait only for previous eye-copy users;
current-frame present dependencies are excluded from that resource wait.
Captured source-image handles are drained before
image destruction; queue exposure, swapchain replacement and teardown also
+29 -8
View File
@@ -7,25 +7,41 @@
- The Frame's Turnip/Freedreno Vulkan driver under `/usr/lib` and its Vulkan ICD
under `/usr/share/vulkan/icd.d`.
- Rootless Podman, Git, Python 3 and a C++17 compiler on the Frame.
- `zstd` for importing the compressed ARM64 release archive.
- A Linux filesystem for the checkout, internet access and space for the source,
Fedora build image and a separate Roblox client download.
The build dependencies are installed inside a Fedora 44 ARM64 container,
including `glslangValidator` and `spirv-val` for the FSR shaders. Keep the
Frame's own graphics drivers: the launcher mounts them into the container.
Use a case-sensitive Linux checkout because some Android headers differ only
by letter case. The repository is private, so GitHub access is required to clone it.
The runtime uses Fedora 44 ARM64 in a rootless container. Keep the Frame's
own graphics drivers: the launcher mounts them into the container. Building
from source also needs a case-sensitive Linux checkout because some Android
headers differ only by letter case. The source and ARM64 build are public.
## 1. Get the source
```bash
git clone --branch steam-frame --recurse-submodules \
https://github.com/InstanceBrick/nightcap-steam-frame.git \
https://github.com/InstanceBrick/nightcap.git \
"$HOME/.local/share/nightcap-steam-frame-src"
cd "$HOME/.local/share/nightcap-steam-frame-src"
```
## 2. Build Nightcap
## 2. Install the ARM64 image
Download `nightcap-steam-frame-arm64.oci.tar.zst` and its `.sha256` file from
[Releases](https://github.com/InstanceBrick/nightcap/releases). From the
directory containing both files:
```bash
set -euo pipefail
sha256sum -c nightcap-steam-frame-arm64.oci.tar.zst.sha256
zstd -dc nightcap-steam-frame-arm64.oci.tar.zst | podman load
```
The archive imports as `localhost/nightcap-vr-arm64:frame-foveated`. It contains
the ARM64 Nightcap runtime and its Fedora dependencies, with no Roblox client,
personal data, SteamVR installation or proprietary graphics drivers.
To build the same image from source instead:
```bash
podman build --arch arm64 --jobs 1 \
@@ -119,7 +135,12 @@ files instead of copying the already patched dashboard.
## Update Nightcap
Close Nightcap, pull this branch and rebuild under a temporary image tag:
Close Nightcap before replacing its image or launcher. Pull the `steam-frame`
branch, then load the new ARM64 release archive as in step 2 and rerun the
launcher installer. Existing Roblox downloads, sign-in state and settings stay
in the separate data directory.
For source builds, pull this branch and rebuild under a temporary image tag:
```bash
git pull --ff-only
+26 -3
View File
@@ -15,6 +15,7 @@ The gaze radius remains adjustable, and existing saved choices are preserved.
| Foveated Rendering | Off uses full-density shading throughout. Quality, Balanced and Performance progressively reduce peripheral shading work. Each mode keeps the same full-detail gaze region. |
| Gaze sharp radius | Sets the minimum full-detail region around your gaze, from 8% to 35%. Larger values preserve more of the view at full density and cost more GPU time. Hardware tile boundaries can enlarge this region. |
| Anti Aliasing | Off, FXAA, SMAA Low or SMAA High. Filtering runs around your gaze on the composed eye image, including interface edges. SMAA High is the default. |
| Desktop Mirror | Off by default on Frame. On displays the VR image in the desktop window as well as the headset. Relaunch Nightcap after changing it. |
| Performance Overlay | Shows actual game FPS, headset Hz, average frame time, SteamVR GPU time and per-eye resolution at the bottom center of the headset view. It hides while the Steam menu is open and does not capture controller input. |
| Resolution Per Eye | Sets the headset eye-buffer size. Nightcap's recommended default is 2160 × 2160 per eye; Auto and Reset to Defaults use this baseline. Explicit custom values stay selected. Changes apply after the slider settles, with a brief pause while eye buffers are rebuilt at a safe frame boundary. |
| Display Refresh Rate | The standard profile uses 72 Hz. The launcher follows the selected per-game headset rate and any explicit Steam frame limit. Restart Nightcap after changing the rate or frame limit so Roblox's internal cap matches it. |
@@ -81,9 +82,31 @@ audio:
An exact SDL device name can replace `default`. Input device names appear in the
launch log.
Roblox's hidden desktop viewport stays at 1920 × 1080 for stable in-game menu
size. It is independent of the headset eye resolution. Desktop window geometry
is not restored or saved while the VR window is hidden.
## Desktop mirror
Open SteamVR's per-game **Video** settings and set **Desktop Mirror** to **On**
to show the VR view in a desktop window. Set it to **Off** to keep the Frame's
single headset application without the extra desktop view. Relaunch Nightcap
after changing this setting. The Frame default is **Off**.
The Frame launcher reads the saved SteamVR mirror choice and coordinates both
the mirror renderer and desktop-window visibility. This setting takes precedence
over the YAML value when using the Frame launcher. Generic Nightcap launches
can configure the mirror beside the VR option:
```yaml
vr:
enabled: true
desktop_mirror: true
```
`MOCKTAIL_VR_DESKTOP_MIRROR=1` or `0` overrides the YAML setting for generic
launches. VR and desktop mirroring are separate options; enabling VR does not
require the desktop mirror.
Roblox's desktop viewport stays at 1920 × 1080 during VR for stable in-game
menu size, independently of headset eye resolution. On Frame, desktop window geometry
is not restored or saved in VR, including when the mirror is enabled.
## Files and troubleshooting
+78
View File
@@ -0,0 +1,78 @@
#ifndef MOCKTAIL_VR_DESKTOP_MIRROR_H_
#define MOCKTAIL_VR_DESKTOP_MIRROR_H_
#include <algorithm>
#include <vulkan/vulkan.h>
namespace mocktail::vr::internal {
struct DesktopMirrorDispatch {
PFN_vkCmdPipelineBarrier barrier;
PFN_vkCmdClearColorImage clear;
PFN_vkCmdBlitImage blit;
};
// Record the whole copy, including layout restoration, into the submission's
// command buffer. The caller owns synchronization, begin/end and submission.
inline void RecordDesktopMirrorCommands(
VkCommandBuffer command, VkImage source, VkImageLayout source_layout,
VkExtent2D source_extent, VkImage destination, VkExtent2D destination_extent,
const DesktopMirrorDispatch& vk) {
VkImageMemoryBarrier barriers[2]{};
for (auto& barrier : barriers) {
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex =
VK_QUEUE_FAMILY_IGNORED;
barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
barrier.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT | VK_ACCESS_MEMORY_READ_BIT;
}
barriers[0].image = source;
barriers[0].oldLayout = source_layout;
barriers[0].newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
barriers[0].dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
barriers[1].image = destination;
barriers[1].oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
barriers[1].newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barriers[1].dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
vk.barrier(command, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 2,
barriers);
const VkClearColorValue black{{0.f, 0.f, 0.f, 1.f}};
vk.clear(command, destination, barriers[1].newLayout, &black, 1,
&barriers[1].subresourceRange);
VkMemoryBarrier clear_barrier{};
clear_barrier.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER;
clear_barrier.srcAccessMask = clear_barrier.dstAccessMask =
VK_ACCESS_TRANSFER_WRITE_BIT;
vk.barrier(command, VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &clear_barrier, 0, nullptr, 0,
nullptr);
const double scale =
std::min(double(destination_extent.width) / source_extent.width,
double(destination_extent.height) / source_extent.height);
const int width = std::max(1, int(source_extent.width * scale));
const int height = std::max(1, int(source_extent.height * scale));
const int x = (int(destination_extent.width) - width) / 2;
const int y = (int(destination_extent.height) - height) / 2;
VkImageBlit region{};
region.srcSubresource =
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.srcOffsets[1] = {static_cast<int>(source_extent.width),
static_cast<int>(source_extent.height), 1};
region.dstOffsets[0] = {x, y, 0};
region.dstOffsets[1] = {x + width, y + height, 1};
vk.blit(command, source, barriers[0].newLayout, destination,
barriers[1].newLayout, 1, &region, VK_FILTER_NEAREST);
for (auto& barrier : barriers) {
std::swap(barrier.oldLayout, barrier.newLayout);
barrier.srcAccessMask = barrier.dstAccessMask;
barrier.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT;
}
vk.barrier(command, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, nullptr, 2,
barriers);
}
} // namespace mocktail::vr::internal
#endif // MOCKTAIL_VR_DESKTOP_MIRROR_H_
+2
View File
@@ -106,6 +106,7 @@ class RuntimeConfig {
bool headless() const { return headless_; }
bool vr_enabled() const { return vr_enabled_; }
bool vr_desktop_mirror() const { return vr_desktop_mirror_; }
// MOCKTAIL_VR_ENABLED (--vr, --no-vr) wins over MOCKTAIL_VR_MODE.
VrMode vr_mode() const { return vr_mode_; }
bool vr_valid() const { return vr_valid_; }
@@ -184,6 +185,7 @@ class RuntimeConfig {
private:
bool headless_ = false;
bool vr_enabled_ = kDefaultVrEnabled;
bool vr_desktop_mirror_ = true;
VrMode vr_mode_ = kDefaultVrEnabled ? VrMode::kOn : VrMode::kOff;
bool vr_valid_ = true;
std::filesystem::path roblox_library_path_ = "rbx_bin/libroblox.so";
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -euo pipefail
# Run without host mounts, a GPU, SteamVR, account data or network access.
image=${1:-localhost/nightcap-vr-arm64:frame-foveated}
platform=$(podman image inspect --format '{{.Os}}/{{.Architecture}}' "$image")
if [[ "$platform" != linux/arm64 ]]; then
printf 'Expected linux/arm64 image, got %s\n' "$platform" >&2
exit 1
fi
podman run --rm -i --network none --read-only --cap-drop all \
--security-opt no-new-privileges --tmpfs /tmp:rw,nosuid,nodev,size=16m \
--entrypoint python3 "$image" - <<'PY'
import fnmatch
import hashlib
import json
import os
from pathlib import Path
import struct
import subprocess
prefix = Path('/opt/nightcap-vr-arm64')
runtime = prefix / 'lib64/mocktail'
programs = [
prefix / 'bin/mocktail',
prefix / 'bin/mocktail-vr-probe',
prefix / 'bin/nightcap-vr-settings',
prefix / 'bin/nightcap-vr-performance',
runtime / 'mocktail',
runtime / 'mocktail_updater',
runtime / 'mocktail_failure_dialog',
runtime / 'mocktail_webview_helper',
]
libraries = list(runtime.glob('*.so'))
if len(libraries) < 12:
raise SystemExit('Installed Android adapter libraries are missing')
for path in programs + libraries:
if not path.is_file() or not os.access(path, os.X_OK):
raise SystemExit(f'Missing executable: {path}')
with path.open('rb') as stream:
header = stream.read(20)
if (header[:6] != b'\x7fELF\x02\x01' or len(header) != 20
or struct.unpack_from('<H', header, 18)[0] != 183):
raise SystemExit(f'Expected a little-endian AArch64 ELF: {path}')
result = subprocess.run(['ldd', str(path)], capture_output=True, text=True,
timeout=20, check=False)
if result.returncode or 'not found' in result.stdout + result.stderr:
raise SystemExit(f'Unresolved runtime dependencies: {path}\n'
+ result.stdout + result.stderr)
metadata = prefix / 'share/mocktail/metadata'
for name in ('roblox_compatibility.json', 'roblox_bootstrap_sources.json',
'roblox_host_abi_reference.json', 'roblox_signing_certificates.json'):
value = json.loads((metadata / name).read_text())
if not isinstance(value, (dict, list)) or not value:
raise SystemExit(f'Invalid installed metadata: {name}')
license_files = [Path('/work/LICENSE'), Path('/work/THIRD_PARTY.md')]
for path in license_files:
if not path.is_file() or not path.stat().st_size:
raise SystemExit(f'Missing source license notices: {path}')
cache = Path('/work/build-frame-arm64/CMakeCache.txt')
build_policy = 'source-build-cache'
if cache.exists():
settings = cache.read_text().splitlines()
for setting in ('MOCKTAIL_ENABLE_VR:BOOL=ON',
'MOCKTAIL_ENABLE_FSR3:BOOL=OFF',
'MOCKTAIL_VR_DIAGNOSTICS:BOOL=OFF'):
if setting not in settings:
raise SystemExit(f'Unexpected build setting: {setting}')
else:
# Source-audited release images may omit their disposable compiler cache.
provenance = json.loads((metadata / 'steam-frame-source.json').read_text())
if provenance.get('fsr3_compiled') is not False or provenance.get('upscaling') != 'Native':
raise SystemExit('Missing Native-only build provenance')
build_policy = 'installed-source-provenance'
for path in ('/opt/steamvr', '/opt/host-lib', '/work/data', '/work/runtime',
'/work/rbx_bin', '/root/.config/mocktail',
'/root/.local/share/mocktail'):
if Path(path).exists():
raise SystemExit(f'Host runtime or user data must not be packaged: {path}')
patterns = ('libroblox.so', '*.apk', '*.xapk', '*.aab', '*.obb',
'cookies*.txt', 'steamclient.so', 'vrclient.so')
# This upstream archive-parser regression fixture is not a Roblox client.
archive_fixture = Path('/work/third_party/mcpelauncher-linker/core/libziparchive/testdata/crash.apk')
for root in ('/work', '/opt', '/root', '/home'):
for directory, subdirectories, filenames in os.walk(root, followlinks=False):
if '.git' in subdirectories or '.git' in filenames:
raise SystemExit('Git checkout credentials/history must not enter the image')
for name in filenames:
path = Path(directory) / name
if path == archive_fixture:
continue
if any(fnmatch.fnmatch(name.lower(), pattern) for pattern in patterns):
raise SystemExit(f'Proprietary payload or account data in image: {path}')
print(json.dumps({
'platform': 'linux/arm64',
'native_only_build_policy': build_policy,
'aarch64_programs_checked': len(programs),
'aarch64_adapter_libraries_checked': len(libraries),
'dynamic_dependencies': 'resolved',
'client_payload': 'absent',
'host_steamvr_and_driver_mounts': 'absent',
'license_sha256': {str(path): hashlib.sha256(path.read_bytes()).hexdigest()
for path in license_files},
'scope': 'offline packaging checks; no headset or Roblox session started',
}, indent=2))
PY
+2
View File
@@ -202,6 +202,8 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) {
config.vr_mode_ = config.vr_enabled_ ? VrMode::kOn : VrMode::kOff;
}
}
config.vr_desktop_mirror_ = ReadBoolean(
environment, "MOCKTAIL_VR_DESKTOP_MIRROR", true, &config.vr_valid_);
config.roblox_library_path_ = environment.GetOr(
"ROBLOX_LIB_PATH", config.roblox_library_path_.string());
config.graphics_backend_name_ = environment.GetOr(
+4
View File
@@ -80,6 +80,10 @@ graphics:
# enabled: true/false still works as on/off; set one or the other.
# Standalone OpenXR test scene: mocktail-vr-probe --scene.
# mode: off
# Boolean (default: true): also show the left-eye VR view in the desktop
# window. Restart Nightcap after changing this setting. Steam Frame
# uses its Video settings Desktop Mirror control, off by default.
# desktop_mirror: true
# String (default: fxaa): smooth jagged edges in the headset. Roblox draws
# the VR eyes without anti-aliasing; fxaa filters each eye on its way to the
# headset. The Steam Frame profile uses its separate gaze AA control.
+12 -1
View File
@@ -170,6 +170,7 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
"runtime.roblox_library",
"vr.enabled",
"vr.mode",
"vr.desktop_mirror",
"vr.anti_aliasing",
"vr.frame_rate_limit",
"appearance.theme",
@@ -345,6 +346,14 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment,
}
(*environment)["MOCKTAIL_VR_MODE"] = *mode;
}
if (const auto mirror = value("vr.desktop_mirror"); mirror.has_value()) {
bool parsed = false;
if (!ParseBoolean(*mirror, &parsed)) {
*error = "vr.desktop_mirror must be true or false";
return false;
}
(*environment)["MOCKTAIL_VR_DESKTOP_MIRROR"] = parsed ? "1" : "0";
}
if (const auto anti_aliasing = value("vr.anti_aliasing");
anti_aliasing.has_value()) {
if (*anti_aliasing != "fxaa" && *anti_aliasing != "smaa" &&
@@ -830,7 +839,7 @@ RuntimeConfigLoadResult LoadRuntimeConfig(
} else if (!result.config.vr_valid()) {
result.error = "VR configuration is invalid: MOCKTAIL_VR_ENABLED must be "
"1/0, true/false, or on/off, and MOCKTAIL_VR_MODE on, off, "
"or auto";
"or auto, and MOCKTAIL_VR_DESKTOP_MIRROR must be boolean";
} else if (!result.config.device_profile_valid()) {
result.error = "device profile is invalid";
} else if (!result.config.input_capabilities().touch_enabled &&
@@ -984,6 +993,8 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config,
FrameRateValue(config.frame_rate()), error) &&
SetEnvironmentValue("MOCKTAIL_VR_FRAME_RATE_LIMIT",
FrameRateValue(config.vr_frame_rate()), error) &&
SetEnvironmentValue("MOCKTAIL_VR_DESKTOP_MIRROR",
config.vr_desktop_mirror() ? "1" : "0", error) &&
SetEnvironmentValue("MOCKTAIL_VR_ANTI_ALIASING",
config.vr_anti_aliasing(), error) &&
SetEnvironmentValue("MOCKTAIL_VSYNC", config.vsync_mode(), error) &&
+7 -54
View File
@@ -3,6 +3,7 @@
#include "mocktail/vr/anti_aliasing_settings.h"
#include <nlohmann/json.hpp>
#include "mocktail/vr/openxr_backend.h"
#include "mocktail/vr/desktop_mirror.h"
#include "mocktail/vr/vr_perf.h"
#include "mocktail/vr/vr_pose_math.h"
#include "mocktail/vr/foveation_focus.h"
@@ -5423,59 +5424,11 @@ VkResult OpenXrBackend::MirrorDesktop(VkQueue queue,
begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
if (vk_->BeginCommandBuffer(command, &begin) != VK_SUCCESS)
return VK_NOT_READY;
VkImageMemoryBarrier barriers[2]{};
for (auto &b : barriers) {
b.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
b.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT | VK_ACCESS_MEMORY_READ_BIT;
}
barriers[0].image = eyes_[0].image;
barriers[0].oldLayout = eyes_[0].final_layout;
barriers[0].newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
barriers[0].dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
barriers[1].image = target.images[info->pImageIndices[0]];
barriers[1].oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
barriers[1].newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barriers[1].dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
vk_->CmdPipelineBarrier(command, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0,
nullptr, 2, barriers);
const VkClearColorValue black{{0.f, 0.f, 0.f, 1.f}};
vk_->CmdClearColorImage(command, barriers[1].image,
barriers[1].newLayout, &black, 1,
&barriers[1].subresourceRange);
VkMemoryBarrier clear_barrier{};
clear_barrier.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER;
clear_barrier.srcAccessMask = clear_barrier.dstAccessMask =
VK_ACCESS_TRANSFER_WRITE_BIT;
vk_->CmdPipelineBarrier(command, VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &clear_barrier,
0, nullptr, 0, nullptr);
const double scale = std::min(double(target.extent.width) / eyes_[0].width,
double(target.extent.height) / eyes_[0].height);
const int width = std::max(1, int(eyes_[0].width * scale));
const int height = std::max(1, int(eyes_[0].height * scale));
const int x = (int(target.extent.width) - width) / 2;
const int y = (int(target.extent.height) - height) / 2;
VkImageBlit region{};
region.srcSubresource =
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.srcOffsets[1] = {static_cast<int>(eyes_[0].width),
static_cast<int>(eyes_[0].height), 1};
region.dstOffsets[0] = {x, y, 0};
region.dstOffsets[1] = {x + width, y + height, 1};
vk_->CmdBlitImage(command, barriers[0].image, barriers[0].newLayout,
barriers[1].image, barriers[1].newLayout, 1, &region,
VK_FILTER_NEAREST);
for (auto &b : barriers) {
std::swap(b.oldLayout, b.newLayout);
b.srcAccessMask = b.dstAccessMask;
b.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT;
}
vk_->CmdPipelineBarrier(copy_command_, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0,
nullptr, 2, barriers);
const VkImage destination = target.images[info->pImageIndices[0]];
internal::RecordDesktopMirrorCommands(
command, eyes_[0].image, eyes_[0].final_layout,
{eyes_[0].width, eyes_[0].height}, destination, target.extent,
{vk_->CmdPipelineBarrier, vk_->CmdClearColorImage, vk_->CmdBlitImage});
if (vk_->EndCommandBuffer(command) != VK_SUCCESS)
return VK_NOT_READY;
std::vector<VkPipelineStageFlags> stages(info->waitSemaphoreCount,
@@ -5491,7 +5444,7 @@ VkResult OpenXrBackend::MirrorDesktop(VkQueue queue,
// wait also orders the eye copies submitted later on this queue.
submit.signalSemaphoreCount = 1;
submit.pSignalSemaphores = &signal;
slot->images = {eyes_[0].image, barriers[1].image};
slot->images = {eyes_[0].image, destination};
const VkResult result = SubmitAsyncLocked(queue, slot, submit);
if (result != VK_SUCCESS)
return result;
+4 -3
View File
@@ -286,9 +286,10 @@ bool IsDisabledEnv(const char* name) {
}
bool WindowStatePersistenceSuppressed() {
// A hidden VR surface supplies Roblox's logical UI viewport. Restoring a
// tiny desktop window here magnifies in-game panels despite unchanged eyes.
return IsEnabledEnv("MOCKTAIL_VR_HIDE_DESKTOP_WINDOW") ||
// The Frame supplies a fixed logical UI viewport in both mirror modes.
// Restoring a tiny desktop window magnifies panels despite unchanged eyes.
return IsDisabledEnv("MOCKTAIL_WINDOW_STATE_PERSISTENCE") ||
IsEnabledEnv("MOCKTAIL_VR_HIDE_DESKTOP_WINDOW") ||
IsEnabledEnv("MOCKTAIL_RESIZE_READINESS") ||
IsEnabledEnv("MOCKTAIL_FULLSCREEN_READINESS") ||
IsEnabledEnv("MOCKTAIL_INPUT_READINESS") ||
+8
View File
@@ -1532,3 +1532,11 @@ target_include_directories(fsr3_disabled_adapter_test PRIVATE "${PROJECT_SOURCE_
target_link_libraries(fsr3_disabled_adapter_test PRIVATE GTest::gtest_main Vulkan::Headers)
mocktail_apply_compile_options(fsr3_disabled_adapter_test)
gtest_discover_tests(fsr3_disabled_adapter_test)
add_executable(desktop_mirror_test vr/desktop_mirror_test.cc)
target_include_directories(desktop_mirror_test PRIVATE "${PROJECT_SOURCE_DIR}/include")
target_link_libraries(desktop_mirror_test PRIVATE GTest::gtest_main Vulkan::Headers)
mocktail_apply_compile_options(desktop_mirror_test)
gtest_discover_tests(desktop_mirror_test)
add_test(NAME SteamFrameDesktopMirrorPolicy COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/steam_frame_mirror_policy_test.py")
+33
View File
@@ -176,6 +176,39 @@ TEST(RuntimeConfigFileTest, VrModeSelectsOnOffOrAuto) {
EXPECT_NE(invalid.error.find("vr.mode"), std::string::npos);
}
TEST(RuntimeConfigFileTest, DesktopMirrorYamlEnvironmentAndExportAgree) {
TemporaryDirectory temporary;
const auto file = temporary.Write(
"version: 1\nvr:\n enabled: true\n desktop_mirror: false\n");
const auto hidden = LoadRuntimeConfig(MapEnvironment(), file);
ASSERT_TRUE(hidden) << hidden.error;
EXPECT_FALSE(hidden.config.vr_desktop_mirror());
const auto visible = LoadRuntimeConfig(
MapEnvironment({{"MOCKTAIL_VR_DESKTOP_MIRROR", "true"}}), file);
ASSERT_TRUE(visible) << visible.error;
EXPECT_TRUE(visible.config.vr_desktop_mirror());
const char* previous = std::getenv("MOCKTAIL_VR_DESKTOP_MIRROR");
const std::optional<std::string> saved =
previous ? std::optional<std::string>(previous) : std::nullopt;
std::string error;
EXPECT_TRUE(ExportRuntimeConfigEnvironment(hidden.config, &error)) << error;
EXPECT_STREQ(std::getenv("MOCKTAIL_VR_DESKTOP_MIRROR"), "0");
EXPECT_TRUE(ExportRuntimeConfigEnvironment(visible.config, &error)) << error;
EXPECT_STREQ(std::getenv("MOCKTAIL_VR_DESKTOP_MIRROR"), "1");
if (saved)
setenv("MOCKTAIL_VR_DESKTOP_MIRROR", saved->c_str(), 1);
else
unsetenv("MOCKTAIL_VR_DESKTOP_MIRROR");
const auto invalid = LoadRuntimeConfig(
MapEnvironment(), temporary.Write(
"version: 1\nvr:\n desktop_mirror: maybe\n"));
EXPECT_FALSE(invalid);
EXPECT_NE(invalid.error.find("vr.desktop_mirror"), std::string::npos);
const auto invalid_environment = LoadRuntimeConfig(
MapEnvironment({{"MOCKTAIL_VR_DESKTOP_MIRROR", "maybe"}}), file);
EXPECT_FALSE(invalid_environment);
}
TEST(RuntimeConfigFileTest, VrAntiAliasingAcceptsFxaaSmaaOrOff) {
TemporaryDirectory temporary;
for (const char* value : {"fxaa", "smaa", "off"}) {
+18
View File
@@ -99,6 +99,24 @@ TEST(RuntimeConfigTest, UsesSupportedDefaults) {
EXPECT_TRUE(config.unsafe_detached_thread_overrides().empty());
}
TEST(RuntimeConfigTest, DesktopMirrorDefaultsOnAndAcceptsTypedBooleans) {
EXPECT_TRUE(RuntimeConfig::FromEnvironment(MapEnvironment()).vr_desktop_mirror());
for (const char* value : {"1", "true", "on", "0", "false", "off"}) {
const auto config = RuntimeConfig::FromEnvironment(
MapEnvironment({{"MOCKTAIL_VR_DESKTOP_MIRROR", value}}));
EXPECT_TRUE(config.vr_valid()) << value;
EXPECT_EQ(config.vr_desktop_mirror(),
std::string(value) == "1" || std::string(value) == "true" ||
std::string(value) == "on");
}
const auto invalid = RuntimeConfig::FromEnvironment(
MapEnvironment({{"MOCKTAIL_VR_DESKTOP_MIRROR", "maybe"}}));
EXPECT_FALSE(invalid.vr_valid());
const auto invalid_vr = RuntimeConfig::FromEnvironment(MapEnvironment(
{{"MOCKTAIL_VR_ENABLED", "maybe"}, {"MOCKTAIL_VR_DESKTOP_MIRROR", "1"}}));
EXPECT_FALSE(invalid_vr.vr_valid());
}
TEST(RuntimeConfigTest, RejectsMalformedVrEnvironmentInsteadOfEnablingIt) {
const auto config = RuntimeConfig::FromEnvironment(
MapEnvironment({{"MOCKTAIL_VR_ENABLED", "maybe"}}));
+70
View File
@@ -0,0 +1,70 @@
import ast
from pathlib import Path
import unittest
source = Path(__file__).resolve().parents[1] / 'tools/steam_frame/run-nightcap-arm64.py'
module = ast.parse(source.read_text())
functions = [node for node in module.body if isinstance(node, ast.FunctionDef)
and node.name in ('steam_profile_desktop_mirror',
'desktop_mirror_environment')]
scope = {}
exec(compile(ast.Module(body=functions, type_ignores=[]), str(source), 'exec'), scope)
mirror_environment = scope['desktop_mirror_environment']
class DesktopMirrorPolicy(unittest.TestCase):
def test_frame_defaults_to_hidden_mirror_off(self):
self.assertEqual(mirror_environment({}, []), {
'MOCKTAIL_WIN_WIDTH': '1920', 'MOCKTAIL_WIN_HEIGHT': '1080',
'MOCKTAIL_WINDOW_STATE_PERSISTENCE': '0',
'MOCKTAIL_VR_DESKTOP_MIRROR': '0',
'MOCKTAIL_VR_HIDE_DESKTOP_WINDOW': '1',
'MOCKTAIL_SHOW_WINDOW_ON_FIRST_SWAP': '0',
})
def test_saved_mirror_on_shows_the_window(self):
settings = {'nightcap': {'desktopMirror': 1, 'aaMode': 3,
'gazeRadiusPercent': 8}}
self.assertEqual(mirror_environment(settings, []), {
'MOCKTAIL_WIN_WIDTH': '1920', 'MOCKTAIL_WIN_HEIGHT': '1080',
'MOCKTAIL_WINDOW_STATE_PERSISTENCE': '0',
'MOCKTAIL_VR_DESKTOP_MIRROR': '1',
'MOCKTAIL_VR_HIDE_DESKTOP_WINDOW': '0',
'MOCKTAIL_SHOW_WINDOW_ON_FIRST_SWAP': '1',
})
self.assertEqual(settings['nightcap'], {
'desktopMirror': 1, 'aaMode': 3, 'gazeRadiusPercent': 8})
def test_json_integer_and_float_one_both_enable_the_mirror(self):
for value in (1, 1.0):
self.assertEqual(mirror_environment({'nightcap': {'desktopMirror': value}}, [])[
'MOCKTAIL_VR_DESKTOP_MIRROR'], '1')
def test_invalid_saved_values_cannot_accidentally_enable_mirror(self):
for settings in (None, [], {'nightcap': None}, {'nightcap': []},
*({'nightcap': {'desktopMirror': value}}
for value in (0, -1, 2, True, False, '1', '0', None, [], [1], {}))):
with self.subTest(settings=settings):
self.assertEqual(mirror_environment(settings, [])[
'MOCKTAIL_VR_DESKTOP_MIRROR'], '0')
def test_desktop_launch_remains_visible_even_with_mirror_off(self):
for arguments in (['--no-vr'], ['--vr', '--no-vr']):
environment = mirror_environment({}, arguments)
self.assertEqual(environment['MOCKTAIL_VR_HIDE_DESKTOP_WINDOW'], '0')
self.assertEqual(environment['MOCKTAIL_SHOW_WINDOW_ON_FIRST_SWAP'], '1')
def test_both_mirror_modes_keep_the_same_logical_viewport(self):
for value in (0, 1):
environment = mirror_environment({'nightcap': {'desktopMirror': value}}, [])
self.assertEqual(environment['MOCKTAIL_WIN_WIDTH'], '1920')
self.assertEqual(environment['MOCKTAIL_WIN_HEIGHT'], '1080')
self.assertEqual(environment['MOCKTAIL_WINDOW_STATE_PERSISTENCE'], '0')
def test_last_explicit_vr_flag_wins(self):
environment = mirror_environment({}, ['--no-vr', '--vr'])
self.assertEqual(environment['MOCKTAIL_VR_HIDE_DESKTOP_WINDOW'], '1')
if __name__ == '__main__':
unittest.main()
+20 -4
View File
@@ -15,23 +15,39 @@ function harness(saved={}){
GetSettingsValue:setting=>{reads.push(setting);return Promise.resolve(saved[setting.split('/').at(-1)]);},
SetSettingsStringValueWithoutSchema:(setting,value)=>writes.push([setting,value])}}};
vm.createContext(context);vm.runInContext(source,context);
return{writes,reads,get state(){return state;},render(){refIndex=0;const rows=context.NightcapVideoControls({}).children.filter(Boolean);first=false;return rows;},
return{writes,reads,get state(){return state;},render(){refIndex=0;const rows=context.NightcapVideoControls({}).children.filter(value=>value&&value.props.label);first=false;return rows;},
async load(){for(const effect of effects)effect();await Promise.resolve();await Promise.resolve();},
async poll(){for(const timer of timers)timer();await Promise.resolve();await Promise.resolve();},advance:ms=>{now+=ms;}};
}
const row=(rows,label)=>{const result=rows.find(value=>value.props.label===label);assert(result,`Missing ${label}`);return result;};
(async()=>{
const defaults=harness();let rows=defaults.render();
assert.deepEqual(rows.map(value=>value.props.label),['Foveated Rendering','Gaze sharp radius','Performance Overlay','Anti Aliasing']);
assert.deepEqual(rows.map(value=>value.props.label),['Foveated Rendering','Gaze sharp radius','Desktop Mirror','Performance Overlay','Anti Aliasing']);
assert.equal(row(rows,'Foveated Rendering').props.value,3);assert.equal(row(rows,'Anti Aliasing').props.value,3);
assert.deepEqual(Array.from(row(rows,'Anti Aliasing').props.items,item=>[item.value,item.sLabel]),[[0,'Off'],[1,'FXAA (gaze)'],[2,'SMAA Low (gaze)'],[3,'SMAA High (gaze)']]);
assert(rows.every(value=>!('title'in value.props)&&!('description'in value.props)&&!('help'in value.props)));
assert(!/FSR|MSAA|upscal|relaunch|sharpen/i.test(source));
assert(!/FSR|MSAA|upscal|sharpen/i.test(source));
assert(source.includes('Desktop Mirror changes require relaunch.'));
assert.equal(row(rows,'Desktop Mirror').props.value,0);
for(const desktopMirror of [0,1]){
const mirror=harness({desktopMirror});mirror.render();await mirror.load();rows=mirror.render();
assert.equal(row(rows,'Desktop Mirror').props.value,desktopMirror);
assert.equal(mirror.writes.length,0);
row(rows,'Desktop Mirror').props.onChange(1-desktopMirror);
assert.deepEqual(mirror.writes.at(-1),['/settings/nightcap/desktopMirror',1-desktopMirror]);
const count=mirror.writes.length;for(const value of [-1,2,NaN])row(rows,'Desktop Mirror').props.onChange(value);
assert.equal(mirror.writes.length,count);
}
for(const desktopMirror of [true,false,'1','0',[1],[],{},2,-1,NaN]){
const invalid=harness({desktopMirror});invalid.render();await invalid.load();rows=invalid.render();
assert.equal(row(rows,'Desktop Mirror').props.value,0,'Only saved numeric0/1 mirror values are accepted');
assert.equal(invalid.writes.length,0);
}
for(const aaMode of [0,1,2,3]){
const saved=harness({aaMode,gazeRadiusPercent:8,performanceOverlay:1,foveationLevel:3,upscaleMode:3,fsrQuality:5,msaaSamples:4});
saved.render();await saved.load();rows=saved.render();
assert.equal(row(rows,'Anti Aliasing').props.value,aaMode);assert.equal(saved.state.gazeRadiusPercent,8);assert.equal(row(rows,'Performance Overlay').props.value,true);
assert.deepEqual(Object.keys(saved.state).sort(),['aaMode','foveationLevel','gazeRadiusPercent','performanceOverlay']);
assert.deepEqual(Object.keys(saved.state).sort(),['aaMode','desktopMirror','foveationLevel','gazeRadiusPercent','performanceOverlay']);
assert.equal(saved.writes.length,0,'Loading saved choices must not write defaults');
assert(saved.reads.every(setting=>!/(FSR|MSAA|upscale)/i.test(setting)));
row(rows,'Anti Aliasing').props.onChange(3);assert.deepEqual(saved.writes.at(-1),['/settings/nightcap/aaMode',3]);
+116
View File
@@ -0,0 +1,116 @@
#include "mocktail/vr/desktop_mirror.h"
#include <gtest/gtest.h>
#include <array>
#include <cstdint>
#include <vector>
namespace mocktail::vr::internal {
namespace {
template <class T>
T Handle(std::uintptr_t value) {
return reinterpret_cast<T>(value);
}
VkCommandBuffer expected_command;
std::vector<std::array<VkImageMemoryBarrier, 2>> transitions;
std::vector<int> operations;
VkImageBlit copied_region;
VKAPI_ATTR void VKAPI_CALL Barrier(
VkCommandBuffer command, VkPipelineStageFlags, VkPipelineStageFlags,
VkDependencyFlags, uint32_t memory_count, const VkMemoryBarrier* memory,
uint32_t buffer_count, const VkBufferMemoryBarrier*, uint32_t image_count,
const VkImageMemoryBarrier* images) {
EXPECT_EQ(command, expected_command);
EXPECT_EQ(buffer_count, 0U);
if (image_count) {
ASSERT_EQ(image_count, 2U);
EXPECT_EQ(memory_count, 0U);
transitions.push_back({images[0], images[1]});
operations.push_back(1);
} else {
ASSERT_EQ(memory_count, 1U);
EXPECT_EQ(memory->srcAccessMask, VK_ACCESS_TRANSFER_WRITE_BIT);
EXPECT_EQ(memory->dstAccessMask, VK_ACCESS_TRANSFER_WRITE_BIT);
operations.push_back(3);
}
}
VKAPI_ATTR void VKAPI_CALL Clear(
VkCommandBuffer command, VkImage image, VkImageLayout layout,
const VkClearColorValue* color, uint32_t count,
const VkImageSubresourceRange* range) {
EXPECT_EQ(command, expected_command);
EXPECT_EQ(image, Handle<VkImage>(102));
EXPECT_EQ(layout, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
EXPECT_EQ(color->float32[0], 0.F);
EXPECT_EQ(color->float32[3], 1.F);
EXPECT_EQ(count, 1U);
EXPECT_EQ(range->aspectMask, VK_IMAGE_ASPECT_COLOR_BIT);
operations.push_back(2);
}
VKAPI_ATTR void VKAPI_CALL Blit(
VkCommandBuffer command, VkImage source, VkImageLayout source_layout,
VkImage destination, VkImageLayout destination_layout, uint32_t count,
const VkImageBlit* region, VkFilter filter) {
EXPECT_EQ(command, expected_command);
EXPECT_EQ(source, Handle<VkImage>(101));
EXPECT_EQ(destination, Handle<VkImage>(102));
EXPECT_EQ(source_layout, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL);
EXPECT_EQ(destination_layout, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
EXPECT_EQ(count, 1U);
EXPECT_EQ(filter, VK_FILTER_NEAREST);
copied_region = *region;
operations.push_back(4);
}
class DesktopMirrorCommands : public testing::Test {
protected:
void SetUp() override {
expected_command = Handle<VkCommandBuffer>(777);
transitions.clear();
operations.clear();
copied_region = {};
}
void Record(VkExtent2D source, VkExtent2D destination) {
RecordDesktopMirrorCommands(
expected_command, Handle<VkImage>(101),
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, source, Handle<VkImage>(102),
destination, {Barrier, Clear, Blit});
}
};
TEST_F(DesktopMirrorCommands, CopyAndRestorationUseTheSameSubmissionCommand) {
Record({2160, 2160}, {1920, 1080});
EXPECT_EQ(operations, (std::vector<int>{1, 2, 3, 4, 1}));
ASSERT_EQ(transitions.size(), 2U);
for (unsigned image = 0; image != 2; ++image) {
const auto& before = transitions[0][image];
const auto& after = transitions[1][image];
EXPECT_EQ(after.image, before.image);
EXPECT_EQ(after.oldLayout, before.newLayout);
EXPECT_EQ(after.newLayout, before.oldLayout);
EXPECT_EQ(after.srcAccessMask, before.dstAccessMask);
EXPECT_EQ(after.dstAccessMask, VK_ACCESS_MEMORY_READ_BIT);
EXPECT_EQ(after.srcQueueFamilyIndex, VK_QUEUE_FAMILY_IGNORED);
EXPECT_EQ(after.dstQueueFamilyIndex, VK_QUEUE_FAMILY_IGNORED);
}
EXPECT_EQ(transitions[1][0].newLayout,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
EXPECT_EQ(transitions[1][1].newLayout, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR);
}
TEST_F(DesktopMirrorCommands, CentersTheLeftEyeWithoutStretchingIt) {
Record({2160, 2160}, {1920, 1080});
EXPECT_EQ(copied_region.srcOffsets[1].x, 2160);
EXPECT_EQ(copied_region.srcOffsets[1].y, 2160);
EXPECT_EQ(copied_region.dstOffsets[0].x, 420);
EXPECT_EQ(copied_region.dstOffsets[0].y, 0);
EXPECT_EQ(copied_region.dstOffsets[1].x, 1500);
EXPECT_EQ(copied_region.dstOffsets[1].y, 1080);
}
TEST_F(DesktopMirrorCommands, FitsWideEyesWithinTallDesktopWindows) {
Record({2000, 1000}, {600, 800});
EXPECT_EQ(copied_region.dstOffsets[0].x, 0);
EXPECT_EQ(copied_region.dstOffsets[0].y, 250);
EXPECT_EQ(copied_region.dstOffsets[1].x, 600);
EXPECT_EQ(copied_region.dstOffsets[1].y, 550);
}
} // namespace
} // namespace mocktail::vr::internal
+25 -5
View File
@@ -40,6 +40,30 @@ def steam_profile_scene_samples(settings):
return 1
def steam_profile_desktop_mirror(settings):
section = settings.get("nightcap") if isinstance(settings, dict) else None
value = section.get("desktopMirror") if isinstance(section, dict) else None
return type(value) in (int, float) and value == 1
def desktop_mirror_environment(settings, arguments):
# The launcher supplies --vr first; later explicit VR flags win.
vr_enabled = True
for argument in arguments:
if argument in ("--vr", "--no-vr"):
vr_enabled = argument == "--vr"
mirror = steam_profile_desktop_mirror(settings)
hidden = vr_enabled and not mirror
return {
# Keep the logical Roblox UI viewport stable in either mirror mode.
"MOCKTAIL_WIN_WIDTH": "1920", "MOCKTAIL_WIN_HEIGHT": "1080",
"MOCKTAIL_WINDOW_STATE_PERSISTENCE": "0",
"MOCKTAIL_VR_DESKTOP_MIRROR": "1" if mirror else "0",
"MOCKTAIL_VR_HIDE_DESKTOP_WINDOW": "1" if hidden else "0",
"MOCKTAIL_SHOW_WINDOW_ON_FIRST_SWAP": "0" if hidden else "1",
}
def read_steam_profile(path):
try:
with path.open('rb') as profile:
@@ -175,11 +199,7 @@ environment = {
'NIGHTCAP_STEAM_APP_ID': str(steam_app_id),
'NIGHTCAP_VR_MANIFEST': str(manifest),
'MOCKTAIL_WIN_TITLE': 'Nightcap Roblox VR',
# Keep Roblox panel layout stable independently of headset render size.
'MOCKTAIL_WIN_WIDTH': '1920', 'MOCKTAIL_WIN_HEIGHT': '1080',
'MOCKTAIL_VR_DESKTOP_MIRROR': '0',
'MOCKTAIL_VR_HIDE_DESKTOP_WINDOW': '1',
'MOCKTAIL_SHOW_WINDOW_ON_FIRST_SWAP': '0',
**desktop_mirror_environment(steam_settings, sys.argv[1:]),
'MOCKTAIL_CLIENT_SETTINGS_OVERRIDES_JSON': msaa_flags,
'MOCKTAIL_FAST_FLAGS_JSON': msaa_flags,
'MOCKTAIL_GAME_LEAVE_COMPLETES_WITHOUT_LUA_CALLBACK': '1',
@@ -11,17 +11,17 @@ function NightcapVideoControls(props){
style.textContent='.SettingsMain .Modal.AppVideoModal>.Subsection.Body{min-height:0}.SettingsMain .Modal.AppVideoModal>.Subsection.Body>.SettingsItem{flex-shrink:0}.SettingsMain .Modal.AppVideoModal>.Subsection.Top,.SettingsMain .Modal.AppVideoModal>.Subsection.Bottom{flex-shrink:0}';
document.head.appendChild(style);
},[]);
const initial={foveationLevel:3,aaMode:3,gazeRadiusPercent:15,performanceOverlay:0};
const initial={foveationLevel:3,aaMode:3,gazeRadiusPercent:15,performanceOverlay:0,desktopMirror:0};
const[values,setValues]=s.useState(initial);
const dragging=s.useRef(false);
const pending=s.useRef({});
const choices={foveationLevel:[0,1,2,3],aaMode:[0,1,2,3],performanceOverlay:[0,1]};
const choices={foveationLevel:[0,1,2,3],aaMode:[0,1,2,3],performanceOverlay:[0,1],desktopMirror:[0,1]};
const valid=(key,n)=>key==='gazeRadiusPercent'?Number.isFinite(n)&&n>=8&&n<=35:choices[key].includes(n);
s.useEffect(()=>{
let live=true;
const load=()=>{for(const key of Object.keys(initial))g.HR.GetSettingsValue('/settings/nightcap/'+key).then(value=>{
if(value===null||value===undefined||value==='')return;
const n=Number(value);if(live&&valid(key,n)&&!(dragging.current&&key==='gazeRadiusPercent')&&!(Date.now()<(pending.current[key]||0)))setValues(old=>old[key]===n?old:({...old,[key]:n}));
const n=key==='desktopMirror'&&typeof value!=='number'?NaN:Number(value);if(live&&valid(key,n)&&!(dragging.current&&key==='gazeRadiusPercent')&&!(Date.now()<(pending.current[key]||0)))setValues(old=>old[key]===n?old:({...old,[key]:n}));
}).catch(()=>{});};
load();const timer=setInterval(load,1000);
return()=>{live=false;clearInterval(timer);};
@@ -37,6 +37,8 @@ function NightcapVideoControls(props){
return s.createElement(s.Fragment,null,
s.createElement(v.xS,{label:'Foveated Rendering',value:values.foveationLevel,onChange:value=>change('foveationLevel',value),items:[{value:0,sLabel:'Off'},{value:1,sLabel:'Quality'},{value:2,sLabel:'Balanced'},{value:3,sLabel:'Performance'}]}),
s.createElement(v.tE,{label:'Gaze sharp radius',min:8,max:35,step:1,value:values.gazeRadiusPercent,renderValue:value=>Math.round(value)+'%',onChange:value=>change('gazeRadiusPercent',Math.round(value)),onInteractionStart:()=>{dragging.current=true;},onInteractionEnd:()=>{dragging.current=false;}}),
s.createElement(v.xS,{label:'Desktop Mirror',value:values.desktopMirror,onChange:value=>change('desktopMirror',value),items:[{value:0,sLabel:'Off'},{value:1,sLabel:'On'}]}),
s.createElement('div',{className:'SettingsItem',style:{fontSize:'12px',opacity:0.7}},'Desktop Mirror changes require relaunch.'),
s.createElement(v.Al,{label:'Performance Overlay',value:values.performanceOverlay===1,onChange:value=>change('performanceOverlay',value?1:0)}),
s.createElement(v.xS,{label:'Anti Aliasing',value:values.aaMode,onChange:value=>change('aaMode',value),items:[{value:0,sLabel:'Off'},{value:1,sLabel:'FXAA (gaze)'},{value:2,sLabel:'SMAA Low (gaze)'},{value:3,sLabel:'SMAA High (gaze)'}]}));
}