mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
Merge pull request #9 from The412Banner/feat/armada-and-lineage
Deck mode, added games with art, own gamescope and audio sinks, client sound via DirectAudio, Winlator lineage removed
This commit is contained in:
125 files changed
+5419
-1769
No files matched your search
@@ -0,0 +1,31 @@
|
||||
name: Build AAudio sink module
|
||||
|
||||
# A quick check of tools/aaudio-sink on its own; the APK build compiles the same module into the
|
||||
# audio bundle. Runs on pushes that touch the module.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/aaudio-sink/**'
|
||||
- '.github/workflows/build-aaudio-sink.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: PulseAudio 13.0 source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
|
||||
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1
|
||||
- name: Build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/out"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: audio-sink-modules
|
||||
path: out/*.so
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Build gamescope (patched)
|
||||
|
||||
# The runtime's own gamescope (Arch Linux ARM's 3.16.29 package) rebuilt with the patches under
|
||||
# tools/gamescope/patches, in an Arch Linux ARM container on an arm64 runner, then published as a
|
||||
# release asset the apk build fetches (tools/gamescope/release.env names the tag and sha). The
|
||||
# hosted runtime image is never changed: the apk stages the binary over /usr/local/bin.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'tools/gamescope/**'
|
||||
- '.github/workflows/build-gamescope.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish as (e.g. gamescope-3.16.29-p1); empty = artifact only'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build in Arch Linux ARM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/work" -w /work menci/archlinuxarm:base-devel bash tools/gamescope/build-in-arch.sh
|
||||
sudo chown -R "$(id -u):$(id -g)" .
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gamescope-patched
|
||||
path: |
|
||||
gamescope.tzst
|
||||
gamescope.tzst.sha256
|
||||
- name: Publish
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "${{ inputs.tag }}" --title "${{ inputs.tag }}" --notes "gamescope 3.16.29 with tools/gamescope/patches; staged by the apk over /usr/local/bin/gamescope. sha256 $(cut -d' ' -f1 gamescope.tzst.sha256)" gamescope.tzst gamescope.tzst.sha256 || gh release upload "${{ inputs.tag }}" gamescope.tzst gamescope.tzst.sha256 --clobber
|
||||
@@ -150,10 +150,12 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/desktop/*') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq
|
||||
@@ -174,6 +176,21 @@ jobs:
|
||||
for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do
|
||||
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
|
||||
done
|
||||
# The SteamOS helpers the client calls in Deck mode, as no-op stubs (see each file).
|
||||
for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do
|
||||
[ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}"
|
||||
done
|
||||
# The patched gamescope, when a build of it has been published (tools/gamescope/release.env
|
||||
# names the release and the archive's sha256); see tools/gamescope/PATCHES.md.
|
||||
if [ -f tools/gamescope/release.env ]; then
|
||||
. tools/gamescope/release.env
|
||||
# Through the API: the repository is private, so the plain download URL answers 404.
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${GITHUB_REPOSITORY}" -p gamescope.tzst -O gamescope.tzst
|
||||
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
|
||||
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
|
||||
test -f "$D/usr/local/bin/gamescope"
|
||||
rm gamescope.tzst
|
||||
fi
|
||||
# And the desktop's launcher and labwc defaults, staged over the desktop package's copies.
|
||||
install -Dm644 tools/linuxfs/desktop/steamdeck-desktop "$D/usr/local/bin/steamdeck-desktop"
|
||||
install -Dm644 tools/linuxfs/desktop/autostart "$D/etc/xdg/labwc/autostart"
|
||||
@@ -182,6 +199,37 @@ jobs:
|
||||
install -Dm644 tools/linuxfs/desktop/firefox-steamdeck.js "$D/usr/lib/firefox/defaults/pref/steamdeck.js"
|
||||
ls -lR "$D"
|
||||
|
||||
# The daemon's AAudio sink (tools/aaudio-sink), compiled here against upstream PulseAudio 13
|
||||
# headers and the app's own libpulse*, then put into the audio bundle the apk carries. The
|
||||
# committed bundle holds every other module and no sink, so a build without this step would
|
||||
# have no audio - the unpack check in PulseAudioComponent would then fail loudly, not quietly.
|
||||
- name: Cache PulseAudio 13.0 source
|
||||
id: pa-src
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: pa13
|
||||
key: pulseaudio-src-13.0
|
||||
- name: Fetch PulseAudio 13.0 source
|
||||
if: steps.pa-src.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
|
||||
mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
|
||||
- name: Build the AAudio sink into the audio bundle
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
|
||||
tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/sink-out"
|
||||
rm -rf pa-bundle && mkdir -p pa-bundle
|
||||
tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
|
||||
test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
|
||||
install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
|
||||
install -m755 sink-out/module-directaudio-sink.so pa-bundle/modules/arm64/module-directaudio-sink.so
|
||||
(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
|
||||
mv pulseaudio.tzst app/src/main/assets/pulseaudio.tzst
|
||||
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
|
||||
rm -rf pa-bundle sink-out
|
||||
|
||||
# Runs whether the preloads were built or restored: readelf reads any ELF whatever the host,
|
||||
# so these assertions cost nothing and need no cross toolchain.
|
||||
- name: Check the preloads
|
||||
|
||||
@@ -18,4 +18,4 @@ Compatibility and performance vary by device; hardware validation is limited. De
|
||||
|
||||
## Credits and licence
|
||||
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05); audio, gamepad, and session foundations build on Winlator (brunodev85). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
Binary file not shown.
@@ -24,12 +24,12 @@ add_subdirectory(thirdparty/dxbc)
|
||||
# rot. The compositor's frame-generation leg (waylandcomp/CMakeLists.txt) stamps both in.
|
||||
set(WINFG_UPSTREAM "v0.3.0")
|
||||
set(WINFG_CHAIN_SRC
|
||||
winlator/winfg/framegen.cpp
|
||||
winlator/winfg/framegen.hpp
|
||||
winlator/winfg/record_impl.inc
|
||||
winlator/winfg/config.hpp
|
||||
winlator/winfg/embedded_shaders.hpp
|
||||
winlator/winfg/winfg_engine.cpp
|
||||
framegen/winfg/framegen.cpp
|
||||
framegen/winfg/framegen.hpp
|
||||
framegen/winfg/record_impl.inc
|
||||
framegen/winfg/config.hpp
|
||||
framegen/winfg/embedded_shaders.hpp
|
||||
framegen/winfg/winfg_engine.cpp
|
||||
)
|
||||
set(WINFG_HASH_INPUT "")
|
||||
foreach(f ${WINFG_CHAIN_SRC})
|
||||
|
||||
@@ -294,7 +294,8 @@ send_vibration(int strong, int weak, uint16_t duration_ms, uint16_t slot) {
|
||||
if (!fake_vibration_enabled())
|
||||
return;
|
||||
|
||||
// Rumble is best effort. A full Android listener backlog must never park
|
||||
// Rumble is best effort: the app listens on the abstract socket below (RumbleComponent) and
|
||||
// drives the phone's vibrator. A full listener backlog must never park
|
||||
// winebus (or every input hook through controller_mutex()), and a closing
|
||||
// listener must not terminate the guest with SIGPIPE.
|
||||
int sock = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
|
||||
@@ -303,7 +304,7 @@ send_vibration(int strong, int weak, uint16_t duration_ms, uint16_t slot) {
|
||||
|
||||
struct sockaddr_un addr = {};
|
||||
addr.sun_family = AF_UNIX;
|
||||
const char *name = "winlator_vibration";
|
||||
const char *name = "steamdeck-rumble";
|
||||
memcpy(addr.sun_path + 1, name, strlen(name));
|
||||
socklen_t addrlen = offsetof(struct sockaddr_un, sun_path) + 1 + strlen(name);
|
||||
|
||||
|
||||
+2
-2
@@ -108,10 +108,10 @@ struct VkTable {
|
||||
|
||||
#include <android/log.h>
|
||||
#include <string>
|
||||
#define WLOG_TAG "Winlator_Renderer"
|
||||
#define WLOG_TAG "SteamDeck_Renderer"
|
||||
#define RLOG(...) if(verboseLog) __android_log_print(ANDROID_LOG_DEBUG,WLOG_TAG,__VA_ARGS__)
|
||||
#define RLOG_E(...) __android_log_print(ANDROID_LOG_ERROR,WLOG_TAG,__VA_ARGS__)
|
||||
#define SCANOUT_LOG(...) __android_log_print(ANDROID_LOG_DEBUG,"Winlator_Scanout",__VA_ARGS__)
|
||||
#define SCANOUT_LOG(...) __android_log_print(ANDROID_LOG_DEBUG,"SteamDeck_Scanout",__VA_ARGS__)
|
||||
|
||||
#include <vulkan/vulkan_android.h>
|
||||
#include <android/hardware_buffer.h>
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -22,7 +22,7 @@
|
||||
#include <cstdint>
|
||||
|
||||
#ifndef SCANOUT_LOG
|
||||
#define SCANOUT_LOG(...) __android_log_print(ANDROID_LOG_DEBUG,"Winlator_Scanout",__VA_ARGS__)
|
||||
#define SCANOUT_LOG(...) __android_log_print(ANDROID_LOG_DEBUG,"SteamDeck_Scanout",__VA_ARGS__)
|
||||
#endif
|
||||
|
||||
class ScanoutContext {
|
||||
|
||||
@@ -38,19 +38,19 @@ target_include_directories(bannerwayland PRIVATE
|
||||
prebuilt/include
|
||||
src
|
||||
# The X11 Vulkan renderer's committed SPIR-V headers (*_frag.h / upscale_vert.h, made by
|
||||
# winlator/gen_shaders.sh): the screen-effect chain (src/effects_chain.c) runs the same bytes.
|
||||
# Bannerlator's gen_shaders.sh): the screen-effect chain (src/effects_chain.c) runs the same bytes.
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../shaders
|
||||
)
|
||||
|
||||
# ── Frame generation (feat/wayland-framegen) ─────────────────────────────────
|
||||
# The two native engines the X11 renderer runs (LSFG Native: winlator/lsfg, Win-FG Native:
|
||||
# winlator/winfg) are compiled into this library unmodified and driven by
|
||||
# The two native engines the X11 renderer runs (LSFG Native: framegen/lsfg, Win-FG Native:
|
||||
# framegen/winfg) are compiled into this library unmodified and driven by
|
||||
# src/framegen_engine.cpp through the compositor's own Turnip entry points. They need the
|
||||
# winlator tree and the vendored DXBC translator (thirdparty/dxbc, for the LSFG cache format),
|
||||
# framegen tree and the vendored DXBC translator (thirdparty/dxbc, for the LSFG cache format),
|
||||
# which only the app build has: the standalone build (standalone/) gets the stub, which reports
|
||||
# every engine unavailable and generates nothing.
|
||||
set(BANNER_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../winlator/lsfg)
|
||||
set(BANNER_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../winlator/winfg)
|
||||
set(BANNER_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
|
||||
set(BANNER_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
|
||||
if (TARGET dxbc AND EXISTS ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
AND EXISTS ${BANNER_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp)
|
||||
message(STATUS "bannerwayland: frame-generation engines built in (LSFG Native, Win-FG Native)")
|
||||
@@ -79,8 +79,8 @@ if (TARGET dxbc AND EXISTS ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
|
||||
${BANNER_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp
|
||||
)
|
||||
target_include_directories(bannerwayland PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/.. # <thirdparty/...> as the winlator target sees it
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../winlator # VulkanRendererContext.h (VkTable), lsfg/, winfg/
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/.. # <thirdparty/...> as the frame-generation engines see it
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../framegen # VulkanRendererContext.h (VkTable), lsfg/, winfg/
|
||||
)
|
||||
target_compile_options(bannerwayland PRIVATE
|
||||
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17 -fvisibility=hidden>)
|
||||
|
||||
@@ -1,910 +0,0 @@
|
||||
# HDR on the Wayland backend - reconnaissance
|
||||
|
||||
> **2026-09-14: round 1 of Phase A is built** (branch `feat/wayland-hdr`, opt-in, testing only) -
|
||||
> see **§10** at the end for what was built, how it is gated and how a tester's log proves it.
|
||||
> The recon below is kept as written.
|
||||
|
||||
Written 2026-09-13. **Recon only: no product code was changed, nothing was built, nothing was
|
||||
installed on the device.** Branch surveyed: app `feat/wayland-phase2`, proton-wine
|
||||
`feat/winewayland-desktop-11.0-2`, Banners-Turnip `wayland` (Mesa `7cda7850`, v26.3.0).
|
||||
Device reads are `dumpsys` / `getprop` / `strings` only.
|
||||
|
||||
Companion docs: `ZERO_COPY_SPIKE.md` (buffer path), `WAYLAND_RUNTIME.md`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Answer in one paragraph
|
||||
|
||||
**The game half of HDR is already shipping and nobody noticed.** The Wayland Turnip inside
|
||||
wcp v6 (`Proton-11.0-2.1-arm64ec-6/lib/libvulkan_freedreno_wayland.so`, verified on the device)
|
||||
already contains Mesa's full `wp_color_manager_v1` client implementation and advertises
|
||||
`VK_EXT_swapchain_colorspace` + `VK_EXT_hdr_metadata`; the shipping DXVK v3.1-gplasync already
|
||||
carries `dxgi.enableHDR` / `DXVK_HDR` and the `VK_COLOR_SPACE_HDR10_ST2084_EXT` path;
|
||||
`winewayland.drv` is a transparent 121-line shim that neither adds nor removes colour
|
||||
information. **Everything that is missing is in our compositor**, which advertises no colour
|
||||
protocol, offers only four 8-bit DRM formats over `zwp_linux_dmabuf_v1`, takes
|
||||
`surfaceFormats[0]` for its own swapchain, runs an 8-bit scene/effects chain, and never calls
|
||||
`ASurfaceTransaction_setBufferDataSpace`. The work is therefore **app-side only** - the memory
|
||||
note in `project_bannerlator_wayland_phase4` that HDR "does need a new wcp" is **wrong for the
|
||||
HDR10 (10-bit PQ) path**; a new wcp is only needed for scRGB/FP16 zero-copy. Estimated
|
||||
**4–6 engineer-days** to a plumbed, log-verifiable HDR10 path. But the Pocket FIT's panel
|
||||
reports `supportedHdrTypes=[]`, `supportedColorModes=[0]` (NATIVE only), `hdrSdrRatio
|
||||
not_available` and `displayBrightnessNits=-1`, so **nothing of it can be seen on this device**,
|
||||
and turning it on there would actively *cost* us the zero-copy DEVICE-composition win. Verdict
|
||||
in §8: **not now.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Game side - how far an HDR request gets today
|
||||
|
||||
### 1.1 What a Windows game does
|
||||
|
||||
| Call | Where it lands in DXVK | Notes |
|
||||
|---|---|---|
|
||||
| `IDXGIOutput6::GetDesc1` → `DXGI_OUTPUT_DESC1::ColorSpace` | `src/dxgi/dxgi_output.cpp:220-259` | **Never queries Vulkan.** Reports `RGB_FULL_G2084_NONE_P2020` purely because `dxgi.enableHDR` is on. |
|
||||
| `IDXGISwapChain3::CheckColorSpaceSupport` | `src/dxgi/dxgi_swapchain.cpp:655-667` | This *is* backed by Vulkan (below). Note it is a swapchain method, not a factory method. |
|
||||
| `IDXGISwapChain3::SetColorSpace1` | `dxgi_swapchain.cpp:672-688` → `d3d11_swapchain.cpp:306-331` → `Presenter::supportsColorSpace` (`dxvk_presenter.cpp:319-343`) | Returns `E_INVALIDARG` if the Vulkan surface does not list the colour space. |
|
||||
| `IDXGISwapChain4::SetHDRMetaData` | `dxgi_swapchain.cpp:690-717` → `vkSetHdrMetadataEXT` | Only `DXGI_HDR_METADATA_TYPE_HDR10`. Silently dropped if `VK_EXT_hdr_metadata` is absent (`dxvk_presenter.cpp:143-149`). |
|
||||
|
||||
DXGI→Vulkan mapping (`src/d3d11/d3d11_swapchain.cpp:21-28`):
|
||||
|
||||
```
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 -> VK_COLOR_SPACE_HDR10_ST2084_EXT
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709 -> VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT (scRGB)
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709 -> VK_COLOR_SPACE_SRGB_NONLINEAR_KHR
|
||||
```
|
||||
|
||||
Swapchain formats DXVK will accept per colour space (`Presenter::pickFormat`,
|
||||
`dxvk_presenter.cpp:1043-1081`):
|
||||
|
||||
* `HDR10_ST2084` → `A2R10G10B10_UNORM_PACK32`, `A2B10G10R10_UNORM_PACK32`,
|
||||
`E5B9G9R9_UFLOAT_PACK32`, `R16G16B16A16_UNORM`, `R16G16B16A16_SFLOAT`
|
||||
* `EXTENDED_SRGB_LINEAR` (scRGB) → **`R16G16B16A16_SFLOAT` only**
|
||||
|
||||
`VK_EXT_hdr_metadata` is optional in both DXVK and vkd3d-proton. `VK_EXT_swapchain_colorspace`
|
||||
is *not* requested as an instance extension by DXVK's win32 WSI - it simply trusts the driver to
|
||||
report the extra `VkColorSpaceKHR` values from `vkGetPhysicalDeviceSurfaceFormatsKHR`. Mesa's
|
||||
Wayland WSI does exactly that (§2.3), so this is not a problem for us.
|
||||
|
||||
**vkd3d-proton (D3D12)** ships its own swapchain (`libs/vkd3d/swapchain.c`), same colour-space
|
||||
mapping (`convert_color_space`, :1717-1730), but **no fallback table**: if the requested
|
||||
HDR colour space is not in `vkGetPhysicalDeviceSurfaceFormatsKHR`, `dxgi_vk_swap_chain_select_format`
|
||||
(:1773-1791) returns `false` with the comment *"Refuse to present unsupported HDR since it will
|
||||
look completely bogus"*, and the caller skips swapchain recreation for that cycle. A D3D12 game
|
||||
that force-sets HDR without checking can therefore **stall presentation** rather than degrade.
|
||||
DXGI enumeration for D3D12 titles is still DXVK's `dxgi.dll`, so `DXVK_HDR` still drives
|
||||
`DXGI_OUTPUT_DESC1`.
|
||||
|
||||
**Verified in our shipped binaries** (device, `strings`):
|
||||
`contents/DXVK/v3.1-gplasync-0/system32/dxgi.dll` contains `dxgi.enableHDR`, `DXVK_HDR`,
|
||||
`VK_COLOR_SPACE_HDR10_ST2084_EXT`; `d3d11.dll`/`d3d9.dll`/`dxgi.dll` contain
|
||||
`VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT`; `contents/VKD3D/3.1.0-wave64-relax-1/.../d3d12core.dll`
|
||||
contains `VK_EXT_hdr_metadata`. Nothing needs rebuilding on the D3D side.
|
||||
|
||||
Proton-level gating: GE's `proton` script maps `PROTON_ENABLE_HDR` / `PROTON_USE_HDR` to
|
||||
`DXVK_HDR=1` and nothing else. For us that is one container env var - no launcher work.
|
||||
|
||||
### 1.2 The failure mode today, exactly
|
||||
|
||||
DXVK's `DXGI_OUTPUT_DESC1` will claim HDR10 as soon as `DXVK_HDR=1`, regardless of Vulkan.
|
||||
`SetColorSpace1` then fails with `E_INVALIDARG` because our compositor advertises no colour
|
||||
management, so Mesa lists only `SRGB_NONLINEAR` + `PASS_THROUGH` (§2.3). Well-behaved games fall
|
||||
back to SDR. Games that trust `GetDesc1` without checking `CheckColorSpaceSupport` can render an
|
||||
HDR-authored image into an SDR swapchain (washed out) - this is a pre-existing foot-gun, not
|
||||
something we introduce. **Recommendation regardless of whether we do HDR: do not expose a
|
||||
`DXVK_HDR` container toggle until the compositor answers truthfully**, or we invite exactly that
|
||||
bug class.
|
||||
|
||||
### 1.3 `winewayland.drv` - carries nothing for colour, and blocks nothing
|
||||
|
||||
`/home/claude-user/proton-wine-wayland2-wt/dlls/winewayland.drv/vulkan.c` is 121 lines:
|
||||
`wayland_vulkan_surface_create` wraps the `wl_surface` in a `VkSurfaceKHR`,
|
||||
`wayland_map_instance_extensions` aliases `VK_KHR_win32_surface` ⇄ `VK_KHR_wayland_surface`.
|
||||
There is **no surface-format or colour-space filtering**, so whatever Mesa advertises reaches
|
||||
DXVK unmodified. A grep for `color|colour|hdr|st2084|bt2020|gamut|10.?bit` across the whole
|
||||
driver returns only GDI colour-bitmap helpers - nothing colorimetric.
|
||||
|
||||
One real consequence: `wayland_output.c` publishes no EDID, and nothing in the tree writes the
|
||||
`EDID` registry value `win32u/sysparams.c:815-818` reads. DXVK's
|
||||
`src/wsi/win32/wsi_monitor_win32.cpp:288-361` therefore finds no EDID and falls back to its dummy
|
||||
HDR luminances (`wsi_edid.h:38-56`: min 0.01 / max 1499 / maxFullFrame 799 nits). A game will
|
||||
size its tonemapper for a 1499-nit display. Correctable later by synthesising an EDID in
|
||||
`winewayland.drv` (that *would* need a new wcp), but not a blocker.
|
||||
|
||||
### 1.4 Our bundled Turnip already speaks colour management - verified on the device
|
||||
|
||||
```
|
||||
$ strings .../Proton/11.0-2.1-arm64ec-6/lib/libvulkan_freedreno_wayland.so
|
||||
wp_color_manager_v1 PRESENT
|
||||
wp_color_management_surface_v1 PRESENT
|
||||
wp_image_description_creator_params_v1 PRESENT
|
||||
banner_ahb_v1 PRESENT (our zero-copy patch)
|
||||
VK_EXT_swapchain_colorspace PRESENT
|
||||
VK_EXT_hdr_metadata PRESENT
|
||||
```
|
||||
|
||||
The plain adrenotools driver the compositor itself loads
|
||||
(`contents/adrenotools/Mesa Turnip v26.3.0-7cda785 (Android + Wayland)/libvulkan_freedreno.so`)
|
||||
has the same colour-management symbols. Our own WSI patch is already aware of the protocol -
|
||||
`patches/wayland/banner_ahb_wsi.py` anchors one of its edits on Mesa's
|
||||
`if (display->color_manager) wp_color_manager_v1_destroy(...)` teardown line.
|
||||
|
||||
**And the zero-copy patch already maps 10-bit to gralloc** - `banner_ahb_wsi.py:99-100, 225-237`:
|
||||
|
||||
```c
|
||||
#define BANNER_AHB_FORMAT_R8G8B8A8_UNORM 1u
|
||||
#define BANNER_AHB_FORMAT_R10G10B10A2_UNORM 0x2bu /* AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM */
|
||||
...
|
||||
case VK_FORMAT_A2B10G10R10_UNORM_PACK32: return BANNER_AHB_FORMAT_R10G10B10A2_UNORM;
|
||||
```
|
||||
|
||||
So a 10-bit HDR10 swapchain can already be gralloc-backed and go onto the display layer with the
|
||||
**shipped** wcp. `VK_FORMAT_R16G16B16A16_SFLOAT` is *not* mapped, so `banner_ahb_skip_format`
|
||||
(:239-242, applied at `:606-617`) hides FP16 surface formats whenever zero-copy is on - scRGB
|
||||
zero-copy is the one thing that needs a driver change (one enum: `AHARDWAREBUFFER_FORMAT_R16G16B16A16_FLOAT = 0x16`).
|
||||
|
||||
### 1.5 X11 has no path - confirmed
|
||||
|
||||
`app/src/main/cpp/winlator/VulkanRendererContext.cpp:439` hardcodes
|
||||
`swapchainFmt = VK_FORMAT_R8G8B8A8_UNORM` and `:492` hardcodes
|
||||
`imageColorSpace = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR`. Upstream of that, the game presents through
|
||||
`winex11.drv` into our pure-Java X11 server, which has no colour extension of any kind. HDR on X11
|
||||
would mean inventing a private X extension *and* teaching DXVK to use it. Correctly out of scope.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wayland side
|
||||
|
||||
### 2.1 Protocol status
|
||||
|
||||
`wp_color_management_v1` is **merged and stable-ish**: wayland-protocols MR !14, merged
|
||||
2025-02-13, shipped in **wayland-protocols 1.41**, file
|
||||
`staging/color-management/color-management-v1.xml`. Interfaces are at **version 3** upstream today
|
||||
(v2 added `get_image_description`/`preferred_changed2`; v3 added `create_windows_bt2100` and the
|
||||
`absolute_no_adaptation` intent). Predecessors: `zwp_linux_color_management_v1` →
|
||||
`xx_color_management_v4` (the name most 2023–2025 shipping code targeted) → `wp_*` on landing.
|
||||
`frog_color_management_v1` (Valve) is still vendored by gamescope alongside the real one and is
|
||||
still functionally load-bearing on the Deck.
|
||||
|
||||
Implementers: KWin (reference impl, most mature), Mutter (merged 2025-02-25 but still does not
|
||||
advertise enough features for automatic HDR), wlroots 0.20 / Sway 1.12 (HDR10 on the Vulkan
|
||||
renderer), gamescope (v1 semantics + frog), Weston (experimental, no tone mapping).
|
||||
Proton-CachyOS's changelog notes that its `winewayland.drv` `windows_bt2100` path needs protocol
|
||||
**v3** and that *"no compositor supports it yet"* - everybody is converged on **v1**.
|
||||
|
||||
**We only need v1.** That is what Mesa binds.
|
||||
|
||||
### 2.2 Minimum viable compositor subset
|
||||
|
||||
At bind, on the `wp_color_manager_v1` global, send one event per supported item then `done`:
|
||||
|
||||
* `supported_intent`: `perceptual` (mandatory anyway - it is all Mesa ever asks for)
|
||||
* `supported_feature`: `parametric` (gates `create_parametric_creator`); optionally
|
||||
`set_mastering_display_primaries`, and `extended_target_volume` **only if** we want scRGB
|
||||
* `supported_primaries_named`: `bt2020` (HDR10), `srgb` (needed for scRGB and for Mesa's
|
||||
"use a colour surface for sRGB too" branch), `display_p3` (wide gamut)
|
||||
* `supported_tf_named`: `st2084_pq` (HDR10), `ext_linear` (scRGB), `srgb`
|
||||
|
||||
Then implement: `create_parametric_creator` → `wp_image_description_creator_params_v1`
|
||||
(`set_primaries_named`, `set_tf_named`, `set_mastering_display_primaries`,
|
||||
`set_mastering_luminance`, `set_max_cll`, `set_max_fall`) → `create` → emit
|
||||
`wp_image_description_v1.ready` (or `failed`) → `wp_color_manager_v1.get_surface` →
|
||||
`wp_color_management_surface_v1.set_image_description(desc, intent)`, double-buffered on
|
||||
`wl_surface.commit`.
|
||||
|
||||
**Not needed:** ICC creator, `wp_color_management_surface_feedback_v1`,
|
||||
`wp_image_description_info_v1`, output image descriptions, v2/v3 requests. Mesa uses none of them.
|
||||
|
||||
### 2.3 What Mesa's WSI does - read from the exact commit we build
|
||||
|
||||
Read from `src/vulkan/wsi/wsi_common_wayland.c` at Mesa `7cda7850` (4121 lines - the commit
|
||||
recorded in `banners-turnip-wayland/mesa_hash.txt`). Landed upstream as MR !32038 (Xaver Hugl,
|
||||
merged 2025-02-25, Mesa 25.1).
|
||||
|
||||
* **Bind:** `registry_handle_global` binds `wp_color_manager_v1` at **version 1** unconditionally
|
||||
when advertised (`:1536-1538`), and installs `color_manager_listener` (`:1217-1223`).
|
||||
* **Colour-space list:** `wsi_wl_display_determine_colorspaces` (`:1122-1160`) always adds
|
||||
`VK_COLOR_SPACE_SRGB_NONLINEAR_KHR` and `VK_COLOR_SPACE_PASS_THROUGH_EXT`; everything else comes
|
||||
from the static `colorspace_mapping[]` table (`:1038-1118`) and is added **only if** the
|
||||
compositor advertised that entry's primaries *and* transfer function (and, for entries flagged
|
||||
`needs_extended_range`, `feature.extended_target_volume`). Table entries include
|
||||
`HDR10_ST2084` (bt2020 + st2084_pq), `EXTENDED_SRGB_LINEAR` (srgb + ext_linear + extended range),
|
||||
`DISPLAY_P3_NONLINEAR`, `DISPLAY_P3_LINEAR`, `BT709_LINEAR`, `BT709_NONLINEAR`, `BT2020_LINEAR`,
|
||||
`HDR10_HLG`, `ADOBERGB_LINEAR`.
|
||||
* **Surface formats:** `wsi_wl_surface_get_formats2` (`:2205-2246`) emits the **full cross product** of
|
||||
`display.colorspaces` × `display.formats` with **no bit-depth gating**:
|
||||
|
||||
```c
|
||||
u_vector_foreach(cs, &display.colorspaces) {
|
||||
u_vector_foreach(disp_fmt, &display.formats) {
|
||||
if (!(disp_fmt->flags & WSI_WL_FMT_ALPHA) || !(disp_fmt->flags & WSI_WL_FMT_OPAQUE))
|
||||
continue;
|
||||
out_fmt->surfaceFormat.format = disp_fmt->vk_format;
|
||||
out_fmt->surfaceFormat.colorSpace = *cs;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Consequence: if we advertise BT.2020 + PQ but keep only 8-bit DRM formats, Mesa will happily
|
||||
offer `R8G8B8A8_UNORM` + `HDR10_ST2084` and DXVK will **not** take it (`pickFormat`'s HDR10 list
|
||||
is 10-bit/16-bit only) - so there is no accidental 8-bit-PQ trap, but there is also no HDR
|
||||
unless we also advertise a 10-bit DRM format.
|
||||
* **Apply:** `wsi_wl_swapchain_update_colorspace` (`:1323-1455`) builds the parametric creator,
|
||||
blocks on `ready`/`failed` (it dispatches the queue in a loop), sets mastering primaries /
|
||||
luminance / max_cll / max_fall when `should_use_hdr_metadata` is set for that colour space, and
|
||||
calls `set_image_description(..., RENDER_INTENT_PERCEPTUAL)`. `is_hdr_metadata_legal`
|
||||
(`:1277-1299`) drops illegal combinations rather than risking a protocol error. On `failed`
|
||||
without `extended_target_volume` it retries once without metadata.
|
||||
* **`VK_EXT_hdr_metadata`:** `wsi_wl_swapchain_set_hdr_metadata` (`:1459-1463`) just stashes
|
||||
`VkHdrMetadataEXT`; the real work is in `update_colorspace` above.
|
||||
|
||||
**Nothing to build in the driver for HDR10.** Mesa is done.
|
||||
|
||||
### 2.4 Our compositor advertises nothing colorimetric
|
||||
|
||||
Globals (`compositor.c:2527-2538`, plus `ahb_swapchain.c:406`,
|
||||
`wl_clipboard.c:522-523`, `wl_text_input.c:275`, `wl_toplevel_icon.c:35`):
|
||||
`wl_compositor` v6, `wl_subcompositor`, `wp_viewporter`, `wl_output` v2, `xdg_wm_base`,
|
||||
`zwp_linux_dmabuf_v1` **v3**, `wl_seat` v5, `banner_desktop_v1`, `wp_presentation` v2,
|
||||
pointer constraints, relative pointer, `banner_ahb_v1` v2, data device/control, text input,
|
||||
toplevel icon. **No `wp_color_manager_v1`.** `protocols/` has no colour-management XML, and
|
||||
protocol C glue is pre-generated into `generated/` (no `wayland-scanner` at app build time -
|
||||
`CMakeLists.txt:1-4`), so adding a protocol means generating and committing the files offline.
|
||||
|
||||
The dmabuf format table is 8-bit only (`compositor.c:1323-1326`):
|
||||
|
||||
```c
|
||||
#define DMABUF_NFMT 4
|
||||
static struct { uint32_t fmt; uint64_t mods[DMABUF_NMOD]; int n; } g_dmabuf_fmts[DMABUF_NFMT] = {
|
||||
{DRM_ARGB8888}, {DRM_XRGB8888}, {DRM_ABGR8888}, {DRM_XBGR8888}};
|
||||
```
|
||||
|
||||
Good news: the machinery around it is already format-agnostic.
|
||||
`vkp_dmabuf_modifiers` (`vk_present.c`) takes a DRM fourcc and asks the compositor's own Turnip via
|
||||
`VkDrmFormatModifierPropertiesListEXT`, and `drm_to_vk` (`vk_present.c:105-123`) **already** maps
|
||||
`AB30`/`XB30` → `A2B10G10R10_UNORM_PACK32`, `AR30`/`XR30` → `A2R10G10B10_UNORM_PACK32`, and
|
||||
`AB4H`/`XB4H` → `R16G16B16A16_SFLOAT`. Adding 10-bit is a table edit plus the matching
|
||||
`FOURCC` defines next to `compositor.c:415-421`.
|
||||
|
||||
The compositor's own output swapchain is oblivious (`vk_present.c:301-311, 437-466`): two instance
|
||||
extensions (`VK_KHR_surface`, `VK_KHR_android_surface` - **no `VK_EXT_swapchain_colorspace`**),
|
||||
and `VkSurfaceFormatKHR chosen = fmts[0];` (`:441`) - the first format the Android WSI happens to
|
||||
return, with whatever colour space rides along. The scene image and the whole 13-pass effects
|
||||
chain are 8-bit (`vk_present.c:54` `SCENE_FMT = VK_FORMAT_R8G8B8A8_UNORM`; `effects_chain.c:29`
|
||||
`FX_FORMAT = VK_FORMAT_R8G8B8A8_UNORM`).
|
||||
|
||||
> **Naming collision, flag it in any UI work.** The drawer already has an "HDR" effect. It is
|
||||
> `effects_chain.c:197` `P_HDR` / `app/src/main/cpp/winlator/hdr.frag` - a dual-radius bloom +
|
||||
> contrast lift ported from `HDREffect.java`, `HDRPower = 1.30`, entirely SDR and entirely fake.
|
||||
> Real HDR must be named something else ("HDR output", "HDR10") or users will conflate them.
|
||||
|
||||
### 2.5 Is there a protocol-free shortcut?
|
||||
|
||||
No. A `DRM_FORMAT_ABGR2101010` buffer is equally valid as SDR BT.709 or as PQ BT.2020; the format
|
||||
and modifier describe byte layout, not colorimetry. That ambiguity is precisely why
|
||||
`wp_color_management_v1` exists. We *could* add a `set_dataspace` request to our own
|
||||
`banner_ahb_v1` and skip the standard protocol - but Mesa would never call it, so that would need
|
||||
a driver patch (new wcp) to buy something Mesa already gives us for free. **Implement
|
||||
`wp_color_manager_v1` v1 server-side; it is strictly cheaper.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Android side
|
||||
|
||||
### 3.1 The real symbols, their API levels, and what the device has
|
||||
|
||||
`frameworks/native/include/android/surface_control.h`:
|
||||
|
||||
| Symbol | API level | Present in `/system/lib64/libandroid.so` on the Pocket FIT (API 34)? |
|
||||
|---|---|---|
|
||||
| `ASurfaceTransaction_setBufferDataSpace(tx, sc, ADataSpace)` | **29** | **PRESENT** |
|
||||
| `ASurfaceTransaction_setHdrMetadata_smpte2086(tx, sc, AHdrMetadata_smpte2086*)` | **29** | **PRESENT** |
|
||||
| `ASurfaceTransaction_setHdrMetadata_cta861_3(tx, sc, AHdrMetadata_cta861_3*)` | **29** | **PRESENT** |
|
||||
| `ASurfaceTransaction_setExtendedRangeBrightness(tx, sc, float cur, float desired)` | **34** | **PRESENT** |
|
||||
| `ASurfaceTransaction_setDesiredHdrHeadroom(tx, sc, float)` | **35** | absent (expected - device is 34) |
|
||||
| `ASurfaceTransaction_setLuts` | 36 | n/a |
|
||||
| `ASurfaceTransaction_setColorSpace` | **does not exist** | - |
|
||||
| `ASurfaceTransaction_setLuma` | **does not exist** | - |
|
||||
|
||||
(Checked with `strings` on the device's own `libandroid.so`; `setEnableBackPressure` and
|
||||
`setFrameRateWithChangeStrategy` confirmed present alongside, matching what `sc_layer.c` already
|
||||
dlsyms.)
|
||||
|
||||
Our dlsym table is `sc_layer.c:82-97` - 15 entries, **none of them colorimetric**. The additions
|
||||
needed are `setBufferDataSpace` (the only mandatory one), plus optionally
|
||||
`setHdrMetadata_smpte2086` / `_cta861_3` and `setExtendedRangeBrightness`. All four are API ≤ 34,
|
||||
i.e. inside the range `sc_layer.c` already targets, so the existing "missing symbol → path
|
||||
unavailable" pattern covers older devices unchanged.
|
||||
|
||||
### 3.2 Dataspaces we would emit
|
||||
|
||||
`ADataSpace` = `STANDARD (63<<16) | TRANSFER (31<<22) | RANGE (7<<27)`, all in the NDK since 29:
|
||||
|
||||
| Constant | Value | Maps from |
|
||||
|---|---|---|
|
||||
| `ADATASPACE_BT2020_PQ` | 163971072 | `VK_COLOR_SPACE_HDR10_ST2084_EXT` |
|
||||
| `ADATASPACE_BT2020_HLG` | 168165376 | `VK_COLOR_SPACE_HDR10_HLG_EXT` |
|
||||
| `ADATASPACE_SCRGB_LINEAR` | 406913024 | `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT` |
|
||||
| `ADATASPACE_DISPLAY_P3` | 143261696 | `VK_COLOR_SPACE_DISPLAY_P3_NONLINEAR_EXT` |
|
||||
| `ADATASPACE_SRGB` | 142671872 | `VK_COLOR_SPACE_SRGB_NONLINEAR_KHR` (today's implicit default) |
|
||||
|
||||
Every layer on the device right now reports `dataspace=UNKNOWN (0)`, which SF treats as sRGB.
|
||||
|
||||
### 3.3 Buffer formats
|
||||
|
||||
`AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM = 0x2b` (API 26) ↔ `VK_FORMAT_A2B10G10R10_UNORM_PACK32`
|
||||
- the normal 10-bit scanout format on Adreno DPUs. QTI gralloc on this device knows it:
|
||||
`strings /vendor/lib64/libqdMetaData.so` lists `RGBA_1010102`, `RGBX_1010102`, `BGRA_1010102`,
|
||||
`BGRX_1010102`. `AHARDWAREBUFFER_FORMAT_R16G16B16A16_FLOAT = 0x16` (API 26) exists but is a
|
||||
compositing intermediate, not normally a scanout format - it was **not** found in the vendor
|
||||
gralloc strings, so treat FP16 gralloc allocation on this SoC as unproven.
|
||||
`AHardwareBuffer_isSupported()` (API 29) is the correct runtime probe, and we should use it rather
|
||||
than assume.
|
||||
|
||||
The compositor's own AHB pool is hardcoded 8-bit (`sc_layer.c:302`
|
||||
`.format = AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM`) and the imported view is built with
|
||||
`DRM_ABGR8888` (`sc_layer.c:326`). That is the *copy* path; the zero-copy path
|
||||
(`sc_layer_present_ahb`, `sc_layer.c:430`) takes the game's own buffer and so already carries
|
||||
whatever format the game allocated - it just never labels it.
|
||||
|
||||
### 3.4 What SurfaceFlinger does with an HDR layer on an SDR panel
|
||||
|
||||
From `services/surfaceflinger/CompositionEngine/src/Output.cpp`, `getBestDataspace()` /
|
||||
`pickColorProfile()`:
|
||||
|
||||
* A `BT2020_PQ` layer sets `outHdrDataSpace = BT2020_PQ` but the output only *adopts* it when
|
||||
`DisplayColorProfile::hasLegacyHdrSupport(BT2020_PQ)` is true - i.e. when the Composer HAL
|
||||
reported HDR types for that display. Otherwise the output dataspace stays `DISPLAY_P3` with
|
||||
`COLORIMETRIC` intent, and the layer is **tone-mapped, not rejected**.
|
||||
* Because the HWC for an SDR-only display cannot consume a PQ buffer, the standard
|
||||
`validateDisplay()` negotiation marks that layer **CLIENT** instead of **DEVICE**, and
|
||||
RenderEngine/Skia composites it with the `libtonemap` SkSL gain shader
|
||||
(`libtonemap_LookupTonemapGain`, uniforms `in_libtonemap_displayMaxLuminance` /
|
||||
`in_libtonemap_inputMaxLuminance`).
|
||||
* **This is the trap for us.** Our whole zero-copy win is that `banner_wayland_game` composites
|
||||
DEVICE (proven 2026-09-13: DEVICE with ROT_90 + 1.5× scale at 144 Hz, −4 pts GPU busy, −3 %
|
||||
power). Tagging that layer `BT2020_PQ` on a panel with no HDR types is very likely to drop it to
|
||||
CLIENT - i.e. HDR would **buy a washed-out tone-map and pay for it with the zero-copy saving**.
|
||||
Any implementation must gate the dataspace on the *display's* reported HDR types, not on the
|
||||
game's request.
|
||||
* `setExtendedRangeBrightness` is a different mechanism: it applies only to `RANGE_EXTENDED`
|
||||
(scRGB) layers and works by having SF dim the *other* layers by `whitePointNits / maxWhitePoint`
|
||||
(`renderengine/LayerSettings.h`) while leaving the extended layer undimmed, so the boosted layer
|
||||
reads brighter by simultaneous contrast without exceeding the panel ceiling.
|
||||
**It needs the display to expose an HDR/SDR ratio.** This panel does not (§4).
|
||||
* `setDesiredHdrHeadroom` (API 35) is the PQ/HLG equivalent and overrides/`is overridden by`
|
||||
`setExtendedRangeBrightness` - last call wins. Not available on the test device at all.
|
||||
|
||||
### 3.5 `has_HDR_display` vs `getHdrCapabilities()`
|
||||
|
||||
`ro.surface_flinger.has_HDR_display` is a **build-time** declaration (`TARGET_HAS_HDR_DISPLAY`)
|
||||
that the device *has* a wide-gamut, high-luminance panel; it gates SF-internal colour-management
|
||||
machinery. `Display.getHdrCapabilities().getSupportedHdrTypes()` is the **runtime, per-display**
|
||||
answer, built from the Composer HAL's `getHdrCapabilities()` for that connector, itself derived
|
||||
from the EDID CTA-861.3 Static Metadata Data Block. **They can disagree, and on this device they
|
||||
do.** An empty list does not mean rejection - it means "tone-map it."
|
||||
|
||||
### 3.6 External display
|
||||
|
||||
On hotplug, the framework queries `getDisplayConfigs`, `getColorModes`, `getHdrCapabilities`,
|
||||
`getDisplayCapabilities` for the new display handle exactly as for the internal panel, so
|
||||
`Display.getHdrCapabilities()` on an external `Display` *is* architecturally wired to reflect the
|
||||
monitor's EDID. Whether it actually does is a vendor-HWC question; empty lists for genuinely
|
||||
HDR10 USB-C monitors are a commonly reported Snapdragon gap. **Unproven for the Pocket FIT - the
|
||||
only way to know is to plug one in.**
|
||||
|
||||
---
|
||||
|
||||
## 4. What the test device can and cannot show
|
||||
|
||||
All read today from the Pocket FIT (Adreno 750, Android 14 / API 34), read-only:
|
||||
|
||||
```
|
||||
DisplayDeviceInfo{"Built-in Screen": 1080 x 1920, ...
|
||||
supportedModes [... supportedHdrTypes=[] (all four modes) ],
|
||||
colorMode 0, supportedColorModes [0],
|
||||
hdrCapabilities HdrCapabilities{mSupportedHdrTypes=[], mMaxLuminance=500.0,
|
||||
mMaxAverageLuminance=500.0, mMinLuminance=0.0},
|
||||
hdrSdrRatio NaN, brightnessMaximum 1.0 ...}
|
||||
mBaseDisplayInfo: ... hdrSdrRatio not_available, userDisabledHdrTypes []
|
||||
```
|
||||
|
||||
```
|
||||
dumpsys SurfaceFlinger:
|
||||
Wide-Color information:
|
||||
Device supports wide color: 1
|
||||
Device uses color management: 1
|
||||
DisplayColorSetting: Managed
|
||||
Display 4630946852602928515 color modes:
|
||||
ColorMode::NATIVE (0)
|
||||
Current color mode: ColorMode::NATIVE (0)
|
||||
colorMode=NATIVE (0) renderIntent=COLORIMETRIC (0) dataspace=UNKNOWN (0) targetDataspace=UNKNOWN (0)
|
||||
displayBrightnessNits=-1.000000 sdrWhitePointNits=-1.000000 displayBrightness=nullopt
|
||||
(every layer) dataspace=UNKNOWN (0) hdr metadata types=0
|
||||
```
|
||||
|
||||
```
|
||||
getprop:
|
||||
[ro.surface_flinger.has_HDR_display]: [true]
|
||||
[ro.surface_flinger.has_wide_color_display]: [true]
|
||||
[ro.surface_flinger.wcg_composition_dataspace]: [143261696] = ADATASPACE_DISPLAY_P3
|
||||
[persist.sys.sf.color_mode]: [9] = ColorMode::DISPLAY_P3
|
||||
[vendor.display.enable_hdr10_gpu_target]: [1]
|
||||
```
|
||||
|
||||
Reading:
|
||||
|
||||
* The **build** declares an HDR, wide-colour device and even asks for `DISPLAY_P3` as the system
|
||||
colour mode (`persist.sys.sf.color_mode=9`), but the **HWC reports `supportedColorModes=[0]`
|
||||
(NATIVE only) and no HDR types**, so SF runs the display in NATIVE with COLORIMETRIC intent and
|
||||
the P3 request is inert. The props are aspirational; the HAL is the truth.
|
||||
* `hdrSdrRatio not_available` / `NaN` and `displayBrightnessNits = -1` mean
|
||||
`Display.isHdrSdrRatioAvailable()` is false → **`setExtendedRangeBrightness` has nothing to act
|
||||
on here.** The "brighten highlights on an SDR panel" trick is unavailable on this device.
|
||||
* `supportedColorModes=[0]` also means **Display-P3 wide gamut is not available either** - not
|
||||
just HDR. A P3-tagged layer would be colour-converted into the panel's native gamut, i.e. into
|
||||
whatever the panel natively is, with no way for us to know what that is.
|
||||
* 500 nits max luminance is below the ~540-nit bar AOSP's own `has_HDR_display` doc cites, and
|
||||
there is no HDR type to signal anyway.
|
||||
|
||||
**Conclusion: visual HDR cannot be demonstrated on the Pocket FIT's internal panel, and neither
|
||||
can wide gamut, and neither can extended-range brightness.** The only things provable on this
|
||||
device are (a) 10-bit buffers end to end (banding reduction, measurable), and (b) plumbing
|
||||
correctness by logs and `dumpsys`.
|
||||
|
||||
Vulkan-side, for completeness (device `strings`):
|
||||
|
||||
| Library | `VK_EXT_swapchain_colorspace` | `VK_EXT_hdr_metadata` |
|
||||
|---|---|---|
|
||||
| `/system/lib64/libvulkan.so` (platform loader - implements the Android swapchain) | **PRESENT** | **PRESENT** |
|
||||
| `/vendor/lib64/hw/vulkan.adreno.so` (Qualcomm blob) | absent | absent |
|
||||
| `/vendor/lib64/hw/vulkan.turnip.so` (vendor-shipped Turnip) | **PRESENT** | **PRESENT** |
|
||||
| wcp v6 `libvulkan_freedreno_wayland.so` (game driver) | **PRESENT** | **PRESENT** |
|
||||
|
||||
The platform loader contains the string `native_window_set_buffers_data_space(%d) failed: %s (%d)`
|
||||
- i.e. Android's own swapchain implementation is what turns a `VkColorSpaceKHR` into a surface
|
||||
dataspace. So for the **copy path**, the compositor's output HDR is genuinely just "enable
|
||||
`VK_EXT_swapchain_colorspace` at instance creation and pick a better surface format than
|
||||
`fmts[0]`". No driver work.
|
||||
|
||||
---
|
||||
|
||||
## 5. The gap list, ordered, with sizes
|
||||
|
||||
Sizes are engineer-days for someone who already knows this code. "Prove" = device-visible in logs
|
||||
or `dumpsys`, not by eye. **App-only unless marked.**
|
||||
|
||||
### Phase A - make the truth reachable (no user-visible HDR yet)
|
||||
|
||||
| # | Repo | Work | Size |
|
||||
|---|---|---|---|
|
||||
| A1 | app | **Advertise 10-bit DRM formats over `zwp_linux_dmabuf_v1`.** Add `AB30`/`XB30` (and optionally `AR30`/`XR30`, `AB4H`/`XB4H`) to `compositor.c:1323-1326` + `FOURCC` defines near `:415-421`. `vkp_dmabuf_modifiers` and `drm_to_vk` already handle them, so this is a table edit + a log line. Risk: a client picking a 10-bit format while the scene/effects chain stays 8-bit - the import works (`drm_to_vk`), the blit downconverts. Gate behind a flag until A4. | **0.5 d** |
|
||||
| A2 | app | **Generate + commit `color-management-v1` protocol glue.** Fetch `staging/color-management/color-management-v1.xml` from wayland-protocols ≥ 1.41, run `wayland-scanner` offline, commit XML + `generated/color-management-v1-{protocol.c,server-protocol.h}`, add to `CMakeLists.txt`. Mechanical. | **0.5 d** |
|
||||
| A3 | app | **Implement `wp_color_manager_v1` v1 server-side** (new `wl_color_mgmt.c`, ~400–500 lines): the global + bind-time feature/intent/primaries/tf events; the parametric creator with `set_primaries_named`/`set_tf_named`/`set_max_cll`/`set_max_fall`/`set_mastering_*`; image-description objects with `ready`/`failed`; `get_surface` → `wp_color_management_surface_v1` and `set_image_description` stored double-buffered on our `struct surface`. **Watch the blocking loop:** Mesa dispatches its queue waiting for `ready`, so we must send `ready` from the request handler's own flush, not defer it to the next frame, or a game stalls at swapchain creation. Advertise only what the *current output* can actually honour (see A5). | **2 d** |
|
||||
| A4 | app | **Carry the image description onto the Android layer.** dlsym `ASurfaceTransaction_setBufferDataSpace` (+ `setHdrMetadata_smpte2086` / `_cta861_3`) in `sc_layer.c:82-97`; map surface image description → `ADataSpace` + `AHdrMetadata_*`; apply in `sc_layer_present_ahb` (`sc_layer.c:430-470`, next to the existing `setBuffer`). Plumb the per-surface description from `compositor.c` through `ahb_swapchain.c:219`. Also set an explicit `ADATASPACE_SRGB` on the existing 8-bit path so we stop relying on `UNKNOWN`. | **1 d** |
|
||||
| A5 | app | **Capability gate.** Read `Display.getHdrCapabilities()` / `getSupportedHdrTypes()` / `isHdrSdrRatioAvailable()` (and the external `Display` when `ExternalDisplayController` has one) in Java, pass down over JNI, and *only then* advertise `bt2020`/`st2084_pq` from A3. Without this we advertise HDR on a panel that will tone-map it and drop the layer to CLIENT (§3.4). Include a `banner_log("color", ...)` line naming exactly what was advertised and why. | **0.5 d** |
|
||||
|
||||
**Phase A total: ~4.5 days. App-only. No new wcp.** End state: a game with `DXVK_HDR=1` on a
|
||||
genuinely HDR display gets an HDR10 swapchain, its buffer reaches an `ASurfaceControl` layer
|
||||
tagged `BT2020_PQ` with real mastering metadata, and `dumpsys SurfaceFlinger` proves it.
|
||||
|
||||
### Phase B - the copy path and the effects chain (only if A is kept)
|
||||
|
||||
| # | Repo | Work | Size |
|
||||
|---|---|---|---|
|
||||
| B1 | app | **10-bit output swapchain.** Add `VK_EXT_swapchain_colorspace` to `vk_present.c:301-311` (`enabledExtensionCount` 2 → 3), replace `chosen = fmts[0]` (`:441`) with a real preference walk (HDR10 + `A2B10G10R10` when the display supports it, else `R8G8B8A8_UNORM` + sRGB). The Android platform loader does the rest. | **0.5 d** |
|
||||
| B2 | app | **Widen the scene + effects chain.** `SCENE_FMT` (`vk_present.c:54`) and `FX_FORMAT` (`effects_chain.c:29`) → `A2B10G10R10_UNORM_PACK32` (or FP16 for real HDR headroom). Every one of the 13 passes samples/writes it; the debanding pass in particular must be re-tuned because its strength is calibrated for 8-bit quantisation. Memory and bandwidth go up ~25 % (or 100 % for FP16). This is the expensive item and it regresses SDR perf if done unconditionally. | **2 d** |
|
||||
| B3 | app | **Layer pool at 10-bit.** `sc_layer.c:302` pool format → `R10G10B10A2_UNORM` when the chain is 10-bit, guarded by `AHardwareBuffer_isSupported()`, with the existing `sniff_modifier`/linear-fallback retry extended. | **0.5 d** |
|
||||
|
||||
**Phase B total: ~3 days. App-only. No new wcp.**
|
||||
|
||||
### Phase C - scRGB, and the one thing that needs a wcp
|
||||
|
||||
| # | Repo | Work | Size |
|
||||
|---|---|---|---|
|
||||
| C1 | **driver (new wcp)** | Map `VK_FORMAT_R16G16B16A16_SFLOAT` → `AHARDWAREBUFFER_FORMAT_R16G16B16A16_FLOAT` (0x16) in `banner_ahb_format_for` (`patches/wayland/banner_ahb_wsi.py:225-237`) so FP16 swapchains are not hidden by `banner_ahb_skip_format` when zero-copy is on. One enum + a rebuild of eight drivers. **This is the only wcp-requiring item, and only for scRGB zero-copy.** Blocked on proving FP16 gralloc allocation works on Adreno at all (§3.3). | **0.5 d + a driver build cycle** |
|
||||
| C2 | app | Advertise `extended_target_volume` + `ext_linear` + `AB4H`/`XB4H`, emit `ADATASPACE_SCRGB_LINEAR`, and wire `ASurfaceTransaction_setExtendedRangeBrightness` from `Display.getHdrSdrRatio()`. | **1 d** |
|
||||
| C3 | **proton-wine (new wcp)** | *Optional, quality only.* Synthesise an EDID with a CTA-861.3 HDR static metadata block in `winewayland.drv` so DXVK reports the real panel luminance in `DXGI_OUTPUT_DESC1` instead of its 1499-nit dummy. | **1–2 d** |
|
||||
|
||||
---
|
||||
|
||||
## 6. Cheap partial wins - and which are visible on a 500-nit panel
|
||||
|
||||
| Idea | Verdict on the Pocket FIT | Notes |
|
||||
|---|---|---|
|
||||
| **10-bit output (banding)** | **Visible, and the best value here.** | This is the one real win. B1 alone (0.5 d) gets a 10-bit output swapchain; A1 (0.5 d) lets the *game* render 10-bit. Adreno 750 + the DPU handle `RGBA_1010102` natively, and `vendor.display.enable_hdr10_gpu_target=1` says the GPU target path expects it. Pairs with the existing debanding pass - arguably makes it unnecessary for 10-bit-capable games, which saves a full-screen pass. Visible on gradients (skyboxes, fog, dark interiors) where our deband pass currently earns its keep. |
|
||||
| **Display-P3 wide gamut on SDR** | **Not visible. Do not build.** | `supportedColorModes=[0]` / `ColorMode::NATIVE` - the HWC exposes no P3 mode. `persist.sys.sf.color_mode=9` asks for P3 and is ignored. Tagging a layer `ADATASPACE_DISPLAY_P3` would make SF convert P3 → the panel's native gamut, which on a NATIVE-mode display is an unknown-to-us transform. Net effect on this device: at best nothing, at worst a colour shift. |
|
||||
| **`setExtendedRangeBrightness` to lift highlights** | **Not available.** | `hdrSdrRatio not_available` / `NaN`, `displayBrightnessNits=-1`, `sdrWhitePointNits=-1`. `Display.isHdrSdrRatioAvailable()` is false, so there is no headroom to claim and SF has no nits data to dim the other layers against. |
|
||||
| **`setDesiredHdrHeadroom`** | **Not available.** | API 35; device is API 34. Absent from `libandroid.so`. |
|
||||
| **Set `ADATASPACE_SRGB` explicitly on our layers** | **Invisible but worth doing anyway (part of A4, ~0 extra cost).** | Every layer currently reports `dataspace=UNKNOWN (0)`. It works today because SF treats UNKNOWN as sRGB, but it is an accident we are relying on, and it will become wrong the moment any colour management is enabled. |
|
||||
|
||||
**So: of the three "cheap wins" in the brief, exactly one - 10-bit - is real on this hardware,
|
||||
and it is worth about 1 engineer-day (A1 + B1) on its own, independent of HDR.**
|
||||
|
||||
---
|
||||
|
||||
## 7. How we would ever prove it
|
||||
|
||||
### 7.1 Which games actually emit HDR under DXVK / VKD3D
|
||||
|
||||
Requirements: a flip-model swapchain at `R10G10B10A2_UNORM` or `R16G16B16A16_FLOAT`, an
|
||||
`IDXGISwapChain3::SetColorSpace1` call, and `DXVK_HDR=1` in the environment. Known-good
|
||||
candidates, easiest first:
|
||||
|
||||
* **D3D11 + scRGB:** *Shadow of the Tomb Raider*, *Resident Evil 2/3 Remake*, *Devil May Cry 5*,
|
||||
*Mass Effect Legendary Edition*. RE Engine titles are the usual smoke test on Linux because they
|
||||
call `SetColorSpace1` early and unconditionally when DXGI reports HDR.
|
||||
* **D3D12 + HDR10:** *Cyberpunk 2077*, *Forza Horizon 5*, *Horizon Zero Dawn*. These go through
|
||||
vkd3d-proton's stricter path (§1.1) - good for catching a half-done implementation, since a
|
||||
wrong answer **stalls presentation** instead of degrading.
|
||||
* **Sanity harness, no game needed:** anything that calls `vkGetPhysicalDeviceSurfaceFormatsKHR`
|
||||
under the layer. `vulkaninfo` is not available in the prefix, but a tiny Win32 Vulkan probe
|
||||
built against the layer's headers, or simply DXVK's own `VK_INSTANCE_LAYERS`-free log with
|
||||
`DXVK_LOG_LEVEL=info` and `DXVK_HDR=1`, will say whether the colour space was accepted. **Start
|
||||
here** - it is the cheapest signal and needs no HDR display at all.
|
||||
|
||||
### 7.2 Platform-side verification (not by eye)
|
||||
|
||||
Order of proof, each step gating the next:
|
||||
|
||||
1. **Compositor log.** New `banner_log("color", ...)` lines: which primaries/tf we advertised and
|
||||
why; every image description created (primaries, tf, max_cll, max_fall); every dataspace set on
|
||||
the layer. This is the only step that works on any device.
|
||||
2. **Guest log.** `DXVK_HDR=1` + `DXVK_LOG_LEVEL=info` → DXVK prints the chosen colour space and
|
||||
format at swapchain creation. If `SetColorSpace1` was refused, DXVK says so. Mesa's WSI prints
|
||||
`Not using HDR metadata to avoid protocol errors` when `is_hdr_metadata_legal` rejects metadata
|
||||
- catching that string is a direct test of A3's metadata plumbing.
|
||||
3. **`dumpsys SurfaceFlinger | grep banner_wayland_game`.** Today it prints
|
||||
`dataspace=UNKNOWN (0) hdr metadata types=0`. Success = `dataspace:0x9c60000` (`BT2020_PQ`, 163971072 =
|
||||
`STANDARD_BT2020|TRANSFER_ST2084|RANGE_FULL`) and `hdr metadata types=` non-zero. **Also record `composition:` on the same line**
|
||||
- if it flips DEVICE → CLIENT, HDR cost us the zero-copy win (§3.4) and that is a failure, not a
|
||||
success.
|
||||
4. **`dumpsys display`.** On whatever display is in use: `supportedHdrTypes=[...]` non-empty,
|
||||
`colorMode` ≠ 0, `hdrSdrRatio` ≠ `not_available`. This is the gate for steps 5–6; on the Pocket
|
||||
FIT's internal panel it will never pass.
|
||||
5. **`dumpsys SurfaceFlinger` Wide-Color section.** `Current color mode` should move off
|
||||
`ColorMode::NATIVE (0)`, and the output line's `dataspace=`/`targetDataspace=` should stop being
|
||||
`UNKNOWN (0)`.
|
||||
6. **Only then, by eye.**
|
||||
|
||||
### 7.3 Getting a display that can actually show it - three options, ranked
|
||||
|
||||
1. **External HDR display over USB-C (recommended, cheapest).** The app already reparents the game
|
||||
into a `Presentation` on a secondary display -
|
||||
`app/src/main/java/com/winlator/star/display/ExternalDisplayController.java`,
|
||||
`findPresentationDisplay()` picks a `DISPLAY_CATEGORY_PRESENTATION` display that is not the
|
||||
activity's own. In the Wayland session the compositor's `SurfaceView` is the reparented view, so
|
||||
`sc_layer.c`'s `ASurfaceControl_createFromWindow(vkp_window(), ...)` follows it automatically -
|
||||
**no new plumbing to test on an external panel.** Risk: `Display.getHdrCapabilities()` for a
|
||||
DP-alt-mode display depends on the vendor HWC reading the external EDID, and empty lists for
|
||||
genuinely HDR10 monitors are a common Snapdragon gap (§3.6). **Step zero is a five-minute,
|
||||
zero-code test: plug an HDR monitor in and run `dumpsys display | grep -A2 hdrCapabilities`.**
|
||||
If the external display reports HDR types, everything else follows. If it does not, options 2
|
||||
and 3 are all that remain.
|
||||
2. **The user's Galaxy Fold (Adreno 840).** Samsung foldables/flagships do report HDR10+ on the
|
||||
internal panel, so this is the most likely device to show real HDR with no extra hardware. But
|
||||
the Fold is *already* the outstanding unknown for basic Wayland bring-up (checkpoint item 1:
|
||||
"Other GPUs… the Galaxy Fold (A840) logs are still outstanding"). Chasing HDR there before
|
||||
plain Wayland is proven there inverts the priority order.
|
||||
3. **Plumbing-only verification on SDR.** Everything in §7.2 steps 1–3 works on the Pocket FIT.
|
||||
This proves the code is *correct* but proves nothing about whether it *looks right* - and
|
||||
tone-mapping quality is the entire user-visible payload of HDR. **Do not ship on this evidence
|
||||
alone.**
|
||||
|
||||
---
|
||||
|
||||
## 8. Verdict
|
||||
|
||||
**Not now. Do the 10-bit slice; park HDR behind the Fold.**
|
||||
|
||||
The reasoning, plainly:
|
||||
|
||||
* **The effort is genuinely small and smaller than we thought.** ~4.5 days to a plumbed HDR10
|
||||
path, **app-only, no new wcp** - because Mesa's colour-management client is already inside the
|
||||
shipped wcp v6 driver and our zero-copy WSI patch already maps 10-bit to gralloc. The phase-4
|
||||
memory note saying HDR "does need a new wcp" should be corrected: only scRGB/FP16 zero-copy (C1)
|
||||
does, and that is optional.
|
||||
* **But the payload is zero on the only device we can test on.** The Pocket FIT reports no HDR
|
||||
types, no colour modes but NATIVE, no HDR/SDR ratio and no brightness nits. We could not see the
|
||||
feature, could not tune the tone-mapping, and could not tell a correct implementation from a
|
||||
subtly wrong one. Shipping HDR proven only by log lines would be the exact opposite of this
|
||||
project's "device-proven, not CI-green" rule.
|
||||
* **On this device it would be a net negative if enabled.** §3.4: an HDR-dataspace layer on a
|
||||
display with no HDR types drops from DEVICE to CLIENT composition. That trades the measured
|
||||
zero-copy win (−4 pts GPU busy, −3 % power) for a GPU tone-map pass and a washed-out image. A
|
||||
capability gate (A5) prevents the harm, but a gate that is always closed on our test hardware is
|
||||
a feature nobody can exercise.
|
||||
* **It is also out of order.** The pre-4 checkpoint's ranked list puts "other GPUs" and "prove
|
||||
zero-copy where it can win, then make it the default" first, with multi-layer presentation in
|
||||
flight. HDR competes with those for the same `sc_layer.c` surface area - and multi-layer will
|
||||
rewrite exactly the code A4 touches. Landing HDR into `sc_layer.c` now means merging it twice.
|
||||
|
||||
**What the user would actually see for the effort, honestly:**
|
||||
|
||||
* **Today, Pocket FIT, full HDR (7.5 days):** nothing. Identical picture, plus a `dumpsys` line
|
||||
that says `BT2020_PQ`, plus a risk of losing zero-copy.
|
||||
* **Today, Pocket FIT, the 10-bit slice only (A1 + B1, ~1 day):** *slightly* smoother gradients in
|
||||
dark scenes and skyboxes for games that offer a 10-bit swapchain, and a plausible route to
|
||||
retiring the debanding pass (one fewer fullscreen pass) for those games. Small, real, and it is
|
||||
the foundation Phase A sits on anyway - so it is not wasted work if HDR later goes ahead.
|
||||
* **Later, on an HDR display (external monitor or the Fold), full Phase A:** genuinely brighter
|
||||
highlights and a wider gamut in the ~15 PC titles that implement HDR properly, on the one Android
|
||||
emulator that can do it. That is a real headline feature and a real differentiator - but only
|
||||
once there is a panel to show it on.
|
||||
|
||||
**Recommended sequence:**
|
||||
|
||||
1. **Five minutes, zero code:** plug an HDR monitor into the Pocket FIT's USB-C and run
|
||||
`dumpsys display | grep -A2 hdrCapabilities`. This single reading decides everything -
|
||||
it is the difference between "we can develop and prove HDR today on hardware we own" and
|
||||
"HDR is blocked on the Fold."
|
||||
2. **~1 day, independent of HDR:** the 10-bit slice (A1 + B1), gated and A/B'd for banding and
|
||||
perf. Ship it as a banding/quality improvement on its own merits.
|
||||
3. **Park Phase A (A2–A5)** until either step 1 comes back positive or the Fold is in the loop
|
||||
and plain Wayland is proven there. Revisit *after* multi-layer presentation lands, so the
|
||||
`sc_layer.c` dataspace work is written once against the final layer API.
|
||||
|
||||
---
|
||||
|
||||
## 9. What I could not establish, and why
|
||||
|
||||
* **Whether FP16 (`R16G16B16A16_FLOAT`) AHardwareBuffers are allocatable on this SoC.** Not in the
|
||||
vendor gralloc strings, and I did not install or run a probe binary (other agents are testing on
|
||||
the device). Decides whether scRGB zero-copy (C1) is possible at all. Cheap to settle later with
|
||||
a one-call `AHardwareBuffer_isSupported()` check inside the compositor's existing startup log.
|
||||
* **Whether an external HDR display over USB-C actually reports HDR types on the Pocket FIT.**
|
||||
No external display was connected and I did not connect one. This is the single highest-value
|
||||
unknown in the whole report (§8, step 1).
|
||||
* **Whether an HDR-tagged layer really drops DEVICE → CLIENT on this HWC.** The AOSP code path is
|
||||
clear (§3.4) but it is vendor-HWC behaviour and I did not test it - testing it would mean
|
||||
launching a game, which was out of scope.
|
||||
* **What the Galaxy Fold (Adreno 840, Android version unknown to me) reports.** Not connected.
|
||||
Its `getHdrCapabilities()` and `isHdrSdrRatioAvailable()` are the other decisive readings.
|
||||
* **Exact Mesa line numbers.** Cited against `wsi_common_wayland.c` fetched from
|
||||
`gitlab.freedesktop.org/mesa/mesa` at `7cda7850edd103ace21aac37d416d2fdf7a282e1`, the commit in
|
||||
`banners-turnip-wayland/mesa_hash.txt`. They are accurate for that file; they will drift if the
|
||||
Turnip base moves.
|
||||
* **DXVK/vkd3d-proton line numbers** are cited against upstream `doitsujin/dxvk@8759acd1` and
|
||||
`HansKristian-Work/vkd3d-proton@2a38e799` (master as of today), not against the exact
|
||||
v3.1-gplasync / 3.1.0-wave64-relax builds we ship. The feature presence in *our* builds was
|
||||
verified independently by `strings` on the device (§1.1); the line numbers are for orientation.
|
||||
* **Whether any of the games in §7.1 launch and run correctly under our Wayland backend at all.**
|
||||
Untested. Picking the HDR test title is its own small exercise and should start from games
|
||||
already known to work in a Wayland container.
|
||||
|
||||
---
|
||||
|
||||
## 10. Round 1 - as built (2026-09-14, `feat/wayland-hdr`)
|
||||
|
||||
Phase A (A1–A5) for HDR10, opt-in, proven by the app's own logs because the first HDR panel in the
|
||||
loop is a tester's non-rooted Galaxy Fold (Adreno 840, Android API 37: HDR10/HLG/HDR10+, 1351 nits,
|
||||
HDR/SDR ratio available). App-only; no layer change.
|
||||
|
||||
### 10.1 The gate (`wl_color_mgmt.c`, `banner_color_init`)
|
||||
|
||||
Open only when ALL hold when the compositor starts:
|
||||
|
||||
| Input | Where it comes from |
|
||||
|---|---|
|
||||
| `BANNER_WAYLAND_HDR=1` (or `true`/`on`) | container or shortcut env (`XServerDisplayActivity.resolvedWaylandHdrMode`) |
|
||||
| the game's display lists **HDR10** | `DisplayHdrInfo.supportsHdr10` for `hdrTargetDisplay()` (the TV when the game is on it) |
|
||||
| display layers with dataspace control | `sc_layer_can_tag_hdr()` = SurfaceControl API + `ASurfaceTransaction_setBufferDataSpace` |
|
||||
| the zero-copy global | `ahb_swapchain_advertised()` |
|
||||
|
||||
`BANNER_WAYLAND_HDR=force` skips the display row only (testing the negotiation on an SDR panel -
|
||||
SurfaceFlinger then tone-maps the layer). A **closed** gate advertises nothing (no
|
||||
`wp_color_manager_v1` global, no 10-bit dma-buf rows), writes `color … HDR gate CLOSED: <why>` and
|
||||
`HDR on screen: no, because <why>`, and the session is otherwise byte-for-byte the old one. With the
|
||||
switch unset the module is silent unless the display lists HDR10 or `DXVK_HDR=1` is set (one hint line).
|
||||
On an HDR10 display the app also turns **zero-copy presentation on** for the session (an HDR frame is
|
||||
only right on the game's own layer) and exports `BANNER_WSI_AHB=1` to match.
|
||||
|
||||
### 10.2 What an open gate offers (§2.2, exactly what Mesa binds)
|
||||
|
||||
`wp_color_manager_v1` **version 1**: intent `perceptual`; features `parametric` +
|
||||
`set_mastering_display_primaries` (Mesa only sends mastering luminance inside that feature's branch);
|
||||
primaries `bt2020`; transfer `st2084_pq`. Not advertised: `srgb` (so Mesa creates no colour surface for
|
||||
SDR swapchains at all), HLG (no DXGI swapchain uses it), `ext_linear`/`extended_target_volume` (scRGB =
|
||||
Phase C, needs FP16 gralloc + a layer change), ICC, `windows_scrgb`. `zwp_linux_dmabuf_v1` gains
|
||||
`AB30`/`XB30` (A2B10G10R10 - Mesa lists a VkFormat only when both the alpha and opaque fourcc are there),
|
||||
appended after the four 8-bit rows. Protocol glue generated from wayland-protocols **1.41** with
|
||||
wayland-scanner 1.24.0 (`protocols/color-management-v1.xml`).
|
||||
|
||||
Strictness: protocol errors only where a conforming client can never hit them (un-advertised
|
||||
feature/intent/primaries/tf, a property set twice, an incomplete set, a not-ready description).
|
||||
Everything a conforming client can produce on a bad day is logged and absorbed instead - out-of-range
|
||||
HDR metadata is dropped from what Android gets (and `max_cll`/`max_fall` of 0 = "unknown", which Mesa
|
||||
sends as-is), a second colour surface for the same `wl_surface` replaces the first, requests on inert
|
||||
objects are ignored. `ready` is sent from inside `create` (Mesa blocks its swapchain on it).
|
||||
Output and surface-feedback image descriptions answer `failed` (Mesa uses neither).
|
||||
|
||||
### 10.3 The layer path (`sc_layer.c`)
|
||||
|
||||
`setBufferDataSpace` (+ `setHdrMetadata_smpte2086` / `_cta861_3` when present) ride the same
|
||||
transaction as the buffer, per layer, only on change, re-sent on every new SurfaceControl (recovery
|
||||
swap, window change). A layer that was never tagged is never touched; once tagged, an untagged frame
|
||||
puts it back to `UNKNOWN` and clears the metadata. The zero-copy frame gets the surface's current
|
||||
description; the 8-bit pool copy of an HDR frame keeps the PQ tag (colours right, 8-bit precision,
|
||||
logged once); the effects pass and the overlay layer are always untagged.
|
||||
|
||||
### 10.4 When an HDR frame cannot be zero-copy - round 1's decision
|
||||
|
||||
No tone-mapping anywhere (the copy path is `vkCmdBlitImage` into an 8-bit sRGB swapchain; a PQ→SDR
|
||||
shader pass is its own project). Instead:
|
||||
|
||||
- **Effects on / frame generation on:** an HDR fullscreen game **keeps its display layer**; the
|
||||
effects chain and frame generation are skipped for it (both are 8-bit SDR and would wash it out),
|
||||
and the base surface's black frame skips them too (`vkp_render_plain`) so generated presents cannot
|
||||
pace the loop. Logged when it starts and when it ends.
|
||||
- **A window above the game on a display that cannot compose a second layer** (phase 5's
|
||||
rotated+scaled rule is untouched), **a windowed HDR game**, **zero-copy switched off**, **layer
|
||||
unavailable:** the scene goes through the copy path and the HDR frames are shown **untone-mapped
|
||||
(washed out)**; every such scene is counted and the reason logged.
|
||||
|
||||
### 10.5 Proof without root - what a tester's logs say
|
||||
|
||||
`Download/Wayland-logs/wayland-*.log`, tag `color` (plus the existing `display` capability line):
|
||||
|
||||
| Line | Means |
|
||||
|---|---|
|
||||
| `HDR gate OPEN: … "Built-in Screen" … reports HDR types HDR10, …` | the offer is on, with its inputs and which Android colour calls exist |
|
||||
| `<program> bound wp_color_manager_v1 version 1 …` | Mesa saw it: `VK_COLOR_SPACE_HDR10_ST2084_EXT` is now listed |
|
||||
| `<window>: colour-management surface created` | a swapchain asked for a non-sRGB colour space |
|
||||
| `image description #N from <program>: BT.2020, ST 2084 (PQ); mastering …; max CLL …, max FALL …` | the game's colour space + `vkSetHdrMetadataEXT` values |
|
||||
| `<window> presents WxH buffers in XB30 (10-bit A2B10G10R10) …, gralloc RGBA1010102 (10-bit)` | the 10-bit swapchain really arrived |
|
||||
| `banner_wayland_game: dataspace BT2020_PQ (0x9c60000) set on the display layer …` | the tag + metadata SurfaceFlinger received |
|
||||
| `HDR last 10 s: N frames on the display layer tagged BT2020_PQ …` | steady state |
|
||||
| `display HDR/SDR ratio X (was Y; HDR frames on screen: yes …)` | `Display.getHdrSdrRatio()` - rises above 1.00 only when Android grants HDR headroom |
|
||||
| `HDR on screen: yes / tagged but NOT confirmed / no, because …` | the verdict (written on changes, at game exit and at session end; the last one counts) |
|
||||
|
||||
DXVK's own `<exe>_dxgi.log` / `<exe>_d3d11.log` add `Color space: VK_COLOR_SPACE_HDR10_ST2084_EXT` and
|
||||
the 10-bit format at swapchain creation; Mesa's `Not using HDR metadata to avoid protocol errors`
|
||||
(stderr → `wine_debug.log`) means the game's metadata failed Mesa's own legality check.
|
||||
|
||||
## 11. Round 2 - as built (2026-09-14, `feat/wayland-hdr`; CI only, not device-proven yet)
|
||||
|
||||
Round 1 is proven on the tester's Fold (God of War, DXVK v3.1: 10-bit zero-copy, `BT2020_PQ` on the
|
||||
layer, HDR/SDR ratio 1.00 → 2.51, `HDR on screen: yes`). Round 2 makes it a setting and removes
|
||||
round 1's big limitation (§10.4: no tone-mapping, effects/frame generation skipped, washed-out copies).
|
||||
The gate itself is unchanged (§10.1) apart from its first input.
|
||||
|
||||
### 11.1 The setting, and what the launch does with it
|
||||
|
||||
- `Container.isWaylandHdr()` (extraData `waylandHdr`) and the shortcut extra `waylandHdr` (`1`/`0`/unset
|
||||
= the container's) - one resolver, `display/WaylandHdr.effective()`, used by the three editors
|
||||
(container, game shortcut, XMB game settings) and the launch path. `BANNER_WAYLAND_HDR` in the
|
||||
environment still overrides it (`1` / `0` / `force`). Editors grey the row with the reason when the
|
||||
built-in screen does not list HDR10 (`WaylandHdr.unavailableReason`), and say it applies from the
|
||||
next launch.
|
||||
- HDR on for the session (setting resolved on AND HDR10 display, or `force`): `DXVK_HDR=1` is exported
|
||||
after the user's env merge unless the user set `DXVK_HDR` themselves.
|
||||
- Every Wayland session whose display lists **HDR10** exports `BANNER_WAYLAND_HDR_MAX_NITS`,
|
||||
`…_MAX_AVG_NITS`, `…_MIN_NITS` (decimal nits from `Display.getHdrCapabilities()`; unknown values and
|
||||
a max of 0 are left out) - the contract the Wayland layer from versionCode 10 turns into EDID HDR
|
||||
metadata for DXGI (checked by the Wine side against DXVK's own EDID parser: the Fold's 1351/1351/0 →
|
||||
DXGI max 1345.43, max full-frame 1345.43, min 0.01). Never on an SDR display, whose EDID would then
|
||||
claim PQ support. One `session environment:` line logs them. Android has no live absolute nits; the
|
||||
live evidence stays the HDR/SDR ratio.
|
||||
- A "DXVK before 3.0 needs layer v10" warning was built and then **removed**: its premise was wrong
|
||||
(Wine already exposes `VK_EXT_swapchain_colorspace` at instance level; DXVK ≥ 2.1 looks for it in the
|
||||
device list, logs 0 on every Mesa system - the working v3.1 run logs 0 too - and never uses it for the
|
||||
HDR decision). Why God of War offered no HDR option on DXVK 2.4.1 is open (Fold A/B).
|
||||
|
||||
### 11.2 HDR-aware composition (`hdr_compose.c`, `hdr_encode.frag`, compositor.c `hdr_plan`)
|
||||
|
||||
Every scene with an HDR draw takes one of four routes (logged on each change, with the reason):
|
||||
|
||||
| Route | When | What reaches the display |
|
||||
|---|---|---|
|
||||
| 0 | the HDR game alone fullscreen, zero-copy on, no effects, nothing above it (or one window the overlay layer can carry) | the game's own 10-bit gralloc frames on its layer, `BT2020_PQ` (round 1) |
|
||||
| 1 | effects on, a window above on a display that cannot take a second layer, a windowed game, zero-copy off | **the HDR picture**: every draw blitted 1:1 into a 10-bit "mixed" image (each pixel still in its own encoding), ONE encode pass to PQ BT.2020 (SDR pixels sRGB → linear → BT.2020, placed at 203 nits, `BANNER_WAYLAND_HDR_SDR_NITS` overrides), the effects chain run on it in 10-bit (`vkp_effects_set_formats`), copied into a 10-bit (`RGBA1010102`) layer buffer, tagged with the game's description; 8-bit buffers if gralloc refuses 10-bit (logged) |
|
||||
| 2 | frame generation on | composed into PQ in the 8-bit scene image, through the frame-generation engine, presented through an **HDR10 swapchain** (`VK_EXT_swapchain_colorspace` enabled on the compositor instance in HDR sessions only; first `A2B10G10R10` else FP16 pair the surface lists with `HDR10_ST2084`); where the surface lists none, **tone-mapped** into the ordinary swapchain. 8 bits through the engine: gradients may band |
|
||||
| 3 | an HDR frame the compositor could not import | layer only; effects/frame generation skipped for it (round 1's behaviour, now the rare case) |
|
||||
|
||||
The tone map (mode 1 of `hdr_encode.frag`): PQ → nits → BT.2020 → BT.709, relative to SDR white, maxRGB
|
||||
curve linear to a 0.8 knee then an exponential roll-off that puts the content peak (the game's max CLL,
|
||||
else mastering max, else 1000 nits) at 98 %, sRGB-encoded. Which pixels are HDR comes from up to six
|
||||
top-first scene rects + a mask in the push constants (more windows over the game → the lowest are
|
||||
treated as SDR, logged once). A composed frame that cannot reach the layer falls back to route 2 for as
|
||||
long as that lasts (logged once each way).
|
||||
|
||||
### 11.3 The live switch (lead's addition): drawer → Graphics → "HDR output"
|
||||
|
||||
Shown only while the gate is open (the activity's 1 s HDR sampler flips `waylandHdrAvailable`). Per
|
||||
session, starts on, nothing saved. Off (`banner_color_set_output(0)`, posted through the host queue
|
||||
to the compositor thread, logged, redraw): route 0 becomes route 1 with the tone map, the picture goes
|
||||
on the layer **untagged** on 8-bit buffers (the layer's dataspace returns to `UNKNOWN`); route 2
|
||||
rebuilds the swapchain as SDR and tone-maps; route 3 cannot (nothing can read those frames) and stays
|
||||
HDR, said in the log. The game is told nothing (its DXVK_HDR and colour-manager offer were decided at
|
||||
launch). The HUD reads `Wayland · HDR` while HDR frames are on screen (tagged in the last 1.5 s and
|
||||
ratio > 1.01 where reported), `Wayland · HDR off` while the switch is off. Frames tone-mapped by the
|
||||
switch are counted separately in the verdict.
|
||||
|
||||
### 11.4 Live headroom (lead's addition after the second Fold run)
|
||||
|
||||
The Fold's second run (brightness slider at maximum) showed the ratio at 3.23 for a minute and then
|
||||
**1.00 for 3+ minutes with HDR frames on screen the whole time**, while the verdict kept saying "rose to
|
||||
3.23". Android simply gave the layer no headroom (SDR white already at the panel's limit). Now, from the
|
||||
ratio samples the app already sends (1 s + the display listener): the time with HDR frames on screen and
|
||||
the part of it with ratio > 1.01; a no-headroom streak (HDR frames on screen, ratio <= 1.01) that after
|
||||
5 s writes `no HDR headroom for 5 s … the screen brightness is probably at maximum …` (and `HDR headroom
|
||||
is back …` when it ends), tags the 10 s line and the steady ratio line, puts the HUD on `Wayland · HDR
|
||||
(no headroom)` and the drawer row on the same hint (`banner_color_hdr_state` = 2); and the verdict
|
||||
reports `headroom above 1.00 for P% of the HDR time (a of b s), now R` plus the hint while the streak
|
||||
lasts. Cheap: no new sampling, a few integers under the existing lock.
|
||||
|
||||
**Round-2 Fold run (20:23, run 34911468350):** the first no-headroom streak was a **screen recording**,
|
||||
not the brightness: the user's recording (210.8 s, BT.709 SDR H.264, finalised 20:27:30, so started
|
||||
~20:23:59) lines up with the ratio falling 2.98 → 1.00 at 20:24:00.9 and staying there to the end. So
|
||||
Android (at least Samsung's) turns HDR headroom off while the screen is recorded. Every hint now names
|
||||
both causes; whether maximum brightness does it too is still unmeasured (tester note step H).
|
||||
|
||||
### 11.5 The HUD's HDR line (round 2b, the user's layout request)
|
||||
|
||||
`40.3ms · Wayland · HDR (no headroom)` ran off the pill's right edge (the capsule's ends curve in, and the
|
||||
latency line is the bottom of the stack). The display-server label is plain `Wayland` again, and the HDR
|
||||
state is a line of its own directly under it, fed as a `FusionHdr` code (`FusionHudView.setHdrState`):
|
||||
`HDR` / `HDR (no headroom)` / `HDR off` / `HDR tone-mapped` (switch on, frame generation without an HDR10
|
||||
swapchain) / `HDR ready` (gate open, no HDR frames right now). `NONE` - every session whose gate is
|
||||
closed - draws nothing, so those HUDs are unchanged to the pixel. Pill: the extra line makes the capsule
|
||||
taller and its ends rounder, so in HDR sessions `fitCapsule()` measures every glyph's ink against the
|
||||
two rounded ends (plus the outline and 2 sp) and shifts/widens the pill until nothing crosses. Full: an
|
||||
`HDR` row under `DISP`; Tiles: the *Display* tile's sub-line; Minimal: a line under the footer; Mega: a
|
||||
fragment after `DISP` in the bottom band. Only the Fusion HUD shows the display server at all; the
|
||||
classic, GameHub and GameNative HUDs never received it and are untouched.
|
||||
|
||||
### 11.6 Round 2d (Fold evidence from 2c)
|
||||
|
||||
- **The format scan was a false negative.** FG with the AIO HDR card logged `lists no HDR10 swapchain
|
||||
format (37/0 37/1000104001 … 37/1000104012)`: `swap_init` read the surface's list into
|
||||
`VkSurfaceFormatKHR fmts[32]`, and with `VK_EXT_swapchain_colorspace` the Android WSI lists every
|
||||
format once per colour space (11 on the Fold) - the 10-bit/FP16 rows were past entry 32, and the log's
|
||||
160-char buffer cut the list too. Now: all pairs read (heap), one line with the total, colour spaces
|
||||
per format and every HDR-capable pair (10-bit/FP16 first); HDR10 pick A2B10G10R10 → A2R10G10B10 →
|
||||
FP16 → 8-bit; a driver that refuses the HDR10 swapchain gets the SDR one at once. The only other
|
||||
fixed array near it (queue families, 16) does not hold surface formats or present modes; FIFO is the
|
||||
only present mode used, so no list is read.
|
||||
- **VK_EXT_hdr_metadata** enabled in HDR sessions where the device lists it; the HDR10 swapchain gets the
|
||||
game's SMPTE 2086 / CTA-861.3 once per swapchain and image description (none if the game sent none).
|
||||
- **FP16 frame generation for HDR.** The bridge and both engines already take a format (the chain,
|
||||
the ring); lsfg-vk itself runs HDR in `R16G16B16A16_SFLOAT`. With HDR frames through an HDR10
|
||||
swapchain the scene (`g_fgscene`), the encode pass (new FP16 output), the effects and the engine run
|
||||
in FP16 where `fge_format_ok` (storage + linear sampling) passes; the PQ signal is fed as-is (the
|
||||
shaders' own HDR flags stay off - PQ is already perceptual). A chain that fails to build in FP16 is
|
||||
sticky inside both engines, so the bridge refuses the format for the session and restarts the engine
|
||||
in 8 bits instead of marking frame generation failed.
|
||||
- **Evidence beside the headroom** (app, non-root): PowerManager thermal status (+ listener) and
|
||||
`getThermalHeadroom(10)` every 10 s, `Settings.System` brightness + mode (+ observer), screenshots
|
||||
(`registerScreenCaptureCallback`, API 34) and screen recording (`addScreenRecordingCallback`, API 35,
|
||||
reflection) with the normal `DETECT_SCREEN_CAPTURE` / `DETECT_SCREEN_RECORDING` permissions. Changes
|
||||
are logged; the no-headroom lines, the 10 s line and the verdict carry the values and name the likely
|
||||
cause (recording / hot / brightness at maximum) or say none is visible. The verdict lists only the
|
||||
paths that carried frames, to leave room for it.
|
||||
|
||||
### 11.7 Round 2e (headroom request, display ceiling, coverage)
|
||||
|
||||
2d on the Fold: all pass (68 surface pairs, HDR10 swapchain A2B10G10R10, FP16 FG at 120 fps, 0 tone-mapped,
|
||||
VK_EXT_hdr_metadata, headroom 3.00 at brightness 255 **auto**). A tester's ROG Phone 9 Pro (Adreno 830,
|
||||
API 36): correct HDR frames, composed path, HDR layer ~80% of the 2400x1080 screen (windowed card), not
|
||||
hot, not recording, brightness 8-223 manual - and the HDR/SDR ratio at **1.00 the whole session**.
|
||||
- `ASurfaceTransaction_setDesiredHdrHeadroom` (API 35) on the game layer, per HDR frame, only on change:
|
||||
content peak (max CLL, else mastering max, else the display's peak) / SDR white, capped at the display's
|
||||
highest ratio; 0 (no preference) when the layer stops carrying HDR; re-sent on a fresh SurfaceControl.
|
||||
The screen surface (HDR10 swapchain for frame generation) gets the same through
|
||||
`SurfaceView.setDesiredHdrHeadroom` (API 35, reflection; `SurfaceControl.Transaction` fallback), applied
|
||||
by the app from `banner_color_screen_headroom()`.
|
||||
- `Display.getHighestHdrSdrRatio()` (Android 16+, reflection) in the display line and the verdict; 1.00
|
||||
means no boost at all, and the request is then not made.
|
||||
- HDR layer coverage in the dataspace line, the composed-picture line and on geometry changes.
|
||||
- "brightness at maximum" only for MANUAL >= 250/255; no visible cause -> "this phone may not boost HDR
|
||||
from apps (the requested headroom was …, the display's highest ratio is …)".
|
||||
|
||||
### 11.8 What is still NOT done / not proven
|
||||
|
||||
- Round 2's composition, switch and HUD line were device-tested in parts on the Fold (setting-driven
|
||||
gate, auto environment, composed picture, headroom lines, HUD); 2d is CI only.
|
||||
- The engines' own HDR flags (lsfg `hdr_support` / FSR3 HDR input) stay off: the PQ signal is treated as
|
||||
perceptual SDR-like data. If interpolation artefacts show only in HDR, that is the next thing to try.
|
||||
- Colour effects (brightness/contrast/saturation/gamma, the "HDR" bloom) operate on the PQ signal in
|
||||
route 1 and look stronger than in SDR; sharpening/AA/CRT/upscalers are perceptually fine.
|
||||
- scRGB (FP16) swapchains (Phase C) - unchanged: needs FP16 gralloc and a layer change.
|
||||
@@ -1,472 +0,0 @@
|
||||
# Bannerlator Wayland runtime (feat/wayland-runtime)
|
||||
|
||||
Experimental **parallel** display runtime: run/launch games through Wine's
|
||||
`winewayland.drv` talking to our own embedded Wayland compositor, instead of the
|
||||
X11 path (pure-Java X11 server + `libwinlator.so`). The X11 runtime stays the
|
||||
default and is untouched - this is a separate flavor/branch.
|
||||
|
||||
## What's already proven (spike repo `bannerlator-wayland`, device-tested on Adreno 750)
|
||||
- Minimal libwayland-server compositor: globals + xdg-shell handshake + buffer commit.
|
||||
- **Turnip's Vulkan WSI exports real zero-copy dmabufs to our external compositor**
|
||||
(same Mesa path winewayland.drv uses for DXVK/VKD3D) - risk #1 retired.
|
||||
- Compositor imports that dmabuf into its own Turnip VkImage (`vk_import.c`).
|
||||
The staged `src/` here is that proven code, to grow into the app-embedded compositor.
|
||||
|
||||
## Dependencies
|
||||
1. **A Proton 11 arm64ec wcp that ships `winewayland.drv`** - built on branch
|
||||
`The412Banner/proton-wine:feat/winewayland` (task #1). Nothing runs end-to-end
|
||||
without it.
|
||||
2. **Wayland runtime libs in the imagefs** so `winewayland.so` (unixlib) loads:
|
||||
`libwayland-client.so`, `libwayland-egl.so`, `libxkbcommon.so`, `libxkbregistry.so`
|
||||
(bionic aarch64). The wcp bundles them in its `lib/` as a fallback; the clean home
|
||||
is the imagefs - add via `ImageFsInstaller` (new `installWaylandLibs()`), same
|
||||
pattern as `installFFmpeg8()`.
|
||||
|
||||
## Integration plan (M4)
|
||||
- **CMake**: add `waylandcomp` as a native lib (`libbannerwayland.so`) built with the
|
||||
NDK, linking the bionic `libwayland-server`/`libvulkan`. Generate protocol glue from
|
||||
`protocols/*.xml` at build time (host `wayland-scanner`).
|
||||
- **Surface**: a `WaylandDisplayActivity` (parallel to `XServerDisplayActivity`) hosts a
|
||||
`SurfaceView`; JNI hands the `ANativeWindow` to the compositor, which creates a Vulkan
|
||||
swapchain on it and blits the imported game VkImage each frame (the last un-proven
|
||||
render step; standard Vulkan once the window exists).
|
||||
- **Input**: Android `MotionEvent`/`KeyEvent` → `wl_seat`/`wl_pointer`/`wl_keyboard`.
|
||||
- **Launch wiring**: start the compositor, export `WAYLAND_DISPLAY`, select the wayland
|
||||
driver per-prefix (registry `Drivers\Graphics = winewayland`) instead of `winex11`,
|
||||
and point the container at the winewayland wcp.
|
||||
|
||||
## Status
|
||||
- **Native lib foundation done + compile-verified.** Compositor + vk_import + pre-generated
|
||||
protocol glue build as `libbannerwayland.so` (CMake target added), linking the vendored
|
||||
bionic `libwayland-server`. Verified: compiles/links clean as an aarch64 bionic `.so`,
|
||||
exports `banner_wayland_run`, NEEDED = libwayland-server + libvulkan. JNI entry
|
||||
(`waylandcomp_jni.c`) + `WaylandCompositor.java` bring it up on a thread. Compositor-process
|
||||
runtime deps (libwayland-server/libffi/libandroid-support) staged in `jniLibs/arm64-v8a`.
|
||||
- **Next phase (gated on the winewayland wcp landing green):** `WaylandDisplayActivity`
|
||||
(SurfaceView) + JNI `ANativeWindow`→Vulkan swapchain + blit the imported game VkImage to the
|
||||
window (last un-proven render step) + input + launch wiring (start compositor, `WAYLAND_DISPLAY`,
|
||||
per-prefix `Drivers\Graphics=winewayland`) + `ImageFsInstaller.installWaylandLibs()` (client/egl/xkb
|
||||
into the imagefs for winewayland.so).
|
||||
|
||||
## Pointer lock / relative mouse (zwp_pointer_constraints_v1 + zwp_relative_pointer_manager_v1)
|
||||
- Both globals are advertised (version 1); glue is pre-generated in `generated/` with
|
||||
`wayland-scanner server-header` / `private-code` from `protocols/*-unstable-v1.xml` (same
|
||||
scanner version as the rest, 1.24.0), no build-time scanner needed.
|
||||
- winewayland's use: `ClipCursor`/fullscreen + hidden cursor → `lock_pointer` (persistent);
|
||||
`SetCursorPos` → lock + `set_cursor_position_hint` + commit + unlock; visible cursor +
|
||||
`ClipCursor(rect)` → `confine_pointer` with a one-rectangle region. It only turns relative
|
||||
motion on for a window its `wl_pointer` has entered, so **pointer focus follows an active
|
||||
constraint** (the desktop surface gets `leave`, the constrained surface `enter`) and goes
|
||||
back to the desktop on the next motion after the constraint ends.
|
||||
- Lock: the pointer is frozen; every input delta becomes `relative_motion` (no `motion`).
|
||||
Confine: absolute motion is clamped to surface ∩ region. The position hint is applied on
|
||||
the surface's commit (pointer moves there) and is where the pointer stays when the lock ends.
|
||||
One constraint holds at a time (a newer request ends the older one); oneshot constraints
|
||||
are defunct after ending, persistent ones re-take hold on focus re-entry.
|
||||
- App side: input type 6 = relative delta (1/256 px); `banner_on_pointer_lock(locked, x, y)`
|
||||
tells Java to switch its touch/mouse path to deltas (`XServer.setExternalRelativeMode`) and,
|
||||
on unlock, to re-sync the X pointer (the absolute input's source) to x,y. With Wayland mode
|
||||
`WinHandler.mouseEvent` routes relative-mode input to the compositor instead of the guest.
|
||||
- Session log tag `pointer`: `lock requested by …`, `locked: … frozen at x,y`, `unlocked: … (why)`,
|
||||
`confined: …`, `unconfined: …`, `relative pointer created for …`, `position hint: …`.
|
||||
Test FIFO gained `rel DX DY`.
|
||||
|
||||
## Screen surface, swapchain recovery (vk_present.c)
|
||||
- The app's UI thread never waits on the renderer: `vk_present_set_window()` only leaves the new
|
||||
`ANativeWindow` (or NULL) in a request slot; the compositor thread applies it before its next
|
||||
frame and on every vsync tick (`vkp_apply_window_request`), tearing the old swapchain down and
|
||||
releasing the old window's reference. Acquire waits at most 1 s, never forever.
|
||||
- OUT_OF_DATE / SURFACE_LOST on acquire or present rebuild the swapchain (once inline on acquire, so
|
||||
the frame isn't lost). SUBOPTIMAL is presented as is: the swapchain uses IDENTITY preTransform on
|
||||
purpose, so a rotated panel reports it on every frame. DEVICE_LOST logs
|
||||
`GPU device lost … the compositor has stopped presenting` once and stops touching the device;
|
||||
clients keep being paced (`pace_without_output`) so they don't wedge.
|
||||
- A swapchain that can't be created is retried every 0.5 s and logged once per streak.
|
||||
|
||||
## Buffer lifetime, FPS limiter (compositor.c)
|
||||
- `struct dmabuf_buffer` is reference counted: one ref for the wl_buffer resource, one per surface
|
||||
showing it. Mesa destroys a swapchain's wl_buffers when the game rebuilds its swapchain, while the
|
||||
last committed one is still on screen; the surface keeps the import (`s->dmabuf_buf`) until its
|
||||
next commit, so the window neither blinks to black nor is unmapped/remapped ("closed"/"opened").
|
||||
- Surface destroy releases its buffer immediately (`drop_dmabuf(s, 0)`), replaced buffers go back on
|
||||
the limiter's cadence. The per-surface release schedule is bounded: a slot in the past is brought
|
||||
to now, and the schedule never runs further ahead than `(releases still pending + 1)` intervals.
|
||||
|
||||
## Fullscreen mode + screen alignment
|
||||
- `WaylandCompositor.nativeSetScaleMode(fullscreenMode, screenAlignment)` takes the app's
|
||||
`Container.FULLSCREEN_OFF/FIT/STRETCH/FILL/INTEGER` (0..4) and `ALIGN_CENTER/TOP/BOTTOM` (0..2),
|
||||
any thread, any time (the drawer changes it live). `update_map()` in vk_present.c is a line-for-line
|
||||
mirror of `ViewTransformation.update()`: OFF and FIT both letterbox (OFF only differs in the app's
|
||||
fullscreen gates), TOP/BOTTOM confine the picture to the top/bottom half of the output (the
|
||||
handheld split), FILL overflow is clipped to the region. The app maps touch through the same class
|
||||
(TouchpadView → X server → input sink → scene input), the overlay arrow through
|
||||
`waylandSceneToView`, and the SurfaceView's own INPUT_SPACE touches through `vkp_output_to_scene`.
|
||||
- Log tag `screen`: `<mode>, <alignment>: WxH scene shown WxH at x,y on the WxH output` whenever the
|
||||
mapping changes (mode, alignment, scene or output size).
|
||||
|
||||
## Clipboard, text input, window icons (feat/wayland-clipboard-ime)
|
||||
Three globals winewayland used to complain about at startup, each in its own file behind the
|
||||
small interface in `src/banner_ext.h` (compositor.c only gained hook calls + accessors):
|
||||
- **Clipboard** (`src/wl_clipboard.c`): `wl_data_device_manager` **v3** and
|
||||
`zwlr_data_control_manager_v1` **v1**, selection only (start_drag is refused with `cancelled`).
|
||||
Wine prefers data-control: its desktop process then owns the clipboard without needing keyboard
|
||||
focus and the "clipboard functionality will be limited" ERR goes away too. One shared selection:
|
||||
a program's source, or text from Android (`nativeSetClipboardText`), or nothing. Data-control
|
||||
devices hear every change; `wl_data_device`s only while their client holds keyboard focus (hook in
|
||||
`keyboard_focus()`), and on focus arrival. Guest → Android: a selection with a text mime is read
|
||||
once over a pipe on the event loop (1 MiB cap, 3 s timeout) → `banner_on_clipboard_text` →
|
||||
`WaylandClipboardSync` → `ClipboardManager`. Android → guest: `OnPrimaryClipChangedListener` plus a
|
||||
re-read on resume / window focus (Android hides clipboard changes from background apps); `receive`
|
||||
is served by a non-blocking writer. Echo guard on both sides. Log tag `clipboard`.
|
||||
- **Text input** (`src/wl_text_input.c`): `zwp_text_input_manager_v3` **v1**, double-buffered state,
|
||||
`done(serial)` only when we sent events. winewayland enables text input for whichever surface gets
|
||||
`enter` and posts IME updates inside *that window's process*, so text input can't follow keyboard
|
||||
focus (the desktop surface): it follows the last clicked program window (`g_ime_click`, set in
|
||||
`pointer_input`), else the topmost non-shell window. `set_cursor_rectangle` arrives when an edit
|
||||
control calls `ImmSetCompositionWindow`; the app (`WaylandTextInput`) shows the soft keyboard only
|
||||
then (and with no hardware keyboard), hides it on disable unless the user's own toggle opened it.
|
||||
Typed text: `SurfaceInputView`'s `InputConnection` → `commit_string` / `preedit_string` + `done`.
|
||||
Deletions become Backspace/Delete key presses - winewayland's `delete_surrounding_text` handler is
|
||||
empty. Log tag `text-input`.
|
||||
- **Window icons** (`src/wl_toplevel_icon.c`): `xdg_toplevel_icon_manager_v1` **v1**, accepts and
|
||||
drops icons (`done` at bind, no sizes).
|
||||
- Host → compositor text crosses as UTF-8 `byte[]` through `banner_ext.c`'s queue (mutex + wake
|
||||
pipe), drained on the compositor thread. Glue for the three protocols is pre-generated with
|
||||
wayland-scanner 1.24.0 from `protocols/`.
|
||||
|
||||
## Compressed (UBWC) game buffers (feat/wayland-ubwc)
|
||||
- `zwp_linux_dmabuf_v1` used to advertise `LINEAR` (+`INVALID`) only, so Turnip's Wayland WSI in the
|
||||
game allocated linear swapchain images and DXVK/VKD3D resolved every frame from their tiled/UBWC
|
||||
render targets into that linear copy, before the compositor's own blit. Now `bind_dmabuf` builds
|
||||
its table at the first bind from the renderer's driver (`vkp_dmabuf_modifiers`):
|
||||
`vkGetPhysicalDeviceFormatProperties2` + `VkDrmFormatModifierPropertiesListEXT` per fourcc
|
||||
(AR24/XR24/AB24/XB24), each modifier confirmed with `vkGetPhysicalDeviceImageFormatProperties2`
|
||||
for a dma-buf-backed `TRANSFER_SRC` image. Only `DRM_FORMAT_MOD_LINEAR` (0) and
|
||||
`DRM_FORMAT_MOD_QCOM_COMPRESSED` (`0x0500000000000001`, UBWC, single memory plane on Adreno) are
|
||||
ever advertised; anything else the driver reports goes to logcat as "not advertised".
|
||||
- Mesa's WSI hands every advertised+supported modifier to `vkCreateImage` as a modifier *list* and
|
||||
Turnip picks `QCOM_COMPRESSED` whenever it is in the list (`tu_image.cc`), so with the
|
||||
advertisement the game's swapchain is UBWC. The `wl_buffer` then arrives with that modifier and
|
||||
the pixel-plane pitch/offset from the game's `vkGetImageSubresourceLayout(MEMORY_PLANE_0)`;
|
||||
`vkp_image_import_dmabuf` creates the compositor's image with the explicit modifier and that one
|
||||
plane layout (`VkImageDrmFormatModifierExplicitCreateInfoEXT`), which Turnip validates
|
||||
(`INVALID_DRM_FORMAT_MODIFIER_PLANE_LAYOUT` if the pitch does not fit its `fdl6` alignment). The
|
||||
swapchain blit (`vkp_render`) and the layer-mode blit (`vkp_blit_image`) read it like any other
|
||||
source; nothing CPU-side ever touches a dma-buf. Layer mode still blits into its own gralloc pool.
|
||||
- **A/B switch:** `BANNER_WAYLAND_UBWC=0` (or `false`/`off`) in the container's/shortcut's
|
||||
environment variables → `nativeSetUbwc(false)` → linear-only advertisement, exactly the old
|
||||
behaviour. Default on. The driver is still queried, so the log shows what it could have done.
|
||||
- Log tag `dmabuf`: `formats: AR24 linear+qcom_compressed, XR24 …, AB24 …, XB24 …` at the first
|
||||
bind (with ` (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)` when off, or a second line
|
||||
`the compositor's driver (…) reports no importable qcom_compressed layout: game swapchains stay
|
||||
linear` when the adrenotools Turnip lacks it). Per window, the existing `vulkan` line now names
|
||||
the modifier: `… is presenting GPU frames through Wayland: 1920x1080, format XB24, qcom_compressed
|
||||
(zero-copy)` vs `… linear (zero-copy)`. Import failures are `error` lines naming the stage,
|
||||
modifier, size, pitch and VkResult (`dmabuf: vkCreateImage(qcom_compressed, …) -> …`).
|
||||
- Caveat: both sides must agree on the UBWC encoding for the GPU. The game runs the wcp's Wayland
|
||||
Turnip, the compositor the user's adrenotools Turnip; the layout code (`fdl6`) is the same, but a
|
||||
very different Mesa version on one side is the first suspect if a compressed frame imports fine
|
||||
yet looks scrambled - `=0` is the workaround, and the `gpu` line names both the GPU and the
|
||||
compositor's driver.
|
||||
|
||||
## Phones with no DRM node (OpenGL) - `BANNER_WAYLAND_NO_RENDER_NODE=1`
|
||||
- The feedback's `main_device` is the first of `/dev/dri/renderD128`, `renderD129`, `card0` the app
|
||||
can `stat`; retail phones (Adreno 830/840 reports, 2026-09-14) expose none, so it is `0:0`.
|
||||
Vulkan games do not care. Mesa's EGL did: its Wayland DRM initialiser failed the display in
|
||||
`dri2_setup_device()` without a render node and fell back to a software path that has no
|
||||
rasteriser in these layers - native OpenGL black, sound playing. Wayland layer versionCode 9
|
||||
(Banners-Turnip `patches/wayland/egl_wayland_no_drm_node.py`) runs zink + kopper on the Vulkan
|
||||
device without a node instead; nothing app-side is needed for that.
|
||||
- `BANNER_WAYLAND_NO_RENDER_NODE=1` (container or shortcut env) makes the compositor advertise
|
||||
`main device 0:0` on a device that does have a node, to reproduce those phones here.
|
||||
- Log lines: `dmabuf` `… no display (DRM) device …: OpenGL games need Wayland layer versionCode 9
|
||||
or newer …` when the main device is 0:0; `opengl` `<program> asked for GPU buffers but has drawn
|
||||
only software (shared-memory) frames …` once per program that asked for dma-buf feedback (EGL's
|
||||
GPU path always does) and then committed 150 wl_shm frames without making a dma-buf buffer.
|
||||
|
||||
## Zero-copy window layers (spike, `BANNER_WAYLAND_ZERO_COPY=1`)
|
||||
Research + host-side prototype in `ZERO_COPY_SPIKE.md`: why a dma-buf can't become an
|
||||
`AHardwareBuffer` (the game's buffers are DMA-heap allocations, not gralloc's), why the interop
|
||||
must start from a gralloc buffer whose native-handle fd the drivers import (Turnip's own AHB path),
|
||||
and the WSI patch this needs in our Wayland Turnip. `src/sc_layer.c` implements the receive side:
|
||||
with the variable in the container's environment, one fullscreen game window is shown on its own
|
||||
`ASurfaceControl` child of the SurfaceView (pool of three compositor-allocated AHBs, one blit,
|
||||
release fences from `setOnComplete`, geometry from the fullscreen mode); any other scene falls back
|
||||
to the swapchain blit. Log tag `layer`. Off by default.
|
||||
|
||||
## Zero-copy game frames (`BANNER_WAYLAND_ZERO_COPY=1`, drawer switch live)
|
||||
Option (a') of `ZERO_COPY_SPIKE.md`, both halves:
|
||||
- **Guest (our Wayland Turnip, banners-turnip-wayland `patches/wayland/banner_ahb_wsi.py`, all
|
||||
variants):** while the compositor's mode is on (see the live switch below), the Wayland
|
||||
WSI allocates every swapchain image as a gralloc `AHardwareBuffer` (usage GPU_SAMPLED_IMAGE |
|
||||
GPU_FRAMEBUFFER | COMPOSER_OVERLAY; UBWC when gralloc picks it and a test `vkCreateImage` with the
|
||||
explicit `QCOM_COMPRESSED` layout succeeds, else linear via a CPU usage bit; `BANNER_WSI_AHB_LINEAR=1`
|
||||
forces linear), imports the handle's dma-buf with an explicit modifier + pitch (the compositor's own
|
||||
pool-import path; `vk_android.c` is not in a platforms=wayland build), shares it through
|
||||
`zwp_linux_dmabuf_v1` as before (blit fallback), and sends the `AHardwareBuffer` once per image:
|
||||
`socketpair` + `AHardwareBuffer_sendHandleToUnixSocket` + `banner_ahb_v1.attach(wl_buffer, fd, w, h,
|
||||
stride, modifier hi/lo, image_count)`. Surface formats are limited to `R8G8B8A8_UNORM/SRGB` (and
|
||||
`A2B10G10R10`) while the mode is on: gralloc has no BGRA (DXVK/Zink blit into the swapchain image
|
||||
anyway). Marker: `strings libvulkan_freedreno_wayland*.so | grep banner_ahb_v1`.
|
||||
- **Compositor (`src/ahb_swapchain.c`, protocol `protocols/banner-ahb-v1.xml`):** the global is
|
||||
advertised on every session where a display layer is possible at all (`sc_layer_available()`);
|
||||
whether gralloc buffers are WANTED right now is the `mode` event, not the global's presence.
|
||||
`attach` → `AHardwareBuffer_recvHandleFromUnixSocket`, record on the `dmabuf_buffer`.
|
||||
When that buffer is the one fullscreen frame (`layer_candidate`, or the layer-only candidate when
|
||||
this renderer could not import it), `sc_layer_present_ahb` sets it on the `banner_wayland_game`
|
||||
SurfaceControl as is. **Acquire fence** = `DMA_BUF_IOCTL_EXPORT_SYNC_FILE(READ)` of the dma-buf: Mesa
|
||||
imports the render fence into the dma-buf (`wsi_signal_dma_buf_from_semaphore`) *before* the
|
||||
`wl_surface.commit`, so the export always carries the frame; if the ioctl fails the compositor
|
||||
polls the dma-buf instead (logged once). **Release**: `setOnComplete` →
|
||||
`getPreviousReleaseFenceFd` → `DMA_BUF_IOCTL_IMPORT_SYNC_FILE(READ)` into the dma-buf → then
|
||||
`wl_buffer.release` (still on the FPS limiter's cadence); Mesa's acquire
|
||||
(`wsi_create_sync_for_dma_buf_wait`) exports every fence of the dma-buf, so the game waits for the
|
||||
display. If the import fails the release waits on the fence fd in the event loop instead. A hidden
|
||||
or retired layer gets a 16x16 blank buffer so SurfaceFlinger actually lets go of the game's buffer.
|
||||
- **App:** `BANNER_WAYLAND_ZERO_COPY=1` (container/shortcut env) is the session's STARTING state and
|
||||
also exports `BANNER_WSI_AHB=1` into the guest (`XServerDisplayActivity.isWaylandZeroCopyRequested`),
|
||||
which is what an older driver (bound at `banner_ahb_v1` version 1) needs.
|
||||
|
||||
### The live switch ("Zero-copy presentation", Graphics tab)
|
||||
`banner_ahb_v1` version 2 adds one event, `mode(enabled)`, sent to every bound client on bind and
|
||||
again on every flip. The compositor owns the state:
|
||||
- `WaylandCompositor.nativeSetZeroCopy(on)` is live and callable from any thread. Before the
|
||||
compositor starts it just seeds `g_zero_copy`; afterwards it posts through the `banner_ext.c` host
|
||||
queue (like the clipboard/IME setters) so `ahb_swapchain_set_mode()` runs on the compositor thread,
|
||||
which flips `g_zero_copy`, broadcasts `mode`, flushes the clients and redraws.
|
||||
- **Guest:** the WSI follows `display->banner_ahb_mode`, decided per swapchain at creation. When a
|
||||
live swapchain was built for the other mode, the next `vkAcquireNextImageKHR` /
|
||||
`vkQueuePresentKHR` returns `VK_ERROR_OUT_OF_DATE_KHR` (it reuses Mesa's own `chain->retired`
|
||||
early-outs), which DXVK, vkd3d-proton and Zink all answer by rebuilding the swapchain. The mode
|
||||
event is read with a non-blocking dispatch of the display queue on each acquire/present, because
|
||||
in MAILBOX that queue is otherwise only dispatched when the acquire loop runs out of images.
|
||||
- **Compatibility, both directions.** The registry bind clamps to `MIN(advertised, 2)` - binding
|
||||
above the advertised version is a fatal `wl_display` error, and a Bannerlator from before the live
|
||||
switch advertises version 1. On version 1, and on version 2 before the first `mode` event arrives,
|
||||
the driver keeps the original contract exactly: gralloc images iff `BANNER_WSI_AHB=1`, decided per
|
||||
swapchain, and no swapchain is ever retired. Once the mode is in play `BANNER_WSI_AHB=1` no longer
|
||||
forces it on (the app exports that on every zero-copy launch, which would freeze the switch);
|
||||
`BANNER_WSI_AHB=0` still forces the whole feature off.
|
||||
- **No black frame, either way.** Switching OFF does not tear down anything: the old gralloc chain's
|
||||
frames keep being shown - on the layer when this renderer could not import them, through the copy
|
||||
path when it could - until the game has rebuilt, and only then does `sc_layer_hide()` run. Buffers
|
||||
already on the layer keep their deferred release (`ahb_swapchain_defer_release` is no longer gated
|
||||
on `g_zero_copy`, or the display would be handed a buffer it is still scanning out). Switching ON,
|
||||
the layer path resumes as soon as the first `attach` of the new chain lands.
|
||||
- **Drawer:** the toggle applies live *and* writes the env as the next launch's default. Its status
|
||||
line follows the compositor, not the switch: `nativeZeroCopyLastFrameAgeMs()` (updated on every
|
||||
zero-copy present, unlike the 10 s counter) is what makes it say "Switching on…" / "Switching off…"
|
||||
only for as long as it really is.
|
||||
- **Log lines (tag `layer`):** `zero-copy: banner_ahb_v1 version 2 advertised …`, `zero-copy: <exe>
|
||||
bound banner_ahb_v1 version N …`, `zero-copy: on|off at launch …`, `zero-copy switched on|off from
|
||||
the drawer: N bound programs told to rebuild their swapchains …`, `zero-copy: AHB swapchain from <exe> (N images, WxH, UBWC (QCOM_COMPRESSED)|linear,
|
||||
stride S px)`, `zero-copy: presenting "<title>" (<exe>) without a copy`; the 10 s `stats` line ends
|
||||
with `| N zero-copy frames`. Guest side (Mesa log, stderr of the game): `banner-ahb: WxH swapchain
|
||||
(N images) on gralloc buffers: UBWC|linear, stride S px` or the reason it stayed on standard
|
||||
buffers, plus `banner-ahb: compositor wants gralloc|standard swapchain images` and `banner-ahb:
|
||||
zero-copy switched on|off: retiring the WxH swapchain …` on a live flip.
|
||||
|
||||
## Multi-layer presentation (feat/wayland-multilayer, `src/sc_layer.c`)
|
||||
Layer mode hands the scene to SurfaceFlinger as a small, deliberately ordered **set** of Android
|
||||
display layers instead of one. The whole model, top to bottom:
|
||||
|
||||
```
|
||||
app window ....... Compose UI, the in-game drawer, the perf HUD, the on-screen controls, the
|
||||
pointer arrow - ordinary Android views in the activity's window, ALWAYS on top
|
||||
+-- SurfaceView .. the compositor's Vulkan swapchain (black while layer mode is up)
|
||||
+-- z=1 "banner_wayland_game" ...... the one fullscreen game window
|
||||
+-- z=2 "banner_wayland_overlay" ... at most ONE window drawn above the game
|
||||
```
|
||||
|
||||
- **Two layers is the hard cap** (`SC_LAYER_COUNT`). HWC composes only a few layers before
|
||||
SurfaceFlinger falls back to GPU client composition, which would throw the whole benefit away, so
|
||||
the compositor never asks for a third: a scene with two or more windows above the game goes back
|
||||
to the copy path. The count is said out loud the first time the overlay goes up (`layer`: `2
|
||||
display layers in use: "banner_wayland_game" (z=1) and "banner_wayland_overlay" (z=2) above it …`).
|
||||
- **Who owns what.** Each layer has its own `ASurfaceControl`, its own gralloc buffer pool (3
|
||||
buffers each), its own geometry and its own lifetime; `struct layer`
|
||||
in `sc_layer.c` holds all of it and the z-order is fixed by the id (nothing is re-ordered at
|
||||
runtime). The SurfaceControls are children of the SurfaceView's surface, created on first use and
|
||||
retired together when the output window changes or goes away.
|
||||
- **What can be on the game layer**, cheapest first: the game's own gralloc buffer (zero-copy, no
|
||||
copy anywhere - `ahb_swapchain.c`); one blit of the game's frame into a compositor buffer
|
||||
(`sc_layer_present`); or, with screen effects on, the compositor pass's **result** blitted into
|
||||
such a buffer (`sc_layer_present_pass` → `vkp_pass_begin` / `vkp_pass_copy_to`). The last one is
|
||||
what keeps a Look from dropping the session back to the app's swapchain: the scene → output
|
||||
mapping (Fullscreen Mode / Alignment, and the scaling a mode like FSR asks for) is then done by
|
||||
the display through `setGeometry` instead of by a second full-screen GPU blit.
|
||||
- **Present order matters, and it was measured.** The layers go up first; the base surface's black
|
||||
frame is presented *after* the layer transaction, and the effects chain runs in its own submit
|
||||
with **no swapchain image acquired**. A present holds an acquired image and the acquire semaphore
|
||||
is a vblank gate: folding the chain into that submit (one command buffer, one present - the
|
||||
shape that looks cheaper) put the whole 13-pass chain behind a vblank and measured **72 fps**
|
||||
against **111 fps** for the split shape, on HL2 + Retro CRT at 1920x1080 on the Pocket FIT (the
|
||||
copy path is 129 fps on the same scene). Anyone tempted to "optimise" this into one submit should
|
||||
read this paragraph first.
|
||||
- **What goes on the overlay layer**: exactly one draw above the game - a second Wayland toplevel
|
||||
(a launcher or settings window, a message box), copied 1:1 into the overlay pool and then
|
||||
*cropped and placed* by the display through the same `vkp_map_draw` mapping the copy path uses.
|
||||
The point is what does **not** happen: the game's frames are no longer copied through the
|
||||
compositor's swapchain just because something small sits on top of them.
|
||||
- **Transparency.** The compositor composes with blits, which overwrite - it never alpha-blends,
|
||||
on either path - so every layer is marked `ASC_TRANSPARENCY_OPAQUE` and the overlay layer is
|
||||
cropped to the window it carries. There is no blending to get wrong: no black box over the game,
|
||||
no double-darkening, and the picture is the copy path's, pixel for pixel.
|
||||
- **Input is untouched.** An `ASurfaceControl` has no input channel, so neither layer can take a
|
||||
touch: everything still reaches the app's `SurfaceView` (and the views above it) exactly as
|
||||
before, and the scene mapping touch goes through (`vkp_output_to_scene` / `ViewTransformation`)
|
||||
is the same one the layers are placed with, so the cursor still lands where it is pointing. The
|
||||
SurfaceView is not Z-on-top, so its whole subtree - both layers included - stays under the
|
||||
activity's own window: the drawer, the HUD and every Compose dialog draw above them.
|
||||
- **What cannot be layered, and why.**
|
||||
- *Frame generation*: each generated frame needs a present of its own on consecutive vblanks, and
|
||||
a display layer latches one buffer per refresh - pacing that is the swapchain's job. While an
|
||||
engine is armed the whole scene takes the copy path (unchanged).
|
||||
- *Effects with a window above the game*: the chain has to see the whole scene to look the way it
|
||||
does on the copy path (and on X11), and the game layer only carries the game. Copy path.
|
||||
- *Two or more draws above the game* (several windows, or a window with subsurfaces): a third
|
||||
layer is not worth the client-composition risk. Copy path.
|
||||
- **Lifecycle.** Hiding is top-down so nothing is uncovered for a frame; a hidden or retired layer
|
||||
gets the shared 16x16 blank buffer so SurfaceFlinger really lets go of the game's buffer (and
|
||||
reports a release fence for it) instead of holding it while invisible. `sc_layer_hide()` takes
|
||||
every layer down (scene no longer a fullscreen game, zero-copy switched off live, session end),
|
||||
`sc_layer_hide_overlay()` is used when only the window above the game closed, and
|
||||
`sc_layer_window_gone()` retires both SurfaceControls and drains both pools on a surface loss /
|
||||
HOME / resume. **The overlay layer is always RETIRED, never merely hidden** (both paths) - see
|
||||
the HWC note below: a live second SurfaceControl keeps SurfaceFlinger composing on the GPU even
|
||||
when it is invisible. The game layer keeps its SurfaceControl through a hide, because it goes up
|
||||
and down with every effects / frame-generation toggle.
|
||||
- **The display frame-rate vote belongs to one layer.** The refresh-rate stream's
|
||||
`sc_layer_set_frame_rate()` (the game's cadence, the same value the app votes on its own surface)
|
||||
is carried **only** by the layer the game presents on - the game layer. The overlay layer is
|
||||
explicitly voted `0`, so a window that redraws once a second can never hold the panel at the
|
||||
game's cadence, nor drag the game's cadence down to its own. Each layer remembers what its live
|
||||
SurfaceControl carries (`fps_applied`) and re-applies on the next transaction, and a retired
|
||||
SurfaceControl resets it so a re-created layer is re-voted from scratch. Log: `display frame-rate
|
||||
vote on banner_wayland_game: 60.00 Hz`.
|
||||
- **Stats.** The 10 s `stats` line now ends with `| N zero-copy frames` (the game's own buffers) and
|
||||
`| N layer frames` (frames the compositor put on a layer through one of its own buffers - the
|
||||
plain layer blit or the effects result). Both are hardware-composed; only the first is copy-free.
|
||||
- **Measured on the Pocket FIT (Adreno 750, portrait panel, landscape session → every layer is
|
||||
ROT_90 + scaled), `dumpsys android.hardware.graphics.composer3.IComposer/default`:** one layer is
|
||||
`composition: DEVICE/DEVICE` (game's own gralloc buffer scanned out by the DPU), with effects on
|
||||
the layer it stays `DEVICE/DEVICE`, but **two** layers flip the whole frame to `DEVICE/CLIENT` -
|
||||
SurfaceFlinger composes it on the GPU - **and it does not come back when the overlay goes away**:
|
||||
retiring the overlay's SurfaceControl (which the compositor now does rather than merely hiding
|
||||
it) leaves the frame in client composition; only re-creating the *game* layer's SurfaceControl
|
||||
clears it (HOME + resume does, reproduced twice). Retiring is still right - one fewer live layer,
|
||||
and it leaves the HWC list - it is just not the whole cure; the open follow-up is to retire and
|
||||
immediately re-create the game layer when the overlay goes, which would cost one black frame
|
||||
unless the new SurfaceControl is shown before the old one is dropped. The likely mechanism is the
|
||||
DPU's rotator budget (one rotated+scaled layer), not the layer count as such, so a device or
|
||||
orientation that needs no rotation may well take both on the DPU. Even in client composition the
|
||||
overlay layer is not a loss (SurfaceFlinger does the one blit the compositor would have done), but
|
||||
the hardware-composition win is only real for the single-layer cases. Note `VRI[ScreenDecorHwcOverlay]`
|
||||
is always `DISPLAY_DECORATION/CLIENT` (the system's rounded corners) - that is why SurfaceFlinger's
|
||||
`clientCompositionFrames` counter reads 100 % on this device in every state and is useless here.
|
||||
- **Not done yet:** a second toplevel that is itself rendering into gralloc buffers could go on the
|
||||
overlay layer zero-copy too (the token machinery in `ahb_swapchain.c` is already per-buffer, not
|
||||
per-layer); today the overlay always costs one small blit.
|
||||
|
||||
## Screen effects (feat/wayland-effects, `src/effects_chain.c`)
|
||||
The X11 renderer's screen-effect chain, run by the compositor between the composited scene and the
|
||||
output. Same SPIR-V as the X11 Vulkan renderer (`winlator/*_frag.h`, made by `winlator/gen_shaders.sh`
|
||||
and committed - the NDK build compiles no GLSL, so there is no build-time shader tooling), same
|
||||
push-constant layouts, uniform ranges and pass order, so a saved preset looks the same on both backends.
|
||||
|
||||
**Present-pass hook order (`vk_present.c`, `vkp_render`):**
|
||||
```
|
||||
scene (every draw blitted 1:1 into a scene-sized R8G8B8A8 image; compositor pass only)
|
||||
-> vkp_effects_run() effects_chain.c: scaling -> effects (below)
|
||||
-> vkp_framegen_run() framegen_bridge.c: frame generation on the chain's result (next section)
|
||||
-> update_map / blit Fullscreen Mode + Screen Alignment map the result (and each generated
|
||||
frame) onto its own swapchain image
|
||||
-> present generated frames first, the real frame last, FIFO
|
||||
```
|
||||
The compositor pass runs when either stage needs it (`vkp_effects_active() || vkp_framegen_active()`).
|
||||
Effects operate on the scene; the mapping operates on the output - the chain's result stands for
|
||||
the whole scene and goes through the same `draw_to_blit` as a plain frame. With everything off the
|
||||
frame takes the old path unchanged (draws blitted straight through the mapping); only the blit filter
|
||||
follows the scaling mode (Nearest = `VK_FILTER_NEAREST`).
|
||||
|
||||
**Pass order (locked to the X11 Vulkan renderer, `VulkanRendererContext::recordUpscalePasses`):**
|
||||
scaling → FXAA → Toon → Colour (brightness/contrast/gamma/saturation) → CAS → HDR → NTSC → CRT →
|
||||
Debanding (terminal dither). Scaling modes: 0 None / 1 Linear / 2 Nearest = a filtered resize;
|
||||
3 SGSR, 8 SGSR HQ, 7 NIS = one pass; 4 FSR / 5 FSR Fit = EASU → RCAS (fill vs fit is the Fullscreen
|
||||
Mode's business here, both run the same pair); 6 Sharpen = RCAS 1:1 (nearest scale + sharpen, as X11).
|
||||
Render scale is "Not used on Wayland": the scaling modes resize the scene to its **mapped output size**
|
||||
(3/4/5/7/8 only when that is larger than the scene, the X11 "render below display" gate; 6 always).
|
||||
Every later pass runs at that resolution, ping-ponging between two targets (+ one EASU mid target).
|
||||
|
||||
**Settings** (`WaylandCompositor.nativeSetUpscaler / nativeSetUpscaleSharpness / nativeSetCas /
|
||||
nativeSetHdr / nativeSetDeband / nativeSetScreenEffects / nativeSetLookName`, 1:1 with
|
||||
`VulkanRenderer`'s): any thread; the compositor thread snapshots them per frame, redraws even a
|
||||
static scene, and writes one `effects` line per change:
|
||||
`effects scaling=SGSR HQ 75%, CAS on 55%, Look="Game Clarity", colour b=+2 c=+12 g=1.00 s=108%`
|
||||
(`effects all off: scaling=Linear (plain blit)` when nothing is on). The chain's Vulkan objects are
|
||||
built on the first active frame (`effects chain ready: 13 passes … on <GPU>`; a driver that refuses
|
||||
them logs an `error` and effects stay off for the session). The app wires the drawer's Vulkan
|
||||
post-chain block (`XServerDialogState.on*Apply`) to these in `XServerDisplayActivity.initWaylandEffects`
|
||||
and remembers the same per-game keys as the Vulkan path (#382).
|
||||
|
||||
**Zero-copy interplay:** the chain's result no longer has to land in the app's swapchain - with a
|
||||
fullscreen game alone on screen it is copied into the game layer's own gralloc buffer and the game
|
||||
**stays on its display layer** while a Look is applied (see "Multi-layer presentation" below). It
|
||||
still takes the copy path when a window is drawn above the game (the chain has to see the whole
|
||||
scene to look as it does on X11) - `effects zero-copy paused: a window above the game needs the
|
||||
compositor pass for the whole scene` - and always while frame generation is armed
|
||||
(`framegen zero-copy paused: frame generation needs the compositor pass`); the layer comes back with
|
||||
`effects zero-copy resumed: the game is back on its own display layer`. A frame this renderer could
|
||||
not import (layer-only candidate) still goes on the layer, effects/frame generation skipped, said once.
|
||||
|
||||
## Frame generation (feat/wayland-framegen, `src/framegen_bridge.c`)
|
||||
The two **native** engines the X11 renderer hosts inside `libwinlator`'s compositor run inside the
|
||||
Wayland compositor's own Turnip device: **LSFG Native** (the user's Lossless.dll chain,
|
||||
`winlator/lsfg`) and **Win-FG Native** (our FSR3-derived optical-flow chain, `winlator/winfg`).
|
||||
The engine sources are compiled into `libbannerwayland.so` unmodified (CMake, app build only; the
|
||||
standalone build gets `framegen_engine_stub.c`) and driven by `src/framegen_engine.cpp`, which fills
|
||||
their `VkTable` by name from the compositor's `vkGetInstanceProcAddr`/`vkGetDeviceProcAddr`.
|
||||
**bionic-fg** (the guest-side win-fg Vulkan layer) is X11-only: its frames are born inside the guest
|
||||
and have nothing to attach to here, so on Wayland the "bionic" engine is always Win-FG Native.
|
||||
- **Where it runs** (the hook order above): frame generation takes the effects chain's RESULT
|
||||
(`vkp_effects_run`'s output, at its size - the scene, or the mapped output size under a scaling
|
||||
mode - moved to GENERAL), so generated frames carry the same look as real ones. The ONE hook
|
||||
`vkp_framegen_run(cmd, image, view, w, h, fmt, gens[])` records the engine's chain into the same
|
||||
command buffer and returns 0..3 generated images. Each image is then blitted through the scene ->
|
||||
output mapping into its own acquired swapchain image and presented: **generated frames first, the
|
||||
real frame last** (interpolation produces frames between N-1 and N; that one output interval of
|
||||
latency is inherent). One acquire/render-done semaphore pair and command buffer per present
|
||||
(`MAX_PRESENTS` = 4), the frame fence on the last submit. With the FIFO swapchain the presentation
|
||||
engine shows them on consecutive vblanks: that is the pacing, there are no sleeps and no host pacer.
|
||||
The swapchain is rebuilt with `multiplier - 1` extra images while armed so every present of a frame
|
||||
queues without a vblank wait. Frame generation off and effects off = the direct path, byte-for-byte
|
||||
as before.
|
||||
- **Device**: `dev_init` asks the bridge for the LSFG feature chain (`VkPhysicalDeviceVulkan12Features`
|
||||
vulkanMemoryModel + `shaderStorageImageWriteWithoutFormat/ExtendedFormats`, the same chain
|
||||
`VulkanRendererContext::createLogicalDevice` uses; instance apiVersion is 1.3 for the probe) and
|
||||
retries device creation without it if the driver refuses. Win-FG needs only a storage-capable ring
|
||||
format (RGBA8). The generation ring (`STORAGE|SAMPLED|TRANSFER_SRC|DST`, GENERAL) and the engines'
|
||||
own history live for the session; arming/disarming is a flag, a multiplier change resizes the ring.
|
||||
- **FPS limiter / vsync**: untouched. The limiter still paces the game's buffer returns (real frames);
|
||||
`applyNativeFgLocks` forces it on while multiplying, exactly as on X11 (`pacedLimitWithSlack` still
|
||||
applies through `nativeSetFpsLimit`). Generated frames never touch the limiter, the frame callbacks
|
||||
or the `wp_presentation` feedback - the guest sees one present per real frame.
|
||||
- **Zero-copy**: the layer bypasses the compositor pass, so while frame generation is armed the
|
||||
fullscreen window takes the copy path (`render_scene`: `framegen zero-copy paused: frame generation
|
||||
needs the compositor pass`) and the layer resumes when both it and the effects are off (see the
|
||||
effects section's zero-copy interplay).
|
||||
- **App side**: `WaylandCompositor.nativeSetFrameGenEngine/Armed/Tuning`, `nativeSetLsfgCachePath`,
|
||||
`nativeSetWinFgTuning`, `nativeFrameGenProblem/CapsReason/Stats` (same codes and six-float shape
|
||||
as `VulkanRenderer`). `XServerDisplayActivity.applyWaylandFrameGen` is the single apply:
|
||||
`applyLsfgNative`/`applyWinFgNative` route to it in Wayland mode (launch and drawer), the drawer's
|
||||
`onBionicFgConfigChange` has a Wayland branch, `nativeFgProblemReason`/`startLsfgStatsReadout` read
|
||||
the bridge. Every native setter is a value store, so the launch code may arm before the compositor
|
||||
thread exists.
|
||||
- **Log tag `framegen`**: `engines ready on the compositor's device: LSFG Native available (supported),
|
||||
Win-FG Native available`, `<engine> engine ready (...)`, `<engine> x2 armed (flow scale 0.80, panel
|
||||
120 Hz): ...`, `generating: <engine> x2 at 1920x1080 (1 interpolated frame per game frame)`, the
|
||||
fallbacks (`... can't start: no shader cache`, `... failed to start on the compositor's driver`,
|
||||
`... could not build its chain at WxH`, `the driver refused the LSFG feature set`), `frame generation
|
||||
off (N frames generated this session)`, and the zero-copy pause/resume above. The 10 s `stats` line
|
||||
counts generated frames in `frames on screen` and ends with `| N generated frames`; `GPU frames from
|
||||
games` never includes them.
|
||||
@@ -8,7 +8,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* The X11 Vulkan renderer's SPIR-V, pre-compiled by app/src/main/cpp/winlator/gen_shaders.sh
|
||||
/* The X11 Vulkan renderer's SPIR-V, pre-compiled by app/src/main/cpp/framegen/gen_shaders.sh
|
||||
* (glslangValidator --vn) and committed next to the GLSL: the NDK build compiles no shaders, so
|
||||
* there is no build-time tooling for CI to break. Same bytes as the X11 chain runs. */
|
||||
#include "upscale_vert.h"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#define EFFECTS_CHAIN_H
|
||||
/*
|
||||
* Screen-effect chain for the embedded Wayland compositor: the X11 Vulkan renderer's post
|
||||
* chain (the .frag files in app/src/main/cpp/winlator, VulkanRendererContext::recordUpscalePasses) run
|
||||
* chain (the .frag files in app/src/main/cpp/framegen, VulkanRendererContext::recordUpscalePasses) run
|
||||
* between the composited scene and the output blit, with the same SPIR-V, push-constant
|
||||
* layouts, uniform ranges and pass order, so a saved preset looks the same on both backends.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/*
|
||||
* Frame generation on the Wayland backend (feat/wayland-framegen).
|
||||
*
|
||||
* The two NATIVE engines the X11 renderer runs inside libwinlator's compositor - LSFG Native
|
||||
* The two NATIVE engines the X11 renderer runs inside Bannerlator's X11 compositor - LSFG Native
|
||||
* (the user's Lossless.dll chain, lsfg/) and Win-FG Native (our own FSR3-derived optical-flow
|
||||
* chain, winfg/) - are hosted here inside the Wayland compositor's Turnip device. Both consume
|
||||
* consecutive presented frames and emit interpolated frames between them; the guest never
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// C++ side of the Wayland frame-generation bridge - see framegen_engine.h.
|
||||
//
|
||||
// Hosts the SAME engine objects the X11 renderer drives (lsfg::Engine, winfg::Engine), fed from
|
||||
// the compositor's own Turnip entry points. Nothing in winlator/lsfg or winlator/winfg is
|
||||
// the compositor's own Turnip entry points. Nothing in framegen/lsfg or framegen/winfg is
|
||||
// modified: the engines take a VkTable (VulkanRendererContext.h) for their dispatch, and one is
|
||||
// filled here by name from the compositor's vkGetInstanceProcAddr / vkGetDeviceProcAddr.
|
||||
#include "framegen_engine.h"
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
/*
|
||||
* C face of the C++ frame-generation engines for framegen_bridge.c.
|
||||
*
|
||||
* framegen_engine.cpp wraps lsfg::Engine (winlator/lsfg) and winfg::Engine (winlator/winfg)
|
||||
* framegen_engine.cpp wraps lsfg::Engine (framegen/lsfg) and winfg::Engine (framegen/winfg)
|
||||
* unmodified - the same objects the X11 renderer (VulkanRendererContext) drives - and fills
|
||||
* their dispatch tables from the compositor's own Turnip entry points. The standalone
|
||||
* compositor build (no winlator tree) compiles framegen_engine_stub.c instead, which reports
|
||||
* compositor build (no framegen tree) compiles framegen_engine_stub.c instead, which reports
|
||||
* "engines not built in" and generates nothing.
|
||||
*
|
||||
* Every function runs on the compositor thread.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Stand-in for framegen_engine.cpp when the compositor is built on its own (standalone/, no
|
||||
* winlator tree, no dxbc). Every engine is reported unavailable; nothing is generated. */
|
||||
* framegen tree, no dxbc). Every engine is reported unavailable; nothing is generated. */
|
||||
#include "framegen_engine.h"
|
||||
|
||||
const void *fge_probe(PFN_vkGetInstanceProcAddr gipa, VkInstance inst, VkPhysicalDevice pd,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
/* SPIR-V, pre-compiled (glslangValidator -V hdr_encode.frag --vn hdr_encode_code) and committed next
|
||||
* to the GLSL, like every other shader of the compositor: the NDK build compiles no shaders. The quad
|
||||
* is the effect chain's upscale.vert (same bytes, from app/src/main/cpp/winlator). */
|
||||
* is the effect chain's upscale.vert (same bytes, from app/src/main/cpp/framegen). */
|
||||
/* The headers define plain (external) arrays and effects_chain.c includes upscale_vert.h too: this
|
||||
* translation unit gets its own copy under another name, so the link sees no duplicate symbol. */
|
||||
#define upscale_vert_code hdrc_upscale_vert_code
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.steamdeck.launcher
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import java.io.File
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
@@ -113,6 +114,29 @@ class MainActivity : ComponentActivity() {
|
||||
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = false) }
|
||||
}
|
||||
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
addedGamesArt = SessionPrefs.addedGamesArt(this)
|
||||
refreshAddedGames()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
private var pendingAddedGame: String? = null
|
||||
private val pickAddedGameExe = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val folder = pendingAddedGame ?: return@registerForActivityResult
|
||||
pendingAddedGame = null
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setAddedGameExe(this, folder, path)
|
||||
refreshAddedGames()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
private var addedGamesDirs by mutableStateOf<List<String>>(emptyList())
|
||||
private var addedGamesArt by mutableStateOf(true)
|
||||
@Volatile private var artFetchRunning = false
|
||||
private var addedGames by mutableStateOf<List<com.steamdeck.launcher.ui.AddedGameRow>>(emptyList())
|
||||
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setRomsDir(this, path)
|
||||
@@ -123,6 +147,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var settingsMode by mutableStateOf<String?>(null)
|
||||
private var resolutionCap by mutableStateOf(1080)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var steamChannel by mutableStateOf("publicbeta")
|
||||
private var theme by mutableStateOf("paper")
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
@@ -182,7 +207,7 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onSteamGame = { g ->
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.appId}"))
|
||||
.putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.gameId}"))
|
||||
},
|
||||
onDesktop = {
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
@@ -282,6 +307,9 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refresh()
|
||||
// Added games' art (a store lookup for what the folders lack) starts here, not only when
|
||||
// the cog opens.
|
||||
refreshAddedGames()
|
||||
if (!busy) Thread({ checkCatalog() }, "catalog").start()
|
||||
}
|
||||
|
||||
@@ -369,6 +397,10 @@ class MainActivity : ComponentActivity() {
|
||||
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
|
||||
storageOptions = storageOptions,
|
||||
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
|
||||
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
|
||||
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
|
||||
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
|
||||
addedGamesArt = addedGamesArt,
|
||||
),
|
||||
ModeSettingsActions(
|
||||
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
|
||||
@@ -398,6 +430,15 @@ class MainActivity : ComponentActivity() {
|
||||
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
|
||||
},
|
||||
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
|
||||
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, "Choose a folder of your own games", addedGamesDirs.lastOrNull())) },
|
||||
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
|
||||
onForgetAddedGamesDir = { dir -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs - dir); addedGamesDirs = SessionPrefs.addedGamesDirs(this); refreshAddedGames(); refresh() },
|
||||
onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() },
|
||||
onPickAddedGameExe = { folder ->
|
||||
pendingAddedGame = folder
|
||||
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", folder))
|
||||
},
|
||||
onDismiss = { settingsMode = null },
|
||||
),
|
||||
)
|
||||
@@ -431,10 +472,33 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/** The added games as the settings page lists them; a scan of the folder, on this thread (one level, small). */
|
||||
private fun refreshAddedGames() {
|
||||
addedGames = com.steamdeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.steamdeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
}
|
||||
// Art the games do not have yet, from Steam's store, off the main thread; the rail
|
||||
// redraws when something arrives.
|
||||
if (SessionPrefs.addedGamesArt(this) && !artFetchRunning) {
|
||||
artFetchRunning = true
|
||||
Thread({
|
||||
try {
|
||||
val games = com.steamdeck.launcher.frontend.AddedGames.scan(this)
|
||||
if (com.steamdeck.launcher.frontend.AddedGameArt.fetchMissing(this, games)) ui.post { refresh() }
|
||||
} finally {
|
||||
artFetchRunning = false
|
||||
}
|
||||
}, "added-art").start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openModeSettings(mode: String) {
|
||||
refreshDrivers()
|
||||
resolutionCap = SessionPrefs.resolutionCap(this, mode)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
refreshAddedGames()
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
hdrReason = com.steamdeck.launcher.wayland.HdrSupport.probe(this).reason
|
||||
@@ -570,7 +634,9 @@ class MainActivity : ComponentActivity() {
|
||||
} else null
|
||||
// The libraries, off the main thread: manifests and a folder scan.
|
||||
Thread({
|
||||
val games = if (ready) Library.steamGames(this) else emptyList()
|
||||
val games = if (ready) Library.steamGames(this) + com.steamdeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.steamdeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art -> Library.SteamGame(g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId) }
|
||||
} else emptyList()
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
ui.post { steamGames = games; emulatorList = emus }
|
||||
}, "library").start()
|
||||
|
||||
@@ -178,9 +178,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// Xbox button, or a pad the client hides the controls for, has no other
|
||||
// way to open the client's menu in a game.
|
||||
drawerOpen = false
|
||||
padBridge?.applyTouch { st -> st.setPressed(com.steamdeck.launcher.input.GamepadState.IDX_BUTTON_MODE.toInt(), true) }
|
||||
padBridge?.applyTouch { st -> st.press(com.steamdeck.launcher.input.PadState.GUIDE, true) }
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
padBridge?.applyTouch { st -> st.setPressed(com.steamdeck.launcher.input.GamepadState.IDX_BUTTON_MODE.toInt(), false) }
|
||||
padBridge?.applyTouch { st -> st.press(com.steamdeck.launcher.input.PadState.GUIDE, false) }
|
||||
}, 90)
|
||||
}) else null,
|
||||
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.steamdeck.launcher.audio;
|
||||
import android.os.Process;
|
||||
import android.util.Log;
|
||||
|
||||
import com.steamdeck.launcher.core.EnvironmentComponent;
|
||||
import com.steamdeck.launcher.core.ProcessHelper;
|
||||
import com.steamdeck.launcher.core.SessionPart;
|
||||
import com.steamdeck.launcher.core.HostProcess;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
@@ -28,7 +28,7 @@ import java.util.ArrayList;
|
||||
* picks an audio driver, and reports itself unavailable if not. Ported from Bannerlator (GPL-3.0);
|
||||
* the helper binary is the one built by The412Banner/directaudio's relay workflow.
|
||||
*/
|
||||
public class DirectAudioRelayComponent extends EnvironmentComponent {
|
||||
public class DirectAudioRelayComponent extends SessionPart {
|
||||
private static final String TAG = "DirectAudioRelay";
|
||||
/** The helper, named as a library so Android will run it. */
|
||||
public static final String BINARY = "libdirectaudiorelay.so";
|
||||
@@ -53,7 +53,7 @@ public class DirectAudioRelayComponent extends EnvironmentComponent {
|
||||
@Override
|
||||
public void start() {
|
||||
stop();
|
||||
File binary = new File(context.getApplicationInfo().nativeLibraryDir, BINARY);
|
||||
File binary = new File(app().getApplicationInfo().nativeLibraryDir, BINARY);
|
||||
if (!binary.isFile()) {
|
||||
Log.w(TAG, "helper missing at " + binary + "; DirectAudio will report itself unavailable");
|
||||
return;
|
||||
@@ -97,7 +97,7 @@ public class DirectAudioRelayComponent extends EnvironmentComponent {
|
||||
command.append(" --mic-fifo ").append(micFifoPath.getAbsolutePath());
|
||||
}
|
||||
ArrayList<String> env = new ArrayList<>();
|
||||
env.add("HOME=" + context.getFilesDir());
|
||||
env.add("HOME=" + app().getFilesDir());
|
||||
java.io.PrintWriter out = null;
|
||||
if (logFile != null) {
|
||||
try {
|
||||
@@ -110,8 +110,8 @@ public class DirectAudioRelayComponent extends EnvironmentComponent {
|
||||
}
|
||||
}
|
||||
final java.io.PrintWriter log = out;
|
||||
pid = ProcessHelper.exec(command.toString(), env.toArray(new String[0]),
|
||||
context.getFilesDir(), null,
|
||||
pid = HostProcess.start(command.toString(), env.toArray(new String[0]),
|
||||
app().getFilesDir(), null,
|
||||
line -> {
|
||||
Log.i(TAG, line);
|
||||
if (log != null) synchronized (log) { log.println(line); log.flush(); }
|
||||
|
||||
@@ -5,8 +5,8 @@ import android.os.Process;
|
||||
import android.util.Log;
|
||||
|
||||
import com.steamdeck.launcher.core.FileUtils;
|
||||
import com.steamdeck.launcher.core.EnvironmentComponent;
|
||||
import com.steamdeck.launcher.core.ProcessHelper;
|
||||
import com.steamdeck.launcher.core.SessionPart;
|
||||
import com.steamdeck.launcher.core.HostProcess;
|
||||
import com.steamdeck.launcher.core.TarZst;
|
||||
|
||||
import java.io.File;
|
||||
@@ -26,12 +26,12 @@ import java.util.ArrayList;
|
||||
* recreate - those need the pasink native client, and a session here is a foreground activity
|
||||
* that does not background the way a game container does.
|
||||
*/
|
||||
public class PulseAudioComponent extends EnvironmentComponent {
|
||||
public class PulseAudioComponent extends SessionPart {
|
||||
private static final String TAG = "PulseAudio";
|
||||
/** Where the guest reaches the daemon; the session exports PULSE_SERVER=unix:<this>. */
|
||||
public static final String SOCKET_NAME = "PS0";
|
||||
/** Identifies the bundled pulseaudio.tzst; a change here re-unpacks it over what a device has. */
|
||||
private static final String BUNDLE_STAMP = "2026-09-21-pa13-pipe-modules";
|
||||
private static final String BUNDLE_STAMP = "2026-09-23-pa13-relay-sink-r3";
|
||||
|
||||
private final File workingDir;
|
||||
/** Where the daemon's own output is kept for this session, or null for logcat only. */
|
||||
@@ -43,6 +43,8 @@ public class PulseAudioComponent extends EnvironmentComponent {
|
||||
* turns that one stream into a source the client can see, named DirectAudioMic.
|
||||
*/
|
||||
private final String micFifoPath;
|
||||
/** The DirectAudio relay's socket when the client's output should go through it, else null. */
|
||||
private String relaySocketPath;
|
||||
private int pid = -1;
|
||||
|
||||
public PulseAudioComponent(Context context) {
|
||||
@@ -55,6 +57,15 @@ public class PulseAudioComponent extends EnvironmentComponent {
|
||||
this.micFifoPath = micFifoPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route the daemon's output through the DirectAudio relay at this socket instead of an AAudio
|
||||
* stream of its own. The relay owns the stream outside proot, with its adaptive buffer; the
|
||||
* daemon only fills a shared ring. Set before {@link #start()}; the relay may start later.
|
||||
*/
|
||||
public void setRelaySocket(String path) {
|
||||
this.relaySocketPath = path;
|
||||
}
|
||||
|
||||
/** Send the daemon's output to this file as well as logcat. Set before {@link #start()}. */
|
||||
public void setLogFile(File file) {
|
||||
this.logFile = file;
|
||||
@@ -72,8 +83,8 @@ public class PulseAudioComponent extends EnvironmentComponent {
|
||||
workingDir.mkdirs();
|
||||
FileUtils.chmod(workingDir, 0771);
|
||||
}
|
||||
// The loadable modules (module-aaudio-sink, the native protocol, the pipe modules) ride in
|
||||
// the apk; the daemon and its libraries come from the native library directory, the one
|
||||
// The loadable modules (module-aaudio-sink - the app's own, from tools/aaudio-sink - the
|
||||
// native protocol, the pipe modules) ride in the apk; the daemon and its libraries come from the native library directory, the one
|
||||
// place an app may execute a file from. The bundle is unpacked once per BUNDLE_STAMP, not
|
||||
// once ever: an installed app kept the modules it unpacked on its first run, so a bundle
|
||||
// fixed in a later build never reached the device - which is how a 17.0 glibc build of
|
||||
@@ -86,7 +97,7 @@ public class PulseAudioComponent extends EnvironmentComponent {
|
||||
|| have == null || !BUNDLE_STAMP.equals(have.trim())) {
|
||||
Log.i(TAG, "unpacking pulseaudio.tzst (" + BUNDLE_STAMP + "; had " + have + ")");
|
||||
FileUtils.delete(modulesDir);
|
||||
if (TarZst.extractAsset(context, "pulseaudio.tzst", workingDir)) {
|
||||
if (TarZst.extractAsset(app(), "pulseaudio.tzst", workingDir)) {
|
||||
FileUtils.writeString(stamp, BUNDLE_STAMP);
|
||||
} else {
|
||||
Log.e(TAG, "pulseaudio.tzst did not unpack");
|
||||
@@ -111,8 +122,15 @@ public class PulseAudioComponent extends EnvironmentComponent {
|
||||
+ socket().getAbsolutePath() + "\"");
|
||||
// volume=1.0 is not optional: with no volume argument module-aaudio-sink defaults
|
||||
// the sink to 0% and the session plays silence.
|
||||
config.add("load-module module-aaudio-sink performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink AAudioSink");
|
||||
// The sink's name is what the client's Audio settings show as the output device, so it
|
||||
// says which road the sound takes.
|
||||
if (relaySocketPath != null && !relaySocketPath.isEmpty()) {
|
||||
config.add("load-module module-directaudio-sink sink_name=DirectAudio socket=\"" + relaySocketPath + "\" performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink DirectAudio");
|
||||
} else {
|
||||
config.add("load-module module-aaudio-sink sink_name=AAudioSink performance_mode=1 adaptive=1 volume=1.0");
|
||||
config.add("set-default-sink AAudioSink");
|
||||
}
|
||||
if (micFifoPath != null && !micFifoPath.isEmpty()) {
|
||||
// The format is the helper's, fixed at s16le/48000/mono: it resamples when the device
|
||||
// grants another input rate, so the daemon is never told a rate the bytes are not.
|
||||
@@ -136,7 +154,7 @@ public class PulseAudioComponent extends EnvironmentComponent {
|
||||
// startup: all of it used to reach logcat and nothing else, so a user's folder said nothing
|
||||
// at all about sound. Four separate faults hid behind "no input device" in one night.
|
||||
final java.io.PrintWriter out = openLog();
|
||||
pid = ProcessHelper.exec(command, env.toArray(new String[0]), workingDir, null,
|
||||
pid = HostProcess.start(command, env.toArray(new String[0]), workingDir, null,
|
||||
line -> {
|
||||
Log.i(TAG, line);
|
||||
if (out != null) synchronized (out) { out.println(line); out.flush(); }
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.steamdeck.launcher.core;
|
||||
|
||||
/** One-argument callback, kept so the ported runtime classes read as they did in Bannerlator. */
|
||||
public interface Callback<T> {
|
||||
void call(T value);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ public final class Downloader {
|
||||
* 790 MB download survive a dropped connection instead of starting over.
|
||||
*/
|
||||
public static boolean downloadFile(String url, File destination, boolean resume,
|
||||
Callback<Float> progress) {
|
||||
java.util.function.Consumer<Float> progress) {
|
||||
HttpURLConnection connection = null;
|
||||
long have = resume && destination.isFile() ? destination.length() : 0;
|
||||
try {
|
||||
@@ -87,11 +87,11 @@ public final class Downloader {
|
||||
// enough to move a progress bar smoothly.
|
||||
if (progress != null && written - lastReport > (1 << 20)) {
|
||||
lastReport = written;
|
||||
progress.call(total > 0 ? written / (float) total : -1f);
|
||||
progress.accept(total > 0 ? written / (float) total : -1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (progress != null) progress.call(1f);
|
||||
if (progress != null) progress.accept(1f);
|
||||
return total <= 0 || written >= total;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "download " + url, e);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.steamdeck.launcher.core;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** An ordered NAME=VALUE set, in the shape {@code ProcessBuilder} and proot want it. */
|
||||
public class EnvVars {
|
||||
private final Map<String, String> vars = new LinkedHashMap<>();
|
||||
|
||||
public EnvVars put(String name, String value) {
|
||||
vars.put(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public void putAll(EnvVars other) {
|
||||
if (other != null) vars.putAll(other.vars);
|
||||
}
|
||||
|
||||
public String get(String name) { return vars.get(name); }
|
||||
|
||||
public boolean has(String name) { return vars.containsKey(name); }
|
||||
|
||||
public boolean isEmpty() { return vars.isEmpty(); }
|
||||
|
||||
public String[] toStringArray() {
|
||||
String[] out = new String[vars.size()];
|
||||
int i = 0;
|
||||
for (Map.Entry<String, String> e : vars.entrySet()) out[i++] = e.getKey() + "=" + e.getValue();
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.steamdeck.launcher.core;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
/**
|
||||
* One piece of a running session - the audio daemon, the network-link file, the session process
|
||||
* itself. The session starts them in order and stops them in reverse.
|
||||
*/
|
||||
public abstract class EnvironmentComponent {
|
||||
protected Context context;
|
||||
|
||||
public void setContext(Context context) { this.context = context; }
|
||||
|
||||
public abstract void start();
|
||||
|
||||
public abstract void stop();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.steamdeck.launcher.core
|
||||
|
||||
/** The variables a host process is started with, in the order they were set. */
|
||||
class HostEnvironment {
|
||||
private val entries = LinkedHashMap<String, String>()
|
||||
|
||||
operator fun set(name: String, value: String) {
|
||||
entries[name] = value
|
||||
}
|
||||
|
||||
/** `NAME=VALUE` lines, the shape ProcessBuilder and proot take. */
|
||||
fun asArray(): Array<String> = entries.map { (name, value) -> "$name=$value" }.toTypedArray()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.steamdeck.launcher.core
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* Starts a process on the Android side - proot, the audio daemon, logcat - and hands back its pid.
|
||||
*
|
||||
* The pid matters: stopping a session means killing that exact process (proot runs with
|
||||
* --kill-on-exit, so the guest tree goes with it), and java.lang.Process does not expose it before
|
||||
* API 33, so it is read from the implementation's private field. Output can be drained line by
|
||||
* line to a callback; without one it goes to /dev/null, never to a pipe nobody reads, which would
|
||||
* block the child the moment the pipe filled.
|
||||
*/
|
||||
object HostProcess {
|
||||
private const val TAG = "HostProcess"
|
||||
|
||||
/**
|
||||
* @param command the program and its arguments as one line; a backslash escapes a space, since
|
||||
* paths under /data are full of them.
|
||||
* @param env `NAME=VALUE` lines added to the app's own environment (proot needs nothing from
|
||||
* the inherited set, and the guest starts with `env -i` anyway), or null.
|
||||
* @param onExit told the exit status from a waiting thread, or null.
|
||||
* @param onLine told each line of stdout and stderr, or null to discard both.
|
||||
* @return the pid, or -1 when the process could not be started.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun start(command: String, env: Array<String>?, dir: File?, onExit: Consumer<Int>?, onLine: Consumer<String>?): Int {
|
||||
val argv = split(command)
|
||||
if (argv.isEmpty()) return -1
|
||||
return try {
|
||||
val builder = ProcessBuilder(argv)
|
||||
if (dir != null) builder.directory(dir)
|
||||
env?.forEach { line ->
|
||||
val eq = line.indexOf('=')
|
||||
if (eq > 0) builder.environment()[line.substring(0, eq)] = line.substring(eq + 1)
|
||||
}
|
||||
builder.redirectErrorStream(true)
|
||||
if (onLine == null) builder.redirectOutput(File("/dev/null"))
|
||||
val process = builder.start()
|
||||
val pid = pidOf(process)
|
||||
Log.i(TAG, "started pid $pid: $command")
|
||||
if (onLine != null) {
|
||||
Thread({
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines -> lines.forEach { onLine.accept(it) } }
|
||||
} catch (e: Exception) {
|
||||
// The process ended or the pipe closed; nothing more to read.
|
||||
}
|
||||
}, "proc-out-$pid").apply { priority = Thread.NORM_PRIORITY - 1; isDaemon = true; start() }
|
||||
}
|
||||
if (onExit != null) {
|
||||
Thread({
|
||||
var status = -1
|
||||
try { status = process.waitFor() } catch (e: InterruptedException) { /* reported as -1 */ }
|
||||
onExit.accept(status)
|
||||
}, "proc-wait-$pid").apply { priority = Thread.NORM_PRIORITY - 1; start() }
|
||||
}
|
||||
pid
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "could not start: $command", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
private fun pidOf(process: Process): Int = try {
|
||||
val field = process.javaClass.getDeclaredField("pid")
|
||||
field.isAccessible = true
|
||||
val pid = field.getInt(process)
|
||||
field.isAccessible = false
|
||||
pid
|
||||
} catch (e: Exception) {
|
||||
-1
|
||||
}
|
||||
|
||||
/** Splits a command line on spaces; a backslash keeps the space that follows it. */
|
||||
@JvmStatic
|
||||
fun split(command: String): List<String> {
|
||||
val parts = ArrayList<String>()
|
||||
val current = StringBuilder()
|
||||
var escaped = false
|
||||
for (c in command) {
|
||||
when {
|
||||
escaped -> { current.append(c); escaped = false }
|
||||
c == '\\' -> escaped = true
|
||||
c == ' ' -> if (current.isNotEmpty()) { parts.add(current.toString()); current.setLength(0) }
|
||||
else -> current.append(c)
|
||||
}
|
||||
}
|
||||
if (current.isNotEmpty()) parts.add(current.toString())
|
||||
return parts
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package com.steamdeck.launcher.core;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Starts the session's host processes - proot and the audio daemon - and reports the exit status.
|
||||
* The pid is read out of the hidden field on {@code java.lang.Process} because stopping a session
|
||||
* means killing that exact process: proot runs with {@code --kill-on-exit}, so the whole guest
|
||||
* tree goes with it.
|
||||
*/
|
||||
public final class ProcessHelper {
|
||||
private static final String TAG = "ProcessHelper";
|
||||
|
||||
private ProcessHelper() {}
|
||||
|
||||
public static int exec(String command, String[] envp, File workingDir) {
|
||||
return exec(command, envp, workingDir, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param lineCallback receives stdout and stderr line by line, or null to discard both. The
|
||||
* Linux session sends everything to its own log file, so nothing is passed
|
||||
* here for it; the audio daemon is the one that benefits.
|
||||
*/
|
||||
public static int exec(String command, String[] envp, File workingDir,
|
||||
Callback<Integer> terminationCallback, Callback<String> lineCallback) {
|
||||
int pid = -1;
|
||||
try {
|
||||
ProcessBuilder builder = new ProcessBuilder(splitCommand(command));
|
||||
if (workingDir != null) builder.directory(workingDir);
|
||||
if (envp != null) {
|
||||
// Added to the app's own environment rather than replacing it, which is what
|
||||
// Bannerlator does on the path this was taken from. proot needs nothing from the
|
||||
// inherited set, but the guest command starts with `env -i` anyway, so clearing
|
||||
// here buys nothing and only differs from the configuration proven on a device.
|
||||
for (String entry : envp) {
|
||||
int eq = entry.indexOf('=');
|
||||
if (eq > 0) builder.environment().put(entry.substring(0, eq), entry.substring(eq + 1));
|
||||
}
|
||||
}
|
||||
builder.redirectErrorStream(true);
|
||||
if (lineCallback == null) builder.redirectOutput(new File("/dev/null"));
|
||||
Process process = builder.start();
|
||||
|
||||
Field pidField = process.getClass().getDeclaredField("pid");
|
||||
pidField.setAccessible(true);
|
||||
pid = pidField.getInt(process);
|
||||
pidField.setAccessible(false);
|
||||
Log.i(TAG, "started pid " + pid + ": " + command);
|
||||
|
||||
if (lineCallback != null) drain(process.getInputStream(), lineCallback);
|
||||
if (terminationCallback != null) {
|
||||
Thread waiter = new Thread(() -> {
|
||||
int status = -1;
|
||||
try {
|
||||
status = process.waitFor();
|
||||
} catch (InterruptedException ignored) {
|
||||
} finally {
|
||||
terminationCallback.call(status);
|
||||
}
|
||||
}, "proc-wait-" + pid);
|
||||
waiter.setPriority(Thread.NORM_PRIORITY - 1);
|
||||
waiter.start();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "exec " + command, e);
|
||||
}
|
||||
return pid;
|
||||
}
|
||||
|
||||
private static void drain(InputStream stream, Callback<String> lineCallback) {
|
||||
Thread thread = new Thread(() -> {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) {
|
||||
for (String line = reader.readLine(); line != null; line = reader.readLine()) {
|
||||
lineCallback.call(line);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}, "proc-out");
|
||||
thread.setPriority(Thread.NORM_PRIORITY - 1);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
/** Splits on spaces, honouring backslash-escaped spaces - paths under /data are full of them. */
|
||||
public static String[] splitCommand(String command) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
boolean escaped = false;
|
||||
for (int i = 0; i < command.length(); i++) {
|
||||
char c = command.charAt(i);
|
||||
if (escaped) {
|
||||
current.append(c);
|
||||
escaped = false;
|
||||
} else if (c == '\\') {
|
||||
escaped = true;
|
||||
} else if (c == ' ') {
|
||||
if (current.length() > 0) {
|
||||
parts.add(current.toString());
|
||||
current.setLength(0);
|
||||
}
|
||||
} else {
|
||||
current.append(c);
|
||||
}
|
||||
}
|
||||
if (current.length() > 0) parts.add(current.toString());
|
||||
return parts.toArray(new String[0]);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ object SessionLogCapture {
|
||||
this.target = target
|
||||
// -T 1 starts at the newest line rather than replaying the whole buffer; -v threadtime
|
||||
// keeps the timestamps and thread ids that make two logs line up.
|
||||
pid = ProcessHelper.exec(
|
||||
pid = HostProcess.start(
|
||||
"/system/bin/logcat -v threadtime -T 1 --pid=" + Process.myPid(),
|
||||
null, target.parentFile, null,
|
||||
) { line ->
|
||||
@@ -103,7 +103,7 @@ object SessionLogCapture {
|
||||
// callback on another thread, so a poll for "is it empty yet" declared an empty buffer
|
||||
// every time and still cost the wait. exec() returns a pid, never an exit status - the
|
||||
// first version printed that pid as "rc", which was nonsense in the file.
|
||||
val pid = ProcessHelper.exec(
|
||||
val pid = HostProcess.start(
|
||||
"/system/bin/logcat -b crash -d -v threadtime -t 400", null, target.parentFile,
|
||||
{ done.countDown() },
|
||||
) { line -> synchronized(lines) { lines.append(line).append('\n') } }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.steamdeck.launcher.core;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
/**
|
||||
* One thing a running session depends on beside the guest process itself: the audio daemon, the
|
||||
* rumble listener, the battery the client reads, the network-link file. The session starts its
|
||||
* parts in order before the guest and stops them in reverse after it. A part is handed the
|
||||
* application context, never an activity's, so nothing here can keep a screen alive.
|
||||
*/
|
||||
public abstract class SessionPart {
|
||||
private Context app;
|
||||
|
||||
public final void attach(Context context) {
|
||||
this.app = context.getApplicationContext();
|
||||
}
|
||||
|
||||
/** The application context, valid after {@link #attach}. */
|
||||
protected final Context app() {
|
||||
return app;
|
||||
}
|
||||
|
||||
public abstract void start();
|
||||
|
||||
public abstract void stop();
|
||||
}
|
||||
@@ -151,7 +151,7 @@ enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
|
||||
* Shortens a path from the LEFT, keeping whole segments.
|
||||
*
|
||||
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
|
||||
* matters - `/storage/emulated/0/Winlator/Game…` tells you nothing about where you are.
|
||||
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
|
||||
*/
|
||||
private fun elidePathStart(path: String, max: Int): String {
|
||||
if (path.length <= max) return path
|
||||
@@ -923,7 +923,7 @@ fun FileManagerScreen(
|
||||
Spacer(Modifier.weight(1f))
|
||||
} else {
|
||||
// LANDSCAPE: the CURRENT FOLDER, not the full path. A path ellipsised on the right
|
||||
// hides its tail - the only part that says where you are ("…/Winlator/Game…"). The
|
||||
// hides its tail - the only part that says where you are ("…/Games/Racing/Dir…"). The
|
||||
// full path moves to the line below, where it has room.
|
||||
Text(
|
||||
text = currentDir.name.ifBlank { currentDir.absolutePath },
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.steamdeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import com.steamdeck.launcher.files.PeIconExtractor
|
||||
import com.steamdeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Artwork for an added game: what the user put in the game's folder first, else what Steam's
|
||||
* store has for the same title, fetched once into the app's cache. Four pieces, named the way
|
||||
* the client names them in its grid folder: the portrait capsule, the wide header, the hero
|
||||
* background and the logo; plus an icon pulled out of the game's own .exe. The session hands
|
||||
* the paths to the runtime's shortcuts writer, which copies them into the client's grid.
|
||||
*/
|
||||
object AddedGameArt {
|
||||
private const val TAG = "AddedGameArt"
|
||||
private const val STORE_SEARCH = "https://store.steampowered.com/api/storesearch/?l=english&cc=US&term="
|
||||
private const val CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps/"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
|
||||
class Art(val portrait: File?, val header: File?, val hero: File?, val logo: File?, val icon: File?) {
|
||||
val any get() = portrait != null || header != null || hero != null || logo != null
|
||||
}
|
||||
|
||||
private val IMAGE = listOf("png", "jpg", "jpeg")
|
||||
private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait")
|
||||
private val HEADER = listOf("header", "banner", "library_header", "wide")
|
||||
private val HERO = listOf("hero", "background", "library_hero")
|
||||
private val LOGO = listOf("logo")
|
||||
|
||||
private fun cacheDir(context: Context, game: AddedGames.Game) = File(context.filesDir, "added-art/${game.appId}")
|
||||
|
||||
/** An image in the folder (or its `art` subfolder) under one of the given names, any image extension. */
|
||||
private fun find(folder: File, names: List<String>): File? {
|
||||
val dirs = listOf(folder, File(folder, "art"), File(folder, ".art"))
|
||||
for (dir in dirs) for (name in names) for (ext in IMAGE) {
|
||||
val f = File(dir, "$name.$ext")
|
||||
if (f.isFile) return f
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** What the folder itself offers; the file named after the folder counts as the portrait. */
|
||||
private fun local(game: AddedGames.Game): Art {
|
||||
val folder = game.folder
|
||||
return Art(
|
||||
portrait = find(folder, PORTRAIT + listOf(folder.name)),
|
||||
header = find(folder, HEADER),
|
||||
hero = find(folder, HERO),
|
||||
logo = find(folder, LOGO),
|
||||
icon = listOf("icon.png", "icon.ico").map { File(folder, it) }.firstOrNull { it.isFile },
|
||||
)
|
||||
}
|
||||
|
||||
/** The folder's art, else the cached Steam art, without touching the network. */
|
||||
fun resolve(context: Context, game: AddedGames.Game): Art {
|
||||
val own = local(game)
|
||||
val cache = cacheDir(context, game)
|
||||
fun cached(name: String) = File(cache, name).takeIf { it.isFile }
|
||||
return Art(
|
||||
portrait = own.portrait ?: cached("p.jpg"),
|
||||
header = own.header ?: cached("header.jpg"),
|
||||
hero = own.hero ?: cached("hero.jpg"),
|
||||
logo = own.logo ?: cached("logo.png"),
|
||||
icon = own.icon ?: exeIcon(context, game),
|
||||
)
|
||||
}
|
||||
|
||||
/** The first icon inside the game's .exe, kept as a PNG beside the cached art. */
|
||||
private fun exeIcon(context: Context, game: AddedGames.Game): File? {
|
||||
val cache = cacheDir(context, game)
|
||||
val png = File(cache, "icon.png")
|
||||
if (png.isFile) return png
|
||||
val none = File(cache, "icon.none")
|
||||
if (none.isFile) return null
|
||||
cache.mkdirs()
|
||||
val bitmap = runCatching { PeIconExtractor.extract(game.exe) }.getOrNull()
|
||||
if (bitmap == null) { none.writeText(""); return null }
|
||||
val tmp = File(cache, "icon.png.tmp")
|
||||
tmp.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
return if (tmp.renameTo(png)) png else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks the games up on Steam's store and fetches the art that is missing, once per game
|
||||
* (a miss is retried after a week). Blocking: run it off the main thread. Returns true when
|
||||
* anything new arrived.
|
||||
*/
|
||||
fun fetchMissing(context: Context, games: List<AddedGames.Game>): Boolean {
|
||||
if (!SessionPrefs.addedGamesArt(context)) return false
|
||||
var changed = false
|
||||
for (game in games) {
|
||||
if (local(game).portrait != null) continue
|
||||
val cache = cacheDir(context, game)
|
||||
val lookup = File(cache, "steam-appid")
|
||||
if (File(cache, "p.jpg").isFile) continue
|
||||
if (lookup.isFile && (lookup.readText().trim() != "none" || System.currentTimeMillis() - lookup.lastModified() < RETRY_AFTER_MS)) {
|
||||
val id = lookup.readText().trim()
|
||||
if (id == "none") continue
|
||||
if (download(id, cache)) changed = true
|
||||
continue
|
||||
}
|
||||
cache.mkdirs()
|
||||
val id = runCatching { search(game.name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()
|
||||
if (id == null) { lookup.writeText("none"); continue }
|
||||
lookup.writeText(id)
|
||||
Log.i(TAG, "${game.name}: Steam app $id")
|
||||
if (download(id, cache)) changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun normalize(s: String) = s.lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||
|
||||
/** The Steam appid whose store name matches the folder name best, or null for no match. */
|
||||
private fun search(name: String): String? {
|
||||
val term = name.replace(Regex("(?i)\\b(complete|definitive|legendary|goty|game of the year|deluxe|ultimate|remastered)( edition)?\\b"), " ").trim().ifEmpty { name }
|
||||
val json = get(STORE_SEARCH + URLEncoder.encode(term, "UTF-8"))?.toString(Charsets.UTF_8) ?: return null
|
||||
val items = JSONObject(json).optJSONArray("items") ?: return null
|
||||
val want = normalize(name)
|
||||
val wantShort = normalize(term)
|
||||
var best: String? = null
|
||||
var bestScore = 0
|
||||
for (i in 0 until items.length()) {
|
||||
val item = items.getJSONObject(i)
|
||||
val got = normalize(item.optString("name"))
|
||||
val score = when {
|
||||
got == want -> 4
|
||||
got == wantShort -> 3
|
||||
got.startsWith(wantShort) || wantShort.startsWith(got) -> 2
|
||||
i == 0 -> 1
|
||||
else -> 0
|
||||
}
|
||||
if (score > bestScore) { bestScore = score; best = item.optString("id") }
|
||||
}
|
||||
return best?.takeIf { it.isNotEmpty() && bestScore >= 1 }
|
||||
}
|
||||
|
||||
private fun download(id: String, cache: File): Boolean {
|
||||
var got = false
|
||||
for ((remote, localName) in listOf(
|
||||
"library_600x900.jpg" to "p.jpg", "header.jpg" to "header.jpg", "library_hero.jpg" to "hero.jpg", "logo.png" to "logo.png",
|
||||
)) {
|
||||
val dst = File(cache, localName)
|
||||
if (dst.isFile) continue
|
||||
val bytes = get("$CDN$id/$remote") ?: continue
|
||||
val tmp = File(cache, "$localName.tmp")
|
||||
tmp.writeBytes(bytes)
|
||||
if (tmp.renameTo(dst)) got = true
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
private fun get(url: String): ByteArray? {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000
|
||||
c.readTimeout = 15_000
|
||||
c.setRequestProperty("User-Agent", "SteamDeck-launcher")
|
||||
return try {
|
||||
if (c.responseCode != 200) null else c.inputStream.use { it.readBytes() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$url: ${e.message}"); null
|
||||
} finally {
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.steamdeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.steamdeck.launcher.session.GameStorage
|
||||
import com.steamdeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
/**
|
||||
* The user's own Windows games, added to the Steam client's library.
|
||||
*
|
||||
* A Games folder is scanned one level deep: each subfolder is one game, and the program to launch
|
||||
* is the .exe in it that looks most like the game - the one named after the folder, else the
|
||||
* largest, never an installer, redistributable or crash reporter - unless the user picked one
|
||||
* for that game. Each game becomes a non-Steam shortcut in the client, under the ARM64 Proton,
|
||||
* with the appid the client itself would derive, so launching from the rail and from the
|
||||
* client's own library are the same thing. Any number of Games folders, from anywhere on the
|
||||
* device: each is bound into the session under /root/Games on its own.
|
||||
*/
|
||||
object AddedGames {
|
||||
private const val TAG = "AddedGames"
|
||||
|
||||
class Game(
|
||||
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
|
||||
/** The client's 32-bit appid for this shortcut, as an unsigned value. */
|
||||
val appId: Long,
|
||||
/** What steam://rungameid/ takes for a shortcut. */
|
||||
val gameId: Long,
|
||||
val candidates: List<File>,
|
||||
) {
|
||||
fun folderName(): String = folder.name
|
||||
}
|
||||
|
||||
/** One Games folder and where the session sees it. */
|
||||
class Root(val host: File, val guest: String)
|
||||
|
||||
/**
|
||||
* The chosen folders with their guest paths: /root/Games/<folder name>, or with a short hash of
|
||||
* the host path when two chosen folders share a name (so a folder's guest path, and with it
|
||||
* every shortcut's appid, does not change when another folder is added or removed).
|
||||
*/
|
||||
fun roots(context: Context): List<Root> {
|
||||
val hosts = SessionPrefs.addedGamesDirs(context).map { File(it) }
|
||||
val names = hosts.groupingBy { it.name.lowercase() }.eachCount()
|
||||
return hosts.map { host ->
|
||||
val name = host.name.ifEmpty { "games" }
|
||||
val guestName = if ((names[name.lowercase()] ?: 0) > 1) name + "-" + "%08x".format(CRC32().apply { update(host.path.toByteArray()) }.value).take(4) else name
|
||||
Root(host, "$GUEST_DIR/$guestName")
|
||||
}
|
||||
}
|
||||
|
||||
private val SKIP = Regex(
|
||||
"(?i)^(unins.*|setup.*|.*redist.*|vcredist.*|dxsetup.*|dxwebsetup.*|.*crash.*|.*report.*|dotnet.*|directx.*|.*prereq.*" +
|
||||
"|.*installer.*|.*uninstall.*|.*updater?.*|.*config(ur.*)?|.*settings.*|.*editor.*|.*server.*|.*benchmark.*|.*helper.*|.*eac.*|.*easyanticheat.*|.*battleye.*)\\.exe$",
|
||||
)
|
||||
|
||||
/** Where a host path appears inside the session, or null when the session cannot see it. */
|
||||
/** Under here the chosen Games folders are bound inside the session, one each. */
|
||||
const val GUEST_DIR = "/root/Games"
|
||||
|
||||
fun guestPath(context: Context, host: File): String? {
|
||||
val path = host.absolutePath
|
||||
// The Games folders are bound on their own, so a folder anywhere - an SD card, a USB
|
||||
// drive - works without being inside one of the other binds.
|
||||
for (root in roots(context)) {
|
||||
val dir = root.host.absolutePath
|
||||
if (path == dir) return root.guest
|
||||
if (path.startsWith("$dir/")) return root.guest + "/" + path.removePrefix("$dir/")
|
||||
}
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let { roms ->
|
||||
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
|
||||
}
|
||||
GameStorage.effective(context)?.let { lib ->
|
||||
if (path.startsWith("${lib.path}/")) return "/mnt/bannerlator-sd/" + path.removePrefix("${lib.path}/")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The .exe files a game folder offers, best first. */
|
||||
fun candidates(folder: File): List<File> {
|
||||
val exes = ArrayList<File>()
|
||||
val roots = listOf(folder) + (folder.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList())
|
||||
for (dir in roots) {
|
||||
dir.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(f.name) }
|
||||
?.let { exes.addAll(it) }
|
||||
}
|
||||
val key = folder.name.lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||
return exes.sortedWith(
|
||||
compareByDescending<File> { it.parentFile == folder }
|
||||
.thenByDescending { it.nameWithoutExtension.lowercase().replace(Regex("[^a-z0-9]"), "").let { n -> n == key || key.startsWith(n) || n.startsWith(key) } }
|
||||
.thenByDescending { it.length() },
|
||||
)
|
||||
}
|
||||
|
||||
fun scan(context: Context): List<Game> {
|
||||
val out = ArrayList<Game>()
|
||||
for (root in roots(context)) {
|
||||
val dir = root.host
|
||||
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) scanGame(context, folder, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
run {
|
||||
val candidates = candidates(folder)
|
||||
val chosen = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val exe = chosen ?: candidates.firstOrNull() ?: return
|
||||
val guestExe = guestPath(context, exe)
|
||||
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val name = folder.name
|
||||
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, candidates))
|
||||
}
|
||||
}
|
||||
|
||||
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
val json = StringBuilder("[")
|
||||
games.forEachIndexed { i, g ->
|
||||
if (i > 0) json.append(',')
|
||||
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
// The art, as the session sees it: the app's cache is bound at its own path, a file in
|
||||
// the game's folder at the folder's guest path.
|
||||
val art = AddedGameArt.resolve(context, g)
|
||||
val pieces = listOf("p" to art.portrait, "header" to art.header, "hero" to art.hero, "logo" to art.logo, "icon" to art.icon)
|
||||
.mapNotNull { (k, f) -> f?.let { file -> artGuestPath(context, file)?.let { k to it } } }
|
||||
if (pieces.isNotEmpty()) json.append(",\"art\":{").append(pieces.joinToString(",") { (k, v) -> quote(k) + ":" + quote(v) }).append('}')
|
||||
json.append('}')
|
||||
}
|
||||
file.writeText(json.append(']').toString())
|
||||
return file
|
||||
}
|
||||
|
||||
private fun artGuestPath(context: Context, file: File): String? {
|
||||
val files = context.filesDir.absolutePath
|
||||
if (file.absolutePath.startsWith("$files/")) return file.absolutePath
|
||||
return guestPath(context, file)
|
||||
}
|
||||
|
||||
private fun quote(s: String) = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import java.io.File
|
||||
* folder as a hint). Read on a worker thread; nothing here is cached beyond one screen refresh.
|
||||
*/
|
||||
object Library {
|
||||
class SteamGame(val appId: Int, val name: String, val art: File?, val library: String)
|
||||
/** [gameId] is what steam://rungameid/ takes: the appid for a Steam title, the shortcut id for an added game. */
|
||||
class SteamGame(val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong())
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
|
||||
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
|
||||
|
||||
@@ -64,8 +64,8 @@ public class FakeInputWriter {
|
||||
BTN_SELECT, BTN_START, BTN_THUMBL, BTN_THUMBR,
|
||||
BTN_MODE
|
||||
};
|
||||
/** Bit 10 of the snapshot word. The first ten are GamepadState's own bit order; the Steam
|
||||
* button is not, because GamepadState keeps 10 and 11 for the triggers. */
|
||||
/** Bit 10 of the snapshot word. The first ten are PadState's own bit order; the Steam
|
||||
* button is not, because PadState keeps 10 and 11 for the triggers. */
|
||||
private static final int SNAPSHOT_IDX_MODE = 10;
|
||||
|
||||
private static final int EVENT_SIZE = 24;
|
||||
@@ -595,7 +595,7 @@ public class FakeInputWriter {
|
||||
writeEvent(EV_KEY, BUTTON_MAP[idx], pressed ? 1 : 0);
|
||||
}
|
||||
|
||||
public synchronized void writeGamepadState(GamepadState state) {
|
||||
public synchronized void writePad(PadState state) {
|
||||
if (!isOpen && !open())
|
||||
return;
|
||||
|
||||
@@ -613,19 +613,19 @@ public class FakeInputWriter {
|
||||
|
||||
// Buttons
|
||||
for (int i = 0; i < 10; i++) {
|
||||
writeButton(i, state.isPressed((byte) i));
|
||||
writeButton(i, state.isDown(i));
|
||||
}
|
||||
// The Steam button. In a Linux session this is what opens the client's own in-game menu,
|
||||
// and the client looks for it where an Xbox pad keeps it: evdev BTN_MODE, which SDL then
|
||||
// reports as button 8 - exactly the "guide:b8" in the mapping Steam writes for this pad.
|
||||
writeButton(SNAPSHOT_IDX_MODE,
|
||||
state.isPressed(GamepadState.IDX_BUTTON_MODE));
|
||||
state.isDown(PadState.GUIDE));
|
||||
|
||||
// Sticks
|
||||
int lx = (int) (state.thumbLX * 32767);
|
||||
int ly = (int) (state.thumbLY * 32767);
|
||||
int rx = (int) (state.thumbRX * 32767);
|
||||
int ry = (int) (state.thumbRY * 32767);
|
||||
int lx = (int) (state.leftX * 32767);
|
||||
int ly = (int) (state.leftY * 32767);
|
||||
int rx = (int) (state.rightX * 32767);
|
||||
int ry = (int) (state.rightY * 32767);
|
||||
|
||||
// The fake evdev ring is event-queue semantics, so unchanged axes normally
|
||||
// stay silent; forceResend overrides that to emit a complete keyframe.
|
||||
@@ -647,8 +647,8 @@ public class FakeInputWriter {
|
||||
}
|
||||
|
||||
// L2 and R2 (Triggers)
|
||||
int tl = (int) (state.triggerL * 255);
|
||||
int tr = (int) (state.triggerR * 255);
|
||||
int tl = (int) (state.leftTrigger * 255);
|
||||
int tr = (int) (state.rightTrigger * 255);
|
||||
if (forceResend || tl != prevTriggerL) {
|
||||
prevTriggerL = tl;
|
||||
writeEvent(EV_ABS, ABS_BRAKE, tl);
|
||||
@@ -659,8 +659,8 @@ public class FakeInputWriter {
|
||||
}
|
||||
|
||||
// D-pad
|
||||
int hatX = state.dpad[3] ? -1 : (state.dpad[1] ? 1 : 0);
|
||||
int hatY = state.dpad[0] ? -1 : (state.dpad[2] ? 1 : 0);
|
||||
int hatX = state.hatX();
|
||||
int hatY = state.hatY();
|
||||
if (forceResend || hatX != prevHatX) {
|
||||
prevHatX = hatX;
|
||||
writeEvent(EV_ABS, ABS_HAT0X, hatX);
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package com.steamdeck.launcher.input;
|
||||
|
||||
public class GamepadState {
|
||||
/** Bit index of the Steam/Guide button; 0-9 are the face/shoulder/stick buttons. */
|
||||
public static final byte IDX_BUTTON_MODE = 12;
|
||||
|
||||
public float thumbLX = 0;
|
||||
public float thumbLY = 0;
|
||||
public float thumbRX = 0;
|
||||
public float thumbRY = 0;
|
||||
public float triggerL = 0;
|
||||
public float triggerR = 0;
|
||||
public final boolean[] dpad = new boolean[4];
|
||||
public short buttons = 0;
|
||||
|
||||
public void setPressed(int buttonIdx, boolean pressed) {
|
||||
int flag = 1<<buttonIdx;
|
||||
if (pressed) {
|
||||
buttons |= flag;
|
||||
}
|
||||
else buttons &= ~flag;
|
||||
}
|
||||
|
||||
public boolean isPressed(int buttonIdx) {
|
||||
return (buttons & (1<<buttonIdx)) != 0;
|
||||
}
|
||||
|
||||
public byte getDPadX() {
|
||||
return (byte)(dpad[1] ? 1 : (dpad[3] ? -1 : 0));
|
||||
}
|
||||
|
||||
public byte getDPadY() {
|
||||
return (byte)(dpad[0] ? -1 : (dpad[2] ? 1 : 0));
|
||||
}
|
||||
|
||||
public void copy(GamepadState other) {
|
||||
thumbLX = other.thumbLX;
|
||||
thumbLY = other.thumbLY;
|
||||
thumbRX = other.thumbRX;
|
||||
thumbRY = other.thumbRY;
|
||||
triggerL = other.triggerL;
|
||||
triggerR = other.triggerR;
|
||||
buttons = other.buttons;
|
||||
System.arraycopy(other.dpad, 0, dpad, 0, dpad.length);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
thumbLX = 0;
|
||||
thumbLY = 0;
|
||||
thumbRX = 0;
|
||||
thumbRY = 0;
|
||||
triggerL = 0;
|
||||
triggerR = 0;
|
||||
buttons = 0;
|
||||
java.util.Arrays.fill(dpad, false);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -30,9 +30,9 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont
|
||||
private class Control(
|
||||
val id: String,
|
||||
val label: String,
|
||||
/** Bit index in GamepadState, or -1. */
|
||||
/** A PadState button number, or -1. */
|
||||
val button: Int,
|
||||
/** Index into GamepadState.dpad, or -1. */
|
||||
/** A d-pad direction (0 up, 1 right, 2 down, 3 left), or -1. */
|
||||
val dpad: Int,
|
||||
/** 0 = left stick, 1 = right stick, -1 = a button. */
|
||||
val stick: Int,
|
||||
@@ -83,7 +83,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont
|
||||
Control("select", "⧉", 6, -1, -1, dp(20f)),
|
||||
Control("start", "☰", 7, -1, -1, dp(20f)),
|
||||
// The client's own in-game menu; the interposer publishes it as BTN_MODE.
|
||||
Control("guide", "◉", GamepadState.IDX_BUTTON_MODE.toInt(), -1, -1, dp(22f)),
|
||||
Control("guide", "◉", PadState.GUIDE, -1, -1, dp(22f)),
|
||||
Control("qam", "⋯", -1, -1, -1, dp(22f)),
|
||||
)
|
||||
|
||||
@@ -318,10 +318,13 @@ class OnScreenControls(context: Context, private val pad: PadBridge) : View(cont
|
||||
val held = control.pressedBy != -1
|
||||
when {
|
||||
control.stick >= 0 && !control.dirty -> {}
|
||||
control.stick == 0 -> { state.thumbLX = control.kx / control.radius; state.thumbLY = control.ky / control.radius; control.dirty = false }
|
||||
control.stick == 1 -> { state.thumbRX = control.kx / control.radius; state.thumbRY = control.ky / control.radius; control.dirty = false }
|
||||
control.dpad >= 0 -> state.dpad[control.dpad] = held
|
||||
control.button >= 0 -> state.setPressed(control.button, held)
|
||||
control.stick == 0 -> { state.leftX = control.kx / control.radius; state.leftY = control.ky / control.radius; control.dirty = false }
|
||||
control.stick == 1 -> { state.rightX = control.kx / control.radius; state.rightY = control.ky / control.radius; control.dirty = false }
|
||||
control.dpad == 0 -> state.up = held
|
||||
control.dpad == 1 -> state.right = held
|
||||
control.dpad == 2 -> state.down = held
|
||||
control.dpad == 3 -> state.left = held
|
||||
control.button >= 0 -> state.press(control.button, held)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.io.File;
|
||||
* <p>Android hands a gamepad to the foreground activity as key and motion events; the Steam client
|
||||
* inside the runtime never sees that. What it does scan is {@code /dev/input}, which the session's
|
||||
* interposer (libfakeinput.so) serves out of the shared-memory rings written here. So the path is:
|
||||
* Android event → {@link GamepadState} → {@link FakeInputWriter} ring → interposer → evdev node →
|
||||
* Android event → {@link PadState} → {@link FakeInputWriter} ring → interposer → evdev node →
|
||||
* SDL → Big Picture.
|
||||
*
|
||||
* <p>The identity the interposer reports is an Xbox 360 pad, which is what makes SDL apply a known
|
||||
@@ -31,8 +31,8 @@ public final class PadBridge {
|
||||
private static final long QAM_GUIDE_TAIL_MS = 40;
|
||||
|
||||
private final FakeInputWriter writer;
|
||||
private final GamepadState state = new GamepadState();
|
||||
private final GamepadState effectiveState = new GamepadState();
|
||||
private final PadState state = new PadState();
|
||||
private final PadState effectiveState = new PadState();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean open;
|
||||
private boolean systemGuidePressed;
|
||||
@@ -61,8 +61,8 @@ public final class PadBridge {
|
||||
qamSyntheticAPressed = false;
|
||||
qamChordGeneration++;
|
||||
if (open) {
|
||||
state.reset();
|
||||
writer.writeGamepadState(state);
|
||||
state.clear();
|
||||
writer.writePad(state);
|
||||
writer.close();
|
||||
open = false;
|
||||
}
|
||||
@@ -92,28 +92,28 @@ public final class PadBridge {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A: state.setPressed(0, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_B: state.setPressed(1, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_X: state.setPressed(2, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Y: state.setPressed(3, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: state.setPressed(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: state.setPressed(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_B: state.press(1, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_X: state.press(2, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Y: state.press(3, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: state.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: state.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT:
|
||||
case KeyEvent.KEYCODE_BACK: state.setPressed(6, pressed); break;
|
||||
case KeyEvent.KEYCODE_BACK: state.press(6, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_START:
|
||||
case KeyEvent.KEYCODE_MENU: state.setPressed(7, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL: state.setPressed(8, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: state.setPressed(9, pressed); break;
|
||||
case KeyEvent.KEYCODE_MENU: state.press(7, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL: state.press(8, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: state.press(9, pressed); break;
|
||||
// The client's own in-game menu is opened by this one; the interposer publishes it as
|
||||
// BTN_MODE, which SDL reports as the "guide" button.
|
||||
case KeyEvent.KEYCODE_BUTTON_MODE:
|
||||
case KeyEvent.KEYCODE_HOME: state.setPressed(GamepadState.IDX_BUTTON_MODE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: state.triggerL = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: state.triggerR = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: state.dpad[0] = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: state.dpad[1] = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: state.dpad[2] = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: state.dpad[3] = pressed; break;
|
||||
case KeyEvent.KEYCODE_HOME: state.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: state.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: state.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: state.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: state.right = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: state.down = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: state.left = pressed; break;
|
||||
default: return false;
|
||||
}
|
||||
publish();
|
||||
@@ -124,25 +124,25 @@ public final class PadBridge {
|
||||
public synchronized boolean onMotionEvent(MotionEvent event) {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
state.thumbLX = axis(event, MotionEvent.AXIS_X);
|
||||
state.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
// Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the
|
||||
// pad reports as "down" is what the client is told.
|
||||
state.thumbLY = axis(event, MotionEvent.AXIS_Y);
|
||||
state.thumbRX = axis(event, MotionEvent.AXIS_Z);
|
||||
state.thumbRY = axis(event, MotionEvent.AXIS_RZ);
|
||||
state.leftY = axis(event, MotionEvent.AXIS_Y);
|
||||
state.rightX = axis(event, MotionEvent.AXIS_Z);
|
||||
state.rightY = axis(event, MotionEvent.AXIS_RZ);
|
||||
float lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
|
||||
float rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
|
||||
// Some pads only report the triggers on BRAKE/GAS.
|
||||
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
|
||||
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
|
||||
state.triggerL = lt;
|
||||
state.triggerR = rt;
|
||||
state.leftTrigger = lt;
|
||||
state.rightTrigger = rt;
|
||||
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
|
||||
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
|
||||
state.dpad[0] = hatY < -0.5f;
|
||||
state.dpad[1] = hatX > 0.5f;
|
||||
state.dpad[2] = hatY > 0.5f;
|
||||
state.dpad[3] = hatX < -0.5f;
|
||||
state.up = hatY < -0.5f;
|
||||
state.right = hatX > 0.5f;
|
||||
state.down = hatY > 0.5f;
|
||||
state.left = hatX < -0.5f;
|
||||
publish();
|
||||
return true;
|
||||
}
|
||||
@@ -151,7 +151,7 @@ public final class PadBridge {
|
||||
* The on-screen controls' way in: they mutate the same state a physical pad writes, so the
|
||||
* client only ever sees one device and a user can use both at once without them fighting.
|
||||
*/
|
||||
public synchronized void applyTouch(java.util.function.Consumer<GamepadState> mutation) {
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
mutation.accept(state);
|
||||
publish();
|
||||
}
|
||||
@@ -202,14 +202,14 @@ public final class PadBridge {
|
||||
private void publish() {
|
||||
if (!open && !start()) return;
|
||||
if (systemGuidePressed || qamChordActive) {
|
||||
effectiveState.copy(state);
|
||||
effectiveState.setPressed(GamepadState.IDX_BUTTON_MODE, true);
|
||||
effectiveState.copyFrom(state);
|
||||
effectiveState.press(PadState.GUIDE, true);
|
||||
if (qamSyntheticAPressed) {
|
||||
effectiveState.setPressed(0, state.isPressed(0) || qamSyntheticAPressed);
|
||||
effectiveState.press(PadState.A, state.isDown(PadState.A) || qamSyntheticAPressed);
|
||||
}
|
||||
writer.writeGamepadState(effectiveState);
|
||||
writer.writePad(effectiveState);
|
||||
} else {
|
||||
writer.writeGamepadState(state);
|
||||
writer.writePad(state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.steamdeck.launcher.input
|
||||
|
||||
/**
|
||||
* What one Xbox-shaped pad is doing right now: two sticks and two triggers as -1..1 / 0..1, the
|
||||
* four d-pad directions, and a bitmask of the buttons numbered the way the fake evdev node
|
||||
* reports them. Written by whatever drives the pad (a physical controller, the on-screen one, or
|
||||
* both at once) and read by [FakeInputWriter] into the guest's input ring.
|
||||
*/
|
||||
class PadState {
|
||||
@JvmField var leftX = 0f
|
||||
@JvmField var leftY = 0f
|
||||
@JvmField var rightX = 0f
|
||||
@JvmField var rightY = 0f
|
||||
@JvmField var leftTrigger = 0f
|
||||
@JvmField var rightTrigger = 0f
|
||||
@JvmField var up = false
|
||||
@JvmField var right = false
|
||||
@JvmField var down = false
|
||||
@JvmField var left = false
|
||||
private var buttons = 0
|
||||
|
||||
fun press(button: Int, down: Boolean) {
|
||||
buttons = if (down) buttons or (1 shl button) else buttons and (1 shl button).inv()
|
||||
}
|
||||
|
||||
fun isDown(button: Int): Boolean = buttons and (1 shl button) != 0
|
||||
|
||||
/** The d-pad as a hat: -1 left, 1 right; -1 up, 1 down. Opposites cancel to 0. */
|
||||
fun hatX(): Int = if (right && !left) 1 else if (left && !right) -1 else 0
|
||||
fun hatY(): Int = if (down && !up) 1 else if (up && !down) -1 else 0
|
||||
|
||||
fun copyFrom(other: PadState) {
|
||||
leftX = other.leftX; leftY = other.leftY; rightX = other.rightX; rightY = other.rightY
|
||||
leftTrigger = other.leftTrigger; rightTrigger = other.rightTrigger
|
||||
up = other.up; right = other.right; down = other.down; left = other.left
|
||||
buttons = other.buttons
|
||||
}
|
||||
|
||||
/** Everything released and centred. */
|
||||
fun clear() {
|
||||
leftX = 0f; leftY = 0f; rightX = 0f; rightY = 0f
|
||||
leftTrigger = 0f; rightTrigger = 0f
|
||||
up = false; right = false; down = false; left = false
|
||||
buttons = 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val A = 0
|
||||
const val B = 1
|
||||
const val X = 2
|
||||
const val Y = 3
|
||||
const val LB = 4
|
||||
const val RB = 5
|
||||
const val SELECT = 6
|
||||
const val START = 7
|
||||
const val L3 = 8
|
||||
const val R3 = 9
|
||||
/** The Steam/Guide button, which the fake evdev node reports as BTN_MODE. */
|
||||
const val GUIDE = 12
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import android.net.Network
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.steamdeck.launcher.core.EnvironmentComponent
|
||||
import com.steamdeck.launcher.core.SessionPart
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.Inet4Address
|
||||
@@ -28,7 +28,7 @@ import java.net.Inet6Address
|
||||
class LinuxNetworkLinkComponent(
|
||||
context: Context,
|
||||
private val rootDir: File,
|
||||
) : EnvironmentComponent() {
|
||||
) : SessionPart() {
|
||||
private val appContext = context.applicationContext
|
||||
private val connectivity =
|
||||
appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.steamdeck.launcher.session
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import android.util.Log
|
||||
import com.steamdeck.launcher.core.SessionPart
|
||||
import java.io.File
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The battery the Steam client can see.
|
||||
*
|
||||
* The client reads `/sys/class/power_supply/BAT<n>/...` - a laptop's or a Deck's naming and files
|
||||
* (`energy_now`, `power_now`, `time_to_empty_now`, a `uevent` of `POWER_SUPPLY_*` lines). Android
|
||||
* names its supply `battery` and lays it out differently, and on many phones an app may not read
|
||||
* it at all, so the client showed no battery. This writes a `BAT0` (and a `BAT1`, the Deck's name)
|
||||
* from Android's own battery API - the same on every device - into a directory the session binds
|
||||
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
|
||||
*
|
||||
* Values: charge in µAh from the charge counter; energy in µWh from charge × voltage; power in µW
|
||||
* from |current| × voltage; time to empty from charge ÷ |average current| while discharging. Where
|
||||
* a device reports no current, the client still gets status and percentage.
|
||||
*/
|
||||
class BatteryComponent(val dir: File) : SessionPart() {
|
||||
@Volatile private var running = false
|
||||
private var thread: Thread? = null
|
||||
|
||||
override fun start() {
|
||||
running = true
|
||||
write()
|
||||
thread = Thread({
|
||||
while (running) {
|
||||
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
|
||||
if (running) write()
|
||||
}
|
||||
}, "battery-sysfs").apply { isDaemon = true; start() }
|
||||
Log.i(TAG, "battery: BAT0/BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
running = false
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
}
|
||||
|
||||
private fun write() {
|
||||
val ctx = app() ?: return
|
||||
try {
|
||||
val bm = ctx.getSystemService(BatteryManager::class.java)
|
||||
val sticky: Intent? = ctx.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val status = sticky?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN) ?: BatteryManager.BATTERY_STATUS_UNKNOWN
|
||||
val level = sticky?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
|
||||
val scale = sticky?.getIntExtra(BatteryManager.EXTRA_SCALE, 100)?.takeIf { it > 0 } ?: 100
|
||||
val pct = if (level >= 0) level * 100 / scale else (bm?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: 50)
|
||||
val voltageUv = ((sticky?.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0) ?: 0).toLong() * 1000L).takeIf { it > 0 } ?: 3_800_000L
|
||||
val tempDeci = sticky?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
|
||||
val tech = sticky?.getStringExtra(BatteryManager.EXTRA_TECHNOLOGY)?.takeIf { it.isNotBlank() } ?: "Li-ion"
|
||||
val chargeNowUah = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER)?.takeIf { it > 0 && it != Long.MIN_VALUE } ?: 0L
|
||||
val currentNowUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)?.takeIf { it != Long.MIN_VALUE } ?: 0L
|
||||
val currentAvgUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)?.takeIf { it != Long.MIN_VALUE && it != 0L } ?: currentNowUa
|
||||
// Android's sign convention varies by vendor; the status says which way the current flows.
|
||||
val discharging = status == BatteryManager.BATTERY_STATUS_DISCHARGING || status == BatteryManager.BATTERY_STATUS_NOT_CHARGING
|
||||
val chargeFullUah = if (chargeNowUah > 0 && pct > 0) chargeNowUah * 100 / pct else 0L
|
||||
val energyNowUwh = chargeNowUah * voltageUv / 1_000_000L
|
||||
val energyFullUwh = chargeFullUah * voltageUv / 1_000_000L
|
||||
val powerNowUw = abs(currentNowUa) * voltageUv / 1_000_000L
|
||||
val timeToEmptyS = if (discharging && chargeNowUah > 0 && abs(currentAvgUa) > 0) chargeNowUah * 3600L / abs(currentAvgUa) else 0L
|
||||
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING
|
||||
val timeToFullS = if (charging && chargeFullUah > chargeNowUah && abs(currentAvgUa) > 0) (chargeFullUah - chargeNowUah) * 3600L / abs(currentAvgUa) else 0L
|
||||
val statusText = when (status) {
|
||||
BatteryManager.BATTERY_STATUS_CHARGING -> "Charging"
|
||||
BatteryManager.BATTERY_STATUS_DISCHARGING -> "Discharging"
|
||||
BatteryManager.BATTERY_STATUS_FULL -> "Full"
|
||||
BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "Not charging"
|
||||
else -> "Unknown"
|
||||
}
|
||||
val capacityLevel = when {
|
||||
status == BatteryManager.BATTERY_STATUS_FULL || pct >= 100 -> "Full"
|
||||
pct <= 5 -> "Critical"
|
||||
pct <= 15 -> "Low"
|
||||
else -> "Normal"
|
||||
}
|
||||
for (name in listOf("BAT0", "BAT1")) {
|
||||
val bat = File(dir, name).apply { mkdirs() }
|
||||
val attrs = linkedMapOf(
|
||||
"type" to "Battery",
|
||||
"present" to "1",
|
||||
"status" to statusText,
|
||||
"capacity" to pct.toString(),
|
||||
"capacity_level" to capacityLevel,
|
||||
"technology" to tech,
|
||||
"voltage_now" to voltageUv.toString(),
|
||||
"voltage_min_design" to voltageUv.toString(),
|
||||
"current_now" to currentNowUa.toString(),
|
||||
"current_avg" to currentAvgUa.toString(),
|
||||
"charge_now" to chargeNowUah.toString(),
|
||||
"charge_full" to chargeFullUah.toString(),
|
||||
"charge_full_design" to chargeFullUah.toString(),
|
||||
"energy_now" to energyNowUwh.toString(),
|
||||
"energy_full" to energyFullUwh.toString(),
|
||||
"energy_full_design" to energyFullUwh.toString(),
|
||||
"power_now" to powerNowUw.toString(),
|
||||
"time_to_empty_now" to timeToEmptyS.toString(),
|
||||
"time_to_empty_avg" to timeToEmptyS.toString(),
|
||||
"time_to_full_now" to timeToFullS.toString(),
|
||||
"time_to_full_avg" to timeToFullS.toString(),
|
||||
"temp" to tempDeci.toString(),
|
||||
"model_name" to android.os.Build.MODEL.replace(' ', '_'),
|
||||
"manufacturer" to android.os.Build.MANUFACTURER.replace(' ', '_'),
|
||||
"scope" to "System",
|
||||
)
|
||||
for ((k, v) in attrs) put(File(bat, k), v + "\n")
|
||||
val uevent = StringBuilder("POWER_SUPPLY_NAME=$name\n")
|
||||
for ((k, v) in attrs) uevent.append("POWER_SUPPLY_").append(k.uppercase()).append('=').append(v).append('\n')
|
||||
put(File(bat, "uevent"), uevent.toString())
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "battery: could not write", t)
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomic per file: a reader in the guest never sees a half-written value. */
|
||||
private fun put(target: File, text: String) {
|
||||
val tmp = File(target.parentFile, "." + target.name + ".tmp")
|
||||
tmp.writeText(text)
|
||||
if (!tmp.renameTo(target)) { target.writeText(text); tmp.delete() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
private const val PERIOD_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.steamdeck.launcher.session
|
||||
|
||||
import android.net.LocalServerSocket
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import com.steamdeck.launcher.core.SessionPart
|
||||
import java.io.DataInputStream
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Rumble for the on-screen pad, on the phone itself.
|
||||
*
|
||||
* When a game plays a force-feedback effect on the fake pad, the fake evdev layer inside the guest
|
||||
* connects to the abstract socket [NAME] and sends one packet: strong, weak, duration in ms and
|
||||
* the pad slot, four little-endian 16-bit values. This listens for those and drives the device's
|
||||
* vibrator with the stronger motor's strength for the effect's duration. Best effort both ways:
|
||||
* a missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
*/
|
||||
class RumbleComponent : SessionPart() {
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
|
||||
override fun start() {
|
||||
val s = try { LocalServerSocket(NAME) } catch (e: Exception) { Log.w(TAG, "rumble: no listener ($e)"); return }
|
||||
server = s
|
||||
Thread({
|
||||
while (server === s) {
|
||||
val client = try { s.accept() } catch (e: Exception) { break }
|
||||
try {
|
||||
val bytes = ByteArray(8)
|
||||
DataInputStream(client.inputStream).readFully(bytes)
|
||||
val strong = u16(bytes, 0); val weak = u16(bytes, 2); val ms = u16(bytes, 4)
|
||||
buzz(max(strong, weak), ms)
|
||||
} catch (e: Exception) {
|
||||
// A partial packet or a closed peer: nothing to play.
|
||||
} finally {
|
||||
try { client.close() } catch (e: Exception) { /* already gone */ }
|
||||
}
|
||||
}
|
||||
}, "rumble").apply { isDaemon = true; start() }
|
||||
Log.i(TAG, "rumble: listening on @$NAME")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
val s = server ?: return
|
||||
server = null
|
||||
try { s.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
|
||||
}
|
||||
|
||||
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun buzz(strength: Int, ms: Int) {
|
||||
val ctx = app() ?: return
|
||||
val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator ?: return
|
||||
else @Suppress("DEPRECATION") (ctx.getSystemService(Vibrator::class.java) ?: return)
|
||||
if (!vibrator.hasVibrator()) return
|
||||
if (strength == 0 || ms == 0) { vibrator.cancel(); return }
|
||||
val amplitude = (strength * 255L / 65535L).toInt().coerceIn(1, 255)
|
||||
val duration = ms.toLong().coerceIn(1L, 5000L)
|
||||
try {
|
||||
if (vibrator.hasAmplitudeControl()) vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
|
||||
else vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "rumble: $e")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
|
||||
const val NAME = "steamdeck-rumble"
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,20 @@ object SessionFiles {
|
||||
"usr/local/bin/bannerlator-steam-launch" to "usr/local/bin/bannerlator-steam-launch",
|
||||
"usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games",
|
||||
"usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim",
|
||||
"usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts",
|
||||
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
|
||||
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
|
||||
"usr/bin/steamos-update" to "usr/bin/steamos-update",
|
||||
"usr/bin/steamos-select-branch" to "usr/bin/steamos-select-branch",
|
||||
"usr/bin/jupiter-biosupdate" to "usr/bin/jupiter-biosupdate",
|
||||
"usr/bin/steamos-polkit-helpers/steamos-priv-write" to "usr/bin/steamos-polkit-helpers/steamos-priv-write",
|
||||
"usr/bin/steamos-polkit-helpers/steamos-set-timezone" to "usr/bin/steamos-polkit-helpers/steamos-set-timezone",
|
||||
// On device the client called these four by their polkit-helpers path, not /usr/bin: the
|
||||
// "Update Error" dialog was steamos-update missing there.
|
||||
"usr/bin/steamos-polkit-helpers/steamos-update" to "usr/bin/steamos-polkit-helpers/steamos-update",
|
||||
"usr/bin/steamos-polkit-helpers/steamos-select-branch" to "usr/bin/steamos-polkit-helpers/steamos-select-branch",
|
||||
"usr/bin/steamos-polkit-helpers/jupiter-biosupdate" to "usr/bin/steamos-polkit-helpers/jupiter-biosupdate",
|
||||
"usr/bin/steamos-polkit-helpers/jupiter-dock-updater" to "usr/bin/steamos-polkit-helpers/jupiter-dock-updater",
|
||||
)
|
||||
// The desktop's launcher and labwc defaults, only where the desktop package is installed:
|
||||
// staging them into a runtime without it would make the desktop look present when it is not.
|
||||
@@ -47,7 +61,16 @@ object SessionFiles {
|
||||
"etc/xdg/lxqt/panel.conf" to "etc/xdg/lxqt/panel.conf",
|
||||
"usr/lib/firefox/defaults/pref/steamdeck.js" to "usr/lib/firefox/defaults/pref/steamdeck.js",
|
||||
)
|
||||
val all = if (File(root, "usr/bin/labwc").isFile) files + desktop else files
|
||||
// The patched gamescope (tools/gamescope): the runtime's own version rebuilt with the ARM64
|
||||
// client fixes, over /usr/local/bin so it comes first in the session's PATH. Only when the
|
||||
// apk carries it - a build without the asset leaves the runtime's copy alone.
|
||||
val optional = arrayOf(
|
||||
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
|
||||
).filter { (asset, _) ->
|
||||
val dir = asset.substringBeforeLast('/')
|
||||
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
|
||||
}
|
||||
val all = (if (File(root, "usr/bin/labwc").isFile) files + desktop else files) + optional
|
||||
for ((asset, relative) in all) {
|
||||
val target = File(root, relative)
|
||||
val staged = File(target.parentFile, target.name + ".staged")
|
||||
|
||||
@@ -220,6 +220,49 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("fexPreset", id).apply()
|
||||
}
|
||||
|
||||
/** The Steam client branch forced on the command line: "publicbeta" (every session so far) or "steamdeck_publicbeta" (Armada's). */
|
||||
fun steamChannel(context: Context): String =
|
||||
prefs(context).getString("steamChannel", null)
|
||||
// Deck mode on the publicbeta channel reinstalls the same client at every start (the
|
||||
// client reports "installed version 0" against that manifest and exits 42 to apply it,
|
||||
// losing the launch URL each time); on steamdeck_publicbeta the second launch comes up
|
||||
// clean. Seen on device 2026-09-23. So Deck mode takes the Deck channel unless chosen.
|
||||
?: if (steamDeckMode(context)) "steamdeck_publicbeta" else "publicbeta"
|
||||
|
||||
fun setSteamChannel(context: Context, id: String) {
|
||||
prefs(context).edit().putString("steamChannel", id).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders of the user's own Windows games (one game per subfolder), any number of them
|
||||
* from anywhere on the device. The single folder an earlier build kept is carried in.
|
||||
*/
|
||||
fun addedGamesDirs(context: Context): List<String> {
|
||||
val p = prefs(context)
|
||||
val list = p.getString("addedGamesDirs", null)
|
||||
if (list != null) return list.split('\n').filter { it.isNotEmpty() }
|
||||
val old = p.getString("addedGamesDir", "") ?: ""
|
||||
return if (old.isEmpty()) emptyList() else listOf(old)
|
||||
}
|
||||
|
||||
fun setAddedGamesDirs(context: Context, dirs: List<String>) {
|
||||
prefs(context).edit().putString("addedGamesDirs", dirs.distinct().joinToString("\n")).remove("addedGamesDir").apply()
|
||||
}
|
||||
|
||||
/** Whether added games without art of their own get Steam's store art fetched for them. */
|
||||
fun addedGamesArt(context: Context): Boolean = prefs(context).getBoolean("addedGamesArt", true)
|
||||
|
||||
fun setAddedGamesArt(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("addedGamesArt", on).apply()
|
||||
}
|
||||
|
||||
/** The .exe the user chose for one game folder (by its path), "" = the scanner's pick. */
|
||||
fun addedGameExe(context: Context, folderPath: String): String = prefs(context).getString("addedExe:$folderPath", "") ?: ""
|
||||
|
||||
fun setAddedGameExe(context: Context, folderPath: String, path: String) {
|
||||
prefs(context).edit().putString("addedExe:$folderPath", path).apply()
|
||||
}
|
||||
|
||||
/** The app's colour theme (ui/Themes ids); Paper on black unless chosen otherwise. */
|
||||
fun theme(context: Context): String = prefs(context).getString("theme", "paper") ?: "paper"
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ import com.steamdeck.launcher.audio.DirectAudioRelayComponent
|
||||
import com.steamdeck.launcher.audio.PulseAudioComponent
|
||||
import com.steamdeck.launcher.core.CpuCores
|
||||
import com.steamdeck.launcher.core.DeviceReport
|
||||
import com.steamdeck.launcher.core.EnvVars
|
||||
import com.steamdeck.launcher.core.HostEnvironment
|
||||
import com.steamdeck.launcher.core.SessionLogCapture
|
||||
import com.steamdeck.launcher.core.NetworkReport
|
||||
import com.steamdeck.launcher.core.EnvironmentComponent
|
||||
import com.steamdeck.launcher.core.SessionPart
|
||||
import com.steamdeck.launcher.core.FileUtils
|
||||
import com.steamdeck.launcher.core.ProcessHelper
|
||||
import com.steamdeck.launcher.core.HostProcess
|
||||
import com.steamdeck.launcher.input.FakeInputWriter
|
||||
import com.steamdeck.launcher.runtime.LinuxNetworkLinkComponent
|
||||
import com.steamdeck.launcher.runtime.LinuxRuntime
|
||||
@@ -52,7 +52,7 @@ import java.util.Locale
|
||||
* The activity comes and goes on top of this; see [com.steamdeck.launcher.wayland.CompositorHost].
|
||||
*/
|
||||
class SessionService : Service() {
|
||||
private val components = ArrayList<EnvironmentComponent>()
|
||||
private val components = ArrayList<SessionPart>()
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
private var sessionPid = -1
|
||||
@@ -201,6 +201,7 @@ class SessionService : Service() {
|
||||
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
|
||||
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
|
||||
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
|
||||
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
|
||||
// whose seccomp answers its syscalls normally there is no reason to take it away.
|
||||
@@ -248,15 +249,18 @@ class SessionService : Service() {
|
||||
|
||||
val audioLog = File(sessionDir, "audio.log")
|
||||
val pulse = PulseAudioComponent(this, micFifo?.absolutePath)
|
||||
// With DirectAudio on, the client's own sound goes through the relay too: the daemon
|
||||
// fills the relay's ring and the relay, outside proot, drives the device.
|
||||
if (wantsDirectAudio) pulse.setRelaySocket(relaySocket.absolutePath)
|
||||
pulse.setLogFile(audioLog)
|
||||
pulse.setContext(this)
|
||||
pulse.attach(this)
|
||||
guest.add("PULSE_SERVER=unix:" + pulse.socket().absolutePath)
|
||||
components.add(pulse)
|
||||
if (wantsDirectAudio || wantsMic) {
|
||||
// After the daemon in the list, so it can wait for the pipe the daemon makes.
|
||||
val relay = DirectAudioRelayComponent(relaySocket, micFifo)
|
||||
relay.setLogFile(audioLog)
|
||||
relay.setContext(this)
|
||||
relay.attach(this)
|
||||
components.add(relay)
|
||||
}
|
||||
if (wantsDirectAudio) {
|
||||
@@ -315,6 +319,14 @@ class SessionService : Service() {
|
||||
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
|
||||
guest.add("BL_WLR_RENDERER=" + (override?.takeIf { it.isNotEmpty() } ?: SessionPrefs.desktopRenderer(this)))
|
||||
}
|
||||
// The user's own games, for the runtime's shortcuts writer to put in the client's library
|
||||
// before the client starts (see frontend/AddedGames and bannerlator-steam-shortcuts).
|
||||
if (SessionState.mode == MODE_STEAM) {
|
||||
val added = com.steamdeck.launcher.frontend.AddedGames.scan(this)
|
||||
val listing = com.steamdeck.launcher.frontend.AddedGames.writeListing(this, added)
|
||||
guest.add("BL_ADDED_GAMES=" + listing.path)
|
||||
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
|
||||
}
|
||||
// Where the guest leaves a request for another session (the desktop's Steam launchers).
|
||||
guest.add("BL_LAUNCH_DIR=" + sessionRoot.path)
|
||||
// The second library's name, for bannerlator-steam-library; the bind itself is made below.
|
||||
@@ -349,6 +361,17 @@ class SessionService : Service() {
|
||||
|
||||
val binds = ArrayList<String>()
|
||||
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
|
||||
// The client's battery readout (the Quick Access Menu, the top bar) reads
|
||||
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
|
||||
// called "battery" and its files differ, so the client sees no battery at all. A directory
|
||||
// of our own, written from Android's battery API every few seconds, is bound over it.
|
||||
val battery = BatteryComponent(File(filesDir, "session/sys/power_supply"))
|
||||
battery.attach(this)
|
||||
components.add(battery)
|
||||
binds.add(battery.dir.path + ":/sys/class/power_supply")
|
||||
// Rumble for the on-screen pad: the fake evdev layer sends force-feedback effects to this
|
||||
// listener, which drives the phone's vibrator (see RumbleComponent).
|
||||
if (controllersOn) components.add(RumbleComponent().also { it.attach(this) })
|
||||
// Where the device's files appear inside the session. Internal storage is bound at its own
|
||||
// path already, and every program's file dialog opens at home and lists "Computer" from
|
||||
// /proc/mounts, where a proot bind never shows - so a user saw only the runtime's own
|
||||
@@ -374,6 +397,17 @@ class SessionService : Service() {
|
||||
} else {
|
||||
Log.i(TAG, "game storage: internal only")
|
||||
}
|
||||
// The user's own games folder (the Steam cog's "Added games"), bound at a fixed place so
|
||||
// the shortcuts the app writes point somewhere whatever storage the folder is on.
|
||||
for (root in com.steamdeck.launcher.frontend.AddedGames.roots(this)) {
|
||||
if (root.host.isDirectory && root.host.canRead()) {
|
||||
File(LinuxRuntime.rootDir(this), root.guest.removePrefix("/")).mkdirs()
|
||||
binds.add(root.host.path + ":" + root.guest)
|
||||
Log.i(TAG, "added games: ${root.host} -> ${root.guest}")
|
||||
} else {
|
||||
Log.w(TAG, "added games: ${root.host} is not a readable folder this session")
|
||||
}
|
||||
}
|
||||
val roms = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }?.let { File(it) }
|
||||
if (roms != null && roms.isDirectory && roms.canRead()) {
|
||||
File(home, "ROMs").mkdirs()
|
||||
@@ -387,26 +421,26 @@ class SessionService : Service() {
|
||||
this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds, guest,
|
||||
)
|
||||
|
||||
val hostEnv = EnvVars()
|
||||
hostEnv.put("PROOT_LOADER", LinuxRuntime.prootLoader(this).path)
|
||||
hostEnv.put("PROOT_TMP_DIR", cacheDir.path)
|
||||
val hostEnv = HostEnvironment()
|
||||
hostEnv["PROOT_LOADER"] = LinuxRuntime.prootLoader(this).path
|
||||
hostEnv["PROOT_TMP_DIR"] = cacheDir.path
|
||||
// proot links against a libtalloc beside it, and Android's linker does not search an
|
||||
// executable's own directory: unnamed, the process dies before it starts and says so only
|
||||
// in `logcat -b crash`.
|
||||
// proot reads this itself, so it belongs in proot's own environment rather than the guest's.
|
||||
if (SessionPrefs.prootNoSeccomp(this)) {
|
||||
hostEnv.put("PROOT_NO_SECCOMP", "1")
|
||||
hostEnv["PROOT_NO_SECCOMP"] = "1"
|
||||
Log.i(TAG, "proot: seccomp acceleration off by request")
|
||||
}
|
||||
val prootLibs = LinuxRuntime.prootLibraryPath(this)
|
||||
if (prootLibs.isNotEmpty()) hostEnv.put("LD_LIBRARY_PATH", prootLibs)
|
||||
if (prootLibs.isNotEmpty()) hostEnv["LD_LIBRARY_PATH"] = prootLibs
|
||||
|
||||
// Whether the client signs in to Valve or starts offline: read once, while it starts, and
|
||||
// rewritten by the client when it exits, so it is set again here at every session start.
|
||||
if (SessionState.mode == MODE_STEAM) OfflineMode.apply(this, root)
|
||||
|
||||
val networkLink = LinuxNetworkLinkComponent(this, root)
|
||||
networkLink.setContext(this)
|
||||
networkLink.attach(this)
|
||||
networkLink.publish()
|
||||
components.add(networkLink)
|
||||
components.forEach { it.start() }
|
||||
@@ -417,10 +451,10 @@ class SessionService : Service() {
|
||||
// by the teardown a second after the new one has started, and its exit used to arrive
|
||||
// here as "session ended: 137" and end the NEW session. An exit belongs to the session
|
||||
// that started it.
|
||||
sessionPid = ProcessHelper.exec(line, hostEnv.toStringArray(), root, { status ->
|
||||
sessionPid = HostProcess.start(line, hostEnv.asArray(), root, { status ->
|
||||
if (gen != sessionGen) {
|
||||
Log.i(TAG, "an earlier session's process ended ($status); the current one carries on")
|
||||
return@exec
|
||||
return@start
|
||||
}
|
||||
Log.i(TAG, "session ended: $status")
|
||||
stopSession(status ?: -1)
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -57,9 +58,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -776,16 +774,26 @@ private fun Poster(art: File?, name: String, modifier: Modifier) {
|
||||
@Composable
|
||||
private fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
|
||||
val square = tiles.isNotEmpty() && tiles.all { it.art == null && it.iconRes != null }
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = if (square) 64.dp else if (wide) 92.dp else 70.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(top = 6.dp, bottom = 14.dp, start = 4.dp, end = 4.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(tiles, key = { it.key }) { t ->
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
Box(modifier = Modifier.animateItemPlacement().zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot) }
|
||||
// Thumbnails to recognise a game by, not posters; icon tiles are squares.
|
||||
val minSize = if (square) 64.dp else if (wide) 92.dp else 70.dp
|
||||
val gap = 8.dp
|
||||
// Laid out whole, not lazily: the pad's focus search only finds tiles that exist, and a lazy
|
||||
// grid composes only the rows on screen, so a press towards the next row bounced back among
|
||||
// the visible tiles. A few hundred tiles lay out fine; the scroll follows the focused one.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val avail = maxWidth - 8.dp
|
||||
val cols = ((avail + gap) / (minSize + gap)).toInt().coerceAtLeast(1)
|
||||
val tileWidth = (avail - gap * (cols - 1)) / cols
|
||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = 6.dp, bottom = 14.dp, start = 4.dp, end = 4.dp)) {
|
||||
for (row in tiles.chunked(cols)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(gap), modifier = Modifier.fillMaxWidth().padding(bottom = gap)) {
|
||||
for (t in row) key(t.key) {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,6 @@ fun CreditsDialog(onDismiss: () -> Unit) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("maxjivi05: gamescope runtime and controller support, based on WinNative.", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("Winlator (brunodev85): audio, gamepad, and session components.", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"GPL-3.0. Steam, Steam Deck, and Proton are Valve trademarks. Not affiliated with Valve. Third-party software remains under its authors' licenses.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -33,8 +33,18 @@ class ModeSettings(
|
||||
val gameStorage: String? = null,
|
||||
val storageOptions: List<Pair<String, String>> = emptyList(),
|
||||
val fexPreset: String? = null,
|
||||
/** Steam only: the client branch forced on the command line. */
|
||||
val steamChannel: String? = null,
|
||||
/** Steam only: the user's own games folder and what was found in it. */
|
||||
/** The chosen Games folders (null = not a Steam page). */
|
||||
val addedGamesDirs: List<String>? = null,
|
||||
val addedGames: List<AddedGameRow> = emptyList(),
|
||||
val addedGamesArt: Boolean = true,
|
||||
)
|
||||
|
||||
/** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */
|
||||
class AddedGameRow(val folderPath: String, val folderName: String, val exePath: String, val exeName: String, val candidates: List<Pair<String, String>>)
|
||||
|
||||
class ModeSettingsActions(
|
||||
val onResolution: (Int) -> Unit,
|
||||
val onShape: (String) -> Unit,
|
||||
@@ -53,6 +63,12 @@ class ModeSettingsActions(
|
||||
val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> },
|
||||
val onPickGameStorageFolder: () -> Unit = {},
|
||||
val onFexPreset: (String) -> Unit = {},
|
||||
val onSteamChannel: (String) -> Unit = {},
|
||||
val onPickAddedGamesDir: () -> Unit = {},
|
||||
val onForgetAddedGamesDir: (path: String) -> Unit = {},
|
||||
val onAddedGamesArt: (Boolean) -> Unit = {},
|
||||
val onAddedGameExe: (folderPath: String, path: String) -> Unit = { _, _ -> },
|
||||
val onPickAddedGameExe: (folderPath: String) -> Unit = {},
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
@@ -119,6 +135,39 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
|
||||
)
|
||||
}
|
||||
if (steam && s.steamChannel != null) SettingsGroup("Client") {
|
||||
ChoiceRow(
|
||||
host, "channel", "Client branch", "The Steam client build the session forces. Applies at the next session start; the client may update itself once.",
|
||||
listOf("publicbeta" to "Public beta", "steamdeck_publicbeta" to "Steam Deck public beta"), s.steamChannel,
|
||||
note = "Public beta is what every session ran on before. Steam Deck public beta is the channel Deck mode needs (on public beta it reinstalls the same client at every start) and the one Armada bootstraps from; Deck mode picks it unless you choose here.",
|
||||
onPick = a.onSteamChannel,
|
||||
)
|
||||
}
|
||||
if (steam && s.addedGamesDirs != null) SettingsGroup("Added games") {
|
||||
for (dir in s.addedGamesDirs) {
|
||||
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
|
||||
ActionRow(
|
||||
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) "no game folders with a .exe found" else "$n game${if (n == 1) "" else "s"}") + ". Forget: the games leave the client's library at the next session start; nothing on disk is touched.",
|
||||
"Forget", onClick = { a.onForgetAddedGamesDir(dir) },
|
||||
)
|
||||
}
|
||||
ActionRow(
|
||||
if (s.addedGamesDirs.isEmpty()) "Games folder" else "Another games folder",
|
||||
"Your own Windows games, one subfolder each, anywhere: internal storage, the SD card, a USB drive. As many folders as you like. Each game goes into the client's library as a non-Steam game under the ARM64 Proton, at the next session start.",
|
||||
"Add…", onClick = a.onPickAddedGamesDir,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "addedArt", "Artwork from Steam",
|
||||
"A game with no art of its own gets the store's capsule, header, hero and logo for the same title, looked up by folder name. Your own art wins: drop cover.jpg (or poster, boxart, folder, the folder's name), header.jpg, hero.jpg, logo.png or icon.png into the game's folder or its art subfolder.",
|
||||
s.addedGamesArt, onChange = a.onAddedGamesArt,
|
||||
)
|
||||
for (g in s.addedGames) ChoiceRow(
|
||||
host, "added:" + g.folderPath, g.folderName, "Launches ${g.exeName}" + (if (s.addedGamesDirs.size > 1) " · in " + g.folderPath.substringBeforeLast('/').substringAfterLast('/') else ""),
|
||||
g.candidates + ("__pick__" to "Choose another file…"), g.exePath,
|
||||
note = "The .exe files found in the game's folder; the one named after the folder, else the largest, is picked unless you choose.",
|
||||
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
|
||||
)
|
||||
}
|
||||
if (steam && s.fexPreset != null) SettingsGroup("Games") {
|
||||
ChoiceRow(
|
||||
host, "fex", "FEX preset", "Applies on next game launch.",
|
||||
@@ -127,7 +176,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
)
|
||||
}
|
||||
if (steam && s.directAudio != null && s.mic != null) SettingsGroup("Audio") {
|
||||
ToggleRow(host, "da", "DirectAudio for games", "Bypasses PulseAudio for lower latency.", s.directAudio, onChange = a.onDirectAudio)
|
||||
ToggleRow(host, "da", "DirectAudio", "Bypasses PulseAudio for lower latency, for games and the Steam client.", s.directAudio, onChange = a.onDirectAudio)
|
||||
ToggleRow(host, "mic", "Microphone", "Uses the device microphone for voice chat.", s.mic, onChange = a.onMic)
|
||||
}
|
||||
if (steam && s.gameStorage != null) SettingsGroup("Game storage") {
|
||||
|
||||
@@ -89,7 +89,7 @@ fun PerformancePage(
|
||||
)
|
||||
ToggleRow(
|
||||
host, "deck", "Steam Deck mode",
|
||||
"Uses SteamOS launch flags. Untested on non-Deck hardware.",
|
||||
"Runs the client with -steamdeck. SteamOS helpers and battery info are provided.",
|
||||
steamDeckMode, onChange = onSteamDeckMode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,17 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-23 - branch `feat/armada-and-lineage`
|
||||
|
||||
- Steam Deck mode: `-steamdeck` only; SteamOS helper stubs staged from the apk under `/usr/bin` and `/usr/bin/steamos-polkit-helpers` (the missing `steamos-update` there was the "Update Error" dialog; `jupiter-dock-updater --check` answers 7 so no dock firmware row); Deck mode defaults the `steamdeck_publicbeta` channel (on `publicbeta` every start reinstalled the client and lost the launch URL); client branch row in the Steam cog.
|
||||
- QAM battery: `session/BatteryComponent.kt` writes BAT0/BAT1 from BatteryManager, bound over `/sys/class/power_supply`. Cloud saves checked on device: in sync, nothing was broken.
|
||||
- Added games: any number of Games folders, each bound under `/root/Games`; one shortcut per subfolder in the client's `shortcuts.vdf` under the ARM64 Proton; exe choice per game; art from the folder, else Steam's store (capsule, header, hero, logo) copied into the client's grid.
|
||||
- gamescope: the runtime's 3.16.29 rebuilt in CI (`tools/gamescope`, release `gamescope-3.16.29-p1`) with Armada's ARM64 client fixes, a realtime-queue switch and the gamepad cursor fix; staged over `/usr/local/bin`. Game windows forced fullscreen in Steam mode (a game came back in the top-left corner after the Steam menu).
|
||||
- Audio: own PulseAudio sinks in `tools/aaudio-sink`, built into the bundle by CI. With DirectAudio on, the client's sound goes through the relay's shared ring (`module-directaudio-sink`, sink named DirectAudio); off, `module-aaudio-sink`. Inside proot AAudio only gave 20 ms bursts, the relay gets 4 ms.
|
||||
- Winlator code rewritten or removed (SessionPart, HostEnvironment, HostProcess, PadState, cpp/framegen); rumble now served by `session/RumbleComponent.kt`.
|
||||
- Front end shelf laid out whole so the d-pad reaches every tile.
|
||||
- Hosted runtime and desktop packages untouched.
|
||||
|
||||
## 0.1.5 RELEASED 2026-09-23 03:17 - known-good point
|
||||
|
||||
- **Tag `0.1.5` = `d2f4a84`** (bump commit, versionCode 6), notes `docs/releases/0.1.5.md` on main after it. Private release [0.1.5](https://github.com/The412Banner/SteamDeck/releases/tag/0.1.5) and public [`SteamDeck-0.1.5`](https://github.com/The412Banner/winlator-contents/releases/tag/SteamDeck-0.1.5) on winlator-contents, both Latest, asset `SteamDeck-0.1.5.apk` 21,093,959 bytes, sha256 `85b42954…`, run 35813465422. Staged as `/sdcard/Download/SteamDeck-0.1.5.apk`.
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compiles module-aaudio-sink.so against the upstream PulseAudio 13.0 headers and the daemon's own
|
||||
# libraries the app ships (app/src/main/jniLibs/arm64-v8a), with the Android NDK.
|
||||
# NDK=<ndk root> tools/aaudio-sink/build.sh <pulseaudio-13.0 source dir> <output dir>
|
||||
# Produces module-aaudio-sink.so and module-directaudio-sink.so in the output dir.
|
||||
set -euo pipefail
|
||||
PA_SRC=$1
|
||||
OUTDIR=$2
|
||||
mkdir -p "$OUTDIR"
|
||||
: "${NDK:?set NDK to the Android NDK root}"
|
||||
API=26
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
REPO=$(cd "$HERE/../.." && pwd)
|
||||
LIBS="$REPO/app/src/main/jniLibs/arm64-v8a"
|
||||
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
CC="$TOOLCHAIN/aarch64-linux-android${API}-clang"
|
||||
test -x "$CC"
|
||||
test -f "$PA_SRC/src/pulse/version.h.in"
|
||||
INC=$(mktemp -d)
|
||||
mkdir -p "$INC/pulse"
|
||||
sed -e 's/@PA_MAJOR@/13/g' -e 's/@PA_MINOR@/0/g' -e 's/@PA_API_VERSION@/12/g' -e 's/@PA_PROTOCOL_VERSION@/33/g' \
|
||||
"$PA_SRC/src/pulse/version.h.in" > "$INC/pulse/version.h"
|
||||
cp "$HERE/config.h" "$HERE/ltdl.h" "$INC/"
|
||||
build_one() {
|
||||
local src=$1 OUT=$2 extra=$3
|
||||
# PulseAudio 13's own headers (pulsecore/atomic.h) predate clang's int-conversion error; that
|
||||
# one is downgraded for them, the modules themselves compile clean.
|
||||
"$CC" -O2 -shared -fPIC -Wall -Wno-unused-parameter -Wno-error=int-conversion -Wno-visibility -DHAVE_CONFIG_H \
|
||||
-I"$INC" -I"$PA_SRC/src" -I"$HERE" \
|
||||
-o "$OUT" "$HERE/$src" \
|
||||
-L"$LIBS" -l:libpulsecore-13.0.so -l:libpulsecommon-13.0.so -l:libpulse.so $extra
|
||||
"$TOOLCHAIN/llvm-strip" --strip-unneeded "$OUT"
|
||||
# What the daemon looks for, and nothing linked that a device would not have.
|
||||
for sym in pa__init pa__done pa__get_author pa__get_description pa__get_usage pa__get_version pa__load_once; do
|
||||
"$TOOLCHAIN/llvm-nm" -D --defined-only "$OUT" | grep -q " $sym$" || { echo "ERROR: $OUT does not export $sym"; exit 1; }
|
||||
done
|
||||
local NEEDED
|
||||
NEEDED=$("$TOOLCHAIN/llvm-readelf" -d "$OUT" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p' | tr '\n' ' ')
|
||||
echo "$(basename "$OUT") NEEDED: $NEEDED"
|
||||
for lib in $NEEDED; do
|
||||
case $lib in libpulsecore-13.0.so|libpulsecommon-13.0.so|libpulse.so|libaaudio.so|libc.so|libm.so|libdl.so) ;;
|
||||
*) echo "ERROR: unexpected dependency $lib"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
ls -l "$OUT"
|
||||
}
|
||||
build_one module-aaudio-sink.c "$OUTDIR/module-aaudio-sink.so" -laaudio
|
||||
build_one module-directaudio-sink.c "$OUTDIR/module-directaudio-sink.so" ""
|
||||
@@ -0,0 +1,15 @@
|
||||
/* The handful of build-time facts PulseAudio 13's headers read, for compiling one module against
|
||||
* the upstream source tree without running its configure. The daemon and libraries the app ships
|
||||
* were built for the same target (Android arm64, bionic), so these match them. */
|
||||
#ifndef STEAMDECK_PA_CONFIG_H
|
||||
#define STEAMDECK_PA_CONFIG_H
|
||||
#define PACKAGE "pulseaudio"
|
||||
#define PACKAGE_NAME "pulseaudio"
|
||||
#define PACKAGE_VERSION "13.0"
|
||||
#define HAVE_ATOMIC_BUILTINS 1
|
||||
#define HAVE_ATOMIC_BUILTINS_MEMORY_MODEL 1
|
||||
#define HAVE_CLOCK_GETTIME 1
|
||||
#define HAVE_FAST_64BIT_OPERATIONS 1
|
||||
#define HAVE_SYS_RESOURCE_H 1
|
||||
#define HAVE_MEMFD 1
|
||||
#endif
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user