mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
The last of the Winlator lineage rewritten out; rumble on the phone
The five files the audit measured as Winlator's are replaced: a one-method callback interface by java.util.function.Consumer, the component base by SessionPart (attach an application context, start, stop), the environment list by HostEnvironment, the process starter by HostProcess, and the pad's state holder by PadState with named sticks, triggers, directions and buttons. The frame-generation tree is framegen/ (its contents were never Winlator's: lsfg-vk's port and Bannerlator's Win-FG), the log tags are the app's, and the fake pad's rumble socket is now one the app actually serves: RumbleComponent turns a game's force-feedback effects into the phone's vibrator, which used to go nowhere. The two design notes describing Bannerlator's X11 path are gone, and the README and credits say what is whose.
This commit is contained in:
1 parent
35dbef0536
commit
a604dce6cb
84 files changed
+493
-1771
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +26,7 @@ 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";
|
||||
@@ -136,7 +136,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();
|
||||
}
|
||||
@@ -137,21 +137,21 @@ import java.util.Locale
|
||||
/**
|
||||
* What to do when a pasted item already exists at the destination.
|
||||
*
|
||||
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
|
||||
* directory and truncates existing files - but they mean different things to the user, so both are
|
||||
* OVERWRITE and MERGE resolve to the same call — `copyWithProgress` recurses into an existing
|
||||
* directory and truncates existing files — but they mean different things to the user, so both are
|
||||
* offered and the wording is chosen per item type (files overwrite, folders merge).
|
||||
*/
|
||||
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
|
||||
|
||||
/**
|
||||
* Ordering for the file list. Folders always lead regardless of direction - a descending sort that
|
||||
* Ordering for the file list. Folders always lead regardless of direction — a descending sort that
|
||||
* buries every folder under the files is never what someone means by "Z to A".
|
||||
*/
|
||||
/**
|
||||
* 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/…` tells you nothing about where you are.
|
||||
*/
|
||||
private fun elidePathStart(path: String, max: Int): String {
|
||||
if (path.length <= max) return path
|
||||
@@ -196,7 +196,7 @@ private fun isWithin(child: File, ancestor: File): Boolean {
|
||||
|
||||
// ── DOS file attributes (Read-only / Hidden) ──
|
||||
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
|
||||
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
|
||||
// same two toggles for files browsed from the app side. No root — we only touch permission bits the
|
||||
// app owns (container files) and the Wine DOS-attribute xattr.
|
||||
|
||||
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
|
||||
@@ -204,7 +204,7 @@ private const val FILE_ATTRIBUTE_HIDDEN = 0x2
|
||||
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
|
||||
|
||||
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
|
||||
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
|
||||
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) — the Hidden toggle is then
|
||||
// disabled while Read-only keeps working.
|
||||
private data class FileAttrState(
|
||||
val readOnly: Boolean,
|
||||
@@ -226,7 +226,7 @@ private fun parseDosAttrib(raw: ByteArray): Int {
|
||||
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
|
||||
private fun readFileAttrs(file: File): FileAttrState {
|
||||
val path = file.absolutePath
|
||||
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
|
||||
// Read-only reflects the OWNER write bit — that's what Wine maps to FILE_ATTRIBUTE_READONLY.
|
||||
val readOnly = runCatching {
|
||||
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
|
||||
}.getOrDefault(!file.canWrite())
|
||||
@@ -309,7 +309,7 @@ fun describeLocation(file: File): FavLocation {
|
||||
}
|
||||
|
||||
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
|
||||
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
|
||||
// accent colours — they identify the storage source at a glance. Returns (background, foreground).
|
||||
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
|
||||
val white = Color(0xFFFFFFFF)
|
||||
return when {
|
||||
@@ -350,13 +350,13 @@ fun FileManagerScreen(
|
||||
val rootDir = remember {
|
||||
// Both modes: honour an explicit caller-supplied start dir (e.g. Log Manager's game-log
|
||||
// folder), else open at the INTERNAL STORAGE ROOT. Selection screens (drive-folder pick,
|
||||
// local component pick, imports) previously defaulted to Download which - combined with the
|
||||
// currentRoot floor below - trapped users in Download with no way up (reported bug).
|
||||
// local component pick, imports) previously defaulted to Download which — combined with the
|
||||
// currentRoot floor below — trapped users in Download with no way up (reported bug).
|
||||
initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0")
|
||||
}
|
||||
|
||||
var currentDir by remember { mutableStateOf(rootDir) }
|
||||
// The up/back FLOOR - back + the up-arrow are disabled while currentDir == currentRoot. It MUST be
|
||||
// The up/back FLOOR — back + the up-arrow are disabled while currentDir == currentRoot. It MUST be
|
||||
// the VOLUME ROOT of the start dir (internal /storage/emulated/0, or an SD card /storage/XXXX-XXXX),
|
||||
// NOT the start dir itself: otherwise opening at any subfolder disables up/back and traps the user
|
||||
// there. (Mirrors the volume-root logic in favLocationOf above.)
|
||||
@@ -377,7 +377,7 @@ fun FileManagerScreen(
|
||||
var selectedEntry by remember { mutableStateOf<File?>(null) }
|
||||
var showMenuFor by remember { mutableStateOf<File?>(null) }
|
||||
// Clipboard holds a LIST so one paste can carry a whole selection. Cut/copy semantics are a
|
||||
// flag on the batch rather than per item - mixing the two in one clipboard has no sane meaning.
|
||||
// flag on the batch rather than per item — mixing the two in one clipboard has no sane meaning.
|
||||
var clipboardFiles by remember { mutableStateOf<List<File>>(emptyList()) }
|
||||
var isCutOperation by remember { mutableStateOf(false) }
|
||||
// Multi-select. Keyed by absolute path rather than File so a directory reload (which builds
|
||||
@@ -398,10 +398,10 @@ fun FileManagerScreen(
|
||||
var sortDesc by remember { mutableStateOf(browsePrefs.getBoolean("fmSortDesc", false)) }
|
||||
var showHidden by remember { mutableStateOf(browsePrefs.getBoolean("fmShowHidden", true)) }
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
// View mode: list of cards (default) or a thumbnail grid. Density applies to the list only -
|
||||
// View mode: list of cards (default) or a thumbnail grid. Density applies to the list only —
|
||||
// a grid tile has no second line to compact.
|
||||
// The grid/list toggle is the SOURCE OF TRUTH in BOTH orientations (it drives the view and its
|
||||
// choice persists across rotation). Grid is the default - most useful in landscape, and in
|
||||
// choice persists across rotation). Grid is the default — most useful in landscape, and in
|
||||
// portrait GridCells.Adaptive naturally renders fewer columns (~2). Do NOT force portrait to list:
|
||||
// that broke the toggle on-device (tapping it did nothing in portrait).
|
||||
var gridView by remember { mutableStateOf(browsePrefs.getBoolean("fmGridView", true)) }
|
||||
@@ -502,7 +502,7 @@ fun FileManagerScreen(
|
||||
suspend fun askConflict(file: File): ConflictChoice? {
|
||||
pendingConflict = file
|
||||
conflictChoice = null
|
||||
// Poll rather than plumb a CompletableDeferred through Compose state - the dialog answers
|
||||
// Poll rather than plumb a CompletableDeferred through Compose state — the dialog answers
|
||||
// by setting conflictChoice, and this coroutine is already off the critical path.
|
||||
while (pendingConflict != null && conflictChoice == null) kotlinx.coroutines.delay(50)
|
||||
return conflictChoice
|
||||
@@ -556,7 +556,7 @@ fun FileManagerScreen(
|
||||
operationLabel = buildString {
|
||||
append(if (cut) "Moving" else "Copying")
|
||||
if (sources.size > 1) append(" ${done + 1}/${sources.size}")
|
||||
append(" - ").append(src.name)
|
||||
append(" — ").append(src.name)
|
||||
}
|
||||
var lastPct = -1
|
||||
val onProgress = FileOps.ProgressCallback { copied, total ->
|
||||
@@ -745,7 +745,7 @@ fun FileManagerScreen(
|
||||
isOperationRunning = true
|
||||
var failed = 0
|
||||
victims.forEachIndexed { i, f ->
|
||||
operationLabel = "Deleting ${i + 1}/${victims.size} - ${f.name}"
|
||||
operationLabel = "Deleting ${i + 1}/${victims.size} — ${f.name}"
|
||||
if (!withContext(Dispatchers.IO) { FileOps.delete(f) }) failed++
|
||||
}
|
||||
isOperationRunning = false
|
||||
@@ -761,7 +761,7 @@ fun FileManagerScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Paste conflict - one per colliding item, with "apply to all" for a long batch.
|
||||
// Paste conflict — one per colliding item, with "apply to all" for a long batch.
|
||||
pendingConflict?.let { conflict ->
|
||||
val isDir = conflict.isDirectory
|
||||
OutlinedAlertDialog(
|
||||
@@ -856,7 +856,7 @@ fun FileManagerScreen(
|
||||
val driveChipAlpha = if (showFavorites) 0.45f else 1f
|
||||
Box {
|
||||
// The drive/location selector opens the drive dropdown, so give it the same outlined
|
||||
// look as the "New Folder" button + the rail location items - it reads as a button, not
|
||||
// look as the "New Folder" button + the rail location items — it reads as a button, not
|
||||
// plain text. Border uses the theme accent token; behaviour unchanged.
|
||||
val driveChipShape = RoundedCornerShape(8.dp)
|
||||
Text(
|
||||
@@ -909,7 +909,7 @@ fun FileManagerScreen(
|
||||
|
||||
if (showFavorites) {
|
||||
Text(
|
||||
text = "Favorites",
|
||||
text = "★ Favorites",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -918,12 +918,12 @@ fun FileManagerScreen(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) {
|
||||
// PORTRAIT: hide the current-folder name - it's redundant with the path bar directly
|
||||
// PORTRAIT: hide the current-folder name — it's redundant with the path bar directly
|
||||
// below (which shows the full path). The spacer keeps the action icons right-aligned.
|
||||
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/…"). The
|
||||
// full path moves to the line below, where it has room.
|
||||
Text(
|
||||
text = currentDir.name.ifBlank { currentDir.absolutePath },
|
||||
@@ -1029,7 +1029,7 @@ fun FileManagerScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Free space on the volume being browsed - worth knowing before starting a 60 GB copy.
|
||||
// Free space on the volume being browsed — worth knowing before starting a 60 GB copy.
|
||||
val freeSpace = remember(currentDir.absolutePath, entries) {
|
||||
runCatching { currentDir.usableSpace }.getOrDefault(0L)
|
||||
}
|
||||
@@ -1225,7 +1225,7 @@ fun FileManagerScreen(
|
||||
val locationSections = buildList {
|
||||
add(RailSection("STORAGE", storageItems))
|
||||
if (quickItems.isNotEmpty()) add(RailSection("QUICK", quickItems))
|
||||
if (favItems.isNotEmpty()) add(RailSection("FAVORITES", favItems))
|
||||
if (favItems.isNotEmpty()) add(RailSection("★ FAVORITES", favItems))
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
@@ -1431,7 +1431,7 @@ fun FileManagerScreen(
|
||||
|
||||
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
|
||||
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
|
||||
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
|
||||
// recomputed from [file] here — one source of truth for what each item shows. Every item dismisses
|
||||
// the menu first, then runs its action.
|
||||
@Composable
|
||||
private fun FileContextMenuItems(
|
||||
@@ -1462,7 +1462,7 @@ private fun FileContextMenuItems(
|
||||
onClick = { onDismissMenu(); onProperties() },
|
||||
)
|
||||
MenuItemDivider()
|
||||
// Favorites are directories - only folders get the pin toggle.
|
||||
// Favorites are directories — only folders get the pin toggle.
|
||||
if (isDir) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
|
||||
@@ -1708,7 +1708,7 @@ private fun FavoritesList(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
|
||||
text = "No favorites yet — pin a folder with its ⋮ menu to jump back here fast.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
@@ -1729,7 +1729,7 @@ private fun FavoritesList(
|
||||
}
|
||||
}
|
||||
|
||||
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
|
||||
// A single favourite — matches the FileItemRow card style (surfaceContainer + outline +
|
||||
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
|
||||
// and the full display path; tapping jumps into it, the filled star unpins.
|
||||
@Composable
|
||||
@@ -1818,7 +1818,7 @@ private fun FavoriteCard(
|
||||
/**
|
||||
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
|
||||
*
|
||||
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
|
||||
* Deliberately drops size and date — at this width they truncate to noise. The grid is for
|
||||
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
|
||||
* reading details.
|
||||
*/
|
||||
@@ -1852,7 +1852,7 @@ private fun FileGridTile(
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
}
|
||||
}
|
||||
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
|
||||
// The tile has no ⋮ button — long-press opens this menu, anchored to the Box around the Card.
|
||||
Box {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -1942,7 +1942,7 @@ private fun FileGridTile(
|
||||
|
||||
/**
|
||||
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
|
||||
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
|
||||
* the in-container file manager (wfm.exe) also exposes — Read-only and Hidden. Each toggle applies
|
||||
* immediately (off the main thread) and refreshes the listing via [onChanged].
|
||||
*
|
||||
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
|
||||
@@ -1980,7 +1980,7 @@ private fun FilePropertiesDialog(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
PropertyLine("Location", file.parent ?: "-")
|
||||
PropertyLine("Location", file.parent ?: "—")
|
||||
PropertyLine(
|
||||
"Type",
|
||||
if (file.isDirectory) "Folder"
|
||||
@@ -2033,7 +2033,7 @@ private fun FilePropertiesDialog(
|
||||
attrs = attrs?.copy(hidden = want)
|
||||
onChanged()
|
||||
} else {
|
||||
// The write failed after all - disable the toggle rather than lie.
|
||||
// The write failed after all — disable the toggle rather than lie.
|
||||
attrs = attrs?.copy(hiddenSupported = false)
|
||||
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@@ -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,19 +124,19 @@ 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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -203,13 +203,13 @@ public final class PadBridge {
|
||||
if (!open && !start()) return;
|
||||
if (systemGuidePressed || qamChordActive) {
|
||||
effectiveState.copy(state);
|
||||
effectiveState.setPressed(GamepadState.IDX_BUTTON_MODE, true);
|
||||
effectiveState.setPressed(PadState.IDX_BUTTON_MODE, true);
|
||||
if (qamSyntheticAPressed) {
|
||||
effectiveState.setPressed(0, state.isPressed(0) || qamSyntheticAPressed);
|
||||
}
|
||||
writer.writeGamepadState(effectiveState);
|
||||
writer.writePadState(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
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import android.util.Log
|
||||
import com.steamdeck.launcher.core.EnvironmentComponent
|
||||
import com.steamdeck.launcher.core.SessionPart
|
||||
import java.io.File
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -22,7 +22,7 @@ import kotlin.math.abs
|
||||
* 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) : EnvironmentComponent() {
|
||||
class BatteryComponent(val dir: File) : SessionPart() {
|
||||
@Volatile private var running = false
|
||||
private var thread: Thread? = null
|
||||
|
||||
@@ -45,7 +45,7 @@ class BatteryComponent(val dir: File) : EnvironmentComponent() {
|
||||
}
|
||||
|
||||
private fun write() {
|
||||
val ctx = context ?: return
|
||||
val ctx = app() ?: return
|
||||
try {
|
||||
val bm = ctx.getSystemService(BatteryManager::class.java)
|
||||
val sticky: Intent? = ctx.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -250,14 +250,14 @@ class SessionService : Service() {
|
||||
val audioLog = File(sessionDir, "audio.log")
|
||||
val pulse = PulseAudioComponent(this, micFifo?.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) {
|
||||
@@ -355,9 +355,12 @@ class SessionService : Service() {
|
||||
// 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.setContext(this)
|
||||
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
|
||||
@@ -396,26 +399,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() }
|
||||
@@ -426,10 +429,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)
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.steamdeck.launcher.R
|
||||
|
||||
/** Everything the main screen shows; the activity owns the values and the work behind them. */
|
||||
class MainUiState(
|
||||
val installed: String?,
|
||||
val ready: Boolean,
|
||||
@@ -52,13 +53,29 @@ class MainUiState(
|
||||
val failed: Boolean,
|
||||
val frameGenLabel: String,
|
||||
val desktopInstalled: Boolean,
|
||||
/** The account the client would sign in as offline, or null if it has never signed in. */
|
||||
val offlineAccount: String?,
|
||||
val offline: Boolean,
|
||||
/** The folder every session shows at /root/ROMs, or null if none is chosen. */
|
||||
val romsDir: String?,
|
||||
val logsEnabled: Boolean,
|
||||
/** Installed emulators that can run under gamescope: label to path inside the runtime. */
|
||||
val emulators: List<Pair<String, String>> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The whole app outside a session, in one column that is sized to the window it has.
|
||||
*
|
||||
* Panels and densities vary too much for fixed dp: a 7" 1080p handheld in landscape has barely
|
||||
* 400dp of height, a foldable's inner screen has 900. So the column is designed at one size and
|
||||
* scaled by the height available — everything on it, text included, shrinks together until it
|
||||
* fits — with scrolling left as the last resort, and a width cap so the buttons never span a
|
||||
* wide panel. The system bars are kept clear of.
|
||||
*
|
||||
* Play and Desktop span the column; everything else is a tile in two columns, a title with the
|
||||
* setting's current value under it, so the menu is read at a glance and fits without scrolling
|
||||
* on a handheld.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
@@ -87,6 +104,7 @@ fun MainScreen(
|
||||
.systemBarsPadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// The column as designed needs about this much height; below it, scale everything.
|
||||
val designHeight = 480.dp
|
||||
val k = (maxHeight / designHeight).coerceIn(0.55f, 1f)
|
||||
val colors = MaterialTheme.colorScheme
|
||||
@@ -106,6 +124,10 @@ fun MainScreen(
|
||||
)
|
||||
Spacer(Modifier.height(6.dp * k))
|
||||
Text("SteamDeck", fontSize = 22.sp * k, color = colors.onBackground)
|
||||
Text(
|
||||
"Valve's native ARM64 Steam client, under gamescope",
|
||||
fontSize = 12.sp * k, color = colors.onSurfaceVariant, textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp * k))
|
||||
|
||||
val status = when {
|
||||
@@ -116,14 +138,14 @@ fun MainScreen(
|
||||
Text(status, fontSize = 15.sp * k, color = colors.onBackground)
|
||||
val detail = when {
|
||||
state.busy -> if (state.percent >= 0) "${state.stage} ${state.percent}%" else state.stage
|
||||
state.failed -> "Install failed. Nothing changed."
|
||||
state.failed -> "Install failed — nothing was changed"
|
||||
!state.ready -> state.available?.let { "$it · ${state.availableSize} download, one time" }
|
||||
?: "Could not reach the runtime catalog"
|
||||
state.available != null && state.available != state.installed ->
|
||||
"Runtime ${state.installed ?: "?"} installed · ${state.available} available"
|
||||
else -> null
|
||||
else -> "Runtime ${state.installed ?: "?"} installed"
|
||||
}
|
||||
if (detail != null) Text(
|
||||
Text(
|
||||
detail, fontSize = 12.sp * k, color = colors.onSurfaceVariant, textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 3.dp * k),
|
||||
)
|
||||
@@ -138,6 +160,7 @@ fun MainScreen(
|
||||
Spacer(Modifier.height(14.dp * k))
|
||||
|
||||
val buttonHeight = 40.dp * k
|
||||
// Each launch button has its own cog: what only matters for that mode lives there.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp * k), modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = onPlay, enabled = state.ready && !state.busy,
|
||||
@@ -149,9 +172,11 @@ fun MainScreen(
|
||||
OutlinedButton(
|
||||
onClick = onDesktop, enabled = state.ready && !state.busy && state.desktopInstalled,
|
||||
modifier = Modifier.weight(1f).height(buttonHeight),
|
||||
) { Text("Desktop", fontSize = 13.sp * k, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
) { Text(if (state.desktopInstalled) "Desktop" else "Desktop — install it under Desktop & apps", fontSize = 13.sp * k, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
CogButton(k, onDesktopSettings)
|
||||
}
|
||||
// The ROMs folder, then every installed emulator as a session of its own under
|
||||
// gamescope (where the GPU is); the ? says why they are here and not on the desktop.
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp * k),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp * k),
|
||||
@@ -172,7 +197,7 @@ fun MainScreen(
|
||||
onClick = { onLaunchEmulator(path) }, enabled = state.ready && !state.busy,
|
||||
contentPadding = PaddingValues(horizontal = 10.dp * k, vertical = 0.dp),
|
||||
modifier = Modifier.height(30.dp * k),
|
||||
) { Text(label, fontSize = 11.sp * k, maxLines = 1) }
|
||||
) { Text("▶ $label", fontSize = 11.sp * k, maxLines = 1) }
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onEmulatorHelp,
|
||||
@@ -180,13 +205,15 @@ fun MainScreen(
|
||||
modifier = Modifier.width(30.dp * k).height(30.dp * k),
|
||||
) { Text("?", fontSize = 12.sp * k) }
|
||||
}
|
||||
// Offline is read while the client starts, so it is decided here rather than in the
|
||||
// session's drawer, and it needs credentials from an earlier sign-in to be possible.
|
||||
OutlinedButton(
|
||||
onClick = onOffline, enabled = state.offlineAccount != null,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 5.dp * k).height(buttonHeight),
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
state.offlineAccount == null -> "Start offline"
|
||||
state.offlineAccount == null -> "Start offline — sign in once first"
|
||||
state.offline -> "Start offline: on · ${state.offlineAccount}"
|
||||
else -> "Start offline: off"
|
||||
},
|
||||
@@ -233,6 +260,7 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** The cog beside a launch button. */
|
||||
@Composable
|
||||
private fun CogButton(k: Float, onClick: () -> Unit) {
|
||||
OutlinedButton(
|
||||
@@ -242,6 +270,7 @@ private fun CogButton(k: Float, onClick: () -> Unit) {
|
||||
) { Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(18.dp * k)) }
|
||||
}
|
||||
|
||||
/** One tile of the two-column menu: what it is, and under it what it is set to. */
|
||||
@Composable
|
||||
private fun MenuTile(
|
||||
title: String,
|
||||
@@ -268,12 +297,40 @@ private fun MenuTile(
|
||||
}
|
||||
}
|
||||
|
||||
/** The ? beside the emulator row: what the ▶ buttons are, how to use them, and why. */
|
||||
@Composable
|
||||
fun EmulatorHelpDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Emulators") },
|
||||
text = { Text("Place games in the ROMs folder. In RPCS3, install firmware from File > Install Firmware.") },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text("What the ▶ buttons are", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"One per emulator installed under Desktop & apps. Each starts that emulator fullscreen, as a " +
|
||||
"session of its own under gamescope — the same way the Steam client runs — with the Steam " +
|
||||
"cog's display, driver and HDR settings.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("How to use them", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"Put your games in the folder the ROMs chip beside these buttons points at. In the emulator, open root › ROMs " +
|
||||
"(/root/ROMs); the whole phone is at root › Storage. Firmware and BIOS files go in the " +
|
||||
"same way (RPCS3: File › Install Firmware). Back opens the drawer; Stop session ends it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Why not from the desktop", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"The desktop draws in software (labwc on pixman) and cannot hand a program the GPU: a Vulkan " +
|
||||
"emulator started there dies at its first frame (\"Surface lost\"). gamescope can, so an " +
|
||||
"emulator that renders with Vulkan or OpenGL belongs here. The desktop stays for files, " +
|
||||
"Firefox and anything that draws in software.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } },
|
||||
)
|
||||
}
|
||||
@@ -296,11 +353,10 @@ fun CreditsDialog(onDismiss: () -> Unit) {
|
||||
title = { Text("Credits") },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text("The412Banner - the app, the Wayland compositor, the runtime and the Steam session.", style = MaterialTheme.typography.bodyMedium)
|
||||
Text("The412Banner — the app, the Wayland compositor, the runtime and the Steam session.", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("maxjivi05 (Max) - the gamescope runtime this is built on: the proot session, the session shim, the fake-evdev interposer and the controller work, from WinNative.", style = MaterialTheme.typography.bodyMedium)
|
||||
Text("maxjivi05 (Max) — the gamescope runtime this is built on: the proot session, the session shim, the fake-evdev interposer and the controller work, from WinNative.", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("Winlator (brunodev85) - the audio stack, the gamepad model and the shape of a session's host-side components; WinNative and Bannerlator are both Winlator lineage.", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"GPL-3.0. Valve, Steam, Steam Deck and Proton are trademarks of Valve Corporation; this project is not affiliated with Valve. gamescope, Mesa, Turnip, Xwayland, PulseAudio, proot and Arch Linux ARM are their authors' own.",
|
||||
|
||||
Reference in new issue
Block a user