Compare commits

..
Author SHA1 Message Date
xXJsonDeruloXx 67bf8f1568 feat: add opt-in offline launch for Easy Anti-Cheat games 2026-10-17 17:44:36 -04:00
10 changed files with 98 additions and 222 deletions

No files matched your search

-9
View File
@@ -151,15 +151,6 @@
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".ui.SecondScreenActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@style/SessionTheme"
android:screenOrientation="sensorLandscape"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".gpu.SteamVrRestartActivity"
android:exported="false"
@@ -107,7 +107,7 @@ import com.droiddeck.launcher.ui.HudText
import com.droiddeck.launcher.ui.LoadingOverlay
import com.droiddeck.launcher.ui.SessionDrawer
import com.droiddeck.launcher.ui.SessionPausedOverlay
import com.droiddeck.launcher.ui.SecondScreenWindow
import com.droiddeck.launcher.ui.SecondScreenPresentation
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
@@ -343,7 +343,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
private var started = false
private lateinit var displayManager: DisplayManager
private var secondScreenPresentation: SecondScreenWindow? = null
private var secondScreenPresentation: SecondScreenPresentation? = null
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
@@ -2118,7 +2118,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
var presentation = secondScreenPresentation
if (presentation?.display?.displayId != target.displayId) {
presentation?.closeControls()
presentation = SecondScreenWindow(
presentation = SecondScreenPresentation(
this, target,
sendPointer = ::moveSecondScreenPointer,
sendButton = { button, pressed ->
@@ -32,6 +32,7 @@ object GameEnvironmentOptions {
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
@@ -1425,7 +1425,6 @@ class SessionService : Service() {
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (rootIntent?.component?.className == com.droiddeck.launcher.ui.SecondScreenActivity::class.java.name) return
if (pipTask || xrActivityVisible) {
Log.i(TAG, "panel task dismissed - retaining the session under its active surface policy")
activityVisible = false
@@ -1,17 +1,21 @@
package com.droiddeck.launcher.ui
import com.droiddeck.launcher.R
import android.app.Presentation
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.Display
import android.view.Gravity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewConfiguration
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.FrameLayout
@@ -33,9 +37,9 @@ import com.termux.terminal.TerminalSessionClient
import com.termux.view.TerminalView
import com.termux.view.TerminalViewClient
internal class SecondScreenControls(
context: Context,
class SecondScreenPresentation(
private val sessionActivity: SessionActivity,
display: Display,
private val sendPointer: (x: Float, y: Float, width: Float, height: Float) -> Unit,
private val sendButton: (button: Int, pressed: Boolean) -> Unit,
private val sendWheel: (steps: Int) -> Unit,
@@ -43,8 +47,12 @@ internal class SecondScreenControls(
private val onQam: () -> Unit,
private val onClose: () -> Unit,
private val pad: PadBridge? = null,
) : LinearLayout(context) {
private val root get() = this
) : Presentation(sessionActivity, display) {
private val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.rgb(15, 18, 22))
isFocusableInTouchMode = true
}
private var currentMode = SecondScreenMode.NONE
private var keyboardHost: KeyboardHost? = null
private var terminalSession: TerminalSession? = null
@@ -52,22 +60,35 @@ internal class SecondScreenControls(
private var terminalPid = -1
private var ctrlActive = false
private var altActive = false
private var created = false
init {
orientation = VERTICAL
setBackgroundColor(Color.rgb(15, 18, 22))
isFocusableInTouchMode = true
setCancelable(false)
setCanceledOnTouchOutside(false)
setOnDismissListener { closeTerminal() }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
created = true
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
setContentView(root)
applyImmersiveMode()
renderMode()
}
fun showMode(mode: SecondScreenMode) {
if (currentMode == mode) return
if (currentMode == mode && isShowing) return
currentMode = mode
renderMode()
if (created) renderMode()
}
fun closeControls() {
closeTerminal()
keyboardHost?.hide()
keyboardHost = null
dismiss()
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -77,6 +98,14 @@ internal class SecondScreenControls(
return super.dispatchKeyEvent(event)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
applyImmersiveMode()
sessionActivity.refreshClipboard()
}
}
private fun renderMode() {
keyboardHost?.hide()
keyboardHost = null
@@ -262,6 +291,16 @@ internal class SecondScreenControls(
private fun weightParams() = LinearLayout.LayoutParams(-1, 0, 1f)
private fun applyImmersiveMode() {
@Suppress("DEPRECATION")
window?.decorView?.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
}
private inner class TrackpadView(context: Context) : View(context) {
private val gestures = TouchpadGestures(ViewConfiguration.get(context).scaledTouchSlop.toFloat(),
object : PointerGestures.Listener {
@@ -1,139 +0,0 @@
package com.droiddeck.launcher.ui
import android.app.Activity
import android.app.ActivityOptions
import android.content.Intent
import android.os.Bundle
import android.view.Display
import android.view.View
import android.view.WindowManager
import com.droiddeck.launcher.SessionActivity
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.session.SessionState
import java.lang.ref.WeakReference
import java.util.concurrent.atomic.AtomicLong
class SecondScreenWindow(
private val sessionActivity: SessionActivity,
val display: Display,
private val sendPointer: (Float, Float, Float, Float) -> Unit,
private val sendButton: (Int, Boolean) -> Unit,
private val sendWheel: (Int) -> Unit,
private val onSteamMenu: () -> Unit,
private val onQam: () -> Unit,
private val onClose: () -> Unit,
private val pad: PadBridge? = null,
) {
private val token = tokens.incrementAndGet()
private var activity = WeakReference<SecondScreenActivity>(null)
private var controls: SecondScreenControls? = null
private var mode = SecondScreenMode.NONE
private var launching = false
private var closed = false
val window get() = activity.get()?.window
val isShowing get() = !closed && (launching || activity.get()?.isFinishing == false)
fun show() {
if (isShowing || closed) return
owners[token] = WeakReference(this)
launching = true
try {
val intent = Intent(sessionActivity, SecondScreenActivity::class.java)
.putExtra(OWNER, token)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
sessionActivity.startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(display.displayId).toBundle())
} catch (error: Exception) {
launching = false
owners.remove(token)
throw error
}
}
fun showMode(mode: SecondScreenMode) {
this.mode = mode
controls?.showMode(mode)
}
fun closeControls() {
closed = true
launching = false
owners.remove(token)
controls?.closeControls()
controls = null
activity.get()?.finishAndRemoveTask()
activity.clear()
}
internal fun attach(host: SecondScreenActivity): View {
controls?.closeControls()
activity = WeakReference(host)
launching = false
return SecondScreenControls(host, sessionActivity, sendPointer, sendButton, sendWheel,
onSteamMenu, onQam, onClose, pad).also {
controls = it
it.showMode(mode)
}
}
internal fun detach(host: SecondScreenActivity) {
if (activity.get() !== host) return
controls?.closeControls()
controls = null
activity.clear()
if (host.isFinishing && !closed) onClose()
}
internal fun refreshClipboard() = sessionActivity.refreshClipboard()
companion object {
private const val OWNER = "secondScreenOwner"
private val tokens = AtomicLong()
private val owners = mutableMapOf<Long, WeakReference<SecondScreenWindow>>()
internal fun find(intent: Intent): SecondScreenWindow? =
owners[intent.getLongExtra(OWNER, 0)]?.get()?.takeUnless { it.closed }
}
}
class SecondScreenActivity : Activity() {
private var owner: SecondScreenWindow? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val host = SecondScreenWindow.find(intent)
if (host == null || !SessionState.running) {
finishAndRemoveTask()
return
}
owner = host
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
setContentView(host.attach(this))
applyImmersiveMode()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
applyImmersiveMode()
owner?.refreshClipboard()
}
}
@Deprecated("Back is handled by the session controls")
override fun onBackPressed() = Unit
override fun onDestroy() {
owner?.detach(this)
owner = null
super.onDestroy()
}
private fun applyImmersiveMode() {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
}
}
+1
View File
@@ -86,6 +86,7 @@
<string name="game_env_no_fsync">Set to 1 to keep this game off droiddeck-fsync; it uses droiddeck-esync where a pack exists, otherwise droiddeck-ntsync.</string>
<string name="game_env_fsync_first">Set to 1 to run this game on droiddeck-fsync, or 0 to put droiddeck-esync first where a pack fits its Proton.</string>
<string name="game_env_xalia">Set to 0 to disable Proton’s gamepad-navigation helper for this game.</string>
<string name="game_env_anticheat_offline">Set to 1 to start an Easy Anti-Cheat game without its anti-cheat. The game must support offline play; online modes that require Easy Anti-Cheat remain unavailable.</string>
<string name="game_env_hud">DXVK overlay for D3D9–11. fps displays frame rate; unset hides it.</string>
<string name="game_env_dxvk_config">DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play.</string>
<string name="game_env_vkd3d_limit">D3D12 frame-rate limit. 0 disables the limit.</string>
@@ -1,61 +0,0 @@
package com.droiddeck.launcher.ui
import android.app.Application
import android.content.Intent
import android.hardware.display.DisplayManager
import android.view.Display
import com.droiddeck.launcher.SessionActivity
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class, manifest = Config.NONE, sdk = [28, 33])
@LooperMode(LooperMode.Mode.PAUSED)
class SecondScreenActivityTest {
@Before fun setUp() {
SessionState.running = true
SessionState.stopRequested = false
}
@After fun tearDown() {
SessionState.running = false
SessionState.stopRequested = false
}
@Test fun removingControlsTaskDoesNotStopTheRunningSession() {
val service = Robolectric.buildService(SessionService::class.java).get()
service.onTaskRemoved(Intent(service, SecondScreenActivity::class.java))
assertTrue(SessionState.running)
assertFalse(SessionState.stopRequested)
}
@Test fun activityRestoredWithoutItsSessionOwnerFinishes() {
val activity = Robolectric.buildActivity(SecondScreenActivity::class.java).create().get()
assertTrue(activity.isFinishing)
assertTrue(SessionState.running)
}
@Test fun closingBeforeTheSecondaryActivityStartsRejectsTheLateLaunch() {
val session = Robolectric.buildActivity(SessionActivity::class.java).get()
val display = session.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY)
val controls = SecondScreenWindow(session, display, { _, _, _, _ -> }, { _, _ -> }, {}, {}, {}, {})
controls.show()
val intent = shadowOf(session).nextStartedActivity
controls.closeControls()
val activity = Robolectric.buildActivity(SecondScreenActivity::class.java, intent).create().get()
assertTrue(activity.isFinishing)
assertFalse(controls.isShowing)
assertTrue(SessionState.running)
}
}
@@ -119,6 +119,28 @@ def skip_red_launcher(command):
return command
def offline_without_anticheat(command, env):
"""Start the configured game executable when the player opts into launching without EAC."""
if env.get("DROIDDECK_ANTICHEAT_OFFLINE") != "1" or len(command) <= 2:
return command
folder = os.path.dirname(command[2])
try:
with open(os.path.join(folder, "EasyAntiCheat", "settings.json"), encoding="utf-8-sig") as source:
executable = json.load(source).get("executable")
except (OSError, ValueError, AttributeError):
return command
if not isinstance(executable, str) or not executable:
return command
relative = os.path.normpath(executable.replace("\\", "/"))
game = os.path.join(folder, relative)
if os.path.isabs(relative) or relative.startswith("..") or not os.path.isfile(game):
return command
if os.path.normcase(os.path.abspath(game)) == os.path.normcase(os.path.abspath(command[2])):
return command
print("droiddeck-game-env: started without Easy Anti-Cheat (offline only)", file=sys.stderr)
return command[:2] + [game] + command[3:]
def engine_fixes(exe, args):
"""Environment and extra arguments a game needs because of what it is built with."""
game = Game(exe, args)
@@ -307,6 +329,8 @@ def main():
env = apply_config(env, {"version": 1}, app_id, fixes)
except (OSError, ValueError, TypeError, AttributeError):
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
if app_id and len(command) > 1 and command[1] == "waitforexitandrun":
command = offline_without_anticheat(command, env)
if mic_enabled == "0":
env["BANNER_AUDIO_DIRECT_MIC"] = "0"
env = force_ssbs(command, env)
+21
View File
@@ -179,6 +179,27 @@ class GameEnvironmentTest(unittest.TestCase):
(other / "Other.exe").touch()
self.assertEqual(MODULE["engine_fixes"](str(other / "Other.exe"), []), ({}, [], []))
def test_easy_anti_cheat_games_start_offline_only_when_opted_in(self):
with tempfile.TemporaryDirectory() as tmp:
game = Path(tmp) / "ELDEN RING/Game"
(game / "EasyAntiCheat").mkdir(parents=True)
for name in ("start_protected_game.exe", "eldenring.exe"):
(game / name).touch()
(game / "EasyAntiCheat/settings.json").write_text('\ufeff{ "executable" : "eldenring.exe" }', encoding="utf-8")
command = ["proton", "waitforexitandrun", str(game / "start_protected_game.exe"), "-arg"]
offline = MODULE["offline_without_anticheat"]
self.assertEqual(offline(command, {}), command)
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "0"}), command)
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}),
["proton", "waitforexitandrun", str(game / "eldenring.exe"), "-arg"])
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "../../escape.exe" }')
(Path(tmp) / "escape.exe").touch()
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), command)
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "missing.exe" }')
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), command)
plain = ["proton", "waitforexitandrun", str(Path(tmp) / "Other.exe")]
self.assertEqual(offline(plain, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), plain)
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"