From 7edc60d4fd0df35247e81bcb75c103a333bf4a00 Mon Sep 17 00:00:00 2001 From: kingokksa Date: Sat, 10 Oct 2026 22:11:21 +0800 Subject: [PATCH] X11: keep colour game cursors by implementing the RENDER cursor subset Games that ask for a colour cursor (Kingdom Come Deliverance I and II, Subnautica, Gothic Remake) got the two-colour core arrow instead. winex11 loads libXcursor, libXcursor probes for the RENDER extension, and with no RENDER in this server it falls back to the monochrome core cursor, so the pointer loses its colour depth. RenderExtension answers the subset libXcursor needs and nothing else: QueryVersion, QueryPictFormats, CreatePicture, FreePicture, CreateCursor and CreateAnimCursor. CreatePicture snapshots the pixmap while it is still alive, because libXcursor frees it before CreateCursor; animated cursors show their first frame. Any other request gets BadImplementation, and a failure is reported as an X error instead of dropping the game's connection; after 8 failures the arrow is used for the rest of the session. CursorManager.createArgbCursor builds the cursor drawable from the ARGB data and un-premultiplies it, because the cursor texture is drawn with a straight alpha blend while the Xcursor pixels arrive premultiplied. A fully transparent cursor stays invisible instead of becoming the arrow, since that is how games hide the pointer. The extension is registered only when the session asks for it: applyColorCursors() enables it for X sessions and BANNER_X11_RENDER=0 turns it off, and applyColorCursorRegistry() writes ClientSideWithRender=N so winex11's GDI path does not take the RENDER route into a server that only implements cursors. --- .../display/XServerDisplayActivity.java | 21 ++ .../display/xserver/CursorManager.java | 103 ++++++ .../main/runtime/display/xserver/XServer.java | 9 + .../xserver/extensions/RenderExtension.java | 350 ++++++++++++++++++ 4 files changed, 483 insertions(+) create mode 100644 app/src/main/runtime/display/xserver/extensions/RenderExtension.java diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 9f4d1b080..76050871e 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -2710,6 +2710,7 @@ this, shortcut, isCloudSyncEnabledForShortcut(), extractGraphicsDriverFiles(); resolveAudioDriver(); changeWineAudioDriver(); + applyColorCursorRegistry(); try { if (steamFuture != null) steamFuture.get(); @@ -8288,6 +8289,8 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { overrideEnvVars.clear(); } + applyColorCursors(); + environment = new XEnvironment(this, imageFs); environment.addComponent( new SysVSharedMemoryComponent( @@ -14065,6 +14068,24 @@ private void resolveAudioDriver() { this, R.string.directaudio_unavailable, android.widget.Toast.LENGTH_LONG).show()); } + private void applyColorCursors() { + if (waylandMode || gamescopeMode) return; + String toggle = envVars.get("BANNER_X11_RENDER"); + if (toggle != null && (toggle.equals("0") || toggle.equalsIgnoreCase("false"))) return; + xServer.enableRenderCursors(); + } + + private void applyColorCursorRegistry() { + if (waylandMode) return; + File userRegFile = new File(imageFs.getRootDir(), ImageFs.WINEPREFIX + "/user.reg"); + if (!userRegFile.exists()) return; + try (WineRegistryEditor registryEditor = new WineRegistryEditor(userRegFile)) { + registryEditor.setStringValue("Software\\Wine\\X11 Driver", "ClientSideWithRender", "N"); + } catch (Exception e) { + Log.w(TAG, "Failed to disable client-side RENDER", e); + } + } + private void changeWineAudioDriver() { if (!audioDriver.equals(container.getExtra("audioDriver"))) { File rootDir = imageFs.getRootDir(); diff --git a/app/src/main/runtime/display/xserver/CursorManager.java b/app/src/main/runtime/display/xserver/CursorManager.java index 8ae369563..4d1e52086 100644 --- a/app/src/main/runtime/display/xserver/CursorManager.java +++ b/app/src/main/runtime/display/xserver/CursorManager.java @@ -1,6 +1,8 @@ package com.winlator.cmod.runtime.display.xserver; import android.util.SparseArray; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.IntBuffer; public class CursorManager extends XResourceManager { @@ -36,6 +38,107 @@ public Cursor createCursor(int id, short x, short y, Pixmap sourcePixmap, Pixmap return cursor; } + private static final String[] FALLBACK_ARROW = { + "X ", + "XX ", + "X.X ", + "X..X ", + "X...X ", + "X....X ", + "X.....X ", + "X......X ", + "X.......X ", + "X........X ", + "X.....XXXXX", + "X..X..X ", + "X.X X..X ", + "XX X..X ", + "X X..X ", + " X..X ", + " XX " + }; + + public Cursor createFallbackArrowCursor(int id) { + if (cursors.indexOfKey(id) >= 0) return null; + short width = (short) FALLBACK_ARROW[0].length(); + short height = (short) FALLBACK_ARROW.length; + ByteBuffer pixels = + ByteBuffer.allocateDirect(width * height * 4).order(ByteOrder.LITTLE_ENDIAN); + IntBuffer data = pixels.asIntBuffer(); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + char c = FALLBACK_ARROW[y].charAt(x); + data.put(y * width + x, c == 'X' ? 0xff000000 : c == '.' ? 0xffffffff : 0); + } + } + return addArgbCursor(id, 0, 0, width, height, pixels); + } + + /** + * libXcursor hands over premultiplied ARGB32 pixels, while cursor drawables are blended with + * straight alpha, so the alpha is divided back out here. + */ + public Cursor createArgbCursor( + int id, int hotX, int hotY, short width, short height, ByteBuffer argb) { + if (cursors.indexOfKey(id) >= 0) return null; + if (width <= 0 || height <= 0 || argb.capacity() < width * height * 4) return null; + + ByteBuffer pixels = + ByteBuffer.allocateDirect(width * height * 4).order(ByteOrder.LITTLE_ENDIAN); + IntBuffer src = argb.duplicate().order(ByteOrder.LITTLE_ENDIAN).asIntBuffer(); + IntBuffer dst = pixels.asIntBuffer(); + boolean visible = false; + for (int i = 0; i < width * height; i++) { + int pixel = src.get(i); + int alpha = pixel >>> 24; + if (alpha == 0) { + dst.put(i, 0); + continue; + } + visible = true; + dst.put(i, alpha == 0xff ? pixel : (alpha << 24) | unpremultiply(pixel, alpha)); + } + Cursor cursor = addArgbCursor(id, hotX, hotY, width, height, pixels); + // A fully transparent cursor is how games hide the pointer, so it must not become an arrow. + cursor.setVisible(visible); + return cursor; + } + + private static int unpremultiply(int pixel, int alpha) { + int red = Math.min(0xff, (((pixel >> 16) & 0xff) * 0xff + alpha - 1) / alpha); + int green = Math.min(0xff, (((pixel >> 8) & 0xff) * 0xff + alpha - 1) / alpha); + int blue = Math.min(0xff, ((pixel & 0xff) * 0xff + alpha - 1) / alpha); + return (red << 16) | (green << 8) | blue; + } + + private Cursor addArgbCursor( + int id, int hotX, int hotY, short width, short height, ByteBuffer pixels) { + Drawable drawable = + drawableManager.createDrawable(IDGenerator.generate(), width, height, (byte) 32); + drawable.drawImage( + (short) 0, + (short) 0, + (short) 0, + (short) 0, + width, + height, + (byte) 32, + pixels, + width, + height); + Cursor cursor = + new Cursor( + id, + Math.max(0, Math.min(hotX, width - 1)), + Math.max(0, Math.min(hotY, height - 1)), + drawable, + null, + null); + cursors.put(id, cursor); + triggerOnCreateResourceListener(cursor); + return cursor; + } + public void freeCursor(int id) { triggerOnFreeResourceListener(cursors.get(id)); cursors.remove(id); diff --git a/app/src/main/runtime/display/xserver/XServer.java b/app/src/main/runtime/display/xserver/XServer.java index 7be074a2b..8a2178fb6 100644 --- a/app/src/main/runtime/display/xserver/XServer.java +++ b/app/src/main/runtime/display/xserver/XServer.java @@ -11,6 +11,7 @@ import com.winlator.cmod.runtime.display.xserver.extensions.Extension; import com.winlator.cmod.runtime.display.xserver.extensions.MITSHMExtension; import com.winlator.cmod.runtime.display.xserver.extensions.PresentExtension; +import com.winlator.cmod.runtime.display.xserver.extensions.RenderExtension; import com.winlator.cmod.runtime.display.xserver.extensions.SyncExtension; import com.winlator.cmod.runtime.display.xserver.extensions.XInput2Extension; import com.winlator.cmod.shared.android.CursorLocker; @@ -392,6 +393,14 @@ private void registerExtension(Extension ext, int[] nextEventId, int[] nextError extensions.put(ext.getMajorOpcode(), ext); } + public void enableRenderCursors() { + extensions.put(RenderExtension.MAJOR_OPCODE, new RenderExtension(this)); + } + + public boolean isRenderCursorsEnabled() { + return extensions.get(RenderExtension.MAJOR_OPCODE) != null; + } + private void setupExtensions() { int[] nextEventId = {64}; int[] nextErrorId = {128}; diff --git a/app/src/main/runtime/display/xserver/extensions/RenderExtension.java b/app/src/main/runtime/display/xserver/extensions/RenderExtension.java new file mode 100644 index 000000000..fdaa0c561 --- /dev/null +++ b/app/src/main/runtime/display/xserver/extensions/RenderExtension.java @@ -0,0 +1,350 @@ +package com.winlator.cmod.runtime.display.xserver.extensions; + +import static com.winlator.cmod.runtime.display.xserver.XClientRequestHandler.RESPONSE_CODE_SUCCESS; + +import android.util.Log; +import android.util.SparseArray; +import com.winlator.cmod.runtime.display.connector.XInputStream; +import com.winlator.cmod.runtime.display.connector.XOutputStream; +import com.winlator.cmod.runtime.display.connector.XStreamLock; +import com.winlator.cmod.runtime.display.xserver.Cursor; +import com.winlator.cmod.runtime.display.xserver.Drawable; +import com.winlator.cmod.runtime.display.xserver.IDGenerator; +import com.winlator.cmod.runtime.display.xserver.XClient; +import com.winlator.cmod.runtime.display.xserver.XLock; +import com.winlator.cmod.runtime.display.xserver.XServer; +import com.winlator.cmod.runtime.display.xserver.errors.BadIdChoice; +import com.winlator.cmod.runtime.display.xserver.errors.BadImplementation; +import com.winlator.cmod.runtime.display.xserver.errors.XRequestError; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * The cursor subset of X RENDER: libXcursor, which winex11 loads, only hands over full-colour ARGB + * cursors when the server advertises RENDER, otherwise games get two-colour core cursors. Only the + * requests libXcursor uses are implemented (QueryVersion, QueryPictFormats, CreatePicture, + * FreePicture, CreateCursor, CreateAnimCursor). Wine's own GDI use of RENDER is kept off per prefix + * with "ClientSideWithRender"="N", so nothing else is expected. Registered through + * {@link XServer#enableRenderCursors()}. + */ +public class RenderExtension implements Extension { + public static final byte MAJOR_OPCODE = -108; + private static final String TAG = "RenderExtension"; + private static final int MAX_FAILURES = 8; + private static final int MAX_PICTURE_PIXELS = 512 * 512; + + private static final int FORMAT_A1 = IDGenerator.generate(); + private static final int FORMAT_RGB24 = IDGenerator.generate(); + private static final int FORMAT_ARGB32 = IDGenerator.generate(); + + private static abstract class ClientOpcodes { + private static final byte QUERY_VERSION = 0; + private static final byte QUERY_PICT_FORMATS = 1; + private static final byte CREATE_PICTURE = 4; + private static final byte FREE_PICTURE = 7; + private static final byte CREATE_CURSOR = 27; + private static final byte CREATE_ANIM_CURSOR = 31; + } + + /** Pixels snapshotted at CreatePicture time: libXcursor frees the pixmap before CreateCursor. */ + private static class Picture { + final short width; + final short height; + final ByteBuffer argb; + + Picture(short width, short height, ByteBuffer argb) { + this.width = width; + this.height = height; + this.argb = argb; + } + } + + private final XServer xServer; + private final SparseArray pictures = new SparseArray<>(); + private int failures = 0; + + public RenderExtension(XServer xServer) { + this.xServer = xServer; + } + + @Override + public String getName() { + return "RENDER"; + } + + @Override + public byte getMajorOpcode() { + return MAJOR_OPCODE; + } + + @Override + public byte getFirstErrorId() { + return 0; + } + + @Override + public byte getFirstEventId() { + return 0; + } + + @Override + public int getNumEvents() { + return 0; + } + + @Override + public int getNumErrors() { + return 0; + } + + @Override + public void setFirstEventId(byte id) {} + + @Override + public void setFirstErrorId(byte id) {} + + private boolean safeMode() { + return failures >= MAX_FAILURES; + } + + private void noteFailure(String what, Throwable t) { + failures++; + Log.w( + TAG, + what + + " failed (" + + failures + + "/" + + MAX_FAILURES + + ")" + + (safeMode() ? " - plain arrow cursors for the rest of this session" : ""), + t); + } + + private void queryVersion(XClient client, XInputStream inputStream, XOutputStream outputStream) + throws IOException { + inputStream.skip(8); + try (XStreamLock lock = outputStream.lock()) { + outputStream.writeByte(RESPONSE_CODE_SUCCESS); + outputStream.writeByte((byte) 0); + outputStream.writeShort(client.getSequenceNumber()); + outputStream.writeInt(0); + outputStream.writeInt(0); + outputStream.writeInt(11); + outputStream.writePad(16); + } + } + + private static void writeFormat( + XOutputStream outputStream, + int id, + int depth, + int redShift, + int greenShift, + int blueShift, + int alphaShift, + int alphaMask, + boolean rgb) { + outputStream.writeInt(id); + outputStream.writeByte((byte) 1); // PictTypeDirect + outputStream.writeByte((byte) depth); + outputStream.writePad(2); + outputStream.writeShort((short) redShift); + outputStream.writeShort((short) (rgb ? 0xff : 0)); + outputStream.writeShort((short) greenShift); + outputStream.writeShort((short) (rgb ? 0xff : 0)); + outputStream.writeShort((short) blueShift); + outputStream.writeShort((short) (rgb ? 0xff : 0)); + outputStream.writeShort((short) alphaShift); + outputStream.writeShort((short) alphaMask); + outputStream.writeInt(0); // colormap + } + + private void queryPictFormats(XClient client, XInputStream inputStream, XOutputStream outputStream) + throws IOException { + // 3 formats (28 bytes each); 1 screen (8) with depths 1, 24 and 32 (8 each, one 8-byte visual + // on the depth-32 root); 1 subpixel order (4). + final int formatCount = 3, depthCount = 3, visualCount = 1; + final int bytes = formatCount * 28 + 8 + depthCount * 8 + visualCount * 8 + 4; + int rootVisual = xServer.pixmapManager.visual.id; + try (XStreamLock lock = outputStream.lock()) { + outputStream.writeByte(RESPONSE_CODE_SUCCESS); + outputStream.writeByte((byte) 0); + outputStream.writeShort(client.getSequenceNumber()); + outputStream.writeInt(bytes / 4); + outputStream.writeInt(formatCount); + outputStream.writeInt(1); + outputStream.writeInt(depthCount); + outputStream.writeInt(visualCount); + outputStream.writeInt(1); + outputStream.writePad(4); + + writeFormat(outputStream, FORMAT_A1, 1, 0, 0, 0, 0, 0x1, false); + writeFormat(outputStream, FORMAT_RGB24, 24, 16, 8, 0, 0, 0, true); + writeFormat(outputStream, FORMAT_ARGB32, 32, 16, 8, 0, 24, 0xff, true); + + outputStream.writeInt(depthCount); + outputStream.writeInt(FORMAT_ARGB32); // fallback + for (int depth : new int[] {1, 24}) { + outputStream.writeByte((byte) depth); + outputStream.writePad(1); + outputStream.writeShort((short) 0); + outputStream.writePad(4); + } + outputStream.writeByte((byte) 32); + outputStream.writePad(1); + outputStream.writeShort((short) 1); + outputStream.writePad(4); + outputStream.writeInt(rootVisual); + outputStream.writeInt(FORMAT_ARGB32); + + outputStream.writeInt(0); // SubPixelUnknown + } + } + + private void createPicture(XClient client, XInputStream inputStream) throws XRequestError { + int pictureId = inputStream.readInt(); + int drawableId = inputStream.readInt(); + inputStream.readInt(); // format + inputStream.readInt(); // value-mask + client.skipRequest(); // attribute values: none of them matter for a cursor source + + if (!client.isValidResourceId(pictureId)) throw new BadIdChoice(pictureId); + + Picture picture = new Picture((short) 0, (short) 0, null); + try (XLock lock = + xServer.lock(XServer.Lockable.PIXMAP_MANAGER, XServer.Lockable.DRAWABLE_MANAGER)) { + Drawable drawable = xServer.drawableManager.getDrawable(drawableId); + if (drawable != null + && drawable.width > 0 + && drawable.height > 0 + && drawable.width * drawable.height <= MAX_PICTURE_PIXELS) { + synchronized (drawable.renderLock) { + if (drawable.getData() != null) { + ByteBuffer argb = + drawable.getImage((short) 0, (short) 0, drawable.width, drawable.height); + picture = new Picture(drawable.width, drawable.height, argb); + } + } + } + } catch (RuntimeException e) { + // An unknown or oversized drawable becomes an empty picture: its cursor is the arrow. + noteFailure("CreatePicture", e); + } + synchronized (pictures) { + pictures.put(pictureId, picture); + } + } + + private void freePicture(XInputStream inputStream) { + int pictureId = inputStream.readInt(); + synchronized (pictures) { + pictures.remove(pictureId); + } + } + + private Picture getPicture(int id) { + synchronized (pictures) { + return pictures.get(id); + } + } + + private void makeCursor(XClient client, int cursorId, Picture picture, int hotX, int hotY) + throws XRequestError { + if (!client.isValidResourceId(cursorId)) throw new BadIdChoice(cursorId); + Cursor cursor = null; + try (XLock lock = + xServer.lock( + XServer.Lockable.PIXMAP_MANAGER, + XServer.Lockable.DRAWABLE_MANAGER, + XServer.Lockable.CURSOR_MANAGER)) { + if (!safeMode() && picture != null && picture.argb != null) { + try { + cursor = + xServer.cursorManager.createArgbCursor( + cursorId, hotX, hotY, picture.width, picture.height, picture.argb.duplicate()); + if (cursor == null) throw new BadIdChoice(cursorId); + } catch (RuntimeException e) { + noteFailure("CreateCursor " + picture.width + "x" + picture.height, e); + } + } + if (cursor == null) cursor = xServer.cursorManager.createFallbackArrowCursor(cursorId); + } + if (cursor == null) throw new BadIdChoice(cursorId); + client.registerAsOwnerOfResource(cursor); + } + + private void createCursor(XClient client, XInputStream inputStream) throws XRequestError { + int cursorId = inputStream.readInt(); + Picture picture = getPicture(inputStream.readInt()); + int hotX = inputStream.readUnsignedShort(); + int hotY = inputStream.readUnsignedShort(); + makeCursor(client, cursorId, picture, hotX, hotY); + } + + private void createAnimCursor(XClient client, XInputStream inputStream) throws XRequestError { + int cursorId = inputStream.readInt(); + int frameCount = client.getRemainingRequestLength() / 8; + int firstFrame = frameCount > 0 ? inputStream.readInt() : 0; + client.skipRequest(); // remaining frames + delays: shown as the first frame, static + + Picture picture = null; + int hotX = 0, hotY = 0; + try (XLock lock = + xServer.lock(XServer.Lockable.DRAWABLE_MANAGER, XServer.Lockable.CURSOR_MANAGER)) { + Cursor first = xServer.cursorManager.getCursor(firstFrame); + if (first != null && first.cursorImage != null) { + Drawable image = first.cursorImage; + synchronized (image.renderLock) { + if (image.getData() != null) { + picture = + new Picture( + image.width, + image.height, + image.getImage((short) 0, (short) 0, image.width, image.height)); + } + } + hotX = first.hotSpotX; + hotY = first.hotSpotY; + } + } catch (RuntimeException e) { + noteFailure("CreateAnimCursor", e); + } + makeCursor(client, cursorId, picture, hotX, hotY); + } + + @Override + public void handleRequest( + XClient client, XInputStream inputStream, XOutputStream outputStream) + throws IOException, XRequestError { + int opcode = client.getRequestData(); + try { + switch (opcode) { + case ClientOpcodes.QUERY_VERSION: + queryVersion(client, inputStream, outputStream); + break; + case ClientOpcodes.QUERY_PICT_FORMATS: + queryPictFormats(client, inputStream, outputStream); + break; + case ClientOpcodes.CREATE_PICTURE: + createPicture(client, inputStream); + break; + case ClientOpcodes.FREE_PICTURE: + freePicture(inputStream); + break; + case ClientOpcodes.CREATE_CURSOR: + createCursor(client, inputStream); + break; + case ClientOpcodes.CREATE_ANIM_CURSOR: + createAnimCursor(client, inputStream); + break; + default: + throw new BadImplementation(); + } + } catch (RuntimeException e) { + // Never let a RENDER bug drop the game's X connection: report it as an X error instead. + noteFailure("RENDER request " + opcode, e); + throw new BadImplementation(); + } + } +}