mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
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:
commit
495a42368e
19 files changed
+665
-46
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user