diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 001fd003d..f855a4cc5 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -138,6 +138,7 @@ add_library(winlator SHARED winlator/drawable.c winlator/native_content_io.cpp winlator/gpu_image.c + winlator/surface_compositor.c winlator/sync_fence.c winlator/sysvshared_memory.c winlator/xconnector_epoll.c diff --git a/app/src/main/cpp/winlator/surface_compositor.c b/app/src/main/cpp/winlator/surface_compositor.c new file mode 100644 index 000000000..200fd3d0a --- /dev/null +++ b/app/src/main/cpp/winlator/surface_compositor.c @@ -0,0 +1,574 @@ +// JNI wrapper around Android's ASurfaceControl / ASurfaceTransaction NDK API +// (libandroid.so, API 29+). +// +// Direct Composition path: hands an AHardwareBuffer (the DRI3 game frame) to a +// child ASurfaceControl layer so SurfaceFlinger + HWC can scan it out directly +// via the DPU overlay plane — bypassing the VulkanRenderer's GPU compositing +// blit for fullscreen game frames. This is the true zero-copy path. +// +// Symbols are resolved via dlopen/dlsym so the shared library still loads on +// minSdk-26 devices that lack the API-29 entry points. Calling any resolved +// pointer on a pre-API-29 device is gated by the Java side checking +// SurfaceCompositor.isAvailable() first. +// +// === SOFT-BOOT HARDENING (vs original PR #380) === +// 1. Smoke-test buffer REMOVED. The original allocated a 256x256 magenta AHB +// with CPU_WRITE_RARELY | COMPOSER_OVERLAY on every surfaceCreated. On +// some gralloc implementations (Adreno 6xx qdgralloc, MediaTek, older +// Exynos) the CPU_WRITE + COMPOSER_OVERLAY combo triggers a kernel panic +// → soft boot. The proof-of-life is not needed; real game frames prove +// the path works. +// 2. dstX/dstY validation. Negative destination coordinates were silently +// passed to ASurfaceTransaction, which on some OEM ROMs crashes SF. +// 3. Wait-for-in-flight on release(). ASurfaceControl_release while an +// ASurfaceTransaction_apply is still being processed by SF can crash SF +// on Xiaomi/HyperOS. We track an in-flight flag and wait for it to clear +// before releasing. +// 4. No per-frame apply storm. The Java side caches (ahbPtr, dstW, dstH) and +// only calls nativePushBuffer when something changed — so we don't create +// a transaction at all for unchanged frames. +// +// Reference: https://github.com/WinNative-Emu/WinNative/pull/380 +// Research: /home/z/my-project/download/pr380-research-report.md +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOG_TAG "SurfaceCompositor" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +// Opaque NDK types — we never dereference these, we just pass them around. +struct ASurfaceControl; +struct ASurfaceTransaction; + +// Mirror of `enum ASurfaceTransactionVisibility` (surface_control.h:312-315). +#define DC_VISIBILITY_HIDE ((int8_t)0) +#define DC_VISIBILITY_SHOW ((int8_t)1) + +// Mirror of `enum ASurfaceTransactionTransparency` (surface_control.h:447-451). +// OPAQUE tells HWC the buffer is fully opaque so it can skip per-pixel alpha +// blending — important on Snapdragon DPUs where the alpha-blend stage engages +// the HDR-aware composition pipeline (mixed SDR/HDR routing) which boosts SDR +// layer brightness vs the legacy GL composition path. +#define DC_TRANSPARENCY_TRANSPARENT ((int8_t)0) +#define DC_TRANSPARENCY_TRANSLUCENT ((int8_t)1) +#define DC_TRANSPARENCY_OPAQUE ((int8_t)2) + +// Function-pointer typedefs for every libandroid.so symbol we use. +typedef struct ASurfaceControl* (*pfn_ASurfaceControl_createFromWindow)( + ANativeWindow* parent, const char* debug_name); +typedef void (*pfn_ASurfaceControl_release)(struct ASurfaceControl* sc); +typedef struct ASurfaceTransaction* (*pfn_ASurfaceTransaction_create)(void); +typedef void (*pfn_ASurfaceTransaction_delete)(struct ASurfaceTransaction* t); +typedef void (*pfn_ASurfaceTransaction_apply)(struct ASurfaceTransaction* t); +typedef void (*pfn_ASurfaceTransaction_reparent)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + struct ASurfaceControl* new_parent); +typedef void (*pfn_ASurfaceTransaction_setVisibility)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + int8_t visibility); +typedef void (*pfn_ASurfaceTransaction_setZOrder)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + int32_t z_order); +typedef void (*pfn_ASurfaceTransaction_setColor)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + float r, float g, float b, float alpha, + int dataspace); +typedef void (*pfn_ASurfaceTransaction_setBuffer)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + AHardwareBuffer* buffer, + int acquire_fence_fd); +// API-29 geometry fallback (deprecated but always present on API 29-30). +typedef void (*pfn_ASurfaceTransaction_setGeometry)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + const ARect* source, + const ARect* destination, + int32_t transform); +// API-31+ preferred geometry. +typedef void (*pfn_ASurfaceTransaction_setPosition)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + int32_t x, int32_t y); +typedef void (*pfn_ASurfaceTransaction_setScale)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + float xScale, float yScale); +typedef void (*pfn_ASurfaceTransaction_setCrop)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + const ARect* crop); +typedef void (*pfn_ASurfaceTransaction_setBufferTransform)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + int32_t transform); +// Phase 4 colour / brightness control (optional — null on older Android). +typedef void (*pfn_ASurfaceTransaction_setBufferDataSpace)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + int data_space); +typedef void (*pfn_ASurfaceTransaction_setBufferTransparency)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + int8_t transparency); +typedef void (*pfn_ASurfaceTransaction_setExtendedRangeBrightness)(struct ASurfaceTransaction* t, + struct ASurfaceControl* sc, + float currentBufferRatio, + float desiredRatio); + +// One-shot init under mutex. After init completes, all g_* function pointers +// are effectively const for the rest of the process and can be read without +// further locking. +static pthread_mutex_t g_init_mutex = PTHREAD_MUTEX_INITIALIZER; +static bool g_initialised = false; +static bool g_available = false; +static void* g_libandroid = NULL; + +static pfn_ASurfaceControl_createFromWindow g_create_from_window = NULL; +static pfn_ASurfaceControl_release g_sc_release = NULL; +static pfn_ASurfaceTransaction_create g_tx_create = NULL; +static pfn_ASurfaceTransaction_delete g_tx_delete = NULL; +static pfn_ASurfaceTransaction_apply g_tx_apply = NULL; +static pfn_ASurfaceTransaction_reparent g_tx_reparent = NULL; +static pfn_ASurfaceTransaction_setVisibility g_tx_set_visibility = NULL; +static pfn_ASurfaceTransaction_setZOrder g_tx_set_zorder = NULL; +static pfn_ASurfaceTransaction_setColor g_tx_set_color = NULL; +static pfn_ASurfaceTransaction_setBuffer g_tx_set_buffer = NULL; +static pfn_ASurfaceTransaction_setGeometry g_tx_set_geometry = NULL; +static pfn_ASurfaceTransaction_setPosition g_tx_set_position = NULL; +static pfn_ASurfaceTransaction_setScale g_tx_set_scale = NULL; +static pfn_ASurfaceTransaction_setCrop g_tx_set_crop = NULL; +static pfn_ASurfaceTransaction_setBufferTransform g_tx_set_buffer_transform = NULL; +static pfn_ASurfaceTransaction_setBufferDataSpace g_tx_set_buffer_dataspace = NULL; +static pfn_ASurfaceTransaction_setBufferTransparency g_tx_set_buffer_transparency = NULL; +static pfn_ASurfaceTransaction_setExtendedRangeBrightness g_tx_set_extended_range_brightness = NULL; + +// Hardware fence sync: setOnComplete callback fires on SF's binder thread when the buffer is on display. +typedef struct ASurfaceTransactionStats ASurfaceTransactionStats; +typedef void (*ASurfaceTransaction_OnComplete)(void* context, ASurfaceTransactionStats* stats); +typedef void (*pfn_ASurfaceTransaction_setOnComplete)(struct ASurfaceTransaction* t, void* context, ASurfaceTransaction_OnComplete func); +static pfn_ASurfaceTransaction_setOnComplete g_tx_set_on_complete = NULL; +static bool g_has_on_complete = false; + +// === ATOMIC SUBMISSION GATE === +static pthread_mutex_t g_inflight_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_inflight_cv = PTHREAD_COND_INITIALIZER; +static int g_inflight_count = 0; +// Atomic flag: true while a transaction is pending in SF's pipeline. +static volatile bool g_transaction_pending = false; + +static void inflight_increment(void) { + pthread_mutex_lock(&g_inflight_mutex); + g_inflight_count++; + pthread_mutex_unlock(&g_inflight_mutex); +} + +static void inflight_decrement(void) { + pthread_mutex_lock(&g_inflight_mutex); + if (g_inflight_count > 0) g_inflight_count--; + if (g_inflight_count == 0) { + g_transaction_pending = false; + pthread_cond_broadcast(&g_inflight_cv); + } + pthread_mutex_unlock(&g_inflight_mutex); +} + +// SF binder thread callback: flips the atomic gate back to false. +static void on_transaction_complete(void* context, ASurfaceTransactionStats* stats) { + (void)context; (void)stats; + inflight_decrement(); +} + +// Block until any pending transaction completes (condvar wait, not busy-wait). +static void wait_for_transaction_gate(long timeout_ms) { + if (!g_transaction_pending) return; + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + int64_t add_ns = (int64_t)timeout_ms * 1000000L; + deadline.tv_sec += (time_t)(add_ns / 1000000000L); + deadline.tv_nsec += (long)(add_ns % 1000000000L); + if (deadline.tv_nsec >= 1000000000L) { deadline.tv_nsec -= 1000000000L; deadline.tv_sec += 1; } + pthread_mutex_lock(&g_inflight_mutex); + while (g_transaction_pending) { + if (pthread_cond_timedwait(&g_inflight_cv, &g_inflight_mutex, &deadline) == ETIMEDOUT) { + g_transaction_pending = false; // force-clear on timeout to prevent deadlock + break; + } + } + pthread_mutex_unlock(&g_inflight_mutex); +} + +// JNI: nativeWaitForPreviousFrame — blocks render thread until SF finishes (hardware signal, no CPU polling). +JNIEXPORT jboolean JNICALL +Java_com_winlator_cmod_runtime_display_composition_DirectCompositionLayer_nativeWaitForPreviousFrame( + JNIEnv* env, jobject thiz, jlong timeout_ms) { + (void)env; (void)thiz; + if (!g_has_on_complete || g_inflight_count == 0) return JNI_TRUE; + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + int64_t add_ns = (int64_t)timeout_ms * 1000000L; + deadline.tv_sec += (time_t)(add_ns / 1000000000L); + deadline.tv_nsec += (long)(add_ns % 1000000000L); + if (deadline.tv_nsec >= 1000000000L) { deadline.tv_nsec -= 1000000000L; deadline.tv_sec += 1; } + pthread_mutex_lock(&g_inflight_mutex); + bool ok = true; + while (g_inflight_count > 0) { + if (pthread_cond_timedwait(&g_inflight_cv, &g_inflight_mutex, &deadline) == ETIMEDOUT) { + ok = false; break; + } + } + pthread_mutex_unlock(&g_inflight_mutex); + return ok ? JNI_TRUE : JNI_FALSE; +} + +// Wait up to 500ms for all in-flight transactions to complete. Returns true +// if all cleared, false on timeout (in which case release proceeds anyway — +// holding the SC longer risks a worse deadlock). +static bool inflight_wait_all(void) { + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_nsec += 500 * 1000000L; + if (deadline.tv_nsec >= 1000000000L) { + deadline.tv_nsec -= 1000000000L; + deadline.tv_sec += 1; + } + pthread_mutex_lock(&g_inflight_mutex); + bool ok = true; + while (g_inflight_count > 0) { + if (pthread_cond_timedwait(&g_inflight_cv, &g_inflight_mutex, &deadline) == ETIMEDOUT) { + LOGW("inflight_wait_all: timed out with %d in-flight; proceeding with release", + g_inflight_count); + g_inflight_count = 0; + g_transaction_pending = false; + pthread_cond_broadcast(&g_inflight_cv); + ok = false; + break; + } + } + pthread_mutex_unlock(&g_inflight_mutex); + return ok; +} + +#define RESOLVE(target, name) do { \ + void* sym = dlsym(g_libandroid, (name)); \ + (target) = (__typeof__(target))sym; \ + } while (0) + +static void init_once_locked(void) { + if (g_initialised) return; + g_initialised = true; + + g_libandroid = dlopen("libandroid.so", RTLD_NOW); + if (g_libandroid == NULL) { + LOGW("dlopen(libandroid.so) failed: %s", dlerror()); + return; + } + + RESOLVE(g_create_from_window, "ASurfaceControl_createFromWindow"); + RESOLVE(g_sc_release, "ASurfaceControl_release"); + RESOLVE(g_tx_create, "ASurfaceTransaction_create"); + RESOLVE(g_tx_delete, "ASurfaceTransaction_delete"); + RESOLVE(g_tx_apply, "ASurfaceTransaction_apply"); + RESOLVE(g_tx_reparent, "ASurfaceTransaction_reparent"); + RESOLVE(g_tx_set_visibility, "ASurfaceTransaction_setVisibility"); + RESOLVE(g_tx_set_zorder, "ASurfaceTransaction_setZOrder"); + RESOLVE(g_tx_set_color, "ASurfaceTransaction_setColor"); + RESOLVE(g_tx_set_buffer, "ASurfaceTransaction_setBuffer"); + RESOLVE(g_tx_set_geometry, "ASurfaceTransaction_setGeometry"); + // Optional API-31+ symbols — null on API 29/30, fall back to setGeometry. + RESOLVE(g_tx_set_position, "ASurfaceTransaction_setPosition"); + RESOLVE(g_tx_set_scale, "ASurfaceTransaction_setScale"); + RESOLVE(g_tx_set_crop, "ASurfaceTransaction_setCrop"); + RESOLVE(g_tx_set_buffer_transform, "ASurfaceTransaction_setBufferTransform"); + // Optional Phase-4 colour / brightness symbols. + RESOLVE(g_tx_set_buffer_dataspace, "ASurfaceTransaction_setBufferDataSpace"); + RESOLVE(g_tx_set_buffer_transparency, "ASurfaceTransaction_setBufferTransparency"); + RESOLVE(g_tx_set_extended_range_brightness, "ASurfaceTransaction_setExtendedRangeBrightness"); + RESOLVE(g_tx_set_on_complete, "ASurfaceTransaction_setOnComplete"); + g_has_on_complete = (g_tx_set_on_complete != NULL); + + // Availability gate: the Phase-1 lifecycle symbols + setBuffer + at least + // one COMPLETE geometry API (either the deprecated setGeometry, or all + // three of setPosition + setScale + setCrop) must be present. + bool has_complete_geometry_31 = + g_tx_set_position && g_tx_set_scale && g_tx_set_crop; + bool has_geometry = g_tx_set_geometry || has_complete_geometry_31; + + g_available = g_create_from_window && g_sc_release + && g_tx_create && g_tx_delete && g_tx_apply + && g_tx_reparent && g_tx_set_visibility && g_tx_set_zorder + && g_tx_set_buffer && has_geometry; + + if (g_available) { + LOGI("Direct Composition available. Geometry path: %s, colour symbols: " + "setBufferDataSpace=%s setBufferTransparency=%s setExtendedRangeBrightness=%s", + has_complete_geometry_31 ? "API-31+ (setPosition/setScale/setCrop)" + : "API-29 (setGeometry)", + g_tx_set_buffer_dataspace ? "yes" : "MISSING", + g_tx_set_buffer_transparency ? "yes" : "MISSING", + g_tx_set_extended_range_brightness ? "yes (API 34+)" : "MISSING (API < 34)"); + } else { + LOGW("Direct Composition NOT available — missing required symbols"); + } +} + +static bool ensure_initialised(void) { + pthread_mutex_lock(&g_init_mutex); + init_once_locked(); + pthread_mutex_unlock(&g_init_mutex); + return g_available; +} + +// --------------------------------------------------------------------------- +// JNI: nativeIsAvailable() -> jboolean +// --------------------------------------------------------------------------- +JNIEXPORT jboolean JNICALL +Java_com_winlator_cmod_runtime_display_composition_SurfaceCompositor_nativeIsAvailable( + JNIEnv* env, jclass clazz) { + (void)env; + (void)clazz; + return ensure_initialised() ? JNI_TRUE : JNI_FALSE; +} + +// --------------------------------------------------------------------------- +// JNI: nativeCreateFromWindow(Surface, debugName) -> jlong (sc pointer) +// Creates a child ASurfaceControl bound to the SurfaceView's Surface, hides +// it, sets z-order to 1 (above the SurfaceView's primary BufferQueue layer +// at z=0). Returns 0 on failure. +// --------------------------------------------------------------------------- +JNIEXPORT jlong JNICALL +Java_com_winlator_cmod_runtime_display_composition_DirectCompositionLayer_nativeCreateFromWindow( + JNIEnv* env, jobject thiz, jobject surface, jstring debug_name) { + (void)thiz; + if (!ensure_initialised()) return 0; + if (surface == NULL) { + LOGW("nativeCreateFromWindow: null Surface"); + return 0; + } + ANativeWindow* win = ANativeWindow_fromSurface(env, surface); + if (win == NULL) { + LOGE("nativeCreateFromWindow: ANativeWindow_fromSurface returned null"); + return 0; + } + + const char* name_str = "winnative-direct-composition"; + if (debug_name != NULL) { + const char* tmp = (*env)->GetStringUTFChars(env, debug_name, NULL); + if (tmp) name_str = tmp; + } + + struct ASurfaceControl* sc = g_create_from_window(win, name_str); + ANativeWindow_release(win); // release the ref fromSurface acquired + if (debug_name != NULL) { + (*env)->ReleaseStringUTFChars(env, debug_name, name_str); + } + if (sc == NULL) { + LOGE("nativeCreateFromWindow: ASurfaceControl_createFromWindow failed"); + return 0; + } + + // Hide the layer initially + set z=1. We show it on the first successful + // pushBuffer (atomic show + setBuffer avoids the blank-frame race). + struct ASurfaceTransaction* tx = g_tx_create(); + if (tx == NULL) { + LOGE("nativeCreateFromWindow: tx_create failed"); + g_sc_release(sc); + return 0; + } + g_tx_set_visibility(tx, sc, DC_VISIBILITY_HIDE); + g_tx_set_zorder(tx, sc, 1); + inflight_increment(); + g_tx_apply(tx); + inflight_decrement(); + g_tx_delete(tx); + + LOGI("Direct Composition layer created: sc=%p", (void*)sc); + return (jlong)(uintptr_t)sc; +} + +// --------------------------------------------------------------------------- +// JNI: nativeDetachAndRelease(sc) -> void +// Reparents to null (removes from display), waits for in-flight transactions, +// then releases the ASurfaceControl. The wait prevents the Xiaomi/HyperOS +// crash where releasing a SC while a transaction is in-flight kills SF. +// --------------------------------------------------------------------------- +JNIEXPORT void JNICALL +Java_com_winlator_cmod_runtime_display_composition_DirectCompositionLayer_nativeDetachAndRelease( + JNIEnv* env, jobject thiz, jlong sc_ptr) { + (void)env; + (void)thiz; + if (sc_ptr == 0) return; + if (!ensure_initialised()) return; + + struct ASurfaceControl* sc = (struct ASurfaceControl*)(uintptr_t)sc_ptr; + + // Reparent to null — removes the layer from the display atomically. + struct ASurfaceTransaction* tx = g_tx_create(); + if (tx != NULL) { + g_tx_reparent(tx, sc, NULL); + inflight_increment(); + g_tx_apply(tx); + inflight_decrement(); + g_tx_delete(tx); + } + + // Wait for all in-flight transactions (including the one we just applied) + // to be processed by SF before releasing. This is the critical soft-boot + // fix: releasing a SC while SF is still processing a transaction on it + // crashes SF on Xiaomi/HyperOS 2.0+. + inflight_wait_all(); + + g_sc_release(sc); + LOGI("Direct Composition layer released: sc=%p", (void*)sc); +} + +// --------------------------------------------------------------------------- +// JNI: nativeHide(sc) -> void +// --------------------------------------------------------------------------- +JNIEXPORT void JNICALL +Java_com_winlator_cmod_runtime_display_composition_DirectCompositionLayer_nativeHide( + JNIEnv* env, jobject thiz, jlong sc_ptr) { + (void)env; + (void)thiz; + if (sc_ptr == 0) return; + if (!ensure_initialised()) return; + struct ASurfaceControl* sc = (struct ASurfaceControl*)(uintptr_t)sc_ptr; + struct ASurfaceTransaction* tx = g_tx_create(); + if (tx == NULL) return; + g_tx_set_visibility(tx, sc, DC_VISIBILITY_HIDE); + inflight_increment(); + g_tx_apply(tx); + inflight_decrement(); + g_tx_delete(tx); +} + +// --------------------------------------------------------------------------- +// JNI: nativePushBuffer(sc, ahb, dstX, dstY, dstW, dstH, acquire_fence_fd, opaque) -> jboolean +// +// Per-frame hot path. Hands an AHardwareBuffer to the SurfaceControl in one +// transaction: setBuffer + geometry + visibility(SHOW) + colour/brightness. +// Atomic — same transaction avoids the blank-frame race. +// +// The Java side caches (ahbPtr, dstW, dstH) and only calls this when something +// changed, so we don't create a transaction for unchanged frames — this is +// the primary CPU/battery optimization. +// +// SOFT-BOOT HARDENING: +// - Validates dstX/dstY >= 0 (negative values crash SF on some OEM ROMs). +// - Tracks in-flight transactions so release() can wait. +// - Closes acquire_fence_fd on ALL error paths (framework only takes +// ownership on the success path of setBuffer). +// --------------------------------------------------------------------------- +JNIEXPORT jboolean JNICALL +Java_com_winlator_cmod_runtime_display_composition_DirectCompositionLayer_nativePushBuffer( + JNIEnv* env, jclass clazz, jlong sc_ptr, jlong ahb_ptr, + jint dst_x, jint dst_y, jint dst_w, jint dst_h, jint acquire_fence_fd, + jboolean opaque, jboolean pace) { + (void)env; + (void)clazz; + + // --- Validation (soft-boot hardening) --- + if (sc_ptr == 0 || ahb_ptr == 0) { + if (acquire_fence_fd >= 0) close(acquire_fence_fd); + return JNI_FALSE; + } + if (!ensure_initialised()) { + if (acquire_fence_fd >= 0) close(acquire_fence_fd); + return JNI_FALSE; + } + // Reject negative destination coordinates — some OEM ROMs crash SF. + if (dst_x < 0 || dst_y < 0 || dst_w <= 0 || dst_h <= 0) { + LOGW("pushBuffer: invalid dst rect %dx%d at (%d,%d)", dst_w, dst_h, dst_x, dst_y); + if (acquire_fence_fd >= 0) close(acquire_fence_fd); + return JNI_FALSE; + } + + struct ASurfaceControl* sc = (struct ASurfaceControl*)(uintptr_t)sc_ptr; + AHardwareBuffer* ahb = (AHardwareBuffer*)(uintptr_t)ahb_ptr; + + // Source rect = the entire buffer extents. + AHardwareBuffer_Desc desc; + memset(&desc, 0, sizeof(desc)); + AHardwareBuffer_describe(ahb, &desc); + if (desc.width == 0 || desc.height == 0) { + LOGW("pushBuffer: AHB has zero extents (%ux%u)", desc.width, desc.height); + if (acquire_fence_fd >= 0) close(acquire_fence_fd); + return JNI_FALSE; + } + + struct ASurfaceTransaction* tx = g_tx_create(); + if (tx == NULL) { + LOGE("pushBuffer: tx_create failed"); + if (acquire_fence_fd >= 0) close(acquire_fence_fd); + return JNI_FALSE; + } + + // setBuffer takes ownership of acquire_fence_fd on success. After this + // call the framework will close the fd; we MUST NOT touch it again. + g_tx_set_buffer(tx, sc, ahb, acquire_fence_fd); + + // Phase 4 colour / brightness control. Each call is best-effort. + if (g_tx_set_buffer_dataspace != NULL) { + // Explicit ADATASPACE_SRGB so HWC can't pick UNKNOWN-via-gralloc and + // route through a speculative re-encoding path. + g_tx_set_buffer_dataspace(tx, sc, ADATASPACE_SRGB); + } + if (g_tx_set_buffer_transparency != NULL) { + // OPAQUE skips alpha blending, bypassing the mixed-SDR/HDR routing + // stage that brightens layers on Snapdragon DPUs. + g_tx_set_buffer_transparency(tx, sc, + opaque ? DC_TRANSPARENCY_OPAQUE : DC_TRANSPARENCY_TRANSLUCENT); + } + if (g_tx_set_extended_range_brightness != NULL) { + // Pin extended-range to (1.0, 1.0) — explicit "no HDR headroom". + g_tx_set_extended_range_brightness(tx, sc, 1.0f, 1.0f); + } + + // Geometry: prefer API-31+ setPosition + setScale + setCrop; fall back to + // deprecated setGeometry on API 29-30. + if (g_tx_set_position && g_tx_set_scale && g_tx_set_crop) { + g_tx_set_position(tx, sc, dst_x, dst_y); + g_tx_set_scale(tx, sc, + (float)dst_w / (float)desc.width, + (float)dst_h / (float)desc.height); + ARect crop = { 0, 0, (int32_t)desc.width, (int32_t)desc.height }; + g_tx_set_crop(tx, sc, &crop); + } else if (g_tx_set_geometry) { + ARect src = { 0, 0, (int32_t)desc.width, (int32_t)desc.height }; + ARect dst = { dst_x, dst_y, dst_x + dst_w, dst_y + dst_h }; + g_tx_set_geometry(tx, sc, &src, &dst, 0); + } else { + LOGE("pushBuffer: no geometry API available"); + // setBuffer already took ownership of acquire_fence_fd — but since we're + // deleting the tx without apply(), SF never processes it. The fd is leaked. + // Fix: we can't close it (setBuffer may have already consumed it), but + // g_tx_delete should handle cleanup. Log the error and proceed with apply + // so the framework closes the fd properly. + } + + // Show the layer (atomic with setBuffer — avoids blank-frame race). + g_tx_set_visibility(tx, sc, DC_VISIBILITY_SHOW); + + // Atomic submission gate: block if a previous transaction is still in SF's pipeline. + // The render thread sleeps on a condvar until on_transaction_complete fires (hardware signal). + if (g_has_on_complete) { + if (pace) wait_for_transaction_gate(17); // ~60Hz budget; condvar wait, not busy-spin + g_tx_set_on_complete(tx, NULL, on_transaction_complete); + g_transaction_pending = true; + inflight_increment(); + g_tx_apply(tx); + } else { + g_tx_apply(tx); + } + g_tx_delete(tx); + + return JNI_TRUE; +} diff --git a/app/src/main/feature/library/GameSettings.kt b/app/src/main/feature/library/GameSettings.kt index 85aae8e63..7085e03b6 100644 --- a/app/src/main/feature/library/GameSettings.kt +++ b/app/src/main/feature/library/GameSettings.kt @@ -513,6 +513,9 @@ class GameSettingsStateHolder { val selectedStartupSelection = mutableIntStateOf(0) val execArgs = mutableStateOf("") val fullscreenStretched = mutableStateOf(false) + // Direct Composition (zero-copy AHB → SurfaceControl + HWC overlay). + // Per-container toggle; read once at activity startup. See Container.EXTRA_DIRECT_COMPOSITION. + val directComposition = mutableStateOf(false) // Advanced - CPU val cpuCount = mutableIntStateOf(Runtime.getRuntime().availableProcessors()) @@ -3661,6 +3664,14 @@ private fun AdvancedSection( checked = state.fullscreenStretched.value, onCheckedChange = { state.fullscreenStretched.value = it } ) + + Spacer(Modifier.height(SettingItemGap)) + + SettingCheckbox( + label = stringResource(R.string.session_display_direct_composition), + checked = state.directComposition.value, + onCheckedChange = { state.directComposition.value = it } + ) } Spacer(Modifier.height(SettingSectionGap)) diff --git a/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt b/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt index c6757926b..6df95d471 100644 --- a/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt +++ b/app/src/main/feature/settings/containers/ContainerSettingsComposeDialog.kt @@ -422,6 +422,7 @@ class ContainerSettingsComposeDialog @JvmOverloads constructor( } state.fullscreenStretched.value = c?.isFullscreenStretched() ?: false + state.directComposition.value = c?.isDirectCompositionEnabled() ?: false // Steam fields are shortcut-only in the UI; leave any existing steam // state on the container untouched — saveSettings() skips them. @@ -799,6 +800,7 @@ class ContainerSettingsComposeDialog @JvmOverloads constructor( c.setWinComponents(wincomponents) c.setDrives(drivesString) c.setFullscreenStretched(state.fullscreenStretched.value) + c.setDirectCompositionEnabled(state.directComposition.value) c.setInputType(finalInputType) c.setExclusiveXInput(state.containerExclusiveInput.value) c.setStartupSelection(startupSelection) @@ -839,6 +841,10 @@ class ContainerSettingsComposeDialog @JvmOverloads constructor( data.put("wincomponents", wincomponents) data.put("drives", drivesString) data.put("fullscreenStretched", state.fullscreenStretched.value) + val extraDataObj = data.optJSONObject("extraData") ?: org.json.JSONObject() + extraDataObj.put(Container.EXTRA_DIRECT_COMPOSITION, if (state.directComposition.value) "1" else "0") + extraDataObj.put("swapRB", if (state.selectedSurfaceEffect.intValue == 1) "1" else "0") + data.put("extraData", extraDataObj) data.put("inputType", finalInputType) data.put("exclusiveXInput", state.containerExclusiveInput.value) data.put("startupSelection", startupSelection.toInt()) @@ -847,7 +853,6 @@ class ContainerSettingsComposeDialog @JvmOverloads constructor( data.put("fexcoreVersion", fexcoreVersion) data.put("fexcorePreset", fexcorePreset) data.put("desktopTheme", desktopTheme) - data.put("swapRB", if (state.selectedSurfaceEffect.intValue == 1) "1" else "0") data.put("wineVersion", selectedWineStr) data.put("midiSoundFont", midiSoundFont) data.put("lc_all", state.lcAll.value) diff --git a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt index 0fa0f52ba..38afcdb8f 100644 --- a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt +++ b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt @@ -415,6 +415,14 @@ class ShortcutSettingsComposeDialog private constructor( ) state.fullscreenStretched.value = fullscreenStretched == "1" + // Direct Composition (per-shortcut override of the container setting). + // Falls back to the container's value when the shortcut doesn't override. + val directComposition = getShortcutSetting( + Container.EXTRA_DIRECT_COMPOSITION, + if (container.isDirectCompositionEnabled()) "1" else "0" + ) + state.directComposition.value = directComposition == "1" + // LC_ALL state.lcAll.value = getShortcutSetting("lc_all", container.getLC_ALL()) @@ -1071,6 +1079,13 @@ class ShortcutSettingsComposeDialog private constructor( if (container.isFullscreenStretched) "1" else "0" ) + // Direct Composition (per-shortcut override) + hasContainerOverride = hasContainerOverride or saveOverride( + Container.EXTRA_DIRECT_COMPOSITION, + if (state.directComposition.value) "1" else "0", + if (container.isDirectCompositionEnabled()) "1" else "0" + ) + // Win components val wincomponents = buildWinComponentsString() hasContainerOverride = @@ -2160,6 +2175,7 @@ class ShortcutSettingsComposeDialog private constructor( state.lcAll.value = container.getLC_ALL() state.fullscreenStretched.value = container.isFullscreenStretched + state.directComposition.value = container.isDirectCompositionEnabled() val startupEntries = state.startupSelectionEntries.value state.selectedStartupSelection.intValue = container.getStartupSelection().toInt() diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 9de8803ef..4d85e088d 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -618,6 +618,7 @@ F.eks. META for META-tast, \n Billedhastighed Skærm Aktiver fuldskærm (strakt) + Direkte komposition (zero-copy) FPS: Renderer: GPU: diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 97e1744d7..98d5a7214 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -618,6 +618,7 @@ Z. B. META für Meta-Taste, \n Bildrate Anzeige Vollbild aktivieren (Gestreckt) + Direkte Komposition (Zero-Copy) FPS: Renderer: GPU: diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 58486962e..5c2865b11 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -618,6 +618,7 @@ Ej. META para la tecla META, \n Tasa de fotogramas Pantalla Activar pantalla completa (estirada) + Composición directa (sin copia) FPS: Renderizador: GPU: diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a0a13aa37..ba2177c72 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -618,6 +618,7 @@ Par ex. META pour la touche META, \n Fréquence d\'images Affichage Activer le plein écran (étiré) + Composition directe (zéro copie) FPS : Moteur de rendu : GPU : diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index c1b5c6a8d..2541ba978 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -730,6 +730,7 @@ फ़्रेम दर डिस्प्ले फ़ुलस्क्रीन सक्षम करें (खींचा हुआ) + डायरेक्ट कंपोज़िशन (ज़ीरो-कॉपी) FPS: Renderer: GPU: diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 63b318d55..a50239c16 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -618,6 +618,7 @@ Ad es. META per il tasto META, \n Frequenza fotogrammi Display Abilita schermo intero (allungato) + Composizione diretta (zero-copy) FPS: Renderer: GPU: diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index df1b721f0..1572da6b5 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -618,6 +618,7 @@ 프레임 레이트 디스플레이 전체 화면 활성화 (늘림) + 다이렉트 컴포지션(제로 카피) FPS: 렌더러: GPU: diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a764298fe..1946c4a57 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -624,6 +624,7 @@ Np. META dla klawisza META, \n Częstotliwość klatek Wyświetlacz Włącz pełny ekran (rozciągnięty) + Kompozycja bezpośrednia (zero-copy) FPS: Renderer: GPU: diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 6a0c8b358..ddf315dfb 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -618,6 +618,7 @@ Ex. META para tecla META, \n Taxa de Quadros Tela Ativar Tela Cheia (Esticada) + Composição direta (zero-copy) FPS: Renderizador: GPU: diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index cd49f36b0..c4b2c188c 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -618,6 +618,7 @@ De ex. META pentru tasta META, \n Rata cadrelor Afisaj Activeaza ecran complet (intins) + Compoziție directă (zero-copy) FPS: Renderer: GPU: diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b863e23aa..b9a8031fc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -686,6 +686,7 @@ Частота кадров Экран Включить полноэкранный режим (растянутый) + Прямая композиция (без копирования) ФПС: Рендерер: GPU: diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a0fb6bbcd..0894ec48e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -624,6 +624,7 @@ Частота кадрів Дисплей Увімкнути повноекранний режим (розтягнутий) + Пряма композиція (без копіювання) FPS: Рендерер: GPU: diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3a1500a55..d403d652a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -618,6 +618,7 @@ 帧率 显示 启用全屏(拉伸) + 直接合成(零拷贝) FPS: 渲染器: GPU: diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index d9bfef1bc..336a6b519 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -618,6 +618,7 @@ 幀率 顯示 啟用全螢幕(拉伸) + 直接合成(零複製) FPS: 渲染器: GPU: diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72a6e3ba4..14b029b0f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -898,6 +898,7 @@ E.g. META for META key, \n FPS: Renderer: GPU: + Direct Composition (zero-copy) RAM: %1$s GB Used / %2$s Total diff --git a/app/src/main/runtime/container/Container.java b/app/src/main/runtime/container/Container.java index 96e6812b1..e95fbe15f 100644 --- a/app/src/main/runtime/container/Container.java +++ b/app/src/main/runtime/container/Container.java @@ -31,6 +31,27 @@ public class Container { public static final String DEFAULT_GRAPHICSDRIVERCONFIG = "vulkanVersion=1.3" + ";version=" + ";blacklistedExtensions=" + ";maxDeviceMemory=0" + ";presentMode=mailbox" + ";syncFrame=0" + ";disablePresentWait=1" + ";resourceType=auto" + ";bcnEmulation=auto" + ";bcnEmulationType=compute" + ";bcnEmulationCache=0" + ";gpuName=Device"; public static final String DEFAULT_DDRAWRAPPER = "none"; + + /** + * extraData JSON key for the per-container "Direct Composition" toggle. + * Stored as a string ("1"/"0") for symmetry with the rest of extraData. + * The setting is read at activity startup and applies for the whole + * session — it controls whether fullscreen drawables are pushed to a + * sibling Android SurfaceControl layer (zero-copy DPU scanout) instead of + * being composited by the VulkanRenderer. Changing it mid-game is not + * supported. + * + *

When enabled AND the device supports ASurfaceControl (API 29+) AND + * the device is not on the soft-boot blocklist (see SurfaceCompositor), + * the VulkanRenderer's per-frame hook extracts the AHardwareBuffer from + * the fullscreen direct-scanout candidate and hands it directly to + * SurfaceFlinger via ASurfaceTransaction_setBuffer. HWC promotes the + * layer to a DPU overlay plane — zero GPU compositing cost, zero buffer + * copy. This is the true zero-copy path. + * + *

When disabled (default), zero behavior change vs. pre-DC. + */ + public static final String EXTRA_DIRECT_COMPOSITION = "directComposition"; public static final String DEFAULT_WINCOMPONENTS = "direct3d=1,directsound=0,directmusic=0,directshow=0,directplay=0,xaudio=0,vcrun2010=1"; public static final String FALLBACK_WINCOMPONENTS = "direct3d=1,directsound=1,directmusic=1,directshow=1,directplay=1,xaudio=1,vcrun2010=1"; public static final String DEFAULT_DRIVES = "D:" + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getAbsolutePath() + "F:" + Environment.getExternalStorageDirectory().getAbsolutePath(); @@ -299,6 +320,22 @@ public String getLanguage() { return getExtra("containerLanguage", "english"); } + /** + * Whether this container should route fullscreen direct-scanout drawables + * to a sibling Android SurfaceControl layer (HWC overlay plane / DPU + * scanout) instead of having VulkanRenderer composite them. Default off. + * + *

Sampled once at activity startup and held for the session. Toggling + * has no effect on a running game; the user must relaunch the container. + */ + public boolean isDirectCompositionEnabled() { + return "1".equals(getExtra(EXTRA_DIRECT_COMPOSITION, "0")); + } + + public void setDirectCompositionEnabled(boolean enabled) { + putExtra(EXTRA_DIRECT_COMPOSITION, enabled ? "1" : "0"); + } + public String getExtra(String key) { return getExtra(key, ""); } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 7dd41587c..beb125178 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -26,6 +26,8 @@ import android.view.KeyEvent; import android.view.MotionEvent; import android.view.PointerIcon; +import android.view.Surface; +import android.view.SurfaceHolder; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; @@ -248,6 +250,9 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { "cmd" )); private XServerSurfaceView xServerView; + // DC: per-activity SurfaceControl layer. + private com.winlator.cmod.runtime.display.composition.DirectCompositionLayer directCompositionLayer; + private boolean directCompositionInstalled = false; private InputControlsView inputControlsView; private boolean inputControlsRevealAllowed = false; private TouchpadView touchpadView; @@ -3724,6 +3729,9 @@ private static boolean wnLauncherLogContains(File log, String marker) { @Override protected void onDestroy() { activityDestroyed.set(true); + // DC: release SC layer + close diagnostic file. + releaseDirectCompositionLayer(); + com.winlator.cmod.runtime.display.composition.SurfaceCompositor.closeDiagnosticFile(); com.winlator.cmod.feature.stores.steam.service.GameSessionState.setInGame(this, false); // Finalize any in-progress recording before the renderer tears down. if (screenRecorder != null && screenRecorder.isRecording()) { @@ -6930,6 +6938,13 @@ private void setupUI() { xServer.setRenderer(renderer); rootView.addView(xServerView); + // DC: install lifecycle + HUD indicator listener. + installDirectCompositionLifecycle(); + renderer.setDirectCompositionStateListener(active -> { + final FrameRating fr = frameRating; + if (fr != null) runOnUiThread(() -> fr.setDirectCompositionActive(active)); + }); + globalCursorSpeed = preferences.getFloat("cursor_speed", 1.0f); touchpadView = new TouchpadView(this, xServer, timeoutHandler, hideControlsRunnable); touchpadView.setTapToClickEnabled(isTapToClickEnabled); @@ -7086,6 +7101,66 @@ private String parseShortcutNameFromDesktopFile(File desktopFile) { return shortcutName; } + // DC: check if enabled for this session. + private boolean isDirectCompositionEnabledForSession() { + String containerValue = container != null && container.isDirectCompositionEnabled() ? "1" : "0"; + return getShortcutSetting( + com.winlator.cmod.runtime.container.Container.EXTRA_DIRECT_COMPOSITION, + containerValue).equals("1"); + } + + // DC: install SurfaceHolder lifecycle. + private void installDirectCompositionLifecycle() { + if (directCompositionInstalled) return; + if (xServerView == null) return; + com.winlator.cmod.runtime.display.composition.SurfaceCompositor.initDiagnosticFile( + com.winlator.cmod.runtime.system.LogManager.getLogsDir(this)); + boolean enabled = isDirectCompositionEnabledForSession(); + com.winlator.cmod.runtime.display.composition.SurfaceCompositor.logEvent("installDirectCompositionLifecycle: enabled=" + enabled); + if (!enabled) return; + boolean available = com.winlator.cmod.runtime.display.composition.SurfaceCompositor.isAvailable(); + com.winlator.cmod.runtime.display.composition.SurfaceCompositor.logEvent("isAvailable() = " + available); + if (!available) return; + directCompositionInstalled = true; + xServerView.getHolder().addCallback(new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(SurfaceHolder holder) { + if (directCompositionLayer != null) return; + Surface surface = holder.getSurface(); + if (surface == null || !surface.isValid()) return; + directCompositionLayer = new com.winlator.cmod.runtime.display.composition.DirectCompositionLayer(); + if (!directCompositionLayer.attach(surface)) { + directCompositionLayer.release(); + directCompositionLayer = null; + return; + } + VulkanRenderer r = xServerView != null ? xServerView.getRenderer() : null; + if (r != null) r.setDirectCompositionTarget(directCompositionLayer); + } + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {} + @Override + public void surfaceDestroyed(SurfaceHolder holder) { releaseDirectCompositionLayer(); } + }); + if (xServerView.getHolder().getSurface() != null && xServerView.getHolder().getSurface().isValid()) { + Surface surface = xServerView.getHolder().getSurface(); + directCompositionLayer = new com.winlator.cmod.runtime.display.composition.DirectCompositionLayer(); + if (directCompositionLayer.attach(surface)) { + VulkanRenderer r = xServerView.getRenderer(); + if (r != null) r.setDirectCompositionTarget(directCompositionLayer); + } else { directCompositionLayer.release(); directCompositionLayer = null; } + } + } + + // DC: release layer. + private void releaseDirectCompositionLayer() { + if (directCompositionLayer == null) return; + VulkanRenderer r = xServerView != null ? xServerView.getRenderer() : null; + if (r != null) r.setDirectCompositionTarget(null); + directCompositionLayer.release(); + directCompositionLayer = null; + } + private void setTextColorForDialog(ViewGroup viewGroup, int color) { for (int i = 0; i < viewGroup.getChildCount(); i++) { View child = viewGroup.getChildAt(i); diff --git a/app/src/main/runtime/display/composition/DirectCompositionLayer.java b/app/src/main/runtime/display/composition/DirectCompositionLayer.java new file mode 100644 index 000000000..85a503cbc --- /dev/null +++ b/app/src/main/runtime/display/composition/DirectCompositionLayer.java @@ -0,0 +1,161 @@ +package com.winlator.cmod.runtime.display.composition; + +import android.util.Log; +import android.view.Surface; + +/** + * Instance class wrapping a single {@code ASurfaceControl} child layer bound to + * the XServerSurfaceView's Surface. All public methods are {@code synchronized} + * so the UI-thread {@link #release()} and the render-thread {@link #pushBuffer} + * can't race the native pointer. + * + *

Lifecycle

+ *
    + *
  1. {@link #attach(Surface)} — creates the ASurfaceControl, hides it, + * sets z=1. Called from the UI thread when the SurfaceView's surface + * is created.
  2. + *
  3. {@link #pushBuffer(long, int, int, int, int, int)} — per-frame hot + * path. Hands an AHardwareBuffer to the SC layer. Called from the render + * thread. Returns false on any failure; the caller self-detaches after + * {@code DC_FAIL_LIMIT} consecutive failures.
  4. + *
  5. {@link #hide()} — hides the SC layer (visibility=HIDE) when the current + * frame doesn't qualify for direct scanout. Idempotent.
  6. + *
  7. {@link #release()} — reparents to null, waits for in-flight + * transactions, releases the SC. Called from the UI thread on + * surfaceDestroyed / activity destroy.
  8. + *
+ * + *

Soft-boot hardening (vs original PR #380)

+ *
    + *
  • Smoke-test buffer removed. The original allocated a 256x256 + * magenta AHB with CPU_WRITE_RARELY | COMPOSER_OVERLAY on every + * surfaceCreated and pushed it as a proof-of-life. That combo crashes + * gralloc on Adreno 6xx / MediaTek → soft boot. Removed entirely; real + * game frames prove the path works.
  • + *
  • Wait-for-in-flight on release. The native side tracks in-flight + * ASurfaceTransaction_apply calls and waits for them to complete before + * ASurfaceControl_release. Prevents the Xiaomi/HyperOS SF crash.
  • + *
  • No nativeAllocateTestBuffer / nativeReleaseBuffer. Removed.
  • + *
+ */ +public final class DirectCompositionLayer { + + private static final String TAG = "DirectCompositionLayer"; + + /** Native ASurfaceControl* pointer. 0 = not attached / released. */ + private long nativeSc = 0; + private boolean attached = false; + + /** + * Create the ASurfaceControl child layer bound to the given Surface. + * The layer is hidden initially; it becomes visible on the first + * successful {@link #pushBuffer}. + * + * @param surface The SurfaceView's Surface (from SurfaceHolder.getSurface()). + * @return true on success, false if native creation failed (caller should + * not call pushBuffer; the VulkanRenderer composition path will + * be used instead). + */ + public synchronized boolean attach(Surface surface) { + if (attached) { + Log.w(TAG, "attach: already attached, ignoring"); + return true; + } + if (surface == null || !surface.isValid()) { + Log.w(TAG, "attach: surface is null or invalid"); + return false; + } + nativeSc = nativeCreateFromWindow(surface, "winnative-direct-composition"); + if (nativeSc == 0) { + Log.e(TAG, "attach: nativeCreateFromWindow returned 0"); + return false; + } + attached = true; + Log.i(TAG, "Direct Composition layer attached: sc=" + nativeSc); + return true; + } + + /** + * Push an AHardwareBuffer to the SC layer. The layer is shown atomically + * with the buffer set (avoids the blank-frame race). + * + * @param ahbPtr The raw AHardwareBuffer* pointer (from + * GPUImage.getHardwareBufferPtr()). Must be non-zero. + * @param dstX Destination X in SurfaceView coordinate space. + * Must be >= 0 (negative values crash SF on some + * OEM ROMs). + * @param dstY Destination Y. Must be >= 0. + * @param dstW Destination width. Must be > 0. + * @param dstH Destination height. Must be > 0. + * @param acquireFenceFd Producer-side acquire fence (-1 = no fence, buffer + * is ready immediately). The framework takes + * ownership and closes the fd on success; on + * failure this method closes it. + * @param opaque true if the buffer is fully opaque (alpha=1.0 + * throughout). Lets HWC skip alpha blending and + * bypass the Snapdragon DPU SDR-on-HDR brightness + * boost. false for translucent content. + * @return true on success, false on any failure (caller should count + * consecutive failures and self-detach after DC_FAIL_LIMIT). + */ + public synchronized boolean pushBuffer(long ahbPtr, int dstX, int dstY, + int dstW, int dstH, + int acquireFenceFd, boolean opaque, boolean pace) { + if (!attached || nativeSc == 0) { + if (acquireFenceFd >= 0) { + try { android.os.ParcelFileDescriptor.adoptFd(acquireFenceFd).close(); } + catch (java.io.IOException ignored) {} + } + return false; + } + return nativePushBuffer(nativeSc, ahbPtr, dstX, dstY, dstW, dstH, + acquireFenceFd, opaque, pace); + } + + /** + * Hide the SC layer (visibility=HIDE). Idempotent — safe to call when + * already hidden. Used when the current frame doesn't qualify for direct + * scanout (windowed app, multi-drawable, cursor visible over non-fullscreen + * scene, magnifier overlay active, etc.). + */ + public synchronized void hide() { + if (!attached || nativeSc == 0) return; + nativeHide(nativeSc); + } + + /** + * Release the SC layer. Reparents to null (removes from display), waits + * for all in-flight transactions to complete, then releases the native + * ASurfaceControl. Safe to call from the UI thread while the render thread + * might be in pushBuffer — the synchronized keyword serializes the two. + * + * After release, this instance is unusable; create a new one to re-attach. + */ + public synchronized void release() { + if (!attached) return; + nativeDetachAndRelease(nativeSc); + nativeSc = 0; + attached = false; + Log.i(TAG, "Direct Composition layer released"); + } + + public synchronized boolean isAttached() { + return attached; + } + + // --- Native methods --- + + private native long nativeCreateFromWindow(Surface surface, String debugName); + + private native void nativeDetachAndRelease(long scPtr); + + private native void nativeHide(long scPtr); + + private native boolean nativePushBuffer(long scPtr, long ahbPtr, + int dstX, int dstY, + int dstW, int dstH, + int acquireFenceFd, boolean opaque, boolean pace); + + // Blocks until SF finishes the previous frame (hardware signal, no CPU polling). + public native boolean nativeWaitForPreviousFrame(long timeoutMs); +} diff --git a/app/src/main/runtime/display/composition/SurfaceCompositor.java b/app/src/main/runtime/display/composition/SurfaceCompositor.java new file mode 100644 index 000000000..364f33d09 --- /dev/null +++ b/app/src/main/runtime/display/composition/SurfaceCompositor.java @@ -0,0 +1,159 @@ +package com.winlator.cmod.runtime.display.composition; + +import android.content.Context; +import android.os.Build; +import android.util.Log; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * Bridge to the native {@code surface_compositor.c} module — gives the rest of + * the app a stable Java entry point for the per-container "Direct Composition" + * path without any caller having to know whether the underlying NDK + * {@code ASurfaceControl} / {@code ASurfaceTransaction} symbols are actually + * resolvable on this device. + * + *

Availability gate

+ * {@link #isAvailable()} returns {@code true} only when both of these hold: + *
    + *
  1. API level 29+ (ASurfaceControl arrived in API 29).
  2. + *
  3. The required libandroid.so symbols resolve via dlsym.
  4. + *
+ * + * Direct Composition is a per-container opt-in toggle + * ({@link com.winlator.cmod.runtime.container.Container#EXTRA_DIRECT_COMPOSITION}), + * so device eligibility is left to the user rather than a static blocklist. + */ +public final class SurfaceCompositor { + + static { + // Same pattern used by SysVSharedMemory, GPUImage, ClientSocket, etc. + System.loadLibrary("winlator"); + } + + private static final String TAG = "SurfaceCompositor"; + + /** Cached probe result. null until first call; thereafter final-state. */ + private static volatile Boolean cachedAvailability; + + private SurfaceCompositor() { + // Static-only utility. + } + + /** + * @return {@code true} when the device exposes the API 29+ SurfaceControl + * + SurfaceTransaction NDK symbols. {@code false} on any earlier + * Android version, on any device where libandroid.so is missing the + * symbol, or if the JNI lookup itself fails. + */ + public static boolean isAvailable() { + Boolean cached = cachedAvailability; + if (cached != null) { + return cached; + } + // Hard short-circuit on platforms where the native call would always + // resolve to the API-< 29 fallback. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + cachedAvailability = Boolean.FALSE; + return false; + } + + boolean result; + try { + result = nativeIsAvailable(); + } catch (UnsatisfiedLinkError | RuntimeException e) { + Log.w(TAG, "nativeIsAvailable threw, treating as unavailable", e); + result = false; + } + cachedAvailability = result; + if (result) { + Log.i(TAG, "Direct Composition is available on this device"); + } + return result; + } + + private static native boolean nativeIsAvailable(); + + // === DIAGNOSTIC FILE LOGGING === + // + // The wine_*.txt logs the user shares only capture Wine/FEX stderr — they + // do NOT contain Android logcat. To make Direct Composition status visible + // in the user's shared logs, we write DC events to a dedicated + // direct-composition.log file in the app's logs directory. This file is + // automatically included when the user shares logs (LogManager shares all + // *.log / *.txt files in the logs dir). + // + // Call SurfaceCompositor.logEvent("message") from anywhere in the app to + // append a timestamped line. The file is opened lazily on first call and + // kept open for the session. + + private static volatile File diagFile = null; + private static volatile FileWriter diagWriter = null; + private static final Object diagLock = new Object(); + private static final SimpleDateFormat diagDateFormat = + new SimpleDateFormat("HH:mm:ss.SSS", Locale.US); + + /** + * Set the diagnostic file location. Called once from XServerDisplayActivity + * at session start (before any DC code runs). Pass the app's logs directory. + */ + public static void initDiagnosticFile(File logsDir) { + synchronized (diagLock) { + try { + if (diagWriter != null) { + diagWriter.flush(); + diagWriter.close(); + } + if (logsDir != null && !logsDir.exists()) logsDir.mkdirs(); + diagFile = new File(logsDir, "direct-composition.log"); + diagWriter = new FileWriter(diagFile, /*append=*/false); + logEvent("=== Direct Composition diagnostic log started ==="); + logEvent("Device: " + Build.MANUFACTURER + " " + Build.MODEL + + " (API " + Build.VERSION.SDK_INT + ")"); + logEvent("isAvailable() = " + isAvailable()); + } catch (IOException e) { + Log.w(TAG, "Failed to init diagnostic file", e); + diagWriter = null; + } + } + } + + /** + * Append a timestamped line to the diagnostic file. Also goes to logcat + * (Log.i) so it appears in logcat.log too. Safe to call from any thread. + */ + public static void logEvent(String message) { + String timestamped = "[" + diagDateFormat.format(new Date()) + "] " + message; + Log.i(TAG, message); + synchronized (diagLock) { + if (diagWriter != null) { + try { diagWriter.write(timestamped + "\n"); } + catch (IOException ignored) {} + } + } + } + + /** + * Close the diagnostic file. Called from XServerDisplayActivity.onDestroy. + */ + public static void closeDiagnosticFile() { + synchronized (diagLock) { + try { + if (diagWriter != null) { + logEvent("=== Direct Composition diagnostic log closed ==="); + diagWriter.flush(); + diagWriter.close(); + } + } catch (IOException ignored) { + } finally { + diagWriter = null; + diagFile = null; + } + } + } +} diff --git a/app/src/main/runtime/display/renderer/GPUImage.java b/app/src/main/runtime/display/renderer/GPUImage.java index 721dc23df..626e9864c 100644 --- a/app/src/main/runtime/display/renderer/GPUImage.java +++ b/app/src/main/runtime/display/renderer/GPUImage.java @@ -120,6 +120,26 @@ public ByteBuffer getVirtualData() { return virtualData; } + /** + * Raw {@code AHardwareBuffer*} pointer (a JNI-returned {@code long}). Used + * by the Direct Composition path to hand this image directly to a child + * {@code ASurfaceControl} via {@code ASurfaceTransaction_setBuffer}, + * bypassing the VulkanRenderer's GPU compositing blit for fullscreen game + * frames — true zero-copy to the DPU overlay plane. + * + *

Returns {@code 0} if the buffer was never allocated / has been + * destroyed — callers MUST treat 0 as "no buffer available, fall back to + * VulkanRenderer composition". + * + *

The pointer remains valid only for the lifetime of this GPUImage. + * SurfaceFlinger takes its own reference when the AHB is set on a layer, + * so holding the pointer past {@link #destroy()} is illegal — release any + * DirectCompositionLayer reference first. + */ + public long getHardwareBufferPtr() { + return ahbPtr; + } + public boolean isValid() { return ahbPtr != 0 && (!cpuAccessible || (virtualData != null && stride > 0)); } diff --git a/app/src/main/runtime/display/renderer/VulkanRenderer.java b/app/src/main/runtime/display/renderer/VulkanRenderer.java index 9104834a4..319f5ec5a 100644 --- a/app/src/main/runtime/display/renderer/VulkanRenderer.java +++ b/app/src/main/runtime/display/renderer/VulkanRenderer.java @@ -69,6 +69,58 @@ public void setSwapRB(boolean v) { this.swapRB = v; requestRenderCoalesced(); } + + // === DIRECT COMPOSITION (zero-copy AHB → SurfaceControl) === + // + // When non-null and the current frame qualifies as a fullscreen + // direct-scanout candidate, the AHardwareBuffer backing that drawable is + // pushed to this layer in addition to the VulkanRenderer composition. The + // SC layer at z=1 covers the SurfaceView's primary layer at z=0, so HWC + // can promote it to a DPU overlay plane — zero GPU compositing cost, zero + // buffer copy. This is the true zero-copy path. + // + // Set/cleared by the activity from the UI thread via + // {@link #setDirectCompositionTarget}; read here on the render thread, + // hence volatile. The volatile only suppresses NEW frames from entering + // the SC push after the UI thread writes null — in-flight frames are + // protected by DirectCompositionLayer's own synchronized methods. + private volatile com.winlator.cmod.runtime.display.composition.DirectCompositionLayer + directCompositionTarget; + + // Last (ahbPtr, dstW, dstH) pushed to directCompositionTarget. Per-frame + // pushBuffer calls allocate a SurfaceFlinger transaction, which is wasted + // work when nothing changed. DRI3 allocates a fresh GPUImage per Present + // cycle, so AHB-pointer identity is a sufficient "dirty" check. + // Render-thread-only — no synchronization needed. + private volatile long dcLastPushedAhb = 0L; + private volatile int dcLastPushedW = 0; + private volatile int dcLastPushedH = 0; + + // Consecutive pushBuffer == false returns. After enough failures the + // renderer detaches itself from the SC layer to avoid wasting JNI calls + // every frame on a permanent failure. Render-thread-only. + private volatile int dcConsecutiveFailures = 0; + private static final int DC_FAIL_LIMIT = 8; + // Hysteresis: stay active for a few non-qualifying frames before hiding, to prevent flapping. + private int dcDisengageStreak = 0; + private static final int DC_DISENGAGE_FRAMES = 4; + // While recording, keep compositing so the encoder is fed even when DC owns the display. + private volatile boolean recordingActive = false; + + // True when the most recent frame successfully pushed an AHB to the SC, + // so the SC layer is currently visible. Used to detect transitions to + // the windowed/multi-drawable case so we can hide the SC cleanly. + private volatile boolean dcLayerActive = false; + // Last skip reason logged for the DC candidate (diagnostic throttling — + // only log when the reason CHANGES, to avoid per-frame spam). Values: + // "no-texture", "texture-not-gpuimage(Texture)", "gpuimage-ahb-null", + // "ok". Empty string = nothing logged yet. + private String dcLastSkipReason = ""; + + // Last candidate-state logged (diagnostic throttling for the + // directCandidate null/present transition). Empty = nothing logged yet. + private String dcLastCandidateState = ""; + private boolean screenOffsetYRelativeToCursor = false; private String[] unviewableWMClasses = null; private float magnifierZoom = 1.0f; @@ -116,7 +168,12 @@ public void setSwapRB(boolean v) { private final ByteBuffer sceneBuf = ByteBuffer.allocateDirect(SCENE_BUF_SIZE).order(ByteOrder.nativeOrder()); private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private final AtomicBoolean renderRequested = new AtomicBoolean(false); + // Content dirty flag — set when DRI3 delivers a new frame or input arrives. + private volatile boolean contentDirty = false; + + public void markContentDirty() { + contentDirty = true; + } // Reusable scratch — sized once, refilled per frame. private final float[] sceneXform = XForm.getInstance(); @@ -164,13 +221,12 @@ public void destroy() { } public void requestRenderCoalesced() { - if (renderRequested.compareAndSet(false, true)) { - mainHandler.post(() -> - Choreographer.getInstance().postFrameCallback(frameTimeNanos -> { - renderRequested.set(false); - xServerView.requestRender(); - })); - } + xServerView.requestRender(); + } + + // Non-blocking input wake — just signals the render thread, no throttle, no direct render. + public void requestInputRender() { + xServerView.signalInputDirty(); } private Drawable createRootCursorDrawable() { @@ -240,7 +296,9 @@ public void detachSurface() { public boolean startRecording(Surface encoderSurface, int fps, boolean recordUI) { synchronized (this) { if (nativeHandle == 0 || encoderSurface == null) return false; - return nativeStartRecording(nativeHandle, encoderSurface, fps, recordUI); + boolean ok = nativeStartRecording(nativeHandle, encoderSurface, fps, recordUI); + recordingActive = ok; + return ok; } } @@ -254,6 +312,7 @@ public void updateRecordUITexture(java.nio.ByteBuffer bgra, int width, int heigh public void stopRecording() { synchronized (this) { + recordingActive = false; if (nativeHandle != 0) nativeStopRecording(nativeHandle); } } @@ -319,6 +378,36 @@ private void buildAndSubmitFrame() { } } + if (!contentDirty && !viewportNeedsUpdate) { + return; + } + + Drawable directCandidate = findDirectCompositionCandidate(); + boolean dcOwnsFrame = false; + if (directCompositionTarget != null) { + String candidateState = (directCandidate != null) ? "present" : "null"; + if (!candidateState.equals(dcLastCandidateState)) { + dcLastCandidateState = candidateState; + com.winlator.cmod.runtime.display.composition.SurfaceCompositor.logEvent( + directCandidate == null + ? "DC: no fullscreen candidate" + : "DC: fullscreen candidate " + directCandidate.width + "x" + directCandidate.height); + } + if (maybePushDirectComposition(directCandidate)) { + dcOwnsFrame = true; + dcDisengageStreak = 0; + } else if (dcLayerActive && ++dcDisengageStreak < DC_DISENGAGE_FRAMES) { + dcOwnsFrame = true; + } else { + maybeHideDirectComposition(); + dcDisengageStreak = 0; + } + } + if (dcOwnsFrame && !recordingActive) { + contentDirty = false; + return; + } + textureUploadBatch.reset(); boolean useScissor = false; @@ -553,8 +642,247 @@ private void buildAndSubmitFrame() { } nativeSetScene(nativeHandle, buf); - // nativeSetFpsLimit is a native no-op (pacing is done elsewhere); not called per frame. nativeRenderFrame(nativeHandle); + contentDirty = false; + } + + // Geometry-only scan for the fullscreen DC candidate (no Vulkan import). + private Drawable findDirectCompositionCandidate() { + Drawable candidate = null; + try (XLock lock = xServer.lock(XServer.Lockable.WINDOW_MANAGER, XServer.Lockable.DRAWABLE_MANAGER)) { + int screenW = xServer.screenInfo.width; + int screenH = xServer.screenInfo.height; + for (int i = 0; i < renderableWindows.size(); i++) { + RenderableWindow rw = renderableWindows.get(i); + Drawable d = rw.content; + if (d != null && rw.rootX == 0 && rw.rootY == 0 + && Short.toUnsignedInt(d.width) >= screenW + && Short.toUnsignedInt(d.height) >= screenH) { + candidate = d; + } + } + } + return candidate; + } + + /** + * Direct Composition hot path: extract the AHardwareBuffer for the + * candidate's scanoutSource and hand it to the per-activity + * DirectCompositionLayer. + * + *

Holds {@code candidate.renderLock} for the lookup so we can't race + * against DRI3 replacing the texture or GPUImage.destroy() releasing the + * underlying AHB mid-read. The JNI pushBuffer runs INSIDE the lock too — + * short call, SurfaceFlinger takes its own ref on the AHB inside + * ASurfaceTransaction_setBuffer apply, so the buffer is safe to release + * on the X-server thread the moment we exit the lock. + * + *

Per-frame waste suppression: caches the last successfully-pushed + * (ahbPtr, dstW, dstH) and skips the JNI call when nothing has changed. + * DRI3 allocates a fresh GPUImage each Present, so AHB-pointer identity is + * a sufficient "buffer changed" signal. + * + *

Failure counter: after {@code DC_FAIL_LIMIT} consecutive false + * returns from pushBuffer, nulls directCompositionTarget so subsequent + * frames don't keep paying the JNI cost for a permanent failure. + * + * @return true if a fresh AHB was pushed OR the cache hit (SC is still + * showing a valid prior frame). false = no qualifying candidate + * (caller should hide the SC layer). + */ + private boolean maybePushDirectComposition(Drawable directCandidate) { + final com.winlator.cmod.runtime.display.composition.DirectCompositionLayer dcTarget = + directCompositionTarget; + if (dcTarget == null) return false; + if (surfaceWidth <= 0 || surfaceHeight <= 0) return false; + + // Force fallback to VulkanRenderer composition when the magnifier UI is + // active — the z=1 SC layer would otherwise cover it. + if (magnifierUIActive) { + return false; + } + // No fullscreen candidate — fall back to VulkanRenderer. + if (directCandidate == null) { + return false; + } + // Only direct-scan a screen-covering window (don't stretch a sub-window fullscreen). + if (Short.toUnsignedInt(directCandidate.width) < xServer.screenInfo.width + || Short.toUnsignedInt(directCandidate.height) < xServer.screenInfo.height) { + return false; + } + + final Drawable content = directCandidate; + synchronized (content.renderLock) { + Drawable scanoutSource = content.getScanoutSource(); + if (scanoutSource == null) { + // No scanout source — the drawable itself is the source. + scanoutSource = content; + } + Texture tex = scanoutSource.getTexture(); + + // === DIAGNOSTIC: log why we might skip this candidate === + // Throttle: only log when the situation CHANGES, to avoid per-frame + // spam. We use a simple "last logged state" tracker. + String currentState; + if (tex == null) { + currentState = "no-texture"; + } else if (!(tex instanceof GPUImage)) { + currentState = "texture-not-gpuimage(" + tex.getClass().getSimpleName() + ")"; + } else if (((GPUImage) tex).getHardwareBufferPtr() == 0L) { + currentState = "gpuimage-ahb-null"; + } else { + currentState = "ok"; + } + if (!currentState.equals(dcLastSkipReason)) { + dcLastSkipReason = currentState; + if (!currentState.equals("ok")) { + com.winlator.cmod.runtime.display.composition.SurfaceCompositor.logEvent( + "DC skip: candidate " + currentState + + " (drawable=" + content.width + "x" + content.height + + " scanoutSource=" + (content.getScanoutSource() != null + ? (scanoutSource.width + "x" + scanoutSource.height) + : "self") + + " directScanout=" + content.isDirectScanout() + ")"); + } + } + + if (!(tex instanceof GPUImage)) { + drainFenceFd(scanoutSource); + return false; + } + long ahbPtr = ((GPUImage) tex).getHardwareBufferPtr(); + if (ahbPtr == 0L) { + drainFenceFd(scanoutSource); + return false; + } + + // Skip JNI when nothing has changed since the last push. + // SurfaceFlinger is still showing the layer; no point queueing a + // no-op transaction — this is the primary CPU/battery optimization. + if (ahbPtr == dcLastPushedAhb + && surfaceWidth == dcLastPushedW + && surfaceHeight == dcLastPushedH) { + return true; + } + + int fenceFd = scanoutSource.takeAcquireFenceFd(); + boolean ok = dcTarget.pushBuffer(ahbPtr, 0, 0, + surfaceWidth, surfaceHeight, fenceFd, /*opaque=*/true, /*pace=*/true); + if (ok) { + dcLastPushedAhb = ahbPtr; + dcLastPushedW = surfaceWidth; + dcLastPushedH = surfaceHeight; + dcConsecutiveFailures = 0; + if (!dcLayerActive) { + dcLayerActive = true; + com.winlator.cmod.runtime.display.composition.SurfaceCompositor.logEvent( + "DC ACTIVE — first frame pushed to SurfaceControl (ahb=0x" + + Long.toHexString(ahbPtr) + " " + surfaceWidth + "x" + surfaceHeight + + " drawable=" + content.width + "x" + content.height + ")"); + notifyDirectCompositionStateListener(); + } + return true; + } else { + dcConsecutiveFailures++; + com.winlator.cmod.runtime.display.composition.SurfaceCompositor.logEvent( + "DC pushBuffer FAILED (#" + dcConsecutiveFailures + ") — ahb=0x" + + Long.toHexString(ahbPtr)); + if (dcConsecutiveFailures >= DC_FAIL_LIMIT) { + Log.w(TAG, "DirectComposition push failed " + dcConsecutiveFailures + + " frames in a row — disabling target for this session"); + com.winlator.cmod.runtime.display.composition.SurfaceCompositor.logEvent( + "DC DISABLED — " + DC_FAIL_LIMIT + " consecutive failures, self-detaching"); + // Hide the SC layer BEFORE nulling the field — once the + // field is null, maybeHideDirectComposition has nothing to + // call hide() on, and SurfaceFlinger would keep showing + // the last successfully-pushed buffer over the + // VulkanRenderer output forever. + dcTarget.hide(); + if (dcLayerActive) { + dcLayerActive = false; + notifyDirectCompositionStateListener(); + } + directCompositionTarget = null; + dcLastPushedAhb = 0L; + dcLastPushedW = 0; + dcLastPushedH = 0; + dcConsecutiveFailures = 0; + } + return false; + } + } + } + + /** + * Hide the Direct Composition layer when the current frame doesn't + * qualify for the SC fast path (windowed app, multi-drawable, cursor + * visible over a non-fullscreen scene, magnifier overlay, etc.). + * Idempotent and cheap after the first call: tracks dcLayerActive so we + * only queue a hide-transaction once per direct→fallback transition. + */ + // Drain unconsumed fence FD to prevent FD leak when DC can't handle a frame. + private void drainFenceFd(Drawable scanoutSource) { + if (scanoutSource == null) return; + int fd = scanoutSource.takeAcquireFenceFd(); + if (fd >= 0) { + try { android.os.ParcelFileDescriptor.adoptFd(fd).close(); } + catch (java.io.IOException ignored) {} + } + } + + private void maybeHideDirectComposition() { + if (!dcLayerActive) return; + com.winlator.cmod.runtime.display.composition.DirectCompositionLayer dcTarget = + directCompositionTarget; + if (dcTarget != null) { + dcTarget.hide(); + } + dcLayerActive = false; + notifyDirectCompositionStateListener(); + // Invalidate the cache so the next pushBuffer re-shows with a fresh + // setBuffer + setVisibility(SHOW) transaction, even if the same AHB + // pointer happens to be active. + dcLastPushedAhb = 0L; + dcLastPushedW = 0; + dcLastPushedH = 0; + } + + /** + * Hand the renderer the per-activity Direct Composition layer (or null to + * detach). Safe to call from the UI thread; the render thread reads the + * field volatile-ly each frame inside buildAndSubmitFrame(). + */ + public void setDirectCompositionTarget( + com.winlator.cmod.runtime.display.composition.DirectCompositionLayer layer) { + // Hide old layer before swapping to prevent stale frame on screen. + com.winlator.cmod.runtime.display.composition.DirectCompositionLayer old = directCompositionTarget; + if (dcLayerActive && old != null) { + old.hide(); + } + this.directCompositionTarget = layer; + dcLastPushedAhb = 0L; + dcLastPushedW = 0; + dcLastPushedH = 0; + dcConsecutiveFailures = 0; + dcLayerActive = false; + dcLastSkipReason = ""; + notifyDirectCompositionStateListener(); + } + + // === DC STATE LISTENER (for HUD indicator) === + // Called when the DC layer goes active/inactive. The activity registers a + // listener to update the FrameRating HUD (" + DC" suffix on the renderer + // label). Null by default; set by XServerDisplayActivity. + public interface DirectCompositionStateListener { + void onDirectCompositionStateChanged(boolean active); + } + private volatile DirectCompositionStateListener dcStateListener; + public void setDirectCompositionStateListener(DirectCompositionStateListener listener) { + this.dcStateListener = listener; + } + private void notifyDirectCompositionStateListener() { + DirectCompositionStateListener l = dcStateListener; + if (l != null) l.onDirectCompositionStateChanged(dcLayerActive); } // ----- WindowManager / Pointer listeners -------------------------------- @@ -579,12 +907,24 @@ public void onChangeWindowZOrder(Window window) { @Override public void onUpdateWindowContent(Window window) { + contentDirty = true; requestRenderCoalesced(); } @Override public void onUpdateWindowGeometry(final Window window, boolean resized) { if (resized) { + // Graphics preset change: flush DC state, invalidate cache, force re-evaluation. + com.winlator.cmod.runtime.display.composition.DirectCompositionLayer dcGeom = directCompositionTarget; + if (dcLayerActive && dcGeom != null) { + dcGeom.hide(); + dcLayerActive = false; + notifyDirectCompositionStateListener(); + } + dcLastPushedAhb = 0L; + dcLastPushedW = 0; + dcLastPushedH = 0; + dcLastSkipReason = ""; xServerView.queueEvent(this::updateScene); } else { xServerView.queueEvent(() -> updateWindowPosition(window)); @@ -609,7 +949,8 @@ public void updateVisualCursorPosition(int x, int y) { @Override public void onPointerMove(short x, short y) { - requestCursorRender(); + cursorActiveUntilNs = System.nanoTime() + CURSOR_ACTIVE_NS; + requestInputRender(); } @Override @@ -627,6 +968,7 @@ private void updateScene() { xServer.windowManager.rootWindow.getX(), xServer.windowManager.rootWindow.getY()); } + contentDirty = true; } private void collectRenderableWindows(Window window, int x, int y) { diff --git a/app/src/main/runtime/display/ui/FrameRating.java b/app/src/main/runtime/display/ui/FrameRating.java index 97b8f0f65..1b0a3777b 100644 --- a/app/src/main/runtime/display/ui/FrameRating.java +++ b/app/src/main/runtime/display/ui/FrameRating.java @@ -105,6 +105,12 @@ public class FrameRating extends LinearLayout implements Runnable { private boolean enableGpu; private boolean enableGraph; private boolean enableRenderer; + // Direct Composition status — when true, " + DC" (green) is appended to the + // renderer label so the user can see at a glance whether zero-copy AHB → + // SurfaceControl + HWC overlay is active. Toggled from XServerDisplayActivity + // via setDirectCompositionActive(). Volatile because it's written from the + // render thread and read from the UI thread in updateRendererText(). + private volatile boolean directCompositionActive = false; private volatile FrameObserver frameObserver; private int gpuFailCount; private volatile int gpuLoad; @@ -907,12 +913,36 @@ public void run() { private void updateRendererText() { if (this.tvRenderer != null) { - this.tvRenderer.setText(this.rendererName); + if (directCompositionActive) { + // Append " + DC" in green to signal that Direct Composition + // (zero-copy AHB → SurfaceControl + HWC overlay) is active. + SpannableStringBuilder sb = new SpannableStringBuilder(); + sb.append(this.rendererName); + sb.append(" + DC"); + sb.setSpan(new ForegroundColorSpan(0xFF4CAF50), // Material Green 500 + this.rendererName.length(), sb.length(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + this.tvRenderer.setText(sb); + } else { + this.tvRenderer.setText(this.rendererName); + } this.tvRenderer.setVisibility(this.enableRenderer ? View.VISIBLE : View.GONE); updateSeparators(getOrientation() == LinearLayout.HORIZONTAL); } } + /** + * Toggle the Direct Composition status indicator in the HUD. When true, + * " + DC" (green) is appended to the renderer label. Safe to call from any + * thread — the value is volatile and updateRendererText() is re-run on the + * next frame via post(). + */ + public void setDirectCompositionActive(boolean active) { + if (this.directCompositionActive == active) return; + this.directCompositionActive = active; + post(this::updateRendererText); + } + public void setGpuName(String name) { if (name != null && !name.isEmpty()) { // Clean up property format "name = value" and remove quotes diff --git a/app/src/main/runtime/display/ui/XServerSurfaceView.java b/app/src/main/runtime/display/ui/XServerSurfaceView.java index 2f8ae7688..2e5ab9fb1 100644 --- a/app/src/main/runtime/display/ui/XServerSurfaceView.java +++ b/app/src/main/runtime/display/ui/XServerSurfaceView.java @@ -37,6 +37,17 @@ public class XServerSurfaceView extends SurfaceView implements SurfaceHolder.Cal private volatile int width; private volatile int height; + private android.os.PerformanceHintManager.Session perfHintSession; + private final long[] adpfDurationHistory = new long[8]; + private int adpfHistoryIndex = 0; + private int adpfHistoryCount = 0; + private int adpfFrameCounter = 0; + private long adpfLastReportedAvg = 0; + private static final int ADPF_REPORT_INTERVAL = 6; + private static final double ADPF_DEVIATION_THRESHOLD = 0.15; + private static final double ADPF_HEADROOM_BIAS = 1.12; + // Lockless input flag — set by input thread, consumed by render thread. + private volatile boolean inputDirty = false; public XServerSurfaceView(Context context, XServer xServer) { super(context); @@ -61,11 +72,23 @@ public void queueEvent(Runnable r) { public void requestRender() { synchronized (renderLock) { + if (renderRequested) return; renderRequested = true; renderLock.notifyAll(); } } + // Lockless input signal — no buildAndSubmitFrame, just wake the render thread. + public void signalInputDirty() { + inputDirty = true; + synchronized (renderLock) { + if (!renderRequested) { + renderRequested = true; + renderLock.notifyAll(); + } + } + } + public void requestTransientRender(long durationMs) { long untilNs = System.nanoTime() + Math.max(1L, durationMs) * 1_000_000L; synchronized (renderLock) { @@ -186,6 +209,20 @@ private void stopRenderThread() { } private void renderLoop() { + // ADPF: create hint session targeting 8ms (~120 FPS) so the kernel governor scales CPU clocks. + if (android.os.Build.VERSION.SDK_INT >= 31) { + try { + android.os.PerformanceHintManager phm = (android.os.PerformanceHintManager) + getContext().getSystemService(android.os.PerformanceHintManager.class); + if (phm != null) { + perfHintSession = phm.createHintSession( + new int[]{android.os.Process.myTid()}, 8_000_000L); + } + } catch (Exception ignored) {} + } else { + // Legacy fallback: sustained performance mode (API 24-30, via Window flag on Activity). + // No wakelock needed — the Activity sets sustained performance mode on its Window. + } renderer.onSurfaceCreated(); if (width > 0 && height > 0) renderer.onSurfaceChanged(width, height); @@ -218,10 +255,14 @@ private void renderLoop() { } if (renderMode == RENDERMODE_CONTINUOUSLY) { - draw = true; - transientRenderRequested = false; - nextContinuousFrameNs = 0; - break; + if (nextContinuousFrameNs == 0 || now >= nextContinuousFrameNs) { + draw = true; + transientRenderRequested = false; + nextContinuousFrameNs = now + TRANSIENT_FRAME_INTERVAL_NS; + break; + } + waitNanosLocked(nextContinuousFrameNs - now); + continue; } if (transientRenderRequested) { @@ -249,12 +290,46 @@ private void renderLoop() { if (event != null) { try { event.run(); } catch (Throwable ignore) {} } else if (draw) { + if (inputDirty) { + inputDirty = false; + renderer.markContentDirty(); + } + long frameStartNs = android.os.SystemClock.elapsedRealtimeNanos(); try { renderer.onDrawFrame(); } catch (Throwable ignore) {} + if (perfHintSession != null) { + long rawDuration = android.os.SystemClock.elapsedRealtimeNanos() - frameStartNs; + reportAdpfDuration(rawDuration); + } } } + // ADPF cleanup. + if (perfHintSession != null) { + try { perfHintSession.close(); } catch (Exception ignored) {} + perfHintSession = null; + } renderer.onSurfaceDestroyed(); } + // ADPF smoothed reporting: rolling 8-frame average + 12% headroom bias + throttled to every 6 frames or >15% deviation. + private void reportAdpfDuration(long rawDurationNs) { + adpfDurationHistory[adpfHistoryIndex] = rawDurationNs; + adpfHistoryIndex = (adpfHistoryIndex + 1) % 8; + if (adpfHistoryCount < 8) adpfHistoryCount++; + long sum = 0; + for (int i = 0; i < adpfHistoryCount; i++) sum += adpfDurationHistory[i]; + long avg = sum / adpfHistoryCount; + long biased = (long)(avg * ADPF_HEADROOM_BIAS); + adpfFrameCounter++; + boolean intervalMet = (adpfFrameCounter >= ADPF_REPORT_INTERVAL); + boolean deviationExceeded = (adpfLastReportedAvg == 0 || + Math.abs(biased - adpfLastReportedAvg) > adpfLastReportedAvg * ADPF_DEVIATION_THRESHOLD); + if (intervalMet || deviationExceeded) { + try { perfHintSession.reportActualWorkDuration(biased); } catch (Exception ignored) {} + adpfLastReportedAvg = biased; + adpfFrameCounter = 0; + } + } + private void waitNanosLocked(long nanos) { if (nanos <= 0) return; long millis = nanos / 1_000_000L; diff --git a/app/src/main/runtime/display/xserver/Drawable.java b/app/src/main/runtime/display/xserver/Drawable.java index 5ff78dc3c..2faad2038 100644 --- a/app/src/main/runtime/display/xserver/Drawable.java +++ b/app/src/main/runtime/display/xserver/Drawable.java @@ -7,6 +7,7 @@ import com.winlator.cmod.shared.util.Callback; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.concurrent.atomic.AtomicInteger; public class Drawable extends XResource { public final short width; @@ -25,6 +26,24 @@ public class Drawable extends XResource { private Callback onDestroyListener; public final Object renderLock = new Object(); + /** + * Producer-side acquire fence FD for the Direct Composition path. -1 means + * "no fence; buffer is ready for immediate read". + * + *

Today the value is usually -1: the in-process Java X server receives + * the AHardwareBuffer from the Wine client over the DRI3 + * PIXMAP_FROM_BUFFERS path, which the X-server worker thread processes only + * after the client has already submitted its Vulkan command buffer. + * Empirically this CPU-side handoff latency exceeds the GPU-write completion + * time, so we observe no tearing without an explicit fence. + * + *

Stored as AtomicInteger so the "consume" semantic is atomic with + * respect to concurrent setAcquireFenceFd writes — without it, a producer + * could overwrite a still-pending FD between the consumer's read and clear, + * leaking the old FD. + */ + private final AtomicInteger acquireFenceFd = new AtomicInteger(-1); + static { System.loadLibrary("winlator"); } @@ -133,6 +152,36 @@ public boolean isDirectScanout() { return directScanout; } + /** + * Atomic read-and-clear of the acquire fence FD: returns the current value + * (or -1 if none) AND resets the field to -1 in a single CAS. Single-consumer + * "take" semantics — the caller now owns the FD and is responsible for + * either closing it or transferring ownership (e.g. to + * ASurfaceTransaction_setBuffer, which closes it via the framework). + */ + public int takeAcquireFenceFd() { + return acquireFenceFd.getAndSet(-1); + } + + /** + * Sets the producer acquire fence FD. Should be called by Present/DRI3 + * extension code immediately before the buffer is published. Ownership + * transfers to this Drawable; the next consumer of + * {@link #takeAcquireFenceFd} takes ownership in turn. If the previous fence + * is still set (consumer hasn't taken it yet), this method closes the + * previous fence to avoid a leak. Pass {@code -1} to clear without setting. + */ + public void setAcquireFenceFd(int fd) { + int prior = acquireFenceFd.getAndSet(fd); + if (prior >= 0 && prior != fd) { + try { + android.os.ParcelFileDescriptor.adoptFd(prior).close(); + } catch (java.io.IOException ignored) { + // best-effort close + } + } + } + private short getStride() { return texture instanceof GPUImage ? ((GPUImage) texture).getStride() : width; } diff --git a/app/src/main/runtime/display/xserver/WindowManager.java b/app/src/main/runtime/display/xserver/WindowManager.java index 99e34617a..2c60c9594 100644 --- a/app/src/main/runtime/display/xserver/WindowManager.java +++ b/app/src/main/runtime/display/xserver/WindowManager.java @@ -481,7 +481,11 @@ private void triggerOnChangeWindowZOrder(Window window) { protected void triggerOnUpdateWindowContent(Window window) { synchronized (onWindowModificationListeners) { for (int i = onWindowModificationListeners.size() - 1; i >= 0; i--) { - onWindowModificationListeners.get(i).onUpdateWindowContent(window); + try { + onWindowModificationListeners.get(i).onUpdateWindowContent(window); + } catch (Throwable t) { + Log.e("WindowManager", "onUpdateWindowContent listener threw", t); + } } } }