diff --git a/README.md b/README.md index 50e29d2ea..10490adc6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 440eae30a..cfec315bb 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -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}) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 439903bef..217b94200 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -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); diff --git a/app/src/main/cpp/winlator/VulkanRendererContext.h b/app/src/main/cpp/framegen/VulkanRendererContext.h similarity index 99% rename from app/src/main/cpp/winlator/VulkanRendererContext.h rename to app/src/main/cpp/framegen/VulkanRendererContext.h index 694f8dd9f..f0a09f6d3 100644 --- a/app/src/main/cpp/winlator/VulkanRendererContext.h +++ b/app/src/main/cpp/framegen/VulkanRendererContext.h @@ -108,10 +108,10 @@ struct VkTable { #include #include -#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 #include diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_alpha.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_alpha.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_alpha.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_alpha.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_alpha.hpp rename to app/src/main/cpp/framegen/lsfg/lsfg_alpha.hpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_beta.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_beta.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_beta.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_beta.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_beta.hpp rename to app/src/main/cpp/framegen/lsfg/lsfg_beta.hpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_chain.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_chain.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_chain.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_chain.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_chain.hpp rename to app/src/main/cpp/framegen/lsfg/lsfg_chain.hpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_common.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_common.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_common.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_common.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_common.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_common.hpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_common.hpp rename to app/src/main/cpp/framegen/lsfg/lsfg_common.hpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_delta.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_delta.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_delta.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_delta.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_delta.hpp rename to app/src/main/cpp/framegen/lsfg/lsfg_delta.hpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_dll.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_dll.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_dll.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_dll.h b/app/src/main/cpp/framegen/lsfg/lsfg_dll.h similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_dll.h rename to app/src/main/cpp/framegen/lsfg/lsfg_dll.h diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_dxbc.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_dxbc.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_dxbc.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_dxbc.h b/app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_dxbc.h rename to app/src/main/cpp/framegen/lsfg/lsfg_dxbc.h diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_engine.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_engine.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_engine.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_engine.h b/app/src/main/cpp/framegen/lsfg/lsfg_engine.h similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_engine.h rename to app/src/main/cpp/framegen/lsfg/lsfg_engine.h diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_gamma.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_gamma.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_gamma.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_gamma.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_gamma.hpp rename to app/src/main/cpp/framegen/lsfg/lsfg_gamma.hpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_generate.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_generate.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_generate.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_generate.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_generate.hpp rename to app/src/main/cpp/framegen/lsfg/lsfg_generate.hpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_governor.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_governor.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_governor.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_governor.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_governor.h b/app/src/main/cpp/framegen/lsfg/lsfg_governor.h similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_governor.h rename to app/src/main/cpp/framegen/lsfg/lsfg_governor.h diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_jni.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_jni.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_jni.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_mipmaps.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_mipmaps.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_mipmaps.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_mipmaps.hpp rename to app/src/main/cpp/framegen/lsfg/lsfg_mipmaps.hpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_pacer.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_pacer.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_pacer.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_pacer.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_pacer.hpp rename to app/src/main/cpp/framegen/lsfg/lsfg_pacer.hpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_probe.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_probe.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_probe.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_probe.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_probe.h b/app/src/main/cpp/framegen/lsfg/lsfg_probe.h similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_probe.h rename to app/src/main/cpp/framegen/lsfg/lsfg_probe.h diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_shaders.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_shaders.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_shaders.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_shaders.hpp b/app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_shaders.hpp rename to app/src/main/cpp/framegen/lsfg/lsfg_shaders.hpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_vkd.cpp b/app/src/main/cpp/framegen/lsfg/lsfg_vkd.cpp similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_vkd.cpp rename to app/src/main/cpp/framegen/lsfg/lsfg_vkd.cpp diff --git a/app/src/main/cpp/winlator/lsfg/lsfg_vkd.h b/app/src/main/cpp/framegen/lsfg/lsfg_vkd.h similarity index 100% rename from app/src/main/cpp/winlator/lsfg/lsfg_vkd.h rename to app/src/main/cpp/framegen/lsfg/lsfg_vkd.h diff --git a/app/src/main/cpp/winlator/winfg/LICENSE.win-fg b/app/src/main/cpp/framegen/winfg/LICENSE.win-fg similarity index 100% rename from app/src/main/cpp/winlator/winfg/LICENSE.win-fg rename to app/src/main/cpp/framegen/winfg/LICENSE.win-fg diff --git a/app/src/main/cpp/winlator/winfg/NOTICE_FIDELITYFX_OPTICALFLOW.md b/app/src/main/cpp/framegen/winfg/NOTICE_FIDELITYFX_OPTICALFLOW.md similarity index 100% rename from app/src/main/cpp/winlator/winfg/NOTICE_FIDELITYFX_OPTICALFLOW.md rename to app/src/main/cpp/framegen/winfg/NOTICE_FIDELITYFX_OPTICALFLOW.md diff --git a/app/src/main/cpp/winlator/winfg/config.hpp b/app/src/main/cpp/framegen/winfg/config.hpp similarity index 100% rename from app/src/main/cpp/winlator/winfg/config.hpp rename to app/src/main/cpp/framegen/winfg/config.hpp diff --git a/app/src/main/cpp/winlator/winfg/embedded_shaders.hpp b/app/src/main/cpp/framegen/winfg/embedded_shaders.hpp similarity index 100% rename from app/src/main/cpp/winlator/winfg/embedded_shaders.hpp rename to app/src/main/cpp/framegen/winfg/embedded_shaders.hpp diff --git a/app/src/main/cpp/winlator/winfg/framegen.cpp b/app/src/main/cpp/framegen/winfg/framegen.cpp similarity index 100% rename from app/src/main/cpp/winlator/winfg/framegen.cpp rename to app/src/main/cpp/framegen/winfg/framegen.cpp diff --git a/app/src/main/cpp/winlator/winfg/framegen.hpp b/app/src/main/cpp/framegen/winfg/framegen.hpp similarity index 100% rename from app/src/main/cpp/winlator/winfg/framegen.hpp rename to app/src/main/cpp/framegen/winfg/framegen.hpp diff --git a/app/src/main/cpp/winlator/winfg/log.hpp b/app/src/main/cpp/framegen/winfg/log.hpp similarity index 100% rename from app/src/main/cpp/winlator/winfg/log.hpp rename to app/src/main/cpp/framegen/winfg/log.hpp diff --git a/app/src/main/cpp/winlator/winfg/record_impl.inc b/app/src/main/cpp/framegen/winfg/record_impl.inc similarity index 100% rename from app/src/main/cpp/winlator/winfg/record_impl.inc rename to app/src/main/cpp/framegen/winfg/record_impl.inc diff --git a/app/src/main/cpp/winlator/winfg/vk_dispatch.hpp b/app/src/main/cpp/framegen/winfg/vk_dispatch.hpp similarity index 100% rename from app/src/main/cpp/winlator/winfg/vk_dispatch.hpp rename to app/src/main/cpp/framegen/winfg/vk_dispatch.hpp diff --git a/app/src/main/cpp/winlator/winfg/winfg_engine.cpp b/app/src/main/cpp/framegen/winfg/winfg_engine.cpp similarity index 100% rename from app/src/main/cpp/winlator/winfg/winfg_engine.cpp rename to app/src/main/cpp/framegen/winfg/winfg_engine.cpp diff --git a/app/src/main/cpp/winlator/winfg/winfg_engine.h b/app/src/main/cpp/framegen/winfg/winfg_engine.h similarity index 100% rename from app/src/main/cpp/winlator/winfg/winfg_engine.h rename to app/src/main/cpp/framegen/winfg/winfg_engine.h diff --git a/app/src/main/cpp/winlator/winfg/winfg_vkd.cpp b/app/src/main/cpp/framegen/winfg/winfg_vkd.cpp similarity index 100% rename from app/src/main/cpp/winlator/winfg/winfg_vkd.cpp rename to app/src/main/cpp/framegen/winfg/winfg_vkd.cpp diff --git a/app/src/main/cpp/winlator/winfg/winfg_vkd.h b/app/src/main/cpp/framegen/winfg/winfg_vkd.h similarity index 100% rename from app/src/main/cpp/winlator/winfg/winfg_vkd.h rename to app/src/main/cpp/framegen/winfg/winfg_vkd.h diff --git a/app/src/main/cpp/scanout/ScanoutContext.h b/app/src/main/cpp/scanout/ScanoutContext.h index 6a8a896c7..de2294849 100644 --- a/app/src/main/cpp/scanout/ScanoutContext.h +++ b/app/src/main/cpp/scanout/ScanoutContext.h @@ -22,7 +22,7 @@ #include #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 { diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index 827f029a4..154bd02b6 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -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}/.. # as the winlator target sees it - ${CMAKE_CURRENT_SOURCE_DIR}/../winlator # VulkanRendererContext.h (VkTable), lsfg/, winfg/ + ${CMAKE_CURRENT_SOURCE_DIR}/.. # as the frame-generation engines see it + ${CMAKE_CURRENT_SOURCE_DIR}/../framegen # VulkanRendererContext.h (VkTable), lsfg/, winfg/ ) target_compile_options(bannerwayland PRIVATE $<$:-std=c++17 -fvisibility=hidden>) diff --git a/app/src/main/cpp/waylandcomp/HDR_RECON.md b/app/src/main/cpp/waylandcomp/HDR_RECON.md deleted file mode 100644 index 15885f07a..000000000 --- a/app/src/main/cpp/waylandcomp/HDR_RECON.md +++ /dev/null @@ -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: ` and -`HDR on screen: no, because `, 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 | -| ` bound wp_color_manager_v1 version 1 …` | Mesa saw it: `VK_COLOR_SPACE_HDR10_ST2084_EXT` is now listed | -| `: colour-management surface created` | a swapchain asked for a non-sRGB colour space | -| `image description #N from : BT.2020, ST 2084 (PQ); mastering …; max CLL …, max FALL …` | the game's colour space + `vkSetHdrMetadataEXT` values | -| ` 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 `_dxgi.log` / `_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. diff --git a/app/src/main/cpp/waylandcomp/WAYLAND_RUNTIME.md b/app/src/main/cpp/waylandcomp/WAYLAND_RUNTIME.md deleted file mode 100644 index 802031b12..000000000 --- a/app/src/main/cpp/waylandcomp/WAYLAND_RUNTIME.md +++ /dev/null @@ -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`: `, : 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` ` 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: - 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 (N images, WxH, UBWC (QCOM_COMPRESSED)|linear, - stride S px)`, `zero-copy: presenting "" (<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. diff --git a/app/src/main/cpp/waylandcomp/src/effects_chain.c b/app/src/main/cpp/waylandcomp/src/effects_chain.c index 928ae16d7..a4227102d 100644 --- a/app/src/main/cpp/waylandcomp/src/effects_chain.c +++ b/app/src/main/cpp/waylandcomp/src/effects_chain.c @@ -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" diff --git a/app/src/main/cpp/waylandcomp/src/effects_chain.h b/app/src/main/cpp/waylandcomp/src/effects_chain.h index 3ceb979e8..74203bccc 100644 --- a/app/src/main/cpp/waylandcomp/src/effects_chain.h +++ b/app/src/main/cpp/waylandcomp/src/effects_chain.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. * diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h index a0fe1c4eb..2d4d6d99a 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.h +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.h @@ -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 diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp index dd1b82a33..1d9d29468 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp @@ -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" diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.h b/app/src/main/cpp/waylandcomp/src/framegen_engine.h index c7f6743e2..ecc9e30d1 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.h +++ b/app/src/main/cpp/waylandcomp/src/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. diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c index 187accd60..45bd27dff 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine_stub.c @@ -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, diff --git a/app/src/main/cpp/waylandcomp/src/hdr_compose.c b/app/src/main/cpp/waylandcomp/src/hdr_compose.c index c2abf7a3d..a927df908 100644 --- a/app/src/main/cpp/waylandcomp/src/hdr_compose.c +++ b/app/src/main/cpp/waylandcomp/src/hdr_compose.c @@ -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 diff --git a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt index daa2a23be..be5bb21a5 100644 --- a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt @@ -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) ({ diff --git a/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java b/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java index 55c6625c2..bca34d7a2 100644 --- a/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java +++ b/app/src/main/java/com/steamdeck/launcher/audio/DirectAudioRelayComponent.java @@ -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(); } diff --git a/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java b/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java index 1deb82042..9db4dc7be 100644 --- a/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java +++ b/app/src/main/java/com/steamdeck/launcher/audio/PulseAudioComponent.java @@ -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(); } diff --git a/app/src/main/java/com/steamdeck/launcher/core/Callback.java b/app/src/main/java/com/steamdeck/launcher/core/Callback.java deleted file mode 100644 index 78b675c10..000000000 --- a/app/src/main/java/com/steamdeck/launcher/core/Callback.java +++ /dev/null @@ -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); -} diff --git a/app/src/main/java/com/steamdeck/launcher/core/Downloader.java b/app/src/main/java/com/steamdeck/launcher/core/Downloader.java index 1baf2449d..d711f8e2c 100644 --- a/app/src/main/java/com/steamdeck/launcher/core/Downloader.java +++ b/app/src/main/java/com/steamdeck/launcher/core/Downloader.java @@ -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); diff --git a/app/src/main/java/com/steamdeck/launcher/core/EnvVars.java b/app/src/main/java/com/steamdeck/launcher/core/EnvVars.java deleted file mode 100644 index f2da6ad9b..000000000 --- a/app/src/main/java/com/steamdeck/launcher/core/EnvVars.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/com/steamdeck/launcher/core/EnvironmentComponent.java b/app/src/main/java/com/steamdeck/launcher/core/EnvironmentComponent.java deleted file mode 100644 index b45c5cad1..000000000 --- a/app/src/main/java/com/steamdeck/launcher/core/EnvironmentComponent.java +++ /dev/null @@ -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(); -} diff --git a/app/src/main/java/com/steamdeck/launcher/core/HostEnvironment.kt b/app/src/main/java/com/steamdeck/launcher/core/HostEnvironment.kt new file mode 100644 index 000000000..dbdccf263 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/core/HostEnvironment.kt @@ -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() +} diff --git a/app/src/main/java/com/steamdeck/launcher/core/HostProcess.kt b/app/src/main/java/com/steamdeck/launcher/core/HostProcess.kt new file mode 100644 index 000000000..411054832 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/core/HostProcess.kt @@ -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 + } +} diff --git a/app/src/main/java/com/steamdeck/launcher/core/ProcessHelper.java b/app/src/main/java/com/steamdeck/launcher/core/ProcessHelper.java deleted file mode 100644 index 5547c5e9a..000000000 --- a/app/src/main/java/com/steamdeck/launcher/core/ProcessHelper.java +++ /dev/null @@ -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]); - } -} diff --git a/app/src/main/java/com/steamdeck/launcher/core/SessionLogCapture.kt b/app/src/main/java/com/steamdeck/launcher/core/SessionLogCapture.kt index e76a66edd..9fa2de7c7 100644 --- a/app/src/main/java/com/steamdeck/launcher/core/SessionLogCapture.kt +++ b/app/src/main/java/com/steamdeck/launcher/core/SessionLogCapture.kt @@ -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') } } diff --git a/app/src/main/java/com/steamdeck/launcher/core/SessionPart.java b/app/src/main/java/com/steamdeck/launcher/core/SessionPart.java new file mode 100644 index 000000000..9ba34e127 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/core/SessionPart.java @@ -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(); +} diff --git a/app/src/main/java/com/steamdeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/steamdeck/launcher/files/FileManagerScreen.kt index 101c6d61a..807a2be4e 100644 --- a/app/src/main/java/com/steamdeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/steamdeck/launcher/files/FileManagerScreen.kt @@ -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() } diff --git a/app/src/main/java/com/steamdeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/steamdeck/launcher/input/FakeInputWriter.java index ba578d7b6..20b88d360 100644 --- a/app/src/main/java/com/steamdeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/steamdeck/launcher/input/FakeInputWriter.java @@ -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); diff --git a/app/src/main/java/com/steamdeck/launcher/input/GamepadState.java b/app/src/main/java/com/steamdeck/launcher/input/GamepadState.java deleted file mode 100644 index 67f29c819..000000000 --- a/app/src/main/java/com/steamdeck/launcher/input/GamepadState.java +++ /dev/null @@ -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); - } - -} diff --git a/app/src/main/java/com/steamdeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/steamdeck/launcher/input/OnScreenControls.kt index 6fac100a9..9f8b39dac 100644 --- a/app/src/main/java/com/steamdeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/steamdeck/launcher/input/OnScreenControls.kt @@ -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) } } } diff --git a/app/src/main/java/com/steamdeck/launcher/input/PadBridge.java b/app/src/main/java/com/steamdeck/launcher/input/PadBridge.java index 0580121f2..951b17fcf 100644 --- a/app/src/main/java/com/steamdeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/steamdeck/launcher/input/PadBridge.java @@ -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); } } diff --git a/app/src/main/java/com/steamdeck/launcher/input/PadState.kt b/app/src/main/java/com/steamdeck/launcher/input/PadState.kt new file mode 100644 index 000000000..0cb361032 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/input/PadState.kt @@ -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 + } +} diff --git a/app/src/main/java/com/steamdeck/launcher/runtime/LinuxNetworkLinkComponent.kt b/app/src/main/java/com/steamdeck/launcher/runtime/LinuxNetworkLinkComponent.kt index f9d116c6a..ea110a474 100644 --- a/app/src/main/java/com/steamdeck/launcher/runtime/LinuxNetworkLinkComponent.kt +++ b/app/src/main/java/com/steamdeck/launcher/runtime/LinuxNetworkLinkComponent.kt @@ -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 diff --git a/app/src/main/java/com/steamdeck/launcher/session/BatteryComponent.kt b/app/src/main/java/com/steamdeck/launcher/session/BatteryComponent.kt index a819132cf..1948018bf 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/BatteryComponent.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/BatteryComponent.kt @@ -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)) diff --git a/app/src/main/java/com/steamdeck/launcher/session/RumbleComponent.kt b/app/src/main/java/com/steamdeck/launcher/session/RumbleComponent.kt new file mode 100644 index 000000000..2984a5f63 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/session/RumbleComponent.kt @@ -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" + } +} diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt index 60d1686e7..5dc246c11 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt @@ -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) diff --git a/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt b/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt index 54debba25..daee4ece9 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt @@ -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.",