Compare commits

..
Author SHA1 Message Date
Kurt Himebauch f635e92498 fix: Steam touch controls load the layout Steam picked for DroidDeck's touch controller, including workshop layouts (#840) 2026-10-14 16:09:40 -04:00
RaidennrlandxXJsonDeruloXx eda1e9b9c6 Fix wired PowerA and PDP right-stick and trigger mapping (#692)
Fix wired PowerA and PDP stick and trigger axes

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-14 13:56:17 -04:00
kmnkv1andxXJsonDeruloXx fa9a9446bb Build and test on Windows: LF session scripts, '/' in relative paths (#552)
fix: build and test on Windows

- .gitattributes keeps the session's scripts LF under core.autocrlf=true;
  CRLF scripts made every session exit with status 2.
- runtime_inputs.py and the scrub record / log zip use '/' in relative
  paths, which Windows reported with '\' (runtime inputs never matched).
- The read-only-folder scrub test is skipped on Windows, which still
  creates files in a read-only directory.

Co-authored-by: xXJsonDeruloXx <[email protected]>
2026-10-14 13:56:12 -04:00
Gustash 3e3c4d69e4 feat: hide unfolded controls when a controller is connected (#829)
* feat: hide unfolded controls when a controller is connected

* fix: also hide controls when a BLE controller is connected

* fix: use refreshControls on session resume as well
2026-10-14 13:52:13 -04:00
Tjebbe Kerstens 913806f169 fix: seed redist markers again when the registry lost them (#793)
* fix: redist markers a replaced registry lost are seeded again

droiddeck-seed-redists recorded what it seeded in .droiddeck-redists and
trusted that record from then on. A Proton update replaces its
default_pfx system.reg but leaves our record beside it, and every new
prefix copies both, so new games got a record of markers their registry
never had: Steam then ran their install scripts for real. Batman: Arkham
City's AMD Dual-Core Optimizer installer hangs there, leaving the game
on "Running install script" on every launch.

Each recorded marker is now looked up in the registry, in both views
where both were written, and seeded again when it is gone; a record of
markers nobody wants any more is rewritten without them.

* Check every VC runtime architecture before keeping the vcrt marker

vcrt writes Installed for x86, x64 and arm64 in both registry views, but
present() only looked at x86. If only an x64 or arm64 value was gone the
runtime would not be seeded again. Require all six values and test the
loss of the arm64 Wow6432Node one.
2026-10-14 13:52:07 -04:00
Tjebbe Kerstens 7b018e6c42 fix: Batman: Arkham City starts with full x87 precision under FEX (#792)
Arkham City (BatmanAC.exe) stopped with Unreal's "Fatal error!" about
1.5 s after start: an access violation in BatmanAC.exe right after
steam.dll loads. The game leans on x87 and needs full precision under
FEX (FEX wiki), while the default FEX preset sets
FEX_X87REDUCEDPRECISION=1. Engine fixes sit above the shared preset and
below the player's own game profile, so a fix keyed on BatmanAC.exe
beside the started exe (BatmanAC.exe or BmLauncher.exe) restores
x87 precision for this game only. Arkham Asylum, which shares
BmLauncher.exe but not BatmanAC.exe, keeps the preset.
2026-10-14 13:52:01 -04:00
KirkandKirk e45d6f7192 fix: game shortcuts use the cover art when Steam's icon is too small (#825)
Steam keeps a 32x32 icon for each game, and the home screen stretched
it to launcher size. A square from the middle of the cover art is used
instead unless the icon is at least 128 px; the small icon is still
the fallback when there is no art.

Co-authored-by: Kirk <[email protected]>
2026-10-14 13:51:56 -04:00
Muaz Kadan b3ff93e80f Fix battery time estimates showing thousands of hours on phones that report current in mA (#820)
fix: read battery current reported in mA so time estimates aren't 1000x too long
2026-10-14 13:51:51 -04:00
William AllenandClaude Opus 5.5 c035351b4f fix: read the replaced listing's watch descriptor before detach can free it (#685)
install() replaces an existing cache entry by calling detach(old), which
destroys the listing when no reader holds it, and then reads old->wd to
decide whether to release the watch. When the old listing has no readers,
that read happens after the free.

Two threads that enumerate the same uncached directory at the same time
reach this path: both build a listing, the first installs an entry with
no readers, and the second replaces it.

Save the watch descriptor before detaching, as drop() already does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:51:44 -04:00
Resul Övürandovurrsl 10cdf97480 fix: Turkish translation for zRAM, reset-settings and store storage messages (#826)
Adds the 14 translatable strings that values-tr was missing: the zRAM toggle and its hints, the Reset all settings row, dialog and toasts, and the store install free-space messages. Keys marked translatable=false stay untranslated on purpose.

Co-authored-by: ovurrsl <[email protected]>
2026-10-14 13:51:38 -04:00
Resul Övürandovurrsl f614483d01 docs: README names the child-process setting the way Android shows it (#831)
* docs: README names the child-process setting the way Android shows it

Developer options label this switch "Disable child process restrictions", and it has to be turned on. The README said to turn off "Restrict child processes".

* docs: README limits the child-process toggle to Android 14 and newer

The Developer options switch only exists from Android 14 (PhantomProcessLimit.hasDeveloperToggle); Android 12 and 13 keep the Fix it for me flow.

---------

Co-authored-by: ovurrsl <[email protected]>
2026-10-14 13:51:34 -04:00
Imgau f5eb5e7b81 fix: declare all-files access for raw USB storage (#760) 2026-10-14 13:48:14 -04:00
Kurt Himebauch bf99dbbe73 feat: store sign-in walls and the Steam home wall fill with curated featured art (#835) 2026-10-14 13:42:27 -04:00
Kushagra 46fac69438 feat: map a controller's two back paddles to the Deck's L4 and R4 (#797) 2026-10-14 15:31:59 +00:00
Gustash 82f04fb22e fix: the touchpad moves a game's own cursor when it locks the pointer (#772) 2026-10-14 11:30:54 -04:00
Kurt Himebauch 9796525449 perf: local builds reuse unchanged native steps across runs and worktrees (#819) 2026-10-14 15:30:24 +00:00
37 changed files with 826 additions and 121 deletions

No files matched your search

+2
View File
@@ -1,2 +1,4 @@
tools/linuxfs/** text=auto eol=lf
tools/mangoapp/** text=auto eol=lf
tools/comet/LICENSE whitespace=-blank-at-eof
tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof
+1 -1
View File
@@ -17,7 +17,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier.
Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
On Android 14 and newer, before Steam launches, you must turn on **Disable child process restrictions** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
## Community
+1
View File
@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -2194,6 +2194,7 @@ struct constraint {
int pending_region_set;
int hint_set, pending_hint_set;
double hint_x, hint_y, pending_hint_x, pending_hint_y;
int gave_keys;
};
static struct wl_list g_constraints;
static struct constraint *g_active_constraint;
@@ -2272,6 +2273,14 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh
g_ptr_y = oy + k->hint_y;
}
g_raw_valid = 0;
if (k->gave_keys) {
k->gave_keys = 0;
for (int i = 0; i < g_nkbs; i++) {
if (!k->surface || g_kbs[i].focus != k->surface->resource) continue;
wl_keyboard_send_leave(g_kbs[i].kb, wl_display_next_serial(g_display), g_kbs[i].focus);
g_kbs[i].focus = NULL;
}
}
if (tell_client) constraint_send_state(k, 0);
constraint_describe(k, name, sizeof(name));
droiddeck_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined",
@@ -2290,6 +2299,12 @@ static void constraint_activate(struct constraint *k) {
if (!k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y);
constraint_origin(k, &ox, &oy);
pointer_focus(k->surface->resource, wl_fixed_from_double(g_ptr_x - ox), wl_fixed_from_double(g_ptr_y - oy));
int focused = 0;
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus) focused = 1;
if (k->is_lock && !focused && keyboard_for(wl_resource_get_client(k->surface->resource))) {
keyboard_focus(k->surface->resource);
k->gave_keys = 1;
}
constraint_send_state(k, 1);
constraint_describe(k, name, sizeof(name));
if (k->is_lock)
@@ -177,6 +177,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (pointerLocked == locked) return@post
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
// Touchpad deltas are measured from the middle while locked (moveLockedPointer).
if (locked && ::touchpad.isInitialized) {
drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) }
cursorVisible = false
}
updatePointerCapture()
}
}
@@ -308,16 +313,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
padBridge?.syncConnectedControllers()
updateOnScreenControls()
refreshControls()
updatePointerCapture()
}
override fun onInputDeviceRemoved(deviceId: Int) {
Log.i(TAG, "input device $deviceId disconnected")
padBridge?.onDeviceRemoved(deviceId)
updateOnScreenControls()
refreshControls()
updatePointerCapture()
}
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
override fun onInputDeviceChanged(deviceId: Int) = refreshControls()
}
private var runtimeRemovalBlocked = false
@@ -381,7 +386,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
padBridge = bridge
SessionState.padBridge = bridge
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); refreshControls() } }
steamController = SteamControllerBle(this).also { it.start() }
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
@@ -1705,9 +1710,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private val pointerListener = object : PointerGestures.Listener {
override fun onMove(x: Float, y: Float) = movePointer(x, y)
override fun onMove(x: Float, y: Float) {
if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y)
}
override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) {
movePointer(x, y)
if (!pointerLocked) movePointer(x, y)
WaylandCompositor.nativeSendSceneInput(3, button, if (pressed) 1 else 0)
}
override fun onWheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
@@ -1717,6 +1724,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun moveLockedPointer(x: Float, y: Float) {
val rect = drawnRect() ?: return
val scale = SessionState.outputSize.first / rect.width()
WaylandCompositor.sendPointerDelta((x - rect.centerX()) * scale, (y - rect.centerY()) * scale)
touchpad.place(rect.centerX(), rect.centerY())
cursorVisible = false
}
private fun movePointer(x: Float, y: Float) {
val rect = drawnRect() ?: return
val cx = x.coerceIn(rect.left, rect.right)
@@ -1859,11 +1874,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
applyUnfoldedControls()
}
private fun refreshControls() {
applyUnfoldedControls()
updateOnScreenControls()
}
private fun applyUnfoldedControls() {
if (!::sessionRoot.isInitialized) return
val width = sessionRoot.width
val height = sessionRoot.height
val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) &&
!PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad &&
!pipUi && !isInPictureInPictureMode && width > 0 && height > 0 &&
resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP
@@ -2274,12 +2295,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (SessionState.guestPid > 1) padBridge?.syncConnectedControllers()
onScreenControls?.reload()
controllerSettings = ControllerPrefs.read(this)
updateOnScreenControls()
resumed = true
updatePointerCapture()
if (!pipUi) sessionClipboard.start()
updatePadMotion()
readPrefs()
refreshControls()
if (::pip.isInitialized) pip.resumed()
if (CompositorHost.isStarted) applyFrameGen()
SessionPrefs.sessionPrefs(this).registerOnSharedPreferenceChangeListener(cardboardPrefListener)
@@ -5,12 +5,14 @@ import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.widget.Toast
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import java.io.File
object GameShortcuts {
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
@@ -19,22 +21,35 @@ object GameShortcuts {
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
val name = game.name.ifBlank { context.getString(R.string.app_name) }
val icon = listOfNotNull(game.icon, game.art).firstNotNullOfOrNull { file -> runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
val options = BitmapFactory.Options().apply {
inSampleSize = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2
}
BitmapFactory.decodeFile(file.path, options)?.let(Icon::createWithBitmap)
}.getOrNull() } ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
.setIntent(launchIntent(context, game.gameIdString)).build()
}
internal fun iconBitmap(game: Library.SteamGame): Bitmap? {
val icon = game.icon?.let(::decode)
if (icon != null && minOf(icon.width, icon.height) >= SHARP_ICON_PX) return icon
return game.art?.let(::decode)?.let(::square) ?: icon
}
private fun decode(file: File): Bitmap? = runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
val options = BitmapFactory.Options().apply {
inSampleSize = 1
while (minOf(bounds.outWidth, bounds.outHeight) / (inSampleSize * 2) >= ICON_PX) inSampleSize *= 2
}
BitmapFactory.decodeFile(file.path, options)
}.getOrNull()
private fun square(bitmap: Bitmap): Bitmap {
val side = minOf(bitmap.width, bitmap.height)
return Bitmap.createBitmap(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side)
}
fun pin(activity: Activity, game: Library.SteamGame) {
val manager = activity.getSystemService(ShortcutManager::class.java)
val requested = runCatching { manager.isRequestPinShortcutSupported &&
@@ -44,4 +59,7 @@ object GameShortcuts {
fun result(activity: Activity, game: Library.SteamGame): Intent =
activity.getSystemService(ShortcutManager::class.java).createShortcutResultIntent(info(activity, game))
private const val ICON_PX = 256
private const val SHARP_ICON_PX = 128
}
@@ -0,0 +1,91 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.stores.StoreNet
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
object SteamFeatured {
class Game(val appId: Int, val name: String, val art: String)
private const val TAG = "SteamFeatured"
private const val PREFS = "steam_featured"
private const val KEY_GAMES = "games_v2"
private const val KEY_AT = "at"
private const val FRESH_MS = 6 * 60 * 60 * 1000L
private val SECTIONS = listOf("top_sellers", "new_releases", "specials")
private const val ASSET_ROOT = "https://shared.akamai.steamstatic.com/store_item_assets/"
private val HIDDEN_DESCRIPTORS = setOf(1, 3, 4)
var games by mutableStateOf<List<Game>>(emptyList())
private set
private val loading = AtomicBoolean(false)
fun load(context: Context) {
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (games.isEmpty()) parse(prefs.getString(KEY_GAMES, null))?.let { games = it }
if (System.currentTimeMillis() - prefs.getLong(KEY_AT, 0L) in 0 until FRESH_MS && games.isNotEmpty()) return
if (!loading.compareAndSet(false, true)) return
Thread({
try {
val cc = Locale.getDefault().country.takeIf { it.length == 2 } ?: "US"
val body = StoreNet.get("https://store.steampowered.com/api/featuredcategories?cc=$cc&l=english") ?: return@Thread
val root = JSONObject(body)
val ids = LinkedHashSet<Int>()
for (section in SECTIONS) {
val items = root.optJSONObject(section)?.optJSONArray("items") ?: continue
for (i in 0 until items.length()) {
val o = items.optJSONObject(i) ?: continue
val id = o.optInt("id")
if (id > 0 && o.optInt("type", 0) == 0) ids += id
}
}
val list = withArt(ids, cc)
if (list.isEmpty()) return@Thread
prefs.edit().putString(KEY_GAMES, JSONArray(list.map { JSONObject().put("id", it.appId).put("name", it.name).put("art", it.art) }).toString())
.putLong(KEY_AT, System.currentTimeMillis()).apply()
android.os.Handler(android.os.Looper.getMainLooper()).post { games = list }
Log.i(TAG, "featured games: ${list.size}")
} catch (e: Exception) {
Log.w(TAG, "featured: ${e.javaClass.simpleName}")
} finally {
loading.set(false)
}
}, "steam-featured").start()
}
private fun withArt(ids: Collection<Int>, cc: String): List<Game> {
if (ids.isEmpty()) return emptyList()
val input = JSONObject()
.put("ids", JSONArray(ids.map { JSONObject().put("appid", it) }))
.put("context", JSONObject().put("language", "english").put("country_code", cc))
.put("data_request", JSONObject().put("include_assets", true))
val body = StoreNet.get(
"https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?input_json=" + java.net.URLEncoder.encode(input.toString(), "UTF-8"),
) ?: return emptyList()
val items = JSONObject(body).optJSONObject("response")?.optJSONArray("store_items") ?: return emptyList()
return (0 until items.length()).mapNotNull { i ->
val o = items.optJSONObject(i) ?: return@mapNotNull null
val descriptors = o.optJSONArray("content_descriptorids")
if (descriptors != null && (0 until descriptors.length()).any { descriptors.optInt(it) in HIDDEN_DESCRIPTORS }) return@mapNotNull null
val assets = o.optJSONObject("assets") ?: return@mapNotNull null
val format = assets.optString("asset_url_format").takeIf { "\${FILENAME}" in it } ?: return@mapNotNull null
val file = listOf("library_capsule_2x", "library_capsule").firstNotNullOfOrNull { k -> assets.optString(k).takeIf { it.isNotEmpty() } }
?: return@mapNotNull null
Game(o.optInt("appid"), o.optString("name"), ASSET_ROOT + format.replace("\${FILENAME}", file))
}
}
private fun parse(text: String?): List<Game>? {
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.map { Game(it.optInt("id"), it.optString("name"), it.optString("art")) }
.filter { it.appId > 0 && it.art.isNotEmpty() }.takeIf { it.isNotEmpty() }
}
}
@@ -19,6 +19,7 @@ public final class PadBridge {
private static final float DEAD_ZONE = 0.12f;
private static final int[] JOYSTICK_AXES = {
MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ,
MotionEvent.AXIS_RX, MotionEvent.AXIS_RY,
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER,
MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS
};
@@ -100,6 +101,7 @@ public final class PadBridge {
}
public synchronized void stop() {
releaseBackPaddles();
activeControllerId = NO_CONTROLLER;
for (int slot = 0; slot < SLOTS; slot++) {
owners[slot] = NO_CONTROLLER;
@@ -243,6 +245,12 @@ public final class PadBridge {
case KeyEvent.KEYCODE_BUTTON_THUMBR: pad.press(9, pressed); break;
case KeyEvent.KEYCODE_BUTTON_MODE:
case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break;
case KeyEvent.KEYCODE_BUTTON_Z:
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.L4, pressed);
break;
case KeyEvent.KEYCODE_BUTTON_C:
if (isPrimary(event)) DeckControls.INSTANCE.setGrip(DeckControls.R4, pressed);
break;
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
@@ -269,12 +277,27 @@ public final class PadBridge {
scheduleStats();
pad.leftX = axis(event, MotionEvent.AXIS_X);
pad.leftY = axis(event, MotionEvent.AXIS_Y);
pad.rightX = axis(event, MotionEvent.AXIS_Z);
pad.rightY = axis(event, MotionEvent.AXIS_RZ);
float lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
float rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
InputDevice device = event.getDevice();
boolean wiredXboxLayout = device != null
&& ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063)
|| (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0))
&& device.getMotionRange(MotionEvent.AXIS_RX, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_RY, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_Z, event.getSource()) != null
&& device.getMotionRange(MotionEvent.AXIS_RZ, event.getSource()) != null;
pad.rightX = axis(event, wiredXboxLayout ? MotionEvent.AXIS_RX : MotionEvent.AXIS_Z);
pad.rightY = axis(event, wiredXboxLayout ? MotionEvent.AXIS_RY : MotionEvent.AXIS_RZ);
float lt;
float rt;
if (wiredXboxLayout) {
lt = Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_Z) + 1f) / 2f));
rt = Math.max(0f, Math.min(1f, (event.getAxisValue(MotionEvent.AXIS_RZ) + 1f) / 2f));
} else {
lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
}
pad.leftTrigger = lt;
pad.rightTrigger = rt;
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
@@ -322,6 +345,7 @@ public final class PadBridge {
}
public synchronized void releaseAll() {
releaseBackPaddles();
state.clear();
publish();
for (int slot = 1; slot < SLOTS; slot++) {
@@ -415,11 +439,21 @@ public final class PadBridge {
return slot == 0 ? state : playerStates[slot];
}
private boolean isPrimary(KeyEvent event) {
return owners[0] == event.getDevice().getId();
}
private void releaseBackPaddles() {
DeckControls.INSTANCE.setGrip(DeckControls.L4, false);
DeckControls.INSTANCE.setGrip(DeckControls.R4, false);
}
private void free(int slot) {
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
owners[slot] = NO_CONTROLLER;
slotControllers.set(slot, NO_CONTROLLER);
if (slot == 0) {
releaseBackPaddles();
state.clear();
publish();
return;
@@ -158,7 +158,8 @@ object SteamTouchConfig {
fun load(context: Context, appId: Int): Config {
val dir = configDir(context)
val file = dir?.let { autosaveConfig(it, appId) }
val file = dir?.let { deviceConfig(context, it, appId) }
?: dir?.let { autosaveConfig(it, appId) }
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
?: dir?.let { configsetConfig(context, it, appId) }
if (file == null) {
@@ -207,11 +208,16 @@ object SteamTouchConfig {
null
}
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId)
private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId)
private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString())
when {
set == null -> null
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!)
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
else -> null
}?.takeIf { it.isFile }
@@ -219,7 +225,16 @@ object SteamTouchConfig {
null
}
internal fun workshopConfig(context: Context, id: String): File? {
if (!id.all(Char::isDigit)) return null
return File(steamRoot(context), "steamapps/workshop/content/$CONFIGS_APP/$id").listFiles()
?.filter { it.isFile && (it.name.endsWith(".vdf") || it.name.endsWith(".bin")) }
?.maxByOrNull { it.lastModified() }
}
private const val CONFIGSET = "configset_controller_mobile_touch.vdf"
private const val DEVICE_CONFIGSET = "configset_MT-DROIDDECK0001.vdf"
private const val CONFIGS_APP = 241100
private fun availability(mappings: KeyValues.Node): Map<Int, Set<Int>> {
@@ -363,10 +378,20 @@ object SteamTouchConfig {
tmp.writeText(text)
if (!tmp.renameTo(target)) return null
selectAutosave(dir, appId)
dropDeviceEntry(dir, appId)
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
return target
}
private fun dropDeviceEntry(dir: File, appId: Int) {
val file = File(dir, DEVICE_CONFIGSET)
if (!file.isFile) return
val text = file.readText()
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
if (!block.containsMatchIn(text)) return
File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file)
}
private fun selectAutosave(dir: File, appId: Int) {
val file = File(dir, CONFIGSET)
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
@@ -45,8 +45,9 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
val tempDeci = sticky?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0
val tech = sticky?.getStringExtra(BatteryManager.EXTRA_TECHNOLOGY)?.takeIf { it.isNotBlank() } ?: "Li-ion"
val chargeNowUah = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER)?.takeIf { it > 0 && it != Long.MIN_VALUE } ?: 0L
val currentNowUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)?.takeIf { it != Long.MIN_VALUE } ?: 0L
val currentAvgUa = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)?.takeIf { it != Long.MIN_VALUE && it != 0L } ?: currentNowUa
val currentNowUa = toMicroamps(bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)?.takeIf { it != Long.MIN_VALUE } ?: 0L)
val currentAvgUa = toMicroamps(bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_AVERAGE)?.takeIf { it != Long.MIN_VALUE && it != 0L } ?: 0L)
.takeIf { it != 0L } ?: currentNowUa
val discharging = status == BatteryManager.BATTERY_STATUS_DISCHARGING || status == BatteryManager.BATTERY_STATUS_NOT_CHARGING
val chargeFullUah = if (chargeNowUah > 0 && pct > 0) chargeNowUah * 100 / pct else 0L
val energyNowUwh = chargeNowUah * voltageUv / 1_000_000L
@@ -125,6 +126,12 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
companion object {
private const val TAG = "SessionService"
// CURRENT_NOW/CURRENT_AVERAGE are documented as µA, but Samsung and other vendors report mA,
// which made every lifetime estimate 1000x too long. No real µA reading under load is below 10 mA,
// and no mA reading is above 10 A, so anything smaller than 10_000 is taken as mA.
private const val MILLIAMP_CEILING = 10_000L
internal fun toMicroamps(raw: Long): Long = if (abs(raw) < MILLIAMP_CEILING) raw * 1000L else raw
private const val PERIOD_MS = 5_000L
private val VPOWER_FILES = listOf(
"ac_status", "battery_percent", "battery_status",
@@ -119,7 +119,7 @@ object SessionArtifacts {
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
record.files.forEach { (f, s) ->
lines.append(s.length).append('\t').append(s.modified).append('\t')
.append(f.relativeTo(dir).path).append('\n')
.append(f.relativeTo(dir).invariantSeparatorsPath).append('\n')
}
tree.writeText(lines.toString())
true
@@ -58,7 +58,7 @@ object SessionLogShare {
if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
}
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f); advance(f) }
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).invariantSeparatorsPath, f); advance(f) }
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f); advance(f) }
stores.forEach { f -> addEntry(z, "$STORES_DIR/" + f.name, f); advance(f) }
tools.forEach { f -> addEntry(z, SessionPaths.TOOLS_DIR + "/" + f.name, f); advance(f) }
@@ -31,6 +31,7 @@ class StoreShelves(
val deals: List<CatalogItem> = emptyList(),
val free: List<CatalogItem> = emptyList(),
val trending: List<CatalogItem> = emptyList(),
val art: List<String> = emptyList(),
) {
val isEmpty: Boolean get() = whatsNew.isEmpty() && deals.isEmpty() && free.isEmpty() && trending.isEmpty()
val all: List<CatalogItem> get() = (whatsNew + deals + free + trending).distinctBy { it.id }
@@ -55,7 +55,20 @@ object AmazonBackend : StoreBackend {
Thread({ AmazonLibrary.sync(app, force) }, "amazon-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {}
override fun loadShelves(context: Context, force: Boolean) {
val app = context.applicationContext
AmazonShelves.cached(app)?.let { s -> StoresState.post { withArt(s.art) } }
Thread({
val shelves = AmazonShelves.featured(app, force)
if (shelves != null) StoresState.post { withArt(shelves.art) }
}, "amazon-shelves").start()
}
private fun withArt(art: List<String>) {
val s = StoresState.shelves[store]
StoresState.shelves[store] = if (s == null) com.droiddeck.launcher.stores.StoreShelves(art = art)
else com.droiddeck.launcher.stores.StoreShelves(s.whatsNew, s.deals, s.free, s.trending, art)
}
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
val app = context.applicationContext
@@ -102,7 +102,7 @@ object AmazonLibrary {
StoresState.post {
StoresState.library[store] = mergeItems(StoresState.library[store], items)
val installed = StoresState.installed.filter { it.sidecar.store == store }.map { it.sidecar.id }.toSet()
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed })
StoresState.shelves[store] = StoreShelves(trending = items.filterNot { it.id in installed }, art = StoresState.shelves[store]?.art.orEmpty())
}
}
@@ -0,0 +1,92 @@
package com.droiddeck.launcher.stores.amazon
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoreShelves
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
object AmazonShelves {
private const val TAG = "AmazonShelves"
private const val PAGE = "https://luna.amazon.com/claims/home"
private const val GRAPHQL = "https://luna.amazon.com/graphql"
private const val KEY_ART = "store_art"
private const val KEY_ART_AT = "store_art_at"
private const val FRESH_MS = 3 * 60 * 60 * 1000L
private const val QUERY = "query { items(collectionType: FEATURED_CONTENT, pageSize: 50) { items { assets { title " +
"cardMedia { defaultMedia { src1x src2x } } } } } }"
@Volatile private var memory: StoreShelves? = null
fun cached(context: Context): StoreShelves? =
memory ?: fromPrefs(AmazonPrefs.get(context).getString(KEY_ART, null))?.also { memory = it }
fun featured(context: Context, force: Boolean): StoreShelves? {
val prefs = AmazonPrefs.get(context)
if (!force && System.currentTimeMillis() - prefs.getLong(KEY_ART_AT, 0L) in 0 until FRESH_MS) cached(context)?.let { return it }
val art = runCatching { fetch() }.onFailure { Log.w(TAG, "featured: ${it.javaClass.simpleName}") }.getOrNull().orEmpty()
if (art.isEmpty()) return cached(context)
prefs.edit().putString(KEY_ART, JSONArray(art).toString()).putLong(KEY_ART_AT, System.currentTimeMillis()).apply()
Log.i(TAG, "featured art: ${art.size}")
return StoreShelves(art = art).also { memory = it }
}
private fun fromPrefs(text: String?): StoreShelves? {
val a = runCatching { JSONArray(text ?: return null) }.getOrNull() ?: return null
val art = List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() }
return if (art.isEmpty()) null else StoreShelves(art = art)
}
private fun fetch(): List<String> {
val (page, cookies) = openPage() ?: return emptyList()
val token = Regex("""name=['"]csrf-key['"] value=['"]([^'"]+)['"]""").find(page)?.groupValues?.get(1) ?: run {
Log.w(TAG, "claims page had no csrf key")
return emptyList()
}
val body = StoreNet.postJson(
GRAPHQL, JSONObject().put("query", QUERY).toString(),
headers = mapOf("client-id" to "CarboniteApp", "csrf-token" to token, "Cookie" to cookies, "Origin" to "https://luna.amazon.com", "Referer" to PAGE),
) ?: return emptyList()
val items = JSONObject(body).optJSONObject("data")?.optJSONObject("items")?.optJSONArray("items") ?: return emptyList()
return (0 until items.length()).mapNotNull { i ->
val media = items.optJSONObject(i)?.optJSONObject("assets")?.optJSONObject("cardMedia")?.optJSONObject("defaultMedia")
media?.optString("src2x")?.takeIf { it.isNotEmpty() } ?: media?.optString("src1x")?.takeIf { it.isNotEmpty() }
}.distinct()
}
private fun openPage(): Pair<String, String>? {
val jar = LinkedHashMap<String, String>()
var url = PAGE
repeat(5) {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 20_000
readTimeout = 20_000
instanceFollowRedirects = false
setRequestProperty("User-Agent", StoreNet.BROWSER_UA)
setRequestProperty("Accept", "text/html")
if (jar.isNotEmpty()) setRequestProperty("Cookie", jar.entries.joinToString("; ") { "${it.key}=${it.value}" })
}
try {
val code = conn.responseCode
conn.headerFields["Set-Cookie"].orEmpty().forEach { c ->
val pair = c.substringBefore(';')
val name = pair.substringBefore('=').trim()
if (name.isNotEmpty() && '=' in pair) jar[name] = pair.substringAfter('=')
}
if (code in 300..399) {
url = URL(URL(url), conn.getHeaderField("Location") ?: return null).toString()
return@repeat
}
if (code !in 200..299) { Log.w(TAG, "claims page $code"); return null }
val page = conn.inputStream.bufferedReader().use { it.readText() }
return page to jar.entries.joinToString("; ") { "${it.key}=${it.value}" }
} finally {
runCatching { conn.disconnect() }
}
}
return null
}
}
@@ -64,14 +64,16 @@ object EpicShelves {
val a = o.optJSONArray(key) ?: return emptyList()
return (0 until a.length()).mapNotNull { a.optJSONObject(it) }.mapNotNull(::itemFrom).distinctBy { it.id }
}
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"))
return if (s.isEmpty) null else s
val art = o.optJSONArray("art")?.let { a -> List(a.length()) { a.optString(it) }.filter { it.isNotEmpty() } }.orEmpty()
val s = StoreShelves(shelf("new"), shelf("deals"), shelf("free"), shelf("trending"), art)
return if (s.isEmpty && art.isEmpty()) null else s
}
fun toJson(s: StoreShelves): String = JSONObject().apply {
listOf(s.whatsNew, s.deals, s.free, s.trending).zip(SHELF_KEYS).forEach { (items, key) ->
put(key, JSONArray().apply { items.forEach { put(itemJson(it)) } })
}
put("art", JSONArray(s.art))
}.toString()
fun cached(context: Context): StoreShelves? =
@@ -111,6 +113,6 @@ object EpicShelves {
releaseDate = owned.releaseDate.ifBlank { shelf.releaseDate },
)
}
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
return StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
}
}
@@ -62,7 +62,7 @@ object GogBackend : StoreBackend {
private fun markOwned(s: com.droiddeck.launcher.stores.StoreShelves): com.droiddeck.launcher.stores.StoreShelves {
val owned = StoresState.library[store]?.associateBy { it.id } ?: return s
fun mark(l: List<CatalogItem>) = l.map { i -> owned[i.id]?.let { o -> i.copy(owned = true, sizeBytes = o.sizeBytes) } ?: i }
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending))
return com.droiddeck.launcher.stores.StoreShelves(mark(s.whatsNew), mark(s.deals), mark(s.free), mark(s.trending), s.art)
}
override fun install(context: Context, item: CatalogItem, root: File) {
@@ -46,10 +46,10 @@ import kotlin.math.min
import kotlin.math.sin
@Composable
internal fun EmptyLibrary(avoid: Rect?, fade: @Composable () -> Unit) {
internal fun EmptyLibrary(avoid: Rect?, wall: (@Composable () -> Unit)? = null, fade: @Composable () -> Unit) {
val clock = rememberMarkClock(avoid)
Box(Modifier.fillMaxSize().onSizeChanged { clock.page = Size(it.width.toFloat(), it.height.toFloat()) }) {
EmptyWall(clock)
if (wall != null) wall() else EmptyWall(clock)
fade()
Mark(clock)
}
@@ -6,6 +6,8 @@ import androidx.compose.ui.focus.FocusRequester
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -16,10 +18,14 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -30,15 +36,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PriorityHigh
@@ -65,6 +62,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.semantics.Role
@@ -449,42 +447,42 @@ private fun FrameGenMenu(s: FrontEndState, a: FrontEndActions, host: MenuHost) {
@Composable
internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost, game: com.droiddeck.launcher.frontend.Library.SteamGame? = null) {
val columns = if (LocalNarrowPane.current) 2 else 3
val controller = a.controller
val context = androidx.compose.ui.platform.LocalContext.current
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
val wincompKey = game?.takeIf { it.library == com.droiddeck.launcher.frontend.Library.ADDED || it.appId > 0 }?.profileKey
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
val rows = buildList<@Composable () -> Unit> {
add {
val cards = buildList<@Composable (Modifier) -> Unit> {
add { m ->
val back = stringResource(R.string.hop_from_games)
SettingLine(Icons.Outlined.Layers, stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", Modifier.hopSource("card:components")) {
SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) {
val at = Hops.sources["card:components"]
if (at == null) a.onComponents(true)
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) }
}
}
add {
Box(Modifier.fillMaxWidth()) {
SettingLine(Icons.Outlined.AutoAwesome, stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", menu = true) {
add { m ->
Box(m) {
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
host.open = if (host.open == "fg") null else "fg"
}
FrameGenMenu(s, a, host)
}
}
if (controller != null) add { SettingLine(Icons.Outlined.SportsEsports, stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", onClick = controller.onMapping) }
if (controller != null) add { m -> SettingCard(stringResource(R.string.setup_card_controls), stringResource(R.string.setup_card_controls_hint), "card:controls", m, controller.onMapping) }
val cardStore = com.droiddeck.launcher.stores.Store.byId(game?.source.orEmpty())?.takeIf { it == com.droiddeck.launcher.stores.Store.EPIC || it == com.droiddeck.launcher.stores.Store.GOG }
if (cardStore != null && game?.gameFiles != null) add {
Box(Modifier.fillMaxWidth()) {
if (cardStore != null && game?.gameFiles != null) add { m ->
Box(m) {
StoreLaunchCard(host, game.gameFiles!!, cardStore)
}
}
if (wincompKey != null) add {
SettingLine(
Icons.Outlined.Extension,
if (wincompKey != null) add { m ->
SettingCard(
stringResource(R.string.wincomp_title),
if (wincompPicks.isEmpty()) stringResource(R.string.wincomp_card_none) else wincompPicks.joinToString(", ") { com.droiddeck.launcher.session.WinComponentNames.of(it) },
"card:wincomp",
"card:wincomp", m,
) { wincompOpen = inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard }
}
}
@@ -494,14 +492,12 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
game = game,
) { wincompOpen = null }
val pal = LocalPalette.current
Column(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
.clip(Shape14).background(MaterialTheme.colorScheme.surface).border(1.dp, pal.line2, Shape14),
) {
rows.forEachIndexed { i, row ->
if (i > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
row()
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
for (row in cards.chunked(columns)) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
for (card in row) card(Modifier.weight(1f).fillMaxHeight())
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
@@ -535,7 +531,7 @@ private fun StoreLaunchCard(host: MenuHost, folder: java.io.File, store: com.dro
com.droiddeck.launcher.stores.StoresState.post { sidecar = written ?: com.droiddeck.launcher.stores.StoreGameSidecar.read(folder) }
}, "epic-options").start()
}
SettingLine(Icons.Outlined.Storefront, store.shortLabel, value, "card:$key", menu = true) {
SettingCard(store.shortLabel, value, "card:$key", Modifier.fillMaxSize()) {
host.open = if (host.open == key) null else key
}
AnchoredMenu(host.open == key, onDismiss = { if (host.open == key) host.open = null }, title = store.shortLabel) { first ->
@@ -565,35 +561,26 @@ private fun StoreLaunchCard(host: MenuHost, folder: java.io.File, store: com.dro
}
@Composable
private fun SettingLine(icon: ImageVector, label: String, value: String, id: String, modifier: Modifier = Modifier, menu: Boolean = false, onClick: () -> Unit) {
private fun SettingCard(label: String, value: String, id: String, modifier: Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = modifier.fillMaxWidth().heightIn(min = 58.dp).paneItem(id)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
.glideBorder(hot, RectangleShape, pal.signal, Color.Transparent)
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
Column(
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = modifier.paneItem(id)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, Shape14, pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Icon(icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
Text(label, fontSize = 15.sp, fontWeight = FontWeight.Medium, color = if (hot) pal.signal else colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.widthIn(max = 360.dp).clip(RoundedCornerShape(10.dp)).background(colors.surfaceVariant)
.border(1.dp, pal.line2, RoundedCornerShape(10.dp)).padding(start = 12.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
) {
Text(value, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
Icon(if (menu) Icons.Filled.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null,
tint = colors.onSurfaceVariant, modifier = Modifier.size(18.dp))
}
Text(label, fontSize = 13.sp, color = if (hot) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(value, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@@ -68,11 +68,15 @@ import coil.transform.RoundedCornersTransformation
import kotlin.math.roundToInt
private const val WALL_MIN_GAMES = 4
private const val WALL_DISTINCT = 40
private const val DRIFT_STEP_NS = 15_000_000L
@Composable
internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
val ctx = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(Unit) { com.droiddeck.launcher.frontend.SteamFeatured.load(ctx) }
val featured = com.droiddeck.launcher.frontend.SteamFeatured.games
val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending<Library.SteamGame> { it.lastPlayed }.thenBy { it.gameId }) }
val narrow = LocalNarrowPane.current
var words by remember { mutableStateOf<Rect?>(null) }
@@ -100,9 +104,12 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
background = {
when {
games.isEmpty() -> EmptyLibrary(avoid = words) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
games.isEmpty() -> EmptyLibrary(
avoid = words,
wall = if (featured.isEmpty()) null else { { CapsuleWall(emptyList(), featured = featured) } },
) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
else -> CapsuleWall(games)
else -> CapsuleWall(games, featured = featured)
}
},
scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() },
@@ -143,10 +150,21 @@ internal fun CapsuleWall(
games: List<Library.SteamGame>,
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
modifier: Modifier = Modifier,
featured: List<com.droiddeck.launcher.frontend.SteamFeatured.Game> = emptyList(),
) {
val pal = LocalPalette.current
val blank = remember(pal) { WallCapsule(Brush.linearGradient(listOf(pal.surface, pal.background)), edge = pal.line) }
TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) }
val padding = remember(games, featured) {
val owned = games.map { it.appId }.toSet()
featured.filter { it.appId !in owned }.take((WALL_DISTINCT - games.size).coerceAtLeast(0))
}
TiltedWall(games.size + padding.size, driftMs, modifier) { i ->
when {
i < games.size -> WallCapsule(artBrush(hueOf(games[i].name)), games[i].art)
i - games.size < padding.size -> padding[i - games.size].let { WallCapsule(artBrush(hueOf(it.name)), it.art) }
else -> blank
}
}
}
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null, val icon: Any? = null, val bar: Color? = null)
@@ -649,8 +649,14 @@ private fun SignInCard(store: Store, height: Dp) {
val c = sourceColours(store.id)
LaunchedEffect(store) { StoresState.preview(ctx, store) }
val art = remember(StoresState.shelves[store]) {
StoresState.shelves[store]?.all.orEmpty().filter { !it.mature }.mapNotNull { it.tallImageUrl ?: it.imageUrl }.distinct().take(48)
.takeIf { it.size >= 8 }.orEmpty()
val shelves = StoresState.shelves[store]
val urls = (shelves?.art?.takeIf { it.isNotEmpty() }
?: shelves?.all.orEmpty().filter { !it.mature && !it.owned }.mapNotNull { it.tallImageUrl ?: it.imageUrl }).distinct().take(48)
when {
urls.size >= 8 -> urls
urls.size >= 4 -> urls + urls.drop(urls.size / 2) + urls.take(urls.size / 2)
else -> emptyList()
}
}
val shape = RoundedCornerShape(20.dp)
val blank = WallCapsule(Brush.linearGradient(listOf(c.fill, colors.background)), edge = c.dot.copy(alpha = 0.22f))
+14
View File
@@ -2232,4 +2232,18 @@
<string name="stores_hltb_completionist">Tamamlayıcı</string>
<string name="stores_hltb_hours">%1$s sa</string>
<string name="stores_hltb_minutes">%1$d dk</string>
<string name="mode_zram">zRAM sıkıştırma</string>
<string name="mode_zram_hint">Bir oyun çalışırken Steam istemcisinin belleği cihazın zram\'ına sıkıştırılır ve oyuna daha fazla RAM kalır. Steam\'in bir sonraki başlatılışında uygulanır.</string>
<string name="mode_zram_unsupported">Kullanılamıyor: bu cihazda zram veya swap yok ya da çekirdeği 5.4\'ten eski.</string>
<string name="setup_reset_settings">Tüm ayarları sıfırla</string>
<string name="setup_reset_settings_title">Tüm ayarlar sıfırlansın mı?</string>
<string name="setup_reset_settings_body">Ekran, performans, denetleyici, tema, dil ve başlatıcı ayarları varsayılanlarına döner. Oyunlarınız, Steam oturum bilgileriniz, mağaza hesaplarınız, indirilen sürücüler ve depolama klasörleriniz korunur.</string>
<string name="setup_reset_settings_button">Sıfırla</string>
<string name="setup_reset_settings_hint">Her ayarı varsayılanına döndürür</string>
<string name="setup_reset_settings_partial">Ayarlar sıfırlandı, ancak bazı bölümler sıfırlanamadı. Ayrıntılar günlüklerde.</string>
<string name="setup_reset_settings_profiles">Oyun profillerini de sıfırla (her oyunun Proton, FEX, bileşenleri ve ortamı ile Varsayılan profilin ortamı)</string>
<string name="setup_reset_settings_session_running">Ayarları sıfırlamadan önce oturumu durdurun.</string>
<string name="stores_storage_insufficient">Yeterli boş alan yok: %1$s gerekli, yalnızca %2$s boş</string>
<string name="stores_storage_low">Yetersiz alan (%1$s boş, %2$s gerekli)</string>
<string name="stores_target_free_unknown">Boş alan bilinmiyor</string>
</resources>
+1 -1
View File
@@ -155,7 +155,7 @@ synchronous on the calling thread (network calls block: call them off the main t
| `build.info` | `accessToken`, `namespace`, `catalogItemId`, `appName` | `info`: the opaque build description (`manifest`, `buildVersion`, `deploymentId`, `cdns[]` of `base`/`dir`/`auth`) |
| `build.cdns` | `info` | `cdns[]` |
| `build.prereq` | `info` | `name`, `path`, `read` (downloads and parses the manifest) |
| `shop.shelves` | `country` | `new`, `deals`, `free`, `trending`: arrays in the `store_featured` item shape plus `allTags` |
| `shop.shelves` | `country` | `new`, `deals`, `free`, `trending`: arrays in the `store_featured` item shape plus `allTags`; `art`: cover URLs from every promotion, for the signed-out wall |
| `achievements` | `accessToken`, `namespace`, `accountId`, `locale` | `list[]` of `id`, `name`, `description`, `hidden`, `unlocked`, `unlockedAt` (s), `icon`, `iconLocked` |
| `cloud.list` | `accessToken`, `accountId`, `appName` | `files[]` of `name`, `modified` (ms, -1 unknown), `hash`, `readLink` |
| `cloud.writeLinks` | `accessToken`, `accountId`, `appName`, `names[]` | `links`: name → write link |
+39 -2
View File
@@ -232,6 +232,28 @@ fn unique(items: impl IntoIterator<Item = Value>) -> Vec<Value> {
items.into_iter().filter(|i| seen.insert(net::str_of(i, "id").to_string())).collect()
}
const MATURE_TAGS: [&str; 8] = ["mature", "nsfw", "adult", "adult only", "adults only", "nudity", "sexual content", "sexual-content"];
fn mature_tags(item: &Value) -> bool {
item["allTags"].as_array().is_some_and(|tags| {
tags.iter().filter_map(|t| t.as_str()).any(|t| MATURE_TAGS.contains(&t.trim().to_lowercase().as_str()))
})
}
pub fn wall_art(promotion_list: &[Value]) -> Vec<String> {
let mut seen = HashSet::new();
promotion_list
.iter()
.filter_map(item_of)
.filter(|i| !mature_tags(i))
.map(|i| {
let tall = net::str_of(&i, "tall");
if tall.is_empty() { net::str_of(&i, "image").to_string() } else { tall.to_string() }
})
.filter(|u| !u.is_empty() && seen.insert(u.clone()))
.collect()
}
pub fn shelves(country: &str) -> Value {
let country = country_code(country);
let now = now_stamp();
@@ -240,10 +262,12 @@ pub fn shelves(country: &str) -> Value {
};
let new = unique(shelf(Shelf::New));
let deals = unique(shelf(Shelf::Deals).into_iter().filter(|i| i["disc"].as_i64().unwrap_or(0) > 0));
let promos: Vec<Value> = promotions(&country).iter().filter_map(free_now).collect();
let promotion_list = promotions(&country);
let art = wall_art(&promotion_list);
let promos: Vec<Value> = promotion_list.iter().filter_map(free_now).collect();
let free = unique(promos.into_iter().chain(shelf(Shelf::Free)));
let trending = unique(shelf(Shelf::Trending));
json!({ "new": new, "deals": deals, "free": free, "trending": trending })
json!({ "new": new, "deals": deals, "free": free, "trending": trending, "art": art })
}
#[cfg(test)]
@@ -304,6 +328,19 @@ mod tests {
assert!(free_now(&el).is_none());
}
#[test]
fn wall_art_takes_every_promotion_once_and_skips_mature() {
let mut clean = element();
clean["tags"] = json!([{"name": "Action"}]);
let mut wide_only = clean.clone();
wide_only["id"] = json!("offer2");
wide_only["keyImages"] = json!([{"type": "OfferImageWide", "url": "w2"}]);
let mut mature = element();
mature["id"] = json!("offer3");
mature["keyImages"] = json!([{"type": "DieselStoreFrontTall", "url": "m"}]);
assert_eq!(wall_art(&[clean.clone(), clean, wide_only, mature]), vec!["tl", "w2"]);
}
#[test]
fn query_and_dates() {
assert_eq!(country_code("de"), "DE");
@@ -0,0 +1,66 @@
package com.droiddeck.launcher.frontend
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import java.io.File
import java.nio.file.Files
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], manifest = Config.NONE)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class GameShortcutsIconTest {
private lateinit var dir: File
@Before fun setUp() {
dir = Files.createTempDirectory("shortcut-icons").toFile()
}
@Test fun aSmallSteamIconGivesWayToTheMiddleOfTheCoverArt() {
val icon = image("icon.png", 32, 32, Color.GREEN)
val cover = image("cover.png", 300, 450, Color.BLUE, band = 75, bandColor = Color.RED)
val bitmap = GameShortcuts.iconBitmap(game(icon = icon, art = cover))!!
assertEquals(300, bitmap.width)
assertEquals(300, bitmap.height)
assertEquals(Color.BLUE, bitmap.getPixel(0, 0))
assertEquals(Color.BLUE, bitmap.getPixel(299, 299))
}
@Test fun anIconLargeEnoughForTheHomeScreenIsKept() {
val icon = image("icon.png", 256, 256, Color.GREEN)
val cover = image("cover.png", 300, 450, Color.BLUE)
val bitmap = GameShortcuts.iconBitmap(game(icon = icon, art = cover))!!
assertEquals(256, bitmap.width)
assertEquals(Color.GREEN, bitmap.getPixel(128, 128))
}
@Test fun theSmallIconIsStillUsedWhenThereIsNoArt() {
val icon = image("icon.png", 32, 32, Color.GREEN)
val bitmap = GameShortcuts.iconBitmap(game(icon = icon, art = null))!!
assertEquals(32, bitmap.width)
assertEquals(Color.GREEN, bitmap.getPixel(16, 16))
}
private fun game(icon: File?, art: File?) =
Library.SteamGame(appId = 17480, name = "Game", art = art, library = "test", icon = icon)
private fun image(name: String, width: Int, height: Int, color: Int, band: Int = 0, bandColor: Int = color): File {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(color)
if (band > 0) {
val paint = Paint().apply { this.color = bandColor }
canvas.drawRect(0f, 0f, width.toFloat(), band.toFloat(), paint)
canvas.drawRect(0f, (height - band).toFloat(), width.toFloat(), height.toFloat(), paint)
}
return File(dir, name).also { file -> file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } }
}
}
@@ -0,0 +1,60 @@
package com.droiddeck.launcher.input
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class SteamTouchConfigFilesTest {
private val context = RuntimeEnvironment.getApplication()
private val steam = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
private val dir = File(steam, "steamapps/common/Steam Controller Configs/123/config").apply { mkdirs() }
@After fun tearDown() { steam.deleteRecursively() }
private fun touch(title: String) = "\"controller_mappings\"\n{\n\t\"title\"\t\t\"$title\"\n\t\"controller_type\"\t\t\"controller_mobile_touch\"\n}\n"
private fun configset(name: String, vararg entries: Pair<String, String>) = File(dir, name).writeText(
"\"controller_config\"\n{\n" + entries.joinToString("") { (app, line) -> "\t\"$app\"\n\t{\n\t\t$line\n\t}\n" } + "}\n",
)
private fun autosave(app: Int, title: String) = File(dir, "$app/controller_mobile_touch.vdf").apply { parentFile!!.mkdirs(); writeText(touch(title)) }
private fun workshop(id: String, title: String) =
File(steam, "steamapps/workshop/content/241100/$id/123_legacy.bin").apply { parentFile!!.mkdirs(); writeText(touch(title)) }
@Test fun theDevicesWorkshopPickWinsOverAnOlderAutosave() {
autosave(204360, "old autosave")
val picked = workshop("1764689049", "official")
configset("configset_controller_mobile_touch.vdf", "204360" to "\"autosave\"\t\t\"1\"")
configset("configset_MT-DROIDDECK0001.vdf", "204360" to "\"workshop\"\t\t\"1764689049\"")
assertEquals(picked, SteamTouchConfig.load(context, 204360).file)
}
@Test fun aWorkshopPickInTheTypeConfigsetIsRead() {
val picked = workshop("42", "community")
configset("configset_controller_mobile_touch.vdf", "413150" to "\"workshop\"\t\t\"42\"")
assertEquals(picked, SteamTouchConfig.load(context, 413150).file)
}
@Test fun savingAnEditDropsTheDevicePickSoTheEditLoads() {
workshop("1764689049", "official")
configset("configset_MT-DROIDDECK0001.vdf", "204360" to "\"workshop\"\t\t\"1764689049\"", "8400" to "\"workshop\"\t\t\"7\"")
val config = SteamTouchConfig.load(context, 204360)
val saved = SteamTouchConfig.saveLayouts(context, 204360, config, mapOf(1 to emptyList()))
assertEquals(File(dir, "204360/controller_mobile_touch.vdf"), saved)
val device = File(dir, "configset_MT-DROIDDECK0001.vdf").readText()
assertFalse(device.contains("\"204360\""))
assertTrue(device.contains("\"8400\""))
assertEquals(saved, SteamTouchConfig.load(context, 204360).file)
}
}
@@ -41,6 +41,14 @@ class BatteryComponentTest {
assertTelemetry(supplies, vpower)
}
@Test fun vendorsReportingMilliampsGetTheSameEstimatesAsMicroamps() {
val supplies = tmp.newFolder("power_supply")
val vpower = tmp.newFolder("vpower")
start(supplies, vpower, -2_000L)
assertTelemetry(supplies, vpower)
assertEquals("-2000000", File(supplies, "BAT1/current_now").readText().trim())
}
private fun start(supplies: File, vpower: File, currentUa: Long) {
val context = RuntimeEnvironment.getApplication()
context.sendStickyBroadcast(Intent(Intent.ACTION_BATTERY_CHANGED).apply {
@@ -105,6 +105,7 @@ class SessionLogShareTest {
@Test fun aFileThatCouldNotBeScrubbedLeavesTheFolderUnrecorded() {
val folder = tmp.newFolder("2026-10-06-06-steam")
File(folder, "session.log").writeText("$secret\n")
org.junit.Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows"))
folder.setWritable(false)
try {
SessionArtifacts.scrubAndMark(folder)
+62 -14
View File
@@ -72,7 +72,33 @@ trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
cache_dir=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
mkdir -p "${cache_dir}"
# Steps whose output depends only on tracked sources are kept per input hash in the
# shared cache, so a rerun or a fresh worktree copies them instead of rebuilding.
inputs_hash() {
(cd "${repo_root}" && { printf '%s\n' "$1"; shift; find "$@" -type f ! -name '*.pyc' | LC_ALL=C sort | xargs shasum -a 256; }) \
| shasum -a 256 | cut -d' ' -f1
}
restore_step() {
local stored="${cache_dir}/steps/$1/$2"
[[ -f "${stored}/.complete" ]] || return 1
mkdir -p "$3"
(cd "${stored}" && tar -cf - --exclude .complete .) | tar -xf - -C "$3" || return 1
echo "$1: inputs unchanged, reused ${stored}"
}
save_step() {
local stored="${cache_dir}/steps/$1/$2" part
mkdir -p "${cache_dir}/steps/$1"
part=$(mktemp -d "${stored}.part.XXXXXX")
(cd "$3" && tar -cf - .) | tar -xf - -C "${part}"
touch "${part}/.complete"
# Another checkout may have published the same key meanwhile; its copy is equivalent.
if [[ -e "${stored}" ]]; then rm -rf "${part}"; else mv "${part}" "${stored}" || rm -rf "${part}"; fi
}
python3 "${repo_root}/tools/docker_preflight.py" "${image_name}"
image_id=$(docker image inspect --format '{{.Id}}' "${image_name}")
rm -rf -- "${linuxfs_dir}"
mkdir -p "${linuxfs_dir}"
@@ -118,17 +144,30 @@ docker run --rm --platform linux/amd64 \
READELF=aarch64-linux-gnu-readelf bash tools/linuxfs/check-preloads.sh "$d"
'
docker run --rm --platform linux/amd64 \
-v "${repo_root}:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
set -euo pipefail
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
apt-get -o Acquire::Check-Valid-Until=false update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
chown -R '"$(id -u):$(id -g)"' app/src/main/assets/linuxfs
'
x86_key=$(inputs_hash bullseye tools/linuxfs/build-x86-preloads.sh tools/linuxfs/preload tools/linuxfs/fex \
app/src/main/cpp/fakeinput_steam.cpp)
x86_stage="${staging_dir}/x86-preloads"
if restore_step x86-preloads "${x86_key}" "${linuxfs_dir}"; then :; else
docker run --rm --platform linux/amd64 \
-v "${repo_root}:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
set -euo pipefail
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
apt-get -o Acquire::Check-Valid-Until=false update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
chown -R '"$(id -u):$(id -g)"' app/src/main/assets/linuxfs
'
mkdir -p "${x86_stage}"
cp -R "${linuxfs_dir}/x86_64" "${linuxfs_dir}/i386" "${x86_stage}/"
save_step x86-preloads "${x86_key}" "${x86_stage}"
fi
DROIDDECK_BUILD_IMAGE="${image_name}" "${repo_root}/tools/vr/build.sh" "${linuxfs_dir}/usr/local/lib/droiddeck-vr"
vr_out="${linuxfs_dir}/usr/local/lib/droiddeck-vr"
vr_key=$(inputs_hash "${image_id} ${ndk_version} ${VK_INCLUDE:-}" tools/vr app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c)
if ! restore_step vr "${vr_key}" "${vr_out}"; then
DROIDDECK_BUILD_IMAGE="${image_name}" "${repo_root}/tools/vr/build.sh" "${vr_out}"
save_step vr "${vr_key}" "${vr_out}"
fi
github_repo=${DROIDDECK_GITHUB_REPOSITORY:-}
if [[ -z "${github_repo}" ]]; then
@@ -146,8 +185,6 @@ if [[ -z "${github_repo}" ]]; then
github_repo=${github_repo%.git}
fi
cache_dir=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
mkdir -p "${cache_dir}"
cached() {
local sha=$1 name=$2 out
shift 2
@@ -268,7 +305,12 @@ if [[ -f "${proot_out}/libproot.so" && -f "${proot_out}/libproot-loader.so" \
&& (cd "${proot_out}" && shasum -a 256 -c .proot-outputs >/dev/null 2>&1); then
echo "proot: sources unchanged, keeping ${proot_out}/libproot.so"
else
"${repo_root}/tools/proot/build.sh" "${proot_out}"
proot_stage="${staging_dir}/proot"
if ! restore_step proot "${proot_inputs}" "${proot_stage}"; then
"${repo_root}/tools/proot/build.sh" "${proot_stage}"
save_step proot "${proot_inputs}" "${proot_stage}"
fi
install -m755 "${proot_stage}/libproot.so" "${proot_stage}/libproot-loader.so" "${proot_out}/"
echo "${proot_inputs}" > "${proot_out}/.proot-inputs"
(cd "${proot_out}" && shasum -a 256 libproot.so libproot-loader.so > .proot-outputs)
fi
@@ -301,6 +343,7 @@ else
fi
done
rustup target add --toolchain stable aarch64-linux-android
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${cache_dir}/stores-target}"
if ! command -v cargo-ndk >/dev/null 2>&1; then
cargo install cargo-ndk --locked
fi
@@ -308,7 +351,12 @@ fi
cd "${repo_root}"
python3 tools/quest/build-driver.py "${NDK}"
bash tools/comet/build-local.sh
comet_out="${linuxfs_dir}/usr/local/lib/droiddeck-comet"
comet_key=$(inputs_hash "${image_id}" tools/comet)
if ! restore_step comet "${comet_key}" "${comet_out}"; then
DROIDDECK_BUILD_IMAGE="${image_name}" bash tools/comet/build-local.sh
save_step comet "${comet_key}" "${comet_out}"
fi
python3 tools/release/runtime_inputs.py record
./gradlew "${gradle_task}" --console=plain -PndkVersion="${ndk_version}" ${gradle_rust_arg}
python3 tools/release/check_session_assets.py "app/build/outputs/apk/${build_variant}/app-${build_variant}.apk"
+11 -4
View File
@@ -9,11 +9,18 @@ done
work=$(mktemp -d)
trap 'rm -rf "${work}"' EXIT
cache=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
mkdir -p "${cache}"
fetch() {
local name=$1 sha=$2
curl -fsSL --retry 3 -o "${work}/${name}" \
"https://github.com/${DIRECTAUDIO_REPO}/releases/download/${DIRECTAUDIO_TAG}/${name}"
echo "${sha} ${work}/${name}" | sha256sum -c - >/dev/null || { echo "${name}: checksum mismatch" >&2; exit 1; }
local name=$1 sha=$2 kept="${cache}/$2-$1" part
if ! echo "${sha} ${kept}" | sha256sum -c - >/dev/null 2>&1; then
part=$(mktemp "${kept}.part.XXXXXX")
curl -fsSL --retry 3 -o "${part}" \
"https://github.com/${DIRECTAUDIO_REPO}/releases/download/${DIRECTAUDIO_TAG}/${name}"
echo "${sha} ${part}" | sha256sum -c - >/dev/null || { echo "${name}: checksum mismatch" >&2; rm -f "${part}"; exit 1; }
mv "${part}" "${kept}"
fi
cp "${kept}" "${work}/${name}"
}
fetch directaudio-linux-wine11.zip "${DIRECTAUDIO_WINE11_SHA256}"
fetch directaudio-linux-wine11-systhread.zip "${DIRECTAUDIO_SYSTHREAD_SHA256}"
@@ -98,6 +98,14 @@ def godot_sentry(game):
return {"WINEDLLOVERRIDES": ";".join(n + "=d" for n in sentry)}, [], "Godot: Sentry crash handler off"
@fix
def arkham_city_x87(game):
"""Batman: Arkham City (BatmanAC.exe, also started through BmLauncher.exe beside it) leans on x87
and stops with Unreal's "Fatal error!" right after start when FEX reduces its precision."""
if "batmanac.exe" in game.files:
return {"FEX_X87REDUCEDPRECISION": "0"}, [], "Batman: Arkham City: FEX with full x87 precision"
def skip_red_launcher(command):
"""Cyberpunk 2077's prelauncher opens REDlauncher, a Qt WebEngine window that hands over to the
game half a minute later and then stays up beside it, well over a core busy for as long as the
@@ -139,6 +139,51 @@ unseed() {
return 1
}
# The ledger lines whose values the registry still holds, in both views where we wrote both. A Proton
# update replaces its default_pfx system.reg but not our ledger beside it, and new prefixes copy both.
present() { awk -F'\t' '
NR == FNR {
path = $1
if (path != "vcrt") {
sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path)
if (path == $1)
next
}
n++
line[n] = $0
need[n] = 0
if (path == "vcrt") {
for (a = split("x86 x64 arm64", arch, " "); a > 0; a--) {
runtime = "microsoft\\\\visualstudio\\\\14.0\\\\vc\\\\runtimes\\\\" arch[a]
key[n, ++need[n]] = "[software\\\\" runtime "]" SUBSEP "installed"
key[n, ++need[n]] = "[software\\\\wow6432node\\\\" runtime "]" SUBSEP "installed"
}
} else {
both = substr(path, 1, 10) == "Software\\\\"
path = tolower(path)
key[n, ++need[n]] = "[" path "]" SUBSEP tolower($2)
if (both)
key[n, ++need[n]] = "[software\\\\wow6432node\\\\" substr(path, 11) "]" SUBSEP tolower($2)
}
for (j = 1; j <= need[n]; j++) wanted[key[n, j]] = 1
next
}
/^\[/ { section = tolower(substr($0, 1, index($0, "]"))) }
/^"/ {
value = substr($0, 2)
value = tolower(substr(value, 1, index(value, "\"=") - 1))
if ((section SUBSEP value) in wanted)
seen[section SUBSEP value] = 1
}
END {
for (i = 1; i <= n; i++) {
ok = 1
for (j = 1; j <= need[i]; j++) if (!(key[i, j] in seen)) ok = 0
if (ok) print line[i]
}
}
' - "$1"; }
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
/mnt/droiddeck-sd/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
/root/Games/*/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
@@ -166,6 +211,17 @@ for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/syste
printf '%s\n' "${kept[@]}" >"$seeded"
echo "== took Vulkan runtime markers out of $reg"
fi
if [ ${#had[@]} -gt 0 ]; then
declare -A held=()
while IFS= read -r line; do [[ $line == *$'\t'* ]] && held[$line]=1; done < <(printf '%s\n' "${!had[@]}" | present "$reg")
if [ ${#held[@]} -lt ${#had[@]} ]; then
echo "== $((${#had[@]} - ${#held[@]})) recorded redist markers are gone from $reg"
had=()
for line in "${!held[@]}"; do had[$line]=1; done
printf '%s\n' "${!had[@]}" >"$seeded"
fi
unset held
fi
missing=()
for line in "${want[@]}"; do [ -n "${had[$line]:-}" ] || missing+=("$line"); done
[ ${#missing[@]} -eq 0 ] && continue
+2 -1
View File
@@ -256,9 +256,10 @@ static void install(struct listing *l) {
int slot = find(l->dev, l->ino);
if (slot >= 0) {
struct listing *old = table[slot];
int old_wd = old->wd;
table[slot] = l;
detach(old);
if (old->wd != l->wd) release(old->wd);
if (old_wd != l->wd) release(old_wd);
return;
}
int victim = -1;
+2 -2
View File
@@ -37,7 +37,7 @@ def sources(root):
for name in SOURCE_PATHS:
path = root / name
files.extend(path.rglob('*') if path.is_dir() else [path])
return {str(path.relative_to(root)): digest(path) for path in sorted(set(files))
return {path.relative_to(root).as_posix(): digest(path) for path in sorted(set(files))
if path.is_file() and '__pycache__' not in path.parts and path.suffix != '.pyc'}
@@ -47,7 +47,7 @@ def outputs(root):
base = root / 'app/src/main/assets' / tree
for path in base.rglob('*'):
if path.is_file() and not any(part.startswith('.') for part in path.relative_to(base).parts):
result['assets/' + tree + '/' + str(path.relative_to(base))] = str(path.relative_to(root))
result['assets/' + tree + '/' + path.relative_to(base).as_posix()] = path.relative_to(root).as_posix()
for library in ['libproot.so', 'libproot-loader.so', 'libdirectaudiorelay.so']:
result['lib/arm64-v8a/' + library] = 'app/src/main/jniLibs/arm64-v8a/' + library
result['assets/pulseaudio.tzst'] = 'app/build/prepared-assets/pulseaudio.tzst'
+19
View File
@@ -173,6 +173,25 @@ class GameEnvironmentTest(unittest.TestCase):
(other / "Other.exe").touch()
self.assertEqual(MODULE["engine_fixes"](str(other / "Other.exe"), []), ({}, [], []))
def test_arkham_city_gets_full_x87_precision_over_the_preset(self):
with tempfile.TemporaryDirectory() as tmp:
city = Path(tmp) / "Batman Arkham City GOTY/Binaries/Win32"
city.mkdir(parents=True)
for name in ("BatmanAC.exe", "BmLauncher.exe"):
(city / name).touch()
for exe in ("BatmanAC.exe", "BmLauncher.exe"):
env, extra, notes = MODULE["engine_fixes"](str(city / exe), [])
self.assertEqual((env, extra), ({"FEX_X87REDUCEDPRECISION": "0"}, []))
config = {"version": 1, "shared": {"FEX_X87REDUCEDPRECISION": "1"}}
self.assertEqual(MODULE["apply_config"]({}, config, "200260", env)["FEX_X87REDUCEDPRECISION"], "0")
config["games"] = {"200260": {"FEX_X87REDUCEDPRECISION": "1"}}
self.assertEqual(MODULE["apply_config"]({}, config, "200260", env)["FEX_X87REDUCEDPRECISION"], "1")
asylum = Path(tmp) / "Batman Arkham Asylum GOTY/Binaries"
asylum.mkdir(parents=True)
for name in ("ShippingPC-BmGame.exe", "BmLauncher.exe"):
(asylum / name).touch()
self.assertEqual(MODULE["engine_fixes"](str(asylum / "BmLauncher.exe"), []), ({}, [], []))
def test_a_new_fix_only_needs_registering(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / "Game.exe").touch()
+47
View File
@@ -100,6 +100,53 @@ class SeedRedistsTest(unittest.TestCase):
self.run_seed()
self.assertIn('"VCRedist2022"=dword:00000001', self.reg.read_text())
def test_markers_a_replaced_registry_lost_are_seeded_again(self):
self.game()
stock = self.reg.read_text()
self.run_seed()
ledger = (self.reg.parent / ".droiddeck-redists").read_text()
self.assertIn("VCRedist2022", ledger)
self.reg.write_text(stock)
self.run_seed()
reg = self.reg.read_text()
self.assertEqual(reg.count('"VCRedist2022"=dword:00000001'), 2, "both views again")
self.assertIn("[Software\\\\Microsoft\\\\VisualStudio\\\\14.0\\\\VC\\\\Runtimes\\\\x86]", reg)
self.assertEqual(sorted((self.reg.parent / ".droiddeck-redists").read_text().splitlines()), sorted(ledger.splitlines()))
self.run_seed()
self.assertEqual(reg, self.reg.read_text())
def test_a_marker_kept_in_only_one_view_is_seeded_again(self):
self.game()
self.run_seed()
wow = "[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2022]"
blocks = self.reg.read_text().split("\n\n")
self.reg.write_text("\n\n".join(b for b in blocks if not (b.startswith(wow) and '"VCRedist2022"' in b)))
self.assertEqual(self.reg.read_text().count('"VCRedist2022"'), 1)
self.run_seed()
reg = self.reg.read_text()
self.assertEqual(reg.count('"VCRedist2022"'), 3, "the one left plus both views again")
self.assertTrue(any(b.startswith(wow) and '"VCRedist2022"' in b for b in reg.split("\n\n")))
def test_a_vc_runtime_another_architecture_lost_is_seeded_again(self):
self.run_seed()
arm64 = "[Software\\\\Wow6432Node\\\\Microsoft\\\\VisualStudio\\\\14.0\\\\VC\\\\Runtimes\\\\arm64]"
blocks = self.reg.read_text().split("\n\n")
self.reg.write_text("\n\n".join(b for b in blocks if not b.startswith(arm64)))
self.assertNotIn(arm64, self.reg.read_text())
self.run_seed()
reg = self.reg.read_text()
self.assertIn(arm64, reg)
self.assertEqual(reg.count("[Software\\\\Microsoft\\\\VisualStudio\\\\14.0\\\\VC\\\\Runtimes\\\\x86]"), 2)
def test_a_marker_written_in_one_view_only_is_kept_as_it_is(self):
self.game(script=GAME_SCRIPT.replace("Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2022", "SOFTWARE\\\\AGEIA Technologies").replace("VCRedist2022", "PhysX Version"))
self.run_seed()
once = self.reg.read_text()
self.assertEqual(once.count('"PhysX Version"=dword:00000001'), 1)
result = self.run_seed()
self.assertEqual(once, self.reg.read_text())
self.assertNotIn("gone", result.stdout)
def test_an_evaluator_script_nested_deeper_is_read(self):
script = self.tmp / "legacycompat/evaluatorscript_409710.vdf"
script.parent.mkdir(parents=True)