Merge pull request #18 from Droid-Deck/feat/melonds-preset

melonDS out of the box, cursor shapes and auto-hide, Steam desktop switching
This commit is contained in:
The412Banner authored and GitHub committed 2026-09-25 11:15:19 -04:00
commit 495a42368e
19 files changed
+665 -46

No files matched your search

+123 -15
View File
@@ -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 = {
@@ -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
@@ -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);
@@ -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(
@@ -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 ->
@@ -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<androidx.compose.ui.graphics.ImageBitmap?>(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
@@ -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<String>, val exts: Set<String>)
/** [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<String>, val exts: Set<String>, 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)
}
}
@@ -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<PadState> mutation) {
mutation.accept(state);
notePlayerInput();
publish();
}
@@ -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",
@@ -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", "") ?: ""
@@ -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"
@@ -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)
@@ -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)
}
}
}
}
@@ -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);
+4
View File
@@ -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.
+3 -1
View File
@@ -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,
+9
View File
@@ -19,4 +19,13 @@
<keybind key="W-d"><action name="ToggleMaximize"/></keybind>
</keyboard>
<mouse><doubleClickTime>500</doubleClickTime></mouse>
<!-- Programs opened on the desktop at a size that suits it. melonDS remembers a 1x window for the
front end's full-screen toggle (bannerlator-pad-defaults), too small for a desktop: here it
opens at 800x600, centred (AutoPlace alone kept the top-left corner). Only the desktop reads this file, so the front end is unaffected. -->
<windowRules>
<windowRule title="melonDS*">
<action name="ResizeTo" width="800" height="600"/>
<action name="AutoPlace" policy="center"/>
</windowRule>
</windowRules>
</labwc_config>
@@ -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
@@ -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