mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 19:00:48 +02:00
Add optional desktop mirror and native ARM64 release packaging
This commit is contained in:
1 parent
7dc4f55642
commit
bc223c4273
23 files changed
+806
-127
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,6 +7,7 @@ graphics:
|
||||
|
||||
vr:
|
||||
enabled: true
|
||||
desktop_mirror: false
|
||||
frame_rate_limit: unlimited
|
||||
|
||||
audio:
|
||||
|
||||
+21
-39
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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, ®ion, 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_
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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, ®ion,
|
||||
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;
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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")
|
||||
@@ -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"}) {
|
||||
|
||||
@@ -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"}}));
|
||||
|
||||
@@ -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()
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
@@ -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)'}]}));
|
||||
}
|
||||
Reference in new issue
Block a user