diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 5b7642bb7..aba2baa58 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -226,7 +226,7 @@ static void on_client_created(struct wl_listener *l, void *data) { /* ------------------------------------------------------------------ surfaces */ -enum surface_role { ROLE_NONE, ROLE_TOPLEVEL, ROLE_SUBSURFACE, ROLE_DESKTOP }; +enum surface_role { ROLE_NONE, ROLE_TOPLEVEL, ROLE_SUBSURFACE, ROLE_DESKTOP, ROLE_CURSOR }; struct surface { struct wl_resource *resource; @@ -474,6 +474,69 @@ static struct seat_keyboard g_kbs[MAX_PTRS]; static int g_nkbs; static struct seat_touch g_touches[MAX_PTRS]; static int g_ntouches; + +/* ---- the client's cursor (wl_pointer.set_cursor) ------------------------------------------- + * Not composited: under zero-copy the program's buffer goes straight to an Android layer and there + * is nothing to composite into. The cursor surface is snapshotted here instead and the app draws it + * as its overlay, above every present path (Bannerlator's design). What it buys: the real SHAPE - + * labwc's resize arrows at a window's edges and corners, a text field's I-beam - and an + * authoritative HIDE (set_cursor with a NULL surface, what a mouse-look game does). + * wl_shm cursors (Wine, pixman) are copied directly; a GPU cursor (labwc on Vulkan/GLES, which + * wlroots hands over as a dma-buf, usually UBWC) is read back through vkp_image_readback. */ +#define CURSOR_MAX_PX (256 * 256) +static pthread_mutex_t g_cursor_lock = PTHREAD_MUTEX_INITIALIZER; +static struct surface *g_cursor_surface; /* compositor thread only */ +static int g_cursor_hx, g_cursor_hy; /* hotspot, surface-local */ +static uint32_t g_cursor_px[CURSOR_MAX_PX]; /* guarded by g_cursor_lock: ARGB8888 snapshot */ +static uint32_t g_cursor_rb[CURSOR_MAX_PX]; /* compositor thread: GPU readback staging */ +static int g_cursor_w, g_cursor_h; /* 0 = nothing to draw */ +static int g_cursor_hidden = 1; /* the client asked for no pointer */ +static int g_cursor_serial; /* bumped on every change; the app polls it */ + +static void cursor_publish_hidden(void) { + pthread_mutex_lock(&g_cursor_lock); + if (!g_cursor_hidden) { g_cursor_hidden = 1; g_cursor_serial++; } + pthread_mutex_unlock(&g_cursor_lock); +} + +static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t stride, int hx, int hy) { + if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return; + pthread_mutex_lock(&g_cursor_lock); + for (int y = 0; y < h; y++) + memcpy(&g_cursor_px[y * w], src + (size_t)y * stride, (size_t)w * 4); + g_cursor_w = w; g_cursor_h = h; + g_cursor_hx = hx; g_cursor_hy = hy; + g_cursor_hidden = 0; + g_cursor_serial++; + pthread_mutex_unlock(&g_cursor_lock); +} + +static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) { + int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm); + if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return; + wl_shm_buffer_begin_access(shm); + cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h, + (size_t)wl_shm_buffer_get_stride(shm), hx, hy); + wl_shm_buffer_end_access(shm); +} + +/* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...]. + * Returns the number of ints written, or 0 if out is too small. */ +int banner_cursor_snapshot(int *out, int cap) { + int n = 0; + pthread_mutex_lock(&g_cursor_lock); + int need = 6 + (g_cursor_hidden ? 0 : g_cursor_w * g_cursor_h); + if (cap >= need) { + out[0] = g_cursor_serial; out[1] = g_cursor_hidden; + out[2] = g_cursor_w; out[3] = g_cursor_h; + out[4] = g_cursor_hx; out[5] = g_cursor_hy; + if (!g_cursor_hidden) + memcpy(out + 6, g_cursor_px, (size_t)g_cursor_w * g_cursor_h * 4); + n = need; + } + pthread_mutex_unlock(&g_cursor_lock); + return n; +} struct active_touch { struct surface *surface; int pointer_fallback; }; static struct active_touch g_touch_points[MAX_TOUCH_POINTS]; static void touch_cancel_surface(struct surface *surface); @@ -686,9 +749,11 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res if (ci && ci->asked_feedback) ci->shm_frames++; /* since it asked for GPU buffers */ } -/* The window the app's performance HUD follows: the latest one to start presenting GPU frames - * (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */ +/* The window the app's performance HUD follows: the latest one at least as big as the last to + * start presenting GPU frames (take_dmabuf) (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */ static struct surface *g_hud_surface; +/* When the HUD's window last presented a frame (now_ns), to let a replacement take over. */ +static int64_t g_hud_last_ns; extern void banner_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */ extern void banner_on_game_frame(void); /* The program behind that window: its Linux pid (the Wayland client's credentials) and executable name @@ -731,6 +796,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re s->buf_h = b->height; s->has_content = b->img != NULL; g_stat_dmabuf++; + const int announced_now = !s->announced_vulkan; if (!s->announced_vulkan) { char name[160]; s->announced_vulkan = 1; @@ -751,14 +817,23 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re else banner_log("error", "could not import GPU frames from %s (%dx%d, modifier %#llx)", name, b->width, b->height, (unsigned long long)b->modifier); - /* The HUD follows the window whether its frames are copied or go straight to the layer: - * a zero-copy frame the compositor never imported is still a presented game frame. */ - if (b->img || ahb_swapchain_has_ahb(b)) { - g_hud_surface = s; - banner_on_game_surface(name, vkp_gpu_name()); - struct client_info *ci = client_info_of(wl_resource_get_client(s->resource)); - if (ci) banner_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : ""); - } + } + /* The HUD follows the window whether its frames are copied or go straight to the layer: a + * zero-copy frame the compositor never imported is still a presented game frame. A + * program's small surfaces - gamescope's 1x1 cursor, Qt's menus and tooltips (melonDS) - + * came after the game's and drew too rarely to count, which left the HUD on 0 fps while the + * game ran at 60. So a new window takes the HUD only when it is at least as big (a + * full-screen gamescope over the desktop still does), and a window gone quiet for a second + * gives way to whatever draws next. */ + if (s != g_hud_surface && (b->img || ahb_swapchain_has_ahb(b)) && + (!g_hud_surface || now_ns() - g_hud_last_ns > 1000000000LL || + (announced_now && (int64_t)b->width * b->height >= (int64_t)g_hud_surface->buf_w * g_hud_surface->buf_h))) { + char name[160]; + describe(s, name, sizeof(name)); + g_hud_surface = s; + banner_on_game_surface(name, vkp_gpu_name()); + struct client_info *ci = client_info_of(wl_resource_get_client(s->resource)); + if (ci) banner_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : ""); } /* HDR session (gate open): a DXVK game switches to HDR by REBUILDING its swapchain on the same * surface, so the one-shot line above never sees the 10-bit buffers - name every format change. */ @@ -780,7 +855,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re * Asking whether the compositor could import it, as this used to, is a question about the * copy path and not about whether a frame happened: a game whose buffers go straight to * the display layer draws on screen while the counter sat at 0.0 fps and 1000.0 ms. */ - if (s == g_hud_surface) banner_on_game_frame(); + if (s == g_hud_surface) { g_hud_last_ns = now_ns(); banner_on_game_frame(); } } /* ---- hooks for ahb_swapchain.c (zero-copy layers) */ @@ -936,8 +1011,26 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) { s->pending_buffer = NULL; s->pending_attach = 0; - if (s->role == ROLE_NONE) { - /* Cursor or role-less surface: never drawn (the app draws its own pointer). */ + if (s->role == ROLE_CURSOR) { + /* The client's pointer image, copied out for the app's overlay (see cursor_publish_*) + * rather than composited, so it survives the zero-copy and HDR layer paths. */ + if (!buffer) { + cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */ + } else if (shm) { + cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy); + } else if (db && db->n_planes >= 1 && db->width * db->height <= CURSOR_MAX_PX) { + if (!db->img && !db->import_failed) { + db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height, + db->stride[0], db->offset[0]); + if (!db->img) db->import_failed = 1; + } + if (db->img && vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) == 0) + cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height, + (size_t)db->width * 4, g_cursor_hx, g_cursor_hy); + } + if (buffer) wl_buffer_send_release(buffer); + } else if (s->role == ROLE_NONE) { + /* Role-less surface: never drawn. */ if (buffer) wl_buffer_send_release(buffer); } else if (db) { take_dmabuf(s, db, buffer); @@ -1009,6 +1102,7 @@ static void surface_resource_destroy(struct wl_resource *r) { for (int i = 0; i < g_nptrs; i++) if (g_ptrs[i].focus == r) g_ptrs[i].focus = NULL; for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus == r) g_kbs[i].focus = NULL; touch_cancel_surface(s); + if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); } if (g_grab == s) g_grab = NULL; if (g_key_target == s) g_key_target = NULL; if (g_ime_click == s) g_ime_click = NULL; @@ -2385,9 +2479,23 @@ static uint32_t now_ms(void) { return (uint32_t)(ts.tv_sec * 1000 + ts.tv_nsec / 1000000); } +/* wl_pointer.set_cursor: the client tells us its pointer image, or asks for none. A NULL surface + * means "no cursor" and is authoritative. The surface itself is snapshotted on its next commit. */ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint32_t serial, struct wl_resource *surface, int32_t hx, int32_t hy) { - /* The app draws its own pointer; cursor surfaces stay role-less and aren't drawn. */ + if (!surface) { + if (g_cursor_surface) g_cursor_surface->role = ROLE_NONE; + g_cursor_surface = NULL; + cursor_publish_hidden(); + return; + } + struct surface *s = wl_resource_get_user_data(surface); + if (!s) return; + if (s->role != ROLE_NONE && s->role != ROLE_CURSOR) return; /* already has another role */ + s->role = ROLE_CURSOR; + g_cursor_surface = s; + g_cursor_hx = hx; + g_cursor_hy = hy; } static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } static const struct wl_pointer_interface pointer_impl = { diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index 17846fb95..8c1d897fc 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -1910,6 +1910,104 @@ int vkp_blit_image(struct vkp_image *src, struct vkp_image *dst, int wait_fd) { return 0; } +/* A client's GPU cursor read back to the CPU, for the app's pointer overlay (compositor.c, + * wl_pointer.set_cursor). wlroots hands a nested compositor its cursor as a dma-buf from the parent's + * formats - on Adreno usually UBWC, which the CPU cannot read - so it is blitted into a small linear, + * host-visible image first. Rare (a cursor changes shape, it is not redrawn per frame) and waited + * for on the CPU. out: w*h pixels, B8G8R8A8 in memory = 0xAARRGGBB ints. 0 = ok. */ +static struct vkp_image *g_rb; /* the readback image, kept for the next shape */ + +int vkp_image_readback(struct vkp_image *src, uint32_t *out, int max_px) { + if (!src || !out || src->w <= 0 || src->h <= 0 || src->w * src->h > max_px || + g_dev_state == -2 || dev_init() != 0) + return -1; + if (g_rb && (g_rb->w != src->w || g_rb->h != src->h)) { vkp_image_destroy(g_rb); g_rb = NULL; } + if (!g_rb) { + struct vkp_image *img = calloc(1, sizeof(*img)); + if (!img) return -1; + img->w = src->w; img->h = src->h; + VkImageCreateInfo ici = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D, + .format = VK_FORMAT_B8G8R8A8_UNORM, .extent = {src->w, src->h, 1}, .mipLevels = 1, + .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_LINEAR, + .usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT, .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED}; + if (g_vk.CreateImage(g_dev, &ici, NULL, &img->image) != VK_SUCCESS) { free(img); return -1; } + VkMemoryRequirements req; + g_vk.GetImageMemoryRequirements(g_dev, img->image, &req); + int idx = memory_type(req.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + VkMemoryAllocateInfo mai = {.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = req.size, .memoryTypeIndex = (uint32_t)idx}; + if (idx < 0 || g_vk.AllocateMemory(g_dev, &mai, NULL, &img->mem) != VK_SUCCESS) { + g_vk.DestroyImage(g_dev, img->image, NULL); free(img); return -1; + } + g_vk.BindImageMemory(g_dev, img->image, img->mem, 0); + VkImageSubresource subr = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0}; + VkSubresourceLayout lay; + g_vk.GetImageSubresourceLayout(g_dev, img->image, &subr, &lay); + img->offset = lay.offset; + img->row_pitch = lay.rowPitch; + if (g_vk.MapMemory(g_dev, img->mem, 0, req.size, 0, &img->map) != VK_SUCCESS) { + vkp_image_destroy(img); return -1; + } + g_rb = img; + } + VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + VkCommandBuffer cmd = g_cmds[0]; + g_vk.ResetCommandBuffer(cmd, 0); + VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT}; + g_vk.BeginCommandBuffer(cmd, &bi); + VkImageMemoryBarrier acq[2] = { + {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + .srcQueueFamilyIndex = src->dmabuf ? VK_QUEUE_FAMILY_FOREIGN_EXT : VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = src->dmabuf ? g_qfam : VK_QUEUE_FAMILY_IGNORED, + .image = src->image, .subresourceRange = range, .dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT}, + {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .image = g_rb->image, .subresourceRange = range, + .dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT}}; + g_vk.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 0, NULL, 0, NULL, 2, acq); + VkImageBlit blit = {.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, + .srcOffsets = {{0, 0, 0}, {src->w, src->h, 1}}, + .dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, + .dstOffsets = {{0, 0, 0}, {src->w, src->h, 1}}}; + g_vk.CmdBlitImage(cmd, src->image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + g_rb->image, VK_IMAGE_LAYOUT_GENERAL, 1, &blit, VK_FILTER_NEAREST); + VkImageMemoryBarrier done[2] = { + {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_GENERAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .image = g_rb->image, .subresourceRange = range, + .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, .dstAccessMask = VK_ACCESS_HOST_READ_BIT}, + /* the client's buffer goes back to it */ + {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = src->dmabuf ? g_qfam : VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = src->dmabuf ? VK_QUEUE_FAMILY_FOREIGN_EXT : VK_QUEUE_FAMILY_IGNORED, + .image = src->image, .subresourceRange = range, .srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT}}; + g_vk.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_HOST_BIT | VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, + 0, 0, NULL, 0, NULL, 2, done); + g_vk.EndCommandBuffer(cmd); + VkSubmitInfo si = {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 1, .pCommandBuffers = &cmd}; + g_vk.ResetFences(g_dev, 1, &g_fence); + VkResult qr = g_vk.QueueSubmit(g_queue, 1, &si, g_fence); + if (qr != VK_SUCCESS) { + if (qr == VK_ERROR_DEVICE_LOST) device_lost("cursor readback"); + return -1; + } + VkResult fr = timed_wait(g_fence, 1000000000ULL); + if (fr == VK_ERROR_DEVICE_LOST) { device_lost("cursor readback"); return -1; } + if (fr != VK_SUCCESS) return -1; + for (int y = 0; y < src->h; y++) + memcpy(out + (size_t)y * src->w, (const uint8_t *)g_rb->map + g_rb->offset + (size_t)y * g_rb->row_pitch, + (size_t)src->w * 4); + return 0; +} + /* ---------------------------------------------------------------- the compositor pass, off-screen * * Layer mode with screen effects on: the game's frame still has to go through the compositor pass diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h index 4d77eda7c..899736520 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.h +++ b/app/src/main/cpp/waylandcomp/src/vk_present.h @@ -146,6 +146,9 @@ int vkp_pass_begin_hdr(int scene_w, int scene_h, const struct vkp_draw *draws, i * wait_fd: a sync_file the copy must wait for before writing dst (the display's release fence of that * buffer), -1 = none. Always consumed (handed to the GPU, or waited for and closed). */ int vkp_blit_image(struct vkp_image *src, struct vkp_image *dst, int wait_fd); +/* A client's GPU cursor (an imported dma-buf, often UBWC) read back as w*h 0xAARRGGBB pixels through a + * linear host-visible copy; waited for on the CPU. Compositor thread, between frames. 0 = ok. */ +int vkp_image_readback(struct vkp_image *src, uint32_t *out, int max_px); /* Refresh the scene -> output mapping for this scene size without presenting (creates the * swapchain if needed, since the mapping is in output pixels). 0 = mapping valid. */ int vkp_update_map(int scene_w, int scene_h); diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 2125a5c08..10283f570 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -24,6 +24,7 @@ extern int banner_wayland_run(void); extern void banner_wayland_send_pointer(int action, int x, int y); extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y); +extern int banner_cursor_snapshot(int *out, int cap); extern void banner_wayland_send_key(int evdev, int state); extern void banner_wayland_send_scene_input(int type, int a, int b); extern void banner_wayland_vsync(int64_t frame_time_ns); @@ -278,6 +279,21 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendTouch( banner_wayland_send_touch(action, pointer_id, x, y); } +/* The client's pointer image and whether it wants one at all (wl_pointer.set_cursor). + * out = [serial, hidden, w, h, hotspotX, hotspotY, ARGB pixels...]; returns ints written, 0 if the + * array is too small. The app polls the serial and only rebuilds its overlay when it changes. */ +JNIEXPORT jint JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot( + JNIEnv *env, jclass clazz, jintArray out) { + if (!out) return 0; + jint cap = (*env)->GetArrayLength(env, out); + jint *buf = (*env)->GetIntArrayElements(env, out, NULL); + if (!buf) return 0; + int n = banner_cursor_snapshot((int *)buf, (int)cap); + (*env)->ReleaseIntArrayElements(env, out, buf, 0); + return n; +} + /* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey( diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 825cecdc3..c7b253dd7 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -272,8 +272,11 @@ class MainActivity : ComponentActivity() { ), FrontEndActions( onPlay = { startSession(Intent(this, SessionActivity::class.java)) }, + // Steam's desktop client as a window on the desktop: under gamescope the + // client puts itself into Big Picture whatever it is started with. onPlayDesktopUi = { startSession(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP) .putExtra(SessionService.EXTRA_STEAM_UI, "desktop")) }, onSteamGame = { g -> diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 47f4798c0..c797f1ad9 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.gpu.LsfgNative import com.droiddeck.launcher.gpu.TurnipDriver @@ -57,6 +58,7 @@ import com.droiddeck.launcher.wayland.HdrSupport import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState import com.droiddeck.launcher.ui.CursorOverlay +import androidx.compose.ui.graphics.asImageBitmap import com.droiddeck.launcher.ui.DrawerActions import com.droiddeck.launcher.ui.HudText import com.droiddeck.launcher.ui.LoadingOverlay @@ -94,6 +96,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f)) private var cursorVisible by mutableStateOf(false) private val cursorHide = Runnable { cursorVisible = false } + /** When the pad or the on-screen controls were last used (uptimeMillis); see [showCursor]. */ + private var lastPadInputMs = 0L + // The program's own pointer, from wl_pointer.set_cursor (see syncClientCursor): its image in + // session pixels, hotspot, and whether it asked for no pointer at all. + private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS) + private var cursorSerial = 0 + private var clientHidesCursor = false + private var cursorImage by mutableStateOf(null) + private var cursorHotX by mutableStateOf(0) + private var cursorHotY by mutableStateOf(0) + private var cursorImageScale by mutableStateOf(1f) + /** labwc changes the shape a moment after the move that reached an edge: look again then. */ + private val cursorResync = Runnable { if (syncClientCursor() && clientHidesCursor) cursorVisible = false } private val uiHandler = Handler(Looper.getMainLooper()) private var pendingBackAction: Runnable? = null private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN @@ -197,6 +212,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input")) padBridge = bridge + // A player on the pad or the on-screen controls has no use for the mouse arrow; the next + // touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment. + bridge.setOnPlayerInput { + lastPadInputMs = android.os.SystemClock.uptimeMillis() + uiHandler.removeCallbacks(cursorHide) + cursorVisible = false + } onScreenControls = OnScreenControls(this, bridge).also { root.addView(it) } keyboard = KeyboardHost(this).also { root.addView(it) } touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener) @@ -231,7 +253,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { isFocusableInTouchMode = true setContent { DroidDeckTheme { - CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density) + CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, + cursorImage, cursorHotX, cursorHotY, cursorImageScale) if (hud.text.isNotEmpty()) HudText(hud.text) if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended) // Opening the drawer takes the controller away from the game: release its pad. @@ -279,6 +302,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen }, onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null, + // The desktop with Steam's desktop client in it, in this session's place: the + // session ends with status 0 and onSessionEnded starts the relaunch (Stop's + // finish() would skip it). + onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({ + drawerOpen = false + SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP) + .putExtra(SessionService.EXTRA_STEAM_UI, "desktop") + SessionService.stop(this@SessionActivity) + }) else null, backActionsInverted = backActionsInverted, onBackActionsInverted = { inverted -> SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted) @@ -672,7 +705,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) // A custom resolution is taken as given; the compositor fits it to the panel. SessionPrefs.customResolution(this, mode)?.let { return it } - val cap = SessionPrefs.resolutionCap(this, mode) + // An emulator whose frames cost next to nothing (Library.drawsAtPanel) gets the panel, + // unless the user chose a resolution for the mode. + val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM) + val cap = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN && + Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) 0 + else SessionPrefs.resolutionCap(this, mode) val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt() val width = (height * aspect).toInt() // Odd sizes upset the scaler; both dimensions even is what every mode here would be. @@ -728,11 +766,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (status == 0) { runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread - // A session the guest asked for (the desktop's Steam launchers) takes this one's - // place: the compositor stays, a new activity attaches and starts the service. + // A session asked for in this one's place (the desktop's Steam launchers, the + // drawer's DESKTOP) starts here: this activity restarts on its intent, as an agent + // start does. startActivity(next) could not do it - the activity is singleTop, so + // the request landed in this instance's onNewIntent and went with its finish(). SessionState.relaunch?.let { next -> SessionState.relaunch = null - startActivity(next) + setIntent(next) + recreate() + return@runOnUiThread } finish() } @@ -1004,14 +1046,59 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { showCursor(x.coerceIn(left, left + drawnW), y.coerceIn(top, top + drawnH)) } - /** The arrow stays on a desktop; in a Steam session it shows for a moment after each move. */ + /** + * The arrow stays on a desktop; in a Steam session it shows for a moment after each move. While + * a controller is driving (input in the last [CURSOR_PAD_HOLD_MS]) it stays hidden, so a touch + * during play does not flash it up - Bannerlator's rule for its Wayland pointer. + */ private fun showCursor(x: Float, y: Float) { cursorPos = androidx.compose.ui.geometry.Offset(x, y) + syncClientCursor() + uiHandler.removeCallbacks(cursorResync) + uiHandler.postDelayed(cursorResync, 60) + // A program that asked for no pointer (a mouse-look game) wins over everything below. + if (clientHidesCursor) { cursorVisible = false; return } + if (android.os.SystemClock.uptimeMillis() - lastPadInputMs < CURSOR_PAD_HOLD_MS) return cursorVisible = true uiHandler.removeCallbacks(cursorHide) if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") uiHandler.postDelayed(cursorHide, 2500) } + /** + * Pulls the program's pointer (wl_pointer.set_cursor) if it changed: labwc's resize arrows at a + * window's edges and corners, a text field's I-beam, or a request for no pointer. Serial 0 = no + * program has set one yet, and the built-in arrow stays. Returns true when something changed. + */ + private fun syncClientCursor(): Boolean { + if (!CompositorHost.isStarted) return false + val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf) + if (n < 6) return false + val serial = cursorBuf[0] + if (serial == 0 || serial == cursorSerial) return false + cursorSerial = serial + clientHidesCursor = cursorBuf[1] != 0 + if (clientHidesCursor) return true + val w = cursorBuf[2] + val h = cursorBuf[3] + if (w <= 0 || h <= 0 || n < 6 + w * h) return false + val bitmap = try { + android.graphics.Bitmap.createBitmap(cursorBuf, 6, w, w, h, android.graphics.Bitmap.Config.ARGB_8888) + } catch (e: Exception) { + Log.w(TAG, "cursor bitmap failed", e) + return false + } + cursorImage = bitmap.asImageBitmap() + cursorHotX = cursorBuf[4] + cursorHotY = cursorBuf[5] + // Session pixels -> screen pixels, as the session's picture is scaled (movePointer). + val width = surfaceView.width.takeIf { it > 0 } + val height = surfaceView.height.takeIf { it > 0 } + val out = SessionState.outputSize + if (width != null && height != null) + cursorImageScale = minOf(width / out.first.toFloat(), height / out.second.toFloat()) + return true + } + private fun refreshSecondScreenDisplays() { if (!::displayManager.isInitialized) return val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId @@ -1324,6 +1411,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private const val TAG = "SessionActivity" private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L + private const val CURSOR_PAD_HOLD_MS = 1200L private const val DRAWER_HAT_THRESHOLD = 0.5f private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f private const val DRAWER_STICK_EXIT_THRESHOLD = 0.35f diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 63d8db5d8..3ed6de0a4 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -94,7 +94,8 @@ object Library { .firstOrNull { it.isFile && it.length() > 0L } } - private class Spec(val id: String, val name: String, val system: String, val program: String, val folders: List, val exts: Set) + /** [atPanel]: frames cheap enough to draw at the panel's own size (see [drawsAtPanel]). */ + private class Spec(val id: String, val name: String, val system: String, val program: String, val folders: List, val exts: Set, val atPanel: Boolean = false) private val specs = listOf( Spec("rpcs3", "RPCS3", "PS3", "/opt/appimages/rpcs3.AppImage", listOf("ps3"), setOf("iso")), // PS2: ARMSX2, the PCSX2 fork with ARM64 recompilers. Upstream PCSX2 interprets the PS2's @@ -102,7 +103,7 @@ object Library { Spec("armsx2", "ARMSX2", "PS2", "/opt/appimages/armsx2.AppImage", listOf("ps2"), setOf("iso", "chd", "cso", "gz")), Spec("dolphin", "Dolphin", "GameCube / Wii", "/opt/appimages/dolphin.AppImage", listOf("gc", "gamecube", "wii"), setOf("iso", "rvz", "gcz", "wbfs", "ciso")), Spec("duckstation", "DuckStation", "PS1", "/opt/appimages/duckstation.AppImage", listOf("ps1", "psx"), setOf("cue", "chd", "pbp", "iso", "bin", "img", "ecm", "m3u")), - Spec("melonds", "melonDS", "DS", "/opt/appimages/melonds.AppImage", listOf("ds", "nds"), setOf("nds", "dsi")), + Spec("melonds", "melonDS", "DS", "/opt/appimages/melonds.AppImage", listOf("ds", "nds"), setOf("nds", "dsi"), atPanel = true), Spec("cemu", "Cemu", "Wii U", "/opt/appimages/cemu.AppImage", listOf("wiiu", "wii u"), setOf("wua", "wud", "wux", "rpx")), Spec("ppsspp", "PPSSPP", "PSP", "/usr/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")), Spec("retroarch", "RetroArch", "many systems", "/usr/bin/retroarch", emptyList(), emptySet()), @@ -125,6 +126,14 @@ object Library { /** The emulator's name for a program path from the rail ("ARMSX2"), or null. */ fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name + /** + * Whether a program from the rail runs at the panel's own resolution rather than the session's + * 720p default. melonDS draws two 256x192 screens on the CPU: the panel's size costs it + * nothing, and 720p scaled up to the panel blurs the sharp pixels its screen layout is set + * up for (bannerlator-pad-defaults). + */ + fun drawsAtPanel(program: String?): Boolean = specs.any { it.program == program && it.atPanel } + /** Desktop catalog package that supplies this emulator. */ fun packageId(emulatorId: String): String? = installedIds[emulatorId] @@ -270,6 +279,9 @@ object Library { "-e", guestPath, ) "cemu" -> listOf("-g", guestPath) + // Full screen (CLI.cpp --fullscreen); the guide button leaves it for melonDS's menus and + // comes back (HK_FullscreenToggle, bannerlator-pad-defaults). + "melonds" -> listOf("-f", guestPath) else -> listOf(guestPath) } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index d07ab82a0..518904973 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -40,6 +40,9 @@ public final class PadBridge { private boolean qamChordActive; private boolean qamSyntheticAPressed; private int qamChordGeneration; + /** Told on the main thread when a player uses the pad or the on-screen controls; see [setOnPlayerInput]. */ + private volatile Runnable onPlayerInput; + private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean(); public PadBridge(File fakeInputDir) { writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT); @@ -87,6 +90,24 @@ public final class PadBridge { return false; } + /** + * Called (main thread) when a player presses a button, pushes a stick, trigger or d-pad past + * halfway, or uses the on-screen controls - the session hides its mouse cursor then. Resting + * sticks and trigger noise do not count. + */ + public void setOnPlayerInput(Runnable listener) { + onPlayerInput = listener; + } + + private void notePlayerInput() { + Runnable listener = onPlayerInput; + if (listener == null || !playerInputPosted.compareAndSet(false, true)) return; + mainHandler.post(() -> { + playerInputPosted.set(false); + listener.run(); + }); + } + /** @return true when the event was a pad button and has been consumed. */ public synchronized boolean onKeyEvent(KeyEvent event) { if (!isFromController(event.getDevice())) return false; @@ -116,6 +137,7 @@ public final class PadBridge { case KeyEvent.KEYCODE_DPAD_LEFT: state.left = pressed; break; default: return false; } + if (pressed) notePlayerInput(); publish(); return true; } @@ -143,6 +165,10 @@ public final class PadBridge { state.right = hatX > 0.5f; state.down = hatY > 0.5f; state.left = hatX < -0.5f; + if (Math.max(Math.max(Math.abs(state.leftX), Math.abs(state.leftY)), Math.max(Math.abs(state.rightX), Math.abs(state.rightY))) > 0.5f + || lt > 0.5f || rt > 0.5f || state.up || state.right || state.down || state.left) { + notePlayerInput(); + } publish(); return true; } @@ -153,6 +179,7 @@ public final class PadBridge { */ public synchronized void applyTouch(java.util.function.Consumer mutation) { mutation.accept(state); + notePlayerInput(); publish(); } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 37d937a48..5cd53d1b6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -43,6 +43,8 @@ object SessionFiles { // the three under /usr/bin, all no-ops that answer "nothing to do" (see each file). "usr/bin/steamos-update" to "usr/bin/steamos-update", "usr/bin/steamos-select-branch" to "usr/bin/steamos-select-branch", + // Big Picture's "Switch to Desktop": asks the app for the desktop with Steam in it. + "usr/bin/steamos-session-select" to "usr/bin/steamos-session-select", "usr/bin/jupiter-biosupdate" to "usr/bin/jupiter-biosupdate", "usr/bin/steamos-polkit-helpers/steamos-priv-write" to "usr/bin/steamos-polkit-helpers/steamos-priv-write", "usr/bin/steamos-polkit-helpers/steamos-set-timezone" to "usr/bin/steamos-polkit-helpers/steamos-set-timezone", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index c44ead89a..7498b4ad9 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -282,6 +282,10 @@ object SessionPrefs { */ fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", defaultResolutionCap(mode)) + /** Whether the user chose the mode's resolution (a cap or a custom size) rather than the default. */ + fun resolutionChosen(context: Context, mode: String): Boolean = + prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null + /** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */ fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: "" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 11b87b05b..b7a759838 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -469,6 +469,9 @@ class SessionService : Service() { // The second library's name, for bannerlator-steam-library; the bind itself is made below. GameStorage.effective(this)?.let { guest.add("BL_LIBRARY_LABEL=" + it.label.replace('"', ' ')) } if (SessionState.mode == MODE_STEAM && SessionState.steamUi == "desktop") guest.add("BL_STEAM_UI=desktop") + // The desktop asked for with Steam in it (the front end's "Steam Desktop UI", Big Picture's + // "Switch to Desktop"): the desktop's autostart opens the client's desktop UI there. + if (SessionState.mode == MODE_DESKTOP && SessionState.steamUi == "desktop") guest.add("BL_DESKTOP_STEAM=1") val shellGuest = ArrayList(guest).apply { add("SHELL=/bin/bash") add("TERM=xterm-256color") @@ -734,6 +737,8 @@ class SessionService : Service() { * The desktop's Steam launchers cannot start the client where they are (no dma-buf on the * desktop), so they leave `steam-launch` in the session directory instead: which UI, and a * steam:// URL or nothing. This ends the session and hands the activity the one to start. + * `session=desktop` (Big Picture's "Switch to Desktop", steamos-session-select) asks for the + * desktop instead, with Steam's desktop client started in it. */ private var launchWatcher: android.os.FileObserver? = null @@ -748,15 +753,18 @@ class SessionService : Service() { file.delete() var ui = "desktop" var url = "" + var session = MODE_STEAM text.lineSequence().forEach { line -> when { line.startsWith("ui=") -> ui = line.removePrefix("ui=").trim() line.startsWith("url=") -> url = line.removePrefix("url=").trim() + line.startsWith("session=") && line.removePrefix("session=").trim() == "desktop" -> session = MODE_DESKTOP } } - Log.i(TAG, "launch request from the session: Steam $ui" + (if (url.isNotEmpty()) " $url" else "")) + Log.i(TAG, "launch request from the session: " + (if (session == MODE_DESKTOP) "the desktop with " else "") + + "Steam $ui" + (if (url.isNotEmpty()) " $url" else "")) val next = Intent(this@SessionService, com.droiddeck.launcher.SessionActivity::class.java) - .putExtra(EXTRA_MODE, MODE_STEAM) + .putExtra(EXTRA_MODE, session) .putExtra(EXTRA_STEAM_UI, ui) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (url.isNotEmpty()) next.putExtra(EXTRA_STEAM_URL, url) @@ -1048,7 +1056,8 @@ class SessionService : Service() { const val EXTRA_PROGRAM = "program" /** MODE_RUN: arguments after the program (a game to boot). */ const val EXTRA_PROGRAM_ARGS = "programArgs" - /** MODE_STEAM: "desktop" for the client's desktop UI (default Big Picture); a steam:// URL to hand it. */ + /** MODE_STEAM: "desktop" for the client's desktop UI (default Big Picture); a steam:// URL to hand it. + * MODE_DESKTOP: "desktop" = open Steam's desktop client in the desktop once it is up. */ const val EXTRA_STEAM_UI = "steamUi" const val EXTRA_STEAM_URL = "steamUrl" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt index 3854526b3..6b527c97c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt @@ -6,13 +6,35 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke +/** + * The session's pointer. [image]: the program's own cursor (wl_pointer.set_cursor - labwc's resize + * arrows, a text field's I-beam), in session pixels, drawn [imageScale] times larger with its hotspot + * ([hotX], [hotY]) on [position]; null = the program has not set one, and the built-in arrow is drawn. + */ @Composable -fun CursorOverlay(position: Offset, visible: Boolean, scale: Float) { +fun CursorOverlay( + position: Offset, visible: Boolean, scale: Float, + image: ImageBitmap? = null, hotX: Int = 0, hotY: Int = 0, imageScale: Float = 1f, +) { if (!visible) return Canvas(modifier = Modifier.fillMaxSize()) { + if (image != null) { + val w = (image.width * imageScale).toInt().coerceAtLeast(1) + val h = (image.height * imageScale).toInt().coerceAtLeast(1) + drawImage( + image, + dstOffset = androidx.compose.ui.unit.IntOffset( + (position.x - hotX * imageScale).toInt(), (position.y - hotY * imageScale).toInt()), + dstSize = androidx.compose.ui.unit.IntSize(w, h), + filterQuality = FilterQuality.Low, + ) + return@Canvas + } val s = 11f * scale val path = Path().apply { moveTo(position.x, position.y) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 3b3a08f42..c7db1ff75 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -213,6 +213,8 @@ class DrawerActions( val onHardwareKeyboard: () -> Unit, val onSteamMenu: (() -> Unit)?, val onQam: (() -> Unit)?, + /** Steam sessions: end Big Picture and open the desktop with Steam's desktop client in it. */ + val onSwitchToDesktop: (() -> Unit)? = null, val onOsc: (String) -> Unit, val onSuspendPolicy: (String) -> Unit, val onBackActionsInverted: (Boolean) -> Unit, @@ -352,6 +354,26 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha ) { Text("QAM", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp) } + // Big Picture's own "Switch to Desktop" waits on SteamOS Manager for ever + // here; this does the switch without the client. + if (a.onSwitchToDesktop != null) { + val deskSrc = remember { MutableInteractionSource() } + val deskHot = deskSrc.collectIsFocusedAsState().value || deskSrc.collectIsHoveredAsState().value + OutlinedButton( + onClick = { host.open = null; a.onSwitchToDesktop.invoke() }, + interactionSource = deskSrc, + modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "desktop")) + .semantics { contentDescription = "Switch to Desktop" }.controllerConfirm { + host.open = null + a.onSwitchToDesktop.invoke() + }, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(if (deskHot) 2.dp else 1.dp, if (deskHot) pal.signal else colors.outline), + colors = ButtonDefaults.outlinedButtonColors(containerColor = if (deskHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent), + ) { + Text("DESKTOP", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp) + } + } } } diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index b9b2eb5d1..ffd1a28e5 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -171,6 +171,17 @@ public final class WaylandCompositor { public static native void nativeSendTouch(int action, int pointerId, int x, int y); + /** + * The client's pointer image, straight from wl_pointer.set_cursor (labwc's resize arrows, a text + * field's I-beam). out = [serial, hidden, w, h, hotspotX, hotspotY, ARGB8888 pixels...]; returns + * the number of ints written, or 0 if {@code out} is too small. Serial 0 = the client has not + * set one yet; [1] = 1 means it asked for NO pointer. + */ + public static native int nativeCursorSnapshot(int[] out); + + /** Enough for the header plus a 256x256 cursor, the largest the compositor snapshots. */ + public static final int CURSOR_BUF_INTS = 6 + 256 * 256; + /** Inject a key event. evdev = Linux input keycode (KEY_A=30…); state: 1=down, 0=up. */ public static native void nativeSendKey(int evdev, int state); diff --git a/tools/linuxfs/desktop/autostart b/tools/linuxfs/desktop/autostart index 3aa88e906..b28f23535 100644 --- a/tools/linuxfs/desktop/autostart +++ b/tools/linuxfs/desktop/autostart @@ -36,6 +36,10 @@ swaybg -m fill -i /usr/share/backgrounds/steamdeck.png & run pcmanfm-qt pcmanfm-qt --desktop --profile lxqt run lxqt-panel lxqt-panel /usr/lib/xdg-desktop-portal-wlr >/dev/null 2>&1 & +# The desktop was asked for with Steam in it (the front end's "Steam Desktop UI", Big Picture's +# "Switch to Desktop"): the client's desktop UI, as the menu's Steam entry opens it. A running +# client just gets the request; on a software desktop it goes back to the app as a Steam session. +[ "${BL_DESKTOP_STEAM:-0}" = 1 ] && run steam /usr/local/bin/bannerlator-steam-launch desktop # Remote runs, for driving the desktop from a debugger: with ~/.droiddeck-remote-run present (only # the app or root can create it), each script dropped into ~/.cache/droiddeck/run/ is run once, # inside the session with its environment, its output tagged into the desktop log. diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 3f2ace30b..55adbdf07 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -6,7 +6,9 @@ # installed desktop without re-downloading the package. set -u export XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=LXQt XDG_SESSION_DESKTOP=lxqt -export QT_QPA_PLATFORM=wayland GDK_BACKEND=wayland,x11 SDL_VIDEODRIVER=wayland,x11 +# Qt tries Wayland and falls back to X11 (Xwayland): an AppImage that bundles only Qt's xcb +# plugin - melonDS - exits at once on a plain "wayland" ("Could not find the Qt platform plugin"). +export QT_QPA_PLATFORM="wayland;xcb" GDK_BACKEND=wayland,x11 SDL_VIDEODRIVER=wayland,x11 export QT_QPA_PLATFORMTHEME=lxqt # Firefox's child processes sandbox themselves with seccomp and namespaces, neither of which # behaves under proot: the content process ends up unable to read the font files its parent can, diff --git a/tools/linuxfs/desktop/rc.xml b/tools/linuxfs/desktop/rc.xml index 632c0a109..e32c083d6 100644 --- a/tools/linuxfs/desktop/rc.xml +++ b/tools/linuxfs/desktop/rc.xml @@ -19,4 +19,13 @@ 500 + + + + + + + diff --git a/tools/linuxfs/overlay/usr/bin/steamos-session-select b/tools/linuxfs/overlay/usr/bin/steamos-session-select new file mode 100644 index 000000000..30141e79f --- /dev/null +++ b/tools/linuxfs/overlay/usr/bin/steamos-session-select @@ -0,0 +1,18 @@ +#!/bin/sh +# The SteamOS session switch. Big Picture's power menu runs it for "Switch to Desktop" (a Plasma +# session: "plasma", "plasma-wayland-persistent", ...) and waits for Game Mode to end; with nothing +# here it sat on "Switching to Desktop..." for ever. Here the desktop is DroidDeck's own: the app is +# asked for a desktop session with Steam's desktop client open in it (the request file +# bannerlator-steam-launch uses; the app ends this session and starts that one). Anything else - +# "gamescope", the session Game Mode already is - has nothing to do. +echo "steamos-session-select: $*" >&2 +case "${1:-}" in + ""|gamescope) exit 0 ;; +esac +dir=${BL_LAUNCH_DIR:-} +if [ -z "$dir" ] || [ ! -d "$dir" ]; then + echo "steamos-session-select: not inside a DroidDeck session (BL_LAUNCH_DIR unset)" >&2 + exit 1 +fi +printf 'session=desktop\nui=desktop\nurl=\n' > "$dir/steam-launch.tmp" && mv -f "$dir/steam-launch.tmp" "$dir/steam-launch" +exit 0 diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index 954916d29..5b254a223 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -723,8 +723,11 @@ def cemu(): # melonDS 1.x: raw SDL joystick indices. The session's pad has the kernel's Xbox 360 layout # (buttons A B X Y LB RB Back Start Guide LS RS = 0..10, hat 0 for the d-pad); the d-pad values -# also take the left stick (EmuInstanceInput.cpp; encoding InputConfig/MapButton.h). -MELONDS_JOY = """[Instance0.Joystick] +# also take the left stick (EmuInstanceInput.cpp; encoding InputConfig/MapButton.h). The guide +# button toggles full screen (HK_FullscreenToggle), as it does in Dolphin: the front end starts +# melonDS full screen (-f), and leaving it shows the window's menus for settings. +MELONDS_GUIDE = 8 +MELONDS_JOY = f"""[Instance0.Joystick] A = 1 B = 0 X = 3 @@ -737,14 +740,152 @@ Up = 17891585 Down = 16843012 Left = 1114376 Right = 65794 +HK_FullscreenToggle = {MELONDS_GUIDE} """ +# The screen a handheld's wide panel suits best: the top screen as big as the panel's height allows +# with the bottom screen alone beside it, centred (ScreenLayout.h: Horizontal = 2, EmphTop = 1; +# melonDS's Hybrid shows the big screen a second time, small, above the bottom one; nobody needs +# it twice). Sharp pixels: no filtering, and no integer steps that leave the panel half used. Keys as +# melonDS writes them (Window.cpp windowCfg). +MELONDS_WINDOW = """[Instance0.Window0] +ScreenLayout = 2 +ScreenSizing = 1 +IntegerScaling = false +ScreenFilter = false +""" +# melonDS's own defaults for those, which an existing install is moved off once. Hybrid (3) was +# this preset's first layout, and moves on too. +MELONDS_WINDOW_DEFAULTS = {"ScreenLayout": ("0", "3"), "ScreenSizing": ("0",), "ScreenFilter": ("true",)} + + +# The window melonDS returns to when the guide button leaves full screen: 1x wide (the width of +# two DS screens side by side, Screen.cpp screenGetMinSize) and 16:9, so gamescope stretches it to +# fill the panel and the menu bar and cursor come out large enough to use. A bigger window keeps +# its menus at their real size, tiny on a handheld. Qt's saveGeometry() v3 blob: magic, version, +# frame and normal rects (x, y, right, bottom), screen, maximized, full screen, screen width, and +# the normal rect again (QWidget::restoreGeometry). +def melonds_geometry(): + import base64, struct + w, h = 512, 288 + try: + screen_w = int(os.environ.get("BL_WIDTH", "1920")) + except ValueError: + screen_w = 1920 + rect = (0, 0, w - 1, h - 1) + blob = struct.pack(">IHH4i4iiBBi4i", 0x1D9D0CB, 3, 0, *rect, *rect, 0, 0, 0, screen_w, *rect) + return base64.b64encode(blob).decode() + + +def toml_get(lines, table, key): + """The raw value of key in [table], or None.""" + inside = False + for l in lines: + t = l.strip() + if t.startswith("["): + inside = t == f"[{table}]" + elif inside and "=" in t and t.split("=", 1)[0].strip() == key: + return t.split("=", 1)[1].strip() + return None + + +def toml_set(lines, table, key, value): + """key = value in [table]; the table is added at the end when missing.""" + start = None + for i, l in enumerate(lines): + t = l.strip() + if t.startswith("["): + if start is not None: + break + if t == f"[{table}]": + start = i + elif start is not None and "=" in t and t.split("=", 1)[0].strip() == key: + lines[i] = f"{key} = {value}" + return + if start is None: + lines += ["", f"[{table}]", f"{key} = {value}"] + return + end = start + 1 + while end < len(lines) and not lines[end].strip().startswith("["): + end += 1 + while end > start + 1 and not lines[end - 1].strip(): + end -= 1 + lines.insert(end, f"{key} = {value}") + + +def toml_str(path): + return '"' + path.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +# The user's own BIOS, firmware and DSi NAND, found in the ROMs folder's nds/bios (or firmware) +# folder by what each file is: the DS BIOSes by size and checksum (the GBA BIOS is also 16 KB), +# firmware and NAND by size, the DSi boot ROMs (both 64 KB) by the 7 or 9 in their names. A path +# is only filled where melonDS has none, and external files are only switched on when a whole set +# was found - without them melonDS runs DS games on its built-in free BIOS. +DS_BIOS7_MD5 = "df692a80a5b1bc90728bc3dfc76cd948" +DS_BIOS9_MD5 = "a392174eb3e572fed6447e956bde4b25" + + +def nds_firmware_files(): + import hashlib + base = system_folder(("nds", "ds"), "nds") + roots = [os.path.join(base, n) for n in ("bios", "BIOS", "firmware") if os.path.isdir(os.path.join(base, n))] + found = {} + for root in roots: + for d, _, files in os.walk(root): + if d[len(root):].count(os.sep) > 2: + continue + for name in sorted(files): + f = os.path.join(d, name) + try: + size = os.path.getsize(f) + except OSError: + continue + low = name.lower() + if size == 16384 or size == 4096: + with open(f, "rb") as h: + md5 = hashlib.md5(h.read()).hexdigest() + if md5 == DS_BIOS7_MD5: + found.setdefault("DS.BIOS7Path", f) + elif md5 == DS_BIOS9_MD5: + found.setdefault("DS.BIOS9Path", f) + elif size in (262144, 524288) and "dsi" not in low: + found.setdefault("DS.FirmwarePath", f) + elif size == 65536: + if "arm7" in low or "bios7" in low: + found.setdefault("DSi.BIOS7Path", f) + elif "arm9" in low or "bios9" in low: + found.setdefault("DSi.BIOS9Path", f) + elif size == 131072: + found.setdefault("DSi.FirmwarePath", f) + elif size >= 200 * 1024 * 1024 and "nand" in low: + found.setdefault("DSi.NANDPath", f) + return found + + +def melonds_bios(lines): + found = nds_firmware_files() + changed = [] + for key, f in found.items(): + table, k = key.split(".", 1) + if (toml_get(lines, table, k) or '""') == '""': + toml_set(lines, table, k, toml_str(f)) + changed.append(key) + for table, flag, keys in (("Emu", "ExternalBIOSEnable", ("DS.BIOS7Path", "DS.BIOS9Path", "DS.FirmwarePath")), + ("DSi", "ExternalBIOSEnable", ("DSi.BIOS7Path", "DSi.BIOS9Path", "DSi.FirmwarePath", "DSi.NANDPath"))): + if all(k in found for k in keys) and any(k in changed for k in keys): + toml_set(lines, table, flag, "true") + return changed + def melonds(): path = os.path.join(CONFIG, "melonDS/melonDS.toml") + marker = os.path.join(os.path.dirname(path), ".droiddeck-display-2") if not os.path.isfile(path): - seed(path, "[Instance0]\nJoystickID = 0\n\n" + MELONDS_JOY, "melonDS") - return + seed(path, "[Instance0]\nJoystickID = 0\n\n" + MELONDS_JOY + "\n" + MELONDS_WINDOW + + f'Geometry = "{melonds_geometry()}"\n', "melonDS", + "Player 1 on the controller, big top screen") + open(marker, "w").close() with open(path, encoding="utf-8", errors="replace") as f: text = f.read() try: @@ -752,20 +893,40 @@ def melonds(): joy = tomllib.loads(text).get("Instance0", {}).get("Joystick", {}) except Exception: return - if any(isinstance(v, int) and v != -1 for v in joy.values()): - return # something is bound already - # Replace melonDS's own all-unbound table with ours (a TOML table can be defined only once). - out, skipping = [], False - for line in text.splitlines(): - t = line.strip() - if t.startswith("["): - skipping = t == "[Instance0.Joystick]" - if not skipping: - out.append(line) - with open(path + ".tmp", "w", encoding="utf-8") as f: - f.write("\n".join(out).rstrip("\n") + "\n\n" + MELONDS_JOY) - os.replace(path + ".tmp", path) - print(f"bannerlator-pad-defaults: melonDS: Player 1 on the controller ({path})") + lines = text.splitlines() + done = [] + if not any(isinstance(v, int) and v != -1 and k != "HK_FullscreenToggle" for k, v in joy.items()): + # Replace melonDS's own all-unbound table with ours (a TOML table can be defined only once). + out, skipping = [], False + for line in lines: + t = line.strip() + if t.startswith("["): + skipping = t == "[Instance0.Joystick]" + if not skipping: + out.append(line) + lines = "\n".join(out).rstrip("\n").splitlines() + [""] + MELONDS_JOY.rstrip("\n").splitlines() + done.append("Player 1 on the controller") + elif joy.get("HK_FullscreenToggle", -1) == -1 and MELONDS_GUIDE not in joy.values(): + toml_set(lines, "Instance0.Joystick", "HK_FullscreenToggle", str(MELONDS_GUIDE)) + done.append("guide button toggles full screen") + # Once, for a melonDS set up before this: the big top screen, where it is still on its own + # defaults. The marker keeps whatever the user picks in melonDS's View menu afterwards. + if not os.path.exists(marker): + for l in MELONDS_WINDOW.splitlines()[1:]: + k, v = (x.strip() for x in l.split("=", 1)) + if toml_get(lines, "Instance0.Window0", k) in (None,) + MELONDS_WINDOW_DEFAULTS.get(k, ()): + toml_set(lines, "Instance0.Window0", k, v) + toml_set(lines, "Instance0.Window0", "Geometry", f'"{melonds_geometry()}"') + done.append("big top screen, 1x window") + open(marker, "w").close() + bios = melonds_bios(lines) + if bios: + done.append("BIOS " + ", ".join(bios)) + if done: + with open(path + ".tmp", "w", encoding="utf-8") as f: + f.write("\n".join(lines).rstrip("\n") + "\n") + os.replace(path + ".tmp", path) + print(f"bannerlator-pad-defaults: melonDS: {'; '.join(done)} ({path})") # PPSSPP (SDL): its pad defaults leave the shoulders, the right stick and the triggers unbound on