feat(ubisoft): Ubisoft Connect store with native sign-in, library, downloads and launch (#2322)

* feat(ubisoft): sign in to Ubisoft Connect and list the owned library

* feat(ubisoft): download games natively through Demux and Ubisoft's CDN

* fix(ubisoft): show the owned games on the store page

* fix(ctl): reach the agent bridge by broadcast when the shell has no content command

* feat(ubisoft): run Ubisoft games in one prefix with Ubisoft Connect installed and the game registered

* feat(ubisoft): launch games through a native stand-in for Ubisoft Connect's local service

* feat(ubisoft): document the launcher pipes, log why owned games have no download, drop the installer after extraction

* feat(ubisoft): Steam-owned Ubisoft games install through Steam, and the launch session carries the game's application id

* feat(ubisoft): answer Denuvo activations through Ubisoft's activation service and log unanswered pipe traffic

* feat(ubisoft): report installed chunks to the game and skip add-ons the account does not own

* fix(ubisoft): the overlay stays off, Ubisoft's install steps pick Windows components, and shared-prefix games keep their own settings

* fix(ubisoft): write the manifest's registry values, and pick the game's exe over Ubisoft's installer stubs

* fix(ubisoft): stream download slices, guard the launch hook, and keep per-game components in the shared prefix

* fix(ubisoft): direct launches in the shared prefix keep their own game id

* ci(ubisoft): build the launcher service after the prepared components are verified

* fix(ubisoft): verify reused files, drop syncs from a signed-out account, and clear activation requests before launch

* fix(ubisoft): fetch again a reused file whose slices cannot be checked
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-11-10 15:08:37 -05:00
1 parent d8ed02f2cf
commit 3c96df4ab1
49 files changed
+3960 -24

No files matched your search

+9
View File
@@ -555,6 +555,12 @@ jobs:
SOURCE_TREE: ${{ needs.source.outputs.tree-sha }} SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
run: python3 tools/components/prepare.py unpack --tree "$SOURCE_TREE" run: python3 tools/components/prepare.py unpack --tree "$SOURCE_TREE"
- name: Ubisoft launcher service (x86_64-pc-windows-gnu)
run: |
set -euo pipefail
command -v x86_64-w64-mingw32-gcc >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq gcc-mingw-w64-x86-64; }
bash tools/ubisoft-launcher/build.sh
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
name: xbox-arm64 name: xbox-arm64
@@ -791,6 +797,9 @@ jobs:
set -o pipefail set -o pipefail
cd app/src/main/rust/stores cd app/src/main/rust/stores
cargo test --locked 2>&1 | tail -60 cargo test --locked 2>&1 | tail -60
- name: Ubisoft launcher service host unit tests
timeout-minutes: 10
run: cargo test --locked --manifest-path tools/ubisoft-launcher/Cargo.toml
python-tests: python-tests:
name: Python and native regression tests name: Python and native regression tests
@@ -61,6 +61,7 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
com.droiddeck.launcher.sync.Syncthing.init(this) com.droiddeck.launcher.sync.Syncthing.init(this)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ubisoft.UbisoftBackend)
com.droiddeck.launcher.stores.ea.EaBackend.app = this com.droiddeck.launcher.stores.ea.EaBackend.app = this
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.ea.EaBackend)
} }
@@ -417,7 +417,7 @@ object AddedGames {
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null, reuse: Boolean = false) { private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null, reuse: Boolean = false) {
run { run {
val sidecar = StoreGameSidecar.read(folder) val sidecar = StoreGameSidecar.read(folder)?.let { com.droiddeck.launcher.stores.StoreLaunch.adopt(folder, it) }
sidecar?.takeIf { it.id.isNotEmpty() }?.let { SessionPrefs.followStoreGame(context, "${it.store.id}:${it.id}", folder.path) { old -> !File(old).exists() } } sidecar?.takeIf { it.id.isNotEmpty() }?.let { SessionPrefs.followStoreGame(context, "${it.store.id}:${it.id}", folder.path) { old -> !File(old).exists() } }
val ranking = rank(folder, reuse) val ranking = rank(folder, reuse)
val candidates = ranking.exes val candidates = ranking.exes
@@ -457,7 +457,11 @@ object AddedGames {
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId, steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId,
source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id, source = sidecar?.store?.id ?: Library.ADDED, storeId = sidecar?.id,
exeUncertain = sidecar == null && chosen == null && adopted == null && ranking.uncertain, exeUncertain = sidecar == null && chosen == null && adopted == null && ranking.uncertain,
launchOptions = if (sidecar == null) SessionPrefs.addedGameLaunch(context, folder.path) else "", launchOptions = when {
sidecar == null -> SessionPrefs.addedGameLaunch(context, folder.path)
sidecar.store == Store.UBISOFT -> com.droiddeck.launcher.stores.ubisoft.UbisoftConnect.launchOptions(context)
else -> ""
},
single = single != null)) single = single != null))
} }
} }
@@ -9,7 +9,7 @@ import java.io.File
import java.util.Locale import java.util.Locale
object WinCompSources { object WinCompSources {
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER, XBOX } enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER, XBOX, UBISOFT }
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList()) data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList())
@@ -83,6 +83,13 @@ object WinCompSources {
} }
} }
/** The prerequisite installers a Ubisoft manifest lists, with `$INSTALL_DIR` dropped. */
internal fun ubisoftInstallRuns(setup: String?): List<String> {
val runs = setup?.let { runCatching { org.json.JSONObject(it).optJSONArray("installRuns") }.getOrNull() } ?: return emptyList()
return (0 until runs.length()).mapNotNull { runs.optJSONObject(it)?.optString("exe") }
.map { it.replace('\\', '/').removePrefix("\$INSTALL_DIR").trimStart('/') }.filter { it.isNotEmpty() }
}
fun forGame(context: Context, game: Library.SteamGame): List<Finding> { fun forGame(context: Context, game: Library.SteamGame): List<Finding> {
val folder = game.gameFiles val folder = game.gameFiles
val out = ArrayList<Finding>() val out = ArrayList<Finding>()
@@ -112,6 +119,12 @@ object WinCompSources {
if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false) if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false)
parts.forEach { out += Finding(it, original, Origin.AMAZON, true) } parts.forEach { out += Finding(it, original, Origin.AMAZON, true) }
} }
store == Store.UBISOFT -> ubisoftInstallRuns(sidecar?.extra?.get(com.droiddeck.launcher.stores.ubisoft.UbisoftLaunch.SETUP)).forEach { exe ->
val parts = installer(exe)
val original = exe.substringAfterLast('/')
if (parts.isEmpty()) out += Finding(null, original, Origin.UBISOFT, false)
parts.forEach { out += Finding(it, original, Origin.UBISOFT, true) }
}
else -> {} else -> {}
} }
if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match -> if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match ->
@@ -196,6 +196,7 @@ object SessionFiles {
"usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst", "usr/local/lib/droiddeck-pipewire.tzst" to "usr/local/lib/droiddeck-pipewire.tzst",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE", "usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
"usr/local/lib/droiddeck-dirt/ws2_32.dll" to "usr/local/lib/droiddeck-dirt/ws2_32.dll", "usr/local/lib/droiddeck-dirt/ws2_32.dll" to "usr/local/lib/droiddeck-dirt/ws2_32.dll",
"usr/local/lib/droiddeck-ubisoft/droiddeck-upc.exe" to "usr/local/lib/droiddeck-ubisoft/droiddeck-upc.exe",
) + mangoapp + xclipse.map { it to it } + maliCompat + msitools.map { it to it } + vr + gpuLoad + fexPreloads).filter { (asset, _) -> ) + mangoapp + xclipse.map { it to it } + maliCompat + msitools.map { it to it } + vr + gpuLoad + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/') val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false) runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
@@ -87,7 +87,7 @@ object CloudSaves {
val item = sidecar.extra["catalogItemId"].orEmpty() val item = sidecar.extra["catalogItemId"].orEmpty()
if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder") if (ns.isEmpty() || item.isEmpty()) "" else EpicApiClient.getCustomAttribute(token, ns, item, "CloudSaveFolder")
} }
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> "" Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA, Store.UBISOFT -> ""
} ?: return null } ?: return null
p.edit() p.edit()
.putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE }) .putString("cloud_template_$id", fetched.ifEmpty { TEMPLATE_NONE })
@@ -148,7 +148,7 @@ object CloudSaves {
val dir = when (store) { val dir = when (store) {
Store.GOG -> CloudSavePaths.gog(template, pfx, folder) Store.GOG -> CloudSavePaths.gog(template, pfx, folder)
Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id) Store.EPIC -> CloudSavePaths.epic(template, pfx, folder, EpicCredentialStore.load(context)?.accountId.orEmpty(), id)
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA, Store.UBISOFT -> null
} }
return dir to (if (dir == null) "unresolved" else "ok") return dir to (if (dir == null) "unresolved" else "ok")
} }
@@ -173,7 +173,7 @@ object CloudSaves {
val account = EpicCredentialStore.load(context)?.accountId.orEmpty() val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
if (token == null || account.isEmpty()) null else EpicCloud(account, id, token) if (token == null || account.isEmpty()) null else EpicCloud(account, id, token)
} }
Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA -> null Store.AMAZON, Store.ITCH, Store.GAMEJOLT, Store.XBOX, Store.BATTLENET, Store.EA, Store.UBISOFT -> null
} }
private fun gogScopedToken(context: Context, id: String, clientId: String): String? { private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
@@ -13,12 +13,14 @@ internal object LoginFlows {
Store.EPIC -> epicFlow() Store.EPIC -> epicFlow()
Store.AMAZON -> amazonFlow() Store.AMAZON -> amazonFlow()
Store.GAMEJOLT -> gameJoltFlow(browserUserAgent) Store.GAMEJOLT -> gameJoltFlow(browserUserAgent)
Store.UBISOFT -> ubisoftFlow(browserUserAgent)
Store.EA -> eaFlow(browserUserAgent) Store.EA -> eaFlow(browserUserAgent)
Store.ITCH, Store.XBOX, Store.BATTLENET -> null Store.ITCH, Store.XBOX, Store.BATTLENET -> null
} }
@Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null } @Volatile var epicFlow: () -> StoreLoginActivity.Flow? = { null }
@Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null } @Volatile var amazonFlow: () -> StoreLoginActivity.Flow? = { null }
@Volatile var ubisoftFlow: (String) -> StoreLoginActivity.Flow? = { null }
@Volatile var eaFlow: (String) -> StoreLoginActivity.Flow? = { null } @Volatile var eaFlow: (String) -> StoreLoginActivity.Flow? = { null }
@Volatile var gameJoltFlow: (String) -> StoreLoginActivity.Flow? = { null } @Volatile var gameJoltFlow: (String) -> StoreLoginActivity.Flow? = { null }
@@ -8,6 +8,7 @@ enum class Store(val id: String, val label: String, val shortLabel: String) {
ITCH("itch", "Itch.io", "Itch.io"), ITCH("itch", "Itch.io", "Itch.io"),
GAMEJOLT("gamejolt", "Game Jolt", "Game Jolt"), GAMEJOLT("gamejolt", "Game Jolt", "Game Jolt"),
BATTLENET("battlenet", "Battle.net", "Battle.net"), BATTLENET("battlenet", "Battle.net", "Battle.net"),
UBISOFT("ubisoft", "Ubisoft Connect", "Ubisoft"),
EA("ea", "EA", "EA"); EA("ea", "EA", "EA");
val folder: String get() = if (this == ITCH) "itch" else shortLabel val folder: String get() = if (this == ITCH) "itch" else shortLabel
@@ -3,7 +3,8 @@ package com.droiddeck.launcher.stores
import java.io.File import java.io.File
object StoreExe { object StoreExe {
private val SKIP = listOf("redist", "unins", "setup", "crash", "report", "helper", "dotnet", "vcredist", "directx", "eac", "easyanticheat", "battleye", "launcher", "updater", "config") private val SKIP = listOf("redist", "unins", "setup", "crash", "report", "helper", "dotnet", "vcredist", "directx", "eac", "easyanticheat", "battleye", "launcher", "updater", "config", "installer", "autorun")
private val SKIP_DIRS = setOf("installer_resources", "redist", "_commonredist", "support")
fun pick(folder: File, hint: String?, title: String): String { fun pick(folder: File, hint: String?, title: String): String {
val hinted = hint?.takeIf { it.isNotBlank() }?.replace('\\', '/')?.trimStart('/')?.let { File(folder, it) } val hinted = hint?.takeIf { it.isNotBlank() }?.replace('\\', '/')?.trimStart('/')?.let { File(folder, it) }
@@ -28,7 +29,7 @@ object StoreExe {
if (SKIP.none { lower.contains(it) }) out.add(f) if (SKIP.none { lower.contains(it) }) out.add(f)
} }
} }
for (f in files) if (f.isDirectory && !f.name.startsWith(".")) collect(f, out, depth + 1) for (f in files) if (f.isDirectory && !f.name.startsWith(".") && f.name.lowercase() !in SKIP_DIRS) collect(f, out, depth + 1)
} }
private fun depth(root: File, f: File): Int = relative(root, f).count { it == '/' } private fun depth(root: File, f: File): Int = relative(root, f).count { it == '/' }
@@ -11,12 +11,14 @@ object StoreLaunch {
const val LAUNCHER = ".droiddeck-launch.bat" const val LAUNCHER = ".droiddeck-launch.bat"
const val EPIC_CODE = ".droiddeck-epic-code" const val EPIC_CODE = ".droiddeck-epic-code"
const val EPIC_ARGS = ".droiddeck-epic-args" const val EPIC_ARGS = ".droiddeck-epic-args"
const val UBISOFT_LAUNCH = ".droiddeck-ubisoft.json"
private const val UBISOFT_SERVICE = "Z:\\usr\\local\\lib\\droiddeck-ubisoft\\droiddeck-upc.exe"
const val EA_ENV = ".droiddeck-ea-env" const val EA_ENV = ".droiddeck-ea-env"
/** A sleeping stand-in that the launch hook installs; older EA SDKs require a running Origin process. */ /** A sleeping stand-in that the launch hook installs; older EA SDKs require a running Origin process. */
const val EA_ORIGIN = "C:\\Program Files (x86)\\Origin\\Origin.exe" const val EA_ORIGIN = "C:\\Program Files (x86)\\Origin\\Origin.exe"
fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar { fun writeLauncher(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC || sidecar.store == Store.EA val needs = sidecar.args.isNotEmpty() || sidecar.env.isNotEmpty() || sidecar.store == Store.EPIC || sidecar.store == Store.EA || sidecar.store == Store.UBISOFT
val file = File(folder, LAUNCHER) val file = File(folder, LAUNCHER)
if (!needs) { file.delete(); return sidecar.copy(launcher = null) } if (!needs) { file.delete(); return sidecar.copy(launcher = null) }
file.writeText(launcherText(sidecar)) file.writeText(launcherText(sidecar))
@@ -52,6 +54,13 @@ object StoreLaunch {
lines += "del \"%~dp0$EPIC_ARGS\"" lines += "del \"%~dp0$EPIC_ARGS\""
lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\"" lines += "if exist \"%~dp0$EPIC_CODE\" del \"%~dp0$EPIC_CODE\""
lines += "set \"$CLIENT_VAR=\" & \"%~dp0$exe\" %$CLIENT_VAR%" lines += "set \"$CLIENT_VAR=\" & \"%~dp0$exe\" %$CLIENT_VAR%"
} else if (sidecar.store == Store.UBISOFT) {
lines += "if not exist \"%~dp0$UBISOFT_LAUNCH\" goto direct"
lines += "if not exist \"$UBISOFT_SERVICE\" goto direct"
lines += "\"$UBISOFT_SERVICE\" \"%~dp0$UBISOFT_LAUNCH\""
lines += "goto :eof"
lines += ":direct"
lines += run
} else if (sidecar.store == Store.EA) { } else if (sidecar.store == Store.EA) {
lines += "if not exist \"%~dp0$EA_ENV\" goto run" lines += "if not exist \"%~dp0$EA_ENV\" goto run"
lines += "for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0$EA_ENV\") do set \"%%a=%%b\"" lines += "for /f \"usebackq tokens=1,* delims==\" %%a in (\"%~dp0$EA_ENV\") do set \"%%a=%%b\""
@@ -84,6 +93,17 @@ object StoreLaunch {
return Library.addedGame(context, game) return Library.addedGame(context, game)
} }
/** Gives a Ubisoft game installed before it needed one the launcher that starts the launcher service. */
fun adopt(folder: File, sidecar: StoreGameSidecar): StoreGameSidecar {
if (sidecar.store != Store.UBISOFT || !sidecar.isInstalled) return sidecar
val wrongExe = !sidecar.exeFile(folder).isFile || sidecar.exe.contains("installer", ignoreCase = true) || sidecar.exe.contains("autorun", ignoreCase = true)
if (sidecar.launcher == LAUNCHER && !wrongExe) return sidecar
val exe = if (wrongExe) StoreExe.pick(folder, null, sidecar.title).ifEmpty { sidecar.exe } else sidecar.exe
if (exe == sidecar.exe && sidecar.launcher == LAUNCHER &&
runCatching { File(folder, LAUNCHER).readText() }.getOrNull() == launcherText(sidecar)) return sidecar
return runCatching { writeLauncher(folder, sidecar.copy(exe = exe)).also { it.write(folder) } }.getOrDefault(sidecar)
}
fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) { fun refreshLauncher(folder: File, sidecar: StoreGameSidecar) {
if (sidecar.launcher != LAUNCHER) return if (sidecar.launcher != LAUNCHER) return
val file = File(folder, LAUNCHER) val file = File(folder, LAUNCHER)
@@ -76,6 +76,15 @@ object StoreLaunchRequests {
JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason) JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason)
} }
} }
"ubisoft-launch" -> {
val id = request.optString("id")
if (id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-id")
else {
lastLaunched = Store.UBISOFT to id
runCatching { com.droiddeck.launcher.stores.ubisoft.UbisoftLaunch.prepare(app, id) }
.getOrElse { JSONObject().put("ok", false).put("reason", it.javaClass.simpleName) }
}
}
"cloud-down" -> { "cloud-down" -> {
val store = Store.byId(request.optString("store")) val store = Store.byId(request.optString("store"))
val id = request.optString("id") val id = request.optString("id")
@@ -47,6 +47,9 @@ class StoreLoginActivity : ComponentActivity() {
?.takeIf { "Chrome/" in it } ?.takeIf { "Chrome/" in it }
?: "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36" ?: "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.103 Mobile Safari/537.36"
private const val PAGE_TRIES = 20
private const val PAGE_RETRY_MS = 500L
fun generateState(): String { fun generateState(): String {
val bytes = ByteArray(24) val bytes = ByteArray(24)
java.security.SecureRandom().nextBytes(bytes) java.security.SecureRandom().nextBytes(bytes)
@@ -60,6 +63,8 @@ class StoreLoginActivity : ComponentActivity() {
fun isRedirect(url: String): Boolean fun isRedirect(url: String): Boolean
fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? fun finish(activity: StoreLoginActivity, view: WebView, url: String): String?
val readsPage: Boolean get() = false val readsPage: Boolean get() = false
val pageScript: String get() = "(function(){ try { return document.body.innerText; } catch(e){ return ''; } })()"
fun pageReady(result: String): Boolean = true
fun forgetSignIn(done: () -> Unit) = done() fun forgetSignIn(done: () -> Unit) = done()
} }
@@ -323,6 +328,14 @@ class StoreLoginActivity : ComponentActivity() {
} }
} }
private fun readPage(view: WebView, f: Flow, left: Int) {
view.evaluateJavascript(f.pageScript) { json ->
val result = json ?: ""
if (!f.pageReady(result) && left > 0) view.postDelayed({ readPage(view, f, left - 1) }, PAGE_RETRY_MS)
else Thread({ settle(f.finish(this, view, result)) }, "store-login").start()
}
}
private fun complete(view: WebView, url: String) { private fun complete(view: WebView, url: String) {
if (!captured.compareAndSet(false, true)) return if (!captured.compareAndSet(false, true)) return
val f = flow ?: return val f = flow ?: return
@@ -332,11 +345,7 @@ class StoreLoginActivity : ComponentActivity() {
titleText.text = getString(R.string.stores_login_finishing) titleText.text = getString(R.string.stores_login_finishing)
if (!f.readsPage) view.stopLoading() if (!f.readsPage) view.stopLoading()
} }
if (f.readsPage) { if (f.readsPage) readPage(view, f, PAGE_TRIES) else Thread({ settle(f.finish(this, view, url)) }, "store-login").start()
view.evaluateJavascript("(function(){ try { return document.body.innerText; } catch(e){ return ''; } })()") { json ->
Thread({ settle(f.finish(this, view, json ?: "")) }, "store-login").start()
}
} else Thread({ settle(f.finish(this, view, url)) }, "store-login").start()
} }
private fun settle(problem: String?) { private fun settle(problem: String?) {
@@ -56,7 +56,7 @@ object StoreSizes {
Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.AMAZON -> AmazonPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.ITCH, Store.GAMEJOLT -> {} Store.ITCH, Store.GAMEJOLT -> {}
Store.EA -> com.droiddeck.launcher.stores.ea.EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply() Store.EA -> com.droiddeck.launcher.stores.ea.EaPrefs.get(app).edit().putLong("size_${item.id}", it).apply()
Store.GOG, Store.XBOX, Store.BATTLENET -> {} Store.GOG, Store.XBOX, Store.BATTLENET, Store.UBISOFT -> {}
} }
} }
}, pool).exceptionally { error -> }, pool).exceptionally { error ->
@@ -113,7 +113,7 @@ object StoreSizes {
AmazonManifest.parse(bytes).totalInstallSize AmazonManifest.parse(bytes).totalInstallSize
} }
Store.EA -> com.droiddeck.launcher.stores.ea.EaSizes.size(context, item) Store.EA -> com.droiddeck.launcher.stores.ea.EaSizes.size(context, item)
Store.GOG, Store.XBOX, Store.BATTLENET -> 0L Store.GOG, Store.XBOX, Store.BATTLENET, Store.UBISOFT -> 0L
Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item) Store.ITCH -> com.droiddeck.launcher.stores.itch.ItchLibrary.size(context, item)
Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item) Store.GAMEJOLT -> com.droiddeck.launcher.stores.gamejolt.GameJoltBuilds.size(context, item)
} } } }
@@ -0,0 +1,74 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.stores.StoresState
import org.json.JSONObject
import java.io.File
/**
* Answers Denuvo activations for a running Ubisoft game. droiddeck-upc.exe writes the challenge
* the game's aux DLL sends to `bridge-<product>/denuvo-<id>.req`; the answer goes to `.rsp`.
*/
object UbisoftActivation {
private const val POLL_MS = 250L
private const val IDLE_MS = 60_000L
private val watching = HashMap<Long, Long>()
fun bridge(context: Context, product: Long) = File(LinuxRuntime.rootDir(context), "root/.cache/droiddeck/ubisoft/bridge-$product")
/** Starts answering the product's activations; stale requests are cleared before this returns. */
fun watch(app: Context, product: Long) {
val dir = bridge(app, product)
synchronized(watching) {
val running = watching.containsKey(product)
watching[product] = System.currentTimeMillis()
if (running) return
dir.mkdirs()
dir.listFiles()?.forEach { it.delete() }
}
Thread({
var idleSince = 0L
while (true) {
runCatching { dir.listFiles { f -> f.name.startsWith("denuvo-") && f.name.endsWith(".req") }?.forEach { answer(app, product, it) } }
.onFailure { StoresState.logLine("Ubisoft: activation for $product: ${it.javaClass.simpleName}") }
val now = System.currentTimeMillis()
if (SessionState.running) idleSince = 0L
else if (idleSince == 0L) idleSince = now
else if (now - idleSince > IDLE_MS) synchronized(watching) {
if (now - (watching[product] ?: 0L) > IDLE_MS) { watching.remove(product); return@Thread }
}
runCatching { Thread.sleep(POLL_MS) }
}
}, "ubisoft-activation-$product").start()
}
private fun answer(app: Context, product: Long, request: File) {
val challenge = runCatching { request.readBytes() }.getOrNull() ?: return
request.delete()
val reply = File(request.parentFile, request.name.removeSuffix(".req") + ".rsp")
val token = activate(app, product, challenge)
val part = File(reply.path + ".part")
part.writeBytes(token ?: ByteArray(0))
part.renameTo(reply)
}
private fun activate(app: Context, product: Long, challenge: ByteArray): ByteArray? {
val s = UbisoftAuth.current(app) ?: return null.also { StoresState.logLine("Ubisoft: activation for $product: signed out") }
val answer = UbisoftNative.call("denuvo", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId)
.put("productId", product).put("challenge", hex(challenge)))
answer?.optString("error")?.takeIf { it.isNotEmpty() }?.let {
StoresState.logLine("Ubisoft: activation for $product: $it")
return null
}
val token = answer?.optString("token")?.takeIf { it.isNotEmpty() }?.let(::unhex)
StoresState.logLine("Ubisoft: activation for $product: ${token?.size ?: 0} B game token")
return token
}
internal fun hex(b: ByteArray): String = b.joinToString("") { "%02x".format(it) }
internal fun unhex(s: String): ByteArray? =
if (s.length % 2 != 0) null else runCatching { ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() } }.getOrNull()
}
@@ -0,0 +1,175 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAccounts
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/** UbiServices sessions: a ticket from Ubisoft's own web sign-in, exchanged for a launcher session that remember-me renews. */
object UbisoftAuth {
private const val TAG = "UbisoftAuth"
const val SESSIONS = "https://public-ubiservices.ubi.com/v3/profiles/sessions"
/** The web app and genome connect.ubisoft.com's sign-in page accepts. */
private const val WEB_APP_ID = "f35adcb5-1911-440c-b1c9-48fdc1701c68"
private const val WEB_GENOME_ID = "5b36b900-65d8-47f3-93c8-86bdaa48ab50"
/** The Ubisoft Connect PC app id; its sessions are accepted by the library GraphQL and Demux. */
const val APP_ID = "f68a4bb5-608a-4ff2-8123-be8ef797e0a6"
private const val REFRESH_MARGIN_MS = 10 * 60 * 1000L
private val lock = Any()
class Session(
val ticket: String,
val sessionId: String,
val profileId: String,
val userId: String,
val name: String,
val expiresAt: Long,
val rememberMe: String,
)
class Reply(val status: Int, val body: String, val headers: Map<String, String>) {
val ok get() = status in 200..299
}
private val loggedIn = "https://connect.ubisoft.com/logged-in.html?appId=$WEB_APP_ID&lang=en-US&genomeId=$WEB_GENOME_ID"
val loginUrl = "https://connect.ubisoft.com/login?appId=$WEB_APP_ID&genomeId=$WEB_GENOME_ID&lang=en-US&nextUrl=" + URLEncoder.encode(loggedIn, "UTF-8")
val SIGN_IN_URLS = listOf("https://connect.ubisoft.com", "https://account.ubisoft.com", "https://ubisoft.com")
fun isLoggedInPage(url: String) = url.startsWith("https://connect.ubisoft.com/logged-in.html")
/** Reads connect.ubisoft.com's stored sign-in; the WebAuth SDK keeps it under keys holding `loginData` and `rememberMe`. */
const val PAGE_SCRIPT = "(function(){try{var o={};for(var i=0;i<localStorage.length;i++){var k=localStorage.key(i);" +
"if(/loginData|rememberMe|lastProfile/i.test(k))o[k]=localStorage.getItem(k);}return JSON.stringify(o);}catch(e){return '';}})()"
internal fun request(url: String, method: String = "GET", authorization: String? = null, sessionId: String? = null, body: String? = null, extra: Map<String, String> = emptyMap()): Reply {
val c = URL(url).openConnection() as HttpURLConnection
c.requestMethod = method
c.connectTimeout = 15_000
c.readTimeout = 25_000
c.setRequestProperty("Ubi-AppId", APP_ID)
c.setRequestProperty("Ubi-RequestedPlatformType", "uplay")
c.setRequestProperty("Content-Type", "application/json")
c.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
authorization?.let { c.setRequestProperty("Authorization", it) }
sessionId?.let { c.setRequestProperty("Ubi-SessionId", it) }
for ((k, v) in extra) c.setRequestProperty(k, v)
try {
if (body != null) {
c.doOutput = true
c.outputStream.use { it.write(body.toByteArray()) }
}
val status = c.responseCode
val text = (if (status in 200..299) c.inputStream else c.errorStream)?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty()
val headers = c.headerFields.filterKeys { it != null }.mapKeys { it.key.lowercase() }.mapValues { it.value.joinToString(",") }
return Reply(status, text, headers)
} finally {
c.disconnect()
}
}
/** POSTs a session request, replaying it once when UbiServices answers with a challenge. */
private fun session(authorization: String): Reply {
val body = JSONObject().put("rememberMe", true).toString()
val first = request(SESSIONS, "POST", authorization, body = body)
val challenge = first.headers["ubi-challenge"] ?: return first
return request(SESSIONS, "POST", authorization, body = body, extra = mapOf("Ubi-Challenge" to challenge))
}
internal fun parseSession(body: String, rememberFallback: String = ""): Session? {
val o = runCatching { JSONObject(body) }.getOrNull() ?: return null
val ticket = o.optString("ticket").takeIf { it.isNotEmpty() && it != "null" } ?: return null
return Session(
ticket = ticket,
sessionId = o.optString("sessionId"),
profileId = o.optString("profileId"),
userId = o.optString("userId"),
name = o.optString("nameOnPlatform"),
expiresAt = parseTime(o.optString("expiration")),
rememberMe = o.optString("rememberMeTicket").takeIf { it.isNotEmpty() && it != "null" } ?: rememberFallback,
)
}
internal fun parseTime(s: String): Long = runCatching { java.time.OffsetDateTime.parse(s).toInstant().toEpochMilli() }
.recoverCatching { java.time.Instant.parse(s).toEpochMilli() }.getOrDefault(System.currentTimeMillis() + 60 * 60 * 1000L)
/** Values the web sign-in stored, found wherever the SDK nests them. */
internal fun webValues(dump: String): Map<String, String> {
val out = HashMap<String, String>()
fun walk(v: Any?) {
when (v) {
is JSONObject -> v.keys().forEach { k ->
val x = v.opt(k)
if (x is String && x.isNotEmpty() && k !in out) {
val nested = if (x.startsWith("{") || x.startsWith("[")) runCatching { org.json.JSONTokener(x).nextValue() }.getOrNull() else null
if (nested != null) walk(nested) else out[k] = x
} else walk(x)
}
is JSONArray -> for (i in 0 until v.length()) walk(v.opt(i))
}
}
val top = runCatching { org.json.JSONTokener(dump).nextValue() }.getOrNull()
walk(if (top is String) runCatching { JSONObject(top) }.getOrNull() else top)
return out
}
/** Turns the page's stored sign-in into a launcher session; returns null on success or a reason. */
fun completeWebSignIn(context: Context, dump: String): String? {
val web = webValues(dump)
val ticket = web["ticket"]
val remember = web["rememberMeTicket"].orEmpty()
val fresh = when {
ticket != null -> session("ubi_v1 t=$ticket").let { r ->
parseSession(r.body, remember) ?: run { Log.w(TAG, "ticket exchange: ${r.status} ${r.body.take(200)}"); null }
}
else -> null
} ?: remember.takeIf { it.isNotEmpty() }?.let { rm -> parseSession(session("rm_v1 t=$rm").body, rm) }
?: return "no ticket"
save(context, fresh)
return null
}
private fun save(context: Context, s: Session) {
val name = s.name.ifEmpty { profileName(s) }
StoreAccounts.write(context.applicationContext, Store.UBISOFT, JSONObject()
.put("access_token", s.ticket)
.put("session_id", s.sessionId)
.put("profile_id", s.profileId)
.put("user_id", s.userId)
.put("display_name", name)
.put("expires_at", s.expiresAt)
.put("remember_me", s.rememberMe))
}
private fun profileName(s: Session): String = runCatching {
JSONObject(request("https://public-ubiservices.ubi.com/v3/profiles/me", authorization = "ubi_v1 t=${s.ticket}", sessionId = s.sessionId).body).optString("nameOnPlatform")
}.getOrDefault("")
fun load(context: Context): Session? {
val o = StoreAccounts.read(context.applicationContext, Store.UBISOFT) ?: return null
val ticket = o.optString("access_token").takeIf { it.isNotEmpty() } ?: return null
return Session(ticket, o.optString("session_id"), o.optString("profile_id"), o.optString("user_id"), o.optString("display_name"), o.optLong("expires_at"), o.optString("remember_me"))
}
/** A live session, renewed through remember-me shortly before it expires. */
fun current(context: Context): Session? = synchronized(lock) {
val s = load(context) ?: return null
if (s.expiresAt - System.currentTimeMillis() > REFRESH_MARGIN_MS || s.rememberMe.isEmpty()) return s
val renewed = runCatching { session("rm_v1 t=${s.rememberMe}") }.getOrNull()?.let { parseSession(it.body, s.rememberMe) }
if (renewed == null) {
Log.w(TAG, "remember-me renewal failed")
return if (s.expiresAt > System.currentTimeMillis()) s else null
}
save(context, Session(renewed.ticket, renewed.sessionId, renewed.profileId.ifEmpty { s.profileId }, renewed.userId.ifEmpty { s.userId },
renewed.name.ifEmpty { s.name }, renewed.expiresAt, renewed.rememberMe))
load(context)
}
fun signOut(context: Context) = synchronized(lock) { StoreAccounts.clear(context.applicationContext, Store.UBISOFT) }
}
@@ -0,0 +1,246 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import android.content.Intent
import android.util.Log
import android.webkit.WebView
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.LoginFlows
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreBackend
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.download.DownloadEntry
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.StoreLoginActivity
import com.droiddeck.launcher.stores.StoresState
import com.droiddeck.launcher.stores.WebCookies
import org.json.JSONObject
object UbisoftBackend : StoreBackend {
override val store: Store = Store.UBISOFT
init {
LoginFlows.ubisoftFlow = { ua -> UbisoftLogin(ua) }
}
override fun signIn(context: Context) {
context.startActivity(
Intent(context, StoreLoginActivity::class.java)
.putExtra(StoreLoginActivity.EXTRA_STORE, store.id)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
override fun signOut(context: Context) {
UbisoftAuth.signOut(context.applicationContext)
UbisoftLibrary.reset()
runCatching { WebCookies.expire(UbisoftAuth.SIGN_IN_URLS, ".ubisoft.com", listOf("/")) {} }
}
override fun syncLibrary(context: Context, force: Boolean) {
val app = context.applicationContext
Thread({ UbisoftLibrary.sync(app, force) }, "ubisoft-sync").start()
}
override fun loadShelves(context: Context, force: Boolean) {}
override fun install(context: Context, item: CatalogItem, root: java.io.File) {
val app = context.applicationContext
item.extra[STEAM_APP]?.takeIf { item.extra["productId"] == null }?.let { steam ->
StoresState.logLine("Ubisoft: ${item.title} is owned on Steam; installing Steam app $steam")
installInSteam(app, steam)
return
}
if (!UbisoftNative.ready()) {
StoresState.logLine(UbisoftNative.ENGINE_MISSING)
StoresState.post { StoresState.problems[store] = UbisoftNative.ENGINE_MISSING }
return
}
val folder = StoreInstallRoot.folderFor(app, store, item.id, item.title, root)
val entry = DownloadEntry(store, item.id, item.title, cover = item.tallImageUrl ?: item.imageUrl,
bytesTotal = item.sizeBytes, location = StoreInstallRoot.labelFor(app, folder))
DownloadQueue.enqueue(app, entry) { UbisoftInstaller(app, item, folder) }
}
override fun uninstall(context: Context, game: InstalledStoreGame) {}
const val STEAM_APP = "steamAppId"
/** Steam asks to install a game it does not have when told to run it. */
private fun installInSteam(app: Context, steamAppId: String) {
if (com.droiddeck.launcher.session.SessionState.running) {
if (!com.droiddeck.launcher.session.SessionService.launchGame(app, steamAppId)) StoresState.logLine("Ubisoft: Steam is busy; install Steam app $steamAppId there")
return
}
app.startActivity(Intent(app, com.droiddeck.launcher.SessionActivity::class.java)
.putExtra(com.droiddeck.launcher.session.SessionService.EXTRA_STEAM_URL, com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(steamAppId))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
internal class UbisoftLogin(override val userAgent: String) : StoreLoginActivity.Flow {
override val startUrl: String get() = UbisoftAuth.loginUrl
override val readsPage: Boolean get() = true
override val pageScript: String get() = UbisoftAuth.PAGE_SCRIPT
override fun pageReady(result: String): Boolean = result.contains("ticket", ignoreCase = true)
override fun isRedirect(url: String): Boolean = UbisoftAuth.isLoggedInPage(url)
override fun forgetSignIn(done: () -> Unit) {
runCatching { WebCookies.expire(UbisoftAuth.SIGN_IN_URLS, ".ubisoft.com", listOf("/")) { done() } }.onFailure { done() }
}
override fun finish(activity: StoreLoginActivity, view: WebView, url: String): String? = try {
UbisoftAuth.completeWebSignIn(activity, url)?.let {
Log.w("UbisoftLogin", "sign-in incomplete: $it")
activity.getString(R.string.stores_login_error_verification)
}
} catch (e: Exception) {
Log.w("UbisoftLogin", "sign-in failed: ${e.javaClass.simpleName}")
activity.getString(R.string.stores_login_error_generic)
}
}
object UbisoftLibrary {
private const val GRAPHQL = "https://public-ubiservices.ubi.com/v1/profiles/me/uplay/graphql"
private const val PAGE = 50
private const val QUERY = "query OwnedGames(\$limit: Int, \$offset: Int) { viewer { games(limit: \$limit, offset: \$offset) { totalCount nodes { id spaceId name coverUrl backgroundUrl bannerUrl releaseDate } } } }"
private val generation = java.util.concurrent.atomic.AtomicInteger()
private val running = java.util.concurrent.atomic.AtomicReference<Int?>(null)
/** Forgets the signed-out account: its sync results are dropped and a new account may sync at once. */
fun reset() {
generation.incrementAndGet()
StoresState.post { StoresState.shelves.remove(Store.UBISOFT) }
}
private fun post(gen: Int, block: () -> Unit) = StoresState.post { if (generation.get() == gen) block() }
internal fun parsePage(body: String): Pair<List<CatalogItem>, Int> {
val o = JSONObject(body)
o.optJSONArray("errors")?.optJSONObject(0)?.let { throw IllegalStateException(it.optString("message")) }
val games = o.optJSONObject("data")?.optJSONObject("viewer")?.optJSONObject("games") ?: return emptyList<CatalogItem>() to 0
val nodes = games.optJSONArray("nodes")
val items = (0 until (nodes?.length() ?: 0)).mapNotNull { nodes!!.optJSONObject(it) }.mapNotNull { n ->
val space = n.optString("spaceId").takeIf { it.isNotEmpty() } ?: return@mapNotNull null
fun s(k: String) = n.optString(k).takeIf { it.isNotEmpty() && it != "null" }
CatalogItem(
Store.UBISOFT, space, n.optString("name").trim(), s("backgroundUrl") ?: s("bannerUrl"),
tallImageUrl = s("coverUrl"), releaseDate = s("releaseDate")?.take(10).orEmpty(), owned = true,
extra = mapOf("spaceId" to space, "gameId" to n.optString("id")),
)
}
return items to games.optInt("totalCount", items.size)
}
/** Base games with a manifest, by UbiServices space; add-ons and bundles share spaces with their game. */
internal fun ownedBySpace(owned: JSONObject?): Map<String, JSONObject> {
val list = owned?.optJSONArray("games") ?: return emptyMap()
val games = (0 until list.length()).mapNotNull { list.optJSONObject(it) }
.filter { it.optString("manifest").isNotEmpty() && it.optInt("productType") == 0 && it.optBoolean("owned", true) }
val out = LinkedHashMap<String, JSONObject>()
for (key in listOf("spaceId")) for (g in games) {
val space = g.optString(key).takeIf { it.isNotEmpty() } ?: continue
out.putIfAbsent(space, g)
}
return out
}
private val STEAM_ID = Regex("""^\s*steam_app_id:\s*['"]?(\d+)""", RegexOption.MULTILINE)
private val THIRD_PARTY = Regex("""^\s*third_party_platform:\s*\r?\n\s+name:\s*['"]?Steam\b""", setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))
/** The Steam app of a product Ubisoft says is owned and installed through Steam. */
internal fun steamApp(configuration: String): String? =
if (THIRD_PARTY.containsMatchIn(configuration)) STEAM_ID.find(configuration)?.groupValues?.get(1) else null
/** Owned games Ubisoft delivers through Steam, by UbiServices space. */
internal fun steamBySpace(owned: JSONObject?): Map<String, String> {
val list = owned?.optJSONArray("games") ?: return emptyMap()
return (0 until list.length()).mapNotNull { list.optJSONObject(it) }
.filter { it.optInt("productType") == 0 && it.optBoolean("owned", true) && it.optString("spaceId").isNotEmpty() }
.mapNotNull { o -> steamApp(o.optString("configuration"))?.let { o.optString("spaceId") to it } }.toMap()
}
private val RELATIVE = Regex("""relative:\s*['"]?([^'"\r\n]+?)['"]?\s*$""", RegexOption.MULTILINE)
private val NAME = Regex("""^\s{1,4}name:\s*['"]?(.+?)['"]?\s*$""", RegexOption.MULTILINE)
/** The product's name from its configuration, following a localisation key such as `l1` to its default text. */
internal fun title(configuration: String): String? {
val raw = NAME.find(configuration)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
if (!Regex("""l\d+""").matches(raw)) return raw
val local = configuration.indexOf("localizations:").takeIf { it >= 0 } ?: return raw
return Regex("""^\s+${raw}:\s*['"]?(.+?)['"]?\s*$""", RegexOption.MULTILINE).find(configuration, local)?.groupValues?.get(1)?.trim() ?: raw
}
/** The first launch path under `start_game` in a product's configuration. */
internal fun executable(configuration: String): String? {
val start = configuration.indexOf("start_game").takeIf { it >= 0 } ?: return null
return RELATIVE.find(configuration, start)?.groupValues?.get(1)?.trim()?.takeIf { it.endsWith(".exe", true) }
}
fun sync(app: Context, force: Boolean) {
val gen = generation.get()
if (running.getAndSet(gen) == gen) return
try {
val s = UbisoftAuth.current(app)
if (s == null) {
post(gen) { StoresState.status.remove(Store.UBISOFT); StoresState.problems[Store.UBISOFT] = app.getString(R.string.stores_sync_sign_in) }
return
}
if (StoresState.library[Store.UBISOFT].isNullOrEmpty()) post(gen) { StoresState.status[Store.UBISOFT] = app.getString(R.string.stores_sync_game_list) }
val all = ArrayList<CatalogItem>()
var offset = 0
while (true) {
val body = JSONObject().put("operationName", "OwnedGames").put("query", QUERY)
.put("variables", JSONObject().put("limit", PAGE).put("offset", offset)).toString()
val reply = UbisoftAuth.request(GRAPHQL, "POST", "Ubi_v1 t=${s.ticket}", s.sessionId, body)
if (reply.status == 401) StoresState.markSignInExpired(Store.UBISOFT)
if (!reply.ok) throw IllegalStateException("library ${reply.status}")
val (items, total) = parsePage(reply.body)
all += items
offset += PAGE
if (items.isEmpty() || offset >= total) break
}
val owned = UbisoftNative.call("owned", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId))
owned?.optString("error")?.takeIf { it.isNotEmpty() }?.let { StoresState.logLine("Ubisoft: ownership: $it") }
val bySpace = ownedBySpace(owned)
owned?.takeIf { it.optInt("manifestsAsked") > 0 }?.let {
StoresState.logLine("Ubisoft: latest manifests: ${it.optInt("manifestsFound")} of ${it.optInt("manifestsAsked")} ${it.optString("manifestError")}".trim())
}
fun download(o: JSONObject) = listOfNotNull(
"productId" to o.optLong("productId").toString(),
o.optString("manifest").takeIf { it.isNotEmpty() }?.let { "manifest" to it },
o.optString("appId").takeIf { it.isNotEmpty() }?.let { "appId" to it },
executable(o.optString("configuration"))?.let { "exe" to it },
)
val listed = all.distinctBy { it.id }
val onSteam = steamBySpace(owned)
val matched = listed.map { g -> bySpace[g.id]?.let { g.copy(extra = g.extra + download(it)) } ?: onSteam[g.id]?.let { g.copy(extra = g.extra + (UbisoftBackend.STEAM_APP to it)) } ?: g }
.let { games -> games.filter { g -> g.extra["productId"] == null || games.first { it.extra["productId"] == g.extra["productId"] } === g } }
.let { games -> games.filter { g -> g.extra["productId"] != null || games.none { it !== g && it.title == g.title && it.extra["productId"] != null } } }
val products = matched.mapNotNull { it.extra["productId"] }.toSet()
val unlisted = bySpace.filterKeys { space -> listed.none { it.id == space } }.filterValues { it.optLong("productId").toString() !in products }.mapNotNull { (space, o) ->
val title = title(o.optString("configuration")) ?: return@mapNotNull null
CatalogItem(Store.UBISOFT, space, title, null, owned = true, extra = mapOf("spaceId" to space) + download(o))
}
val games = (matched + unlisted).sortedBy { it.title.lowercase() }
StoresState.logLine("Ubisoft: ${bySpace.size} downloadable products; " +
games.filter { it.extra.containsKey("productId") }.joinToString(", ") { "${it.title}=${it.extra["productId"]}" })
post(gen) {
StoresState.status.remove(Store.UBISOFT)
StoresState.problems.remove(Store.UBISOFT)
StoresState.library[Store.UBISOFT] = games
StoresState.shelves[Store.UBISOFT] = com.droiddeck.launcher.stores.StoreShelves(
trending = games.sortedByDescending { it.extra.containsKey("productId") }, art = games.mapNotNull { it.imageUrl })
}
StoresState.logLine("Ubisoft: ${games.size} owned games; on Steam: " + games.filter { it.extra.containsKey(UbisoftBackend.STEAM_APP) }.joinToString(", ") { "${it.title}=${it.extra[UbisoftBackend.STEAM_APP]}" })
} catch (e: Exception) {
StoresState.logLine("Ubisoft: library sync failed: ${e.javaClass.simpleName} ${e.message.orEmpty().take(200)}")
post(gen) { StoresState.status.remove(Store.UBISOFT); StoresState.problems[Store.UBISOFT] = app.getString(R.string.stores_sync_failed) }
} finally {
running.compareAndSet(gen, null)
}
}
}
@@ -0,0 +1,18 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import com.droiddeck.launcher.frontend.AddedGames
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/** The one prefix every Ubisoft game runs in, so Ubisoft Connect is installed and signed in once. */
object UbisoftConnect {
const val APP_ID = 2929927072L
fun prefix(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/compatdata/$APP_ID")
fun installed(context: Context) = File(prefix(context), "pfx/drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher/upc.exe").isFile
fun launchOptions(context: Context): String =
AddedGames.guestPath(context, prefix(context))?.let { "STEAM_COMPAT_DATA_PATH=$it %command%" }.orEmpty()
}
@@ -0,0 +1,104 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreExe
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoreLanguage
import com.droiddeck.launcher.stores.amazon.AmazonNative
import com.droiddeck.launcher.stores.download.DownloadQueue
import com.droiddeck.launcher.stores.download.DownloadStage
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
internal class UbisoftInstaller(
private val app: Context,
private val item: CatalogItem,
private val folder: File,
) : DownloadQueue.DownloadJob {
private val cancelled = AtomicBoolean(false)
override fun run(handle: DownloadQueue.JobHandle): String? {
handle.stage(DownloadStage.MANIFEST, app.getString(R.string.stores_sync_sign_in))
val s = UbisoftAuth.current(app) ?: throw IllegalStateException("Not signed in to Ubisoft")
val product = item.extra["productId"]?.toLongOrNull() ?: throw IllegalStateException("Ubisoft lists no download for ${item.title} on this account")
val cover = item.tallImageUrl ?: item.imageUrl
StoreInstalls.begin(folder, Store.UBISOFT, item.id, item.title, cover, item.imageUrl)
if (cancelled.get()) return null
val locale = StoreLanguage.localeFor(null, app)
val request = JSONObject()
.put("ticket", s.ticket)
.put("sessionId", s.sessionId)
.put("productId", product)
.put("manifest", item.extra["manifest"].orEmpty())
.put("folder", folder.absolutePath)
.put("language", "${locale.language}-${locale.country}")
val shown = AtomicLong(-1)
val downloading = AtomicBoolean(false)
val listener = object : AmazonNative.InstallListener {
override fun onStatus(text: String) { handle.stage(DownloadStage.MANIFEST, text) }
override fun onCheck(doneBytes: Long, totalBytes: Long, files: Int, filesTotal: Int) {
handle.stageProgress(doneBytes, totalBytes, files, filesTotal, bytes = true)
}
override fun onSizes(pendingBytes: Long, totalBytes: Long) { handle.diskSize(totalBytes) }
override fun onProgress(done: Long, total: Long, files: Int, filesTotal: Int) {
if (downloading.compareAndSet(false, true)) handle.stage(DownloadStage.DOWNLOAD)
while (true) {
val before = shown.get()
if (done < before) return
if (shown.compareAndSet(before, done)) break
}
handle.progress(done, total, "Downloading ($files/$filesTotal files)")
}
override fun onLog(line: String) { handle.log(line) }
override fun beforeReplace(): Boolean = true
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) = Unit
}
val outcome = UbisoftNative.install(request, listener, cancelled)
if (outcome.cancelled || cancelled.get() && !outcome.success) return null
if (!outcome.success) throw IllegalStateException(outcome.error.ifEmpty { "The Ubisoft install failed" })
handle.stage(DownloadStage.INSTALL, app.getString(R.string.stores_registering))
val configured = resolve(folder, item.extra["exe"].orEmpty())
val exe = configured ?: StoreExe.pick(folder, null, item.title).ifEmpty { "game.exe" }
handle.log("ubisoft: exe=$exe")
val sidecar = StoreGameSidecar(
Store.UBISOFT, item.id, item.title, exe = exe,
installVersion = item.extra["manifest"].orEmpty(), installedAt = System.currentTimeMillis(),
cover = cover, hero = item.imageUrl, cloud = false,
extra = item.extra.filterKeys { it in KEPT } + listOfNotNull(
outcome.result?.optJSONArray("chunks")?.let { UbisoftLaunch.CHUNKS to it.toString() },
outcome.result?.optJSONObject("setup")?.let { UbisoftLaunch.SETUP to it.toString() },
),
)
StoreInstalls.complete(app, folder, sidecar, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) })
return folder.path
}
override fun cancel(deleteFiles: Boolean) {
cancelled.set(true)
if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
}
internal companion object {
val KEPT = setOf("productId", "spaceId", "manifest", "appId")
/** A configured exe the way Windows finds it: any letter case, else the same file name anywhere in the install. */
fun resolve(folder: File, configured: String): String? {
val rel = configured.replace('\\', '/').trimStart('/').takeIf { it.isNotEmpty() } ?: return null
var at = folder
for (part in rel.split('/')) {
at = at.listFiles()?.firstOrNull { it.name.equals(part, ignoreCase = true) } ?: return byName(folder, rel.substringAfterLast('/'))
}
return if (at.isFile) at.relativeTo(folder).invariantSeparatorsPath else byName(folder, rel.substringAfterLast('/'))
}
private fun byName(folder: File, name: String): String? =
folder.walkTopDown().maxDepth(5).firstOrNull { it.isFile && it.name.equals(name, ignoreCase = true) }?.relativeTo(folder)?.invariantSeparatorsPath
}
}
@@ -0,0 +1,86 @@
package com.droiddeck.launcher.stores.ubisoft
import android.content.Context
import com.droiddeck.launcher.stores.StoreInstallRoot
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoresState
import org.json.JSONArray
import org.json.JSONObject
/** What the launcher service hands a starting Ubisoft game: the account, its products and a fresh game ticket. */
object UbisoftLaunch {
private const val ENTITLEMENTS = "https://public-ubiservices.ubi.com/v1/profiles/me/global/ubiconnect/entitlement/api/entitlements"
private const val ME = "https://public-ubiservices.ubi.com/v3/users/me"
fun prepare(app: Context, id: String): JSONObject {
val folder = StoreInstallRoot.gameFolders(app).firstOrNull { f ->
StoreGameSidecar.read(f)?.let { it.store == Store.UBISOFT && it.id == id } == true
} ?: return failure("not-installed")
val sidecar = StoreGameSidecar.read(folder) ?: return failure("not-installed")
val product = sidecar.extra["productId"]?.toLongOrNull() ?: return failure("no-product")
val s = UbisoftAuth.current(app) ?: return failure("signed-out")
val native = UbisoftNative.call("launch", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId).put("productId", product))
?: return failure("engine-missing")
native.optString("error").takeIf { it.isNotEmpty() }?.let {
StoresState.logLine("Ubisoft: launch ticket for $product: $it")
return failure("ticket: $it")
}
val auth = "Ubi_v1 t=${s.ticket}"
val me = runCatching { UbisoftAuth.request(ME, "GET", auth, s.sessionId) }.getOrNull()?.takeIf { it.ok }
?.let { runCatching { JSONObject(it.body) }.getOrNull() }
val account = JSONObject()
.put("userId", s.userId)
.put("username", me?.optString("username")?.takeIf { it.isNotEmpty() } ?: s.name)
.put("nameOnPlatform", me?.optString("nameOnPlatform")?.takeIf { it.isNotEmpty() } ?: s.name)
.put("email", me?.optString("email").orEmpty())
.put("country", me?.optString("country")?.takeIf { it.isNotEmpty() } ?: "US")
val appId = runCatching { UbisoftAuth.request(ENTITLEMENTS, "GET", auth, s.sessionId) }.getOrNull()?.takeIf { it.ok }
?.let { applicationId(it.body, product) } ?: sidecar.extra["appId"].orEmpty()
StoresState.logLine("Ubisoft: launch ticket for $product ready (${native.optJSONArray("owned")?.length() ?: 0} products, appId ${if (appId.isEmpty()) "unknown" else "found"})")
val language = com.droiddeck.launcher.stores.StoreLanguage.localeFor(null, app).let { "${it.language}-${it.country}" }
val cached = sidecar.extra[CHUNKS]?.let { runCatching { JSONArray(it) }.getOrNull() }
var setup = sidecar.extra[SETUP]?.let { runCatching { JSONObject(it) }.getOrNull() }
val chunks = if (cached != null && setup != null) cached else
UbisoftNative.call("chunks", JSONObject().put("ticket", s.ticket).put("sessionId", s.sessionId).put("productId", product)
.put("manifest", sidecar.extra["manifest"] ?: sidecar.installVersion).put("folder", folder.path).put("language", language))
?.let { r ->
r.optString("error").takeIf { it.isNotEmpty() }?.let { StoresState.logLine("Ubisoft: manifest for $product: $it") }
setup = r.optJSONObject("setup")
r.optJSONArray("chunks")
}?.also { c ->
runCatching {
StoreGameSidecar.read(folder)?.let { sc ->
sc.copy(extra = sc.extra + (CHUNKS to c.toString()) + listOfNotNull(setup?.let { SETUP to it.toString() })).write(folder)
}
com.droiddeck.launcher.stores.StoreLaunch.installedGame(app, Store.UBISOFT, id)
?.let { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(app, it) }
}
} ?: cached
setup?.let { StoresState.logLine("Ubisoft: setup for $product: ${it.toString().take(1500)}") }
UbisoftActivation.watch(app, product)
return JSONObject().put("ok", true)
.put("productId", product)
.put("ticket", native.optString("ticket"))
.put("owned", native.optJSONArray("owned") ?: JSONArray())
.put("products", native.optJSONArray("products") ?: JSONArray())
.put("account", account)
.put("appId", appId)
.put("language", language)
.put("chunks", chunks ?: JSONArray())
.put("registry", setup?.optJSONArray("registry") ?: JSONArray())
}
const val CHUNKS = "chunks"
const val SETUP = "setup"
/** The entitlement's `applicationId` for the product; the list sits under one of a few keys depending on the API version. */
internal fun applicationId(body: String, product: Long): String? {
val root = runCatching { JSONObject(body) }.getOrNull() ?: return null
val list = sequenceOf("items", "entitlements", "products", "games", "results").firstNotNullOfOrNull { root.optJSONArray(it) } ?: return null
return (0 until list.length()).mapNotNull { list.optJSONObject(it) }
.firstOrNull { it.optLong("productId") == product }?.optString("applicationId")?.takeIf { it.isNotEmpty() }
}
private fun failure(reason: String) = JSONObject().put("ok", false).put("reason", reason)
}
@@ -0,0 +1,57 @@
package com.droiddeck.launcher.stores.ubisoft
import android.util.Log
import com.droiddeck.launcher.stores.StoresNative
import com.droiddeck.launcher.stores.amazon.AmazonNative
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
object UbisoftNative {
private const val TAG = "UbisoftNative"
const val ENGINE_MISSING = "Ubisoft: the store engine is not available"
fun ready(): Boolean = StoresNative.ensureLoaded()
fun call(op: String, request: JSONObject): JSONObject? {
if (!ready()) return null
val text = try { nativeCall(op, request.toString()) } catch (t: Throwable) {
Log.w(TAG, "$op: ${t.javaClass.simpleName}")
null
} ?: return null
return runCatching { JSONObject(text) }.getOrNull()
}
fun install(request: JSONObject, events: AmazonNative.InstallListener, cancelled: AtomicBoolean): AmazonNative.InstallOutcome {
if (!ready()) return AmazonNative.InstallOutcome(false, false, ENGINE_MISSING, null)
val done = CountDownLatch(1)
val holder = arrayOfNulls<AmazonNative.InstallOutcome>(1)
val bridge = object : AmazonNative.InstallListener by events {
override fun onComplete(success: Boolean, cancelled: Boolean, error: String, result: String) {
holder[0] = AmazonNative.InstallOutcome(success, cancelled, error, result.takeIf { it.isNotEmpty() }?.let { runCatching { JSONObject(it) }.getOrNull() })
done.countDown()
}
}
val handle = try { nativeInstall(request.toString(), bridge) } catch (t: Throwable) {
return AmazonNative.InstallOutcome(false, false, "The Ubisoft install could not start: ${t.javaClass.simpleName}", null)
}
if (handle == 0L) return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The Ubisoft install could not start", null)
try {
var forwarded = false
while (true) {
val finished = try { done.await(250, TimeUnit.MILLISECONDS) } catch (e: InterruptedException) { cancelled.set(true); false }
if (finished) break
if (!forwarded && cancelled.get()) { nativeCancel(handle); forwarded = true }
}
} finally {
nativeRelease(handle)
}
return holder[0] ?: AmazonNative.InstallOutcome(false, false, "The Ubisoft install ended without a result", null)
}
@JvmStatic private external fun nativeCall(op: String, request: String): String?
@JvmStatic private external fun nativeInstall(request: String, listener: AmazonNative.InstallListener): Long
@JvmStatic private external fun nativeCancel(handle: Long)
@JvmStatic private external fun nativeRelease(handle: Long)
}
@@ -33,6 +33,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C)) Store.ITCH.id -> SourceColours(Color(0xFF3A1A1E), Color(0xFFFFB3B3), Color(0xFFFF5C5C))
Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00)) Store.GAMEJOLT.id -> SourceColours(Color(0xFF232E00), Color(0xFFE3FF66), Color(0xFFCCFF00))
Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF)) Store.BATTLENET.id -> SourceColours(Color(0xFF0A2440), Color(0xFF9DD1FF), Color(0xFF148EFF))
Store.UBISOFT.id -> SourceColours(Color(0xFF0B1E44), Color(0xFFA8C6FF), Color(0xFF2E7DFF))
Store.EA.id -> SourceColours(Color(0xFF3A0E12), Color(0xFFFF9A9A), Color(0xFFFF4747)) Store.EA.id -> SourceColours(Color(0xFF3A0E12), Color(0xFFFF9A9A), Color(0xFFFF4747))
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF)) Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal) FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
@@ -49,6 +50,7 @@ internal fun sourceLabel(source: String): String = when (source) {
Store.ITCH.id -> Store.ITCH.label Store.ITCH.id -> Store.ITCH.label
Store.GAMEJOLT.id -> Store.GAMEJOLT.label Store.GAMEJOLT.id -> Store.GAMEJOLT.label
Store.BATTLENET.id -> Store.BATTLENET.label Store.BATTLENET.id -> Store.BATTLENET.label
Store.UBISOFT.id -> Store.UBISOFT.shortLabel
Store.EA.id -> Store.EA.label Store.EA.id -> Store.EA.label
else -> stringResource(R.string.source_added) else -> stringResource(R.string.source_added)
} }
@@ -1157,6 +1157,7 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B) Store.ITCH -> Color(0xFFFF5C5C) to Color(0xFFD93B3B)
Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00) Store.GAMEJOLT -> Color(0xFFCCFF00) to Color(0xFFA3CC00)
Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3) Store.BATTLENET -> Color(0xFF148EFF) to Color(0xFF0B6BD3)
Store.UBISOFT -> Color(0xFF2E7DFF) to Color(0xFF1A5BD6)
Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030) Store.EA -> Color(0xFFFF4747) to Color(0xFFE03030)
null -> return base null -> return base
} }
@@ -56,7 +56,7 @@ import java.io.File
private fun installable(name: String, all: Map<String, WinComponents.Component>): String = WinComponents.installable(name, all) private fun installable(name: String, all: Map<String, WinComponents.Component>): String = WinComponents.installable(name, all)
@Composable @Composable
private fun originName(origin: String): String = if (origin == WinCompSources.Origin.XBOX.name) "Xbox" else stringResource( private fun originName(origin: String): String = if (origin == WinCompSources.Origin.XBOX.name) "Xbox" else if (origin == WinCompSources.Origin.UBISOFT.name) "Ubisoft Connect" else stringResource(
when (origin) { when (origin) {
WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog
WinCompSources.Origin.EPIC.name -> R.string.wincomp_src_epic WinCompSources.Origin.EPIC.name -> R.string.wincomp_src_epic
@@ -71,6 +71,7 @@ private fun autoReason(r: WinComponents.AutoReason): String = when (r.kind) {
WinCompSources.Origin.GOG.name -> stringResource(R.string.wincomp_auto_gog) WinCompSources.Origin.GOG.name -> stringResource(R.string.wincomp_auto_gog)
WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic) WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic)
WinCompSources.Origin.AMAZON.name -> stringResource(R.string.wincomp_auto_amazon) WinCompSources.Origin.AMAZON.name -> stringResource(R.string.wincomp_auto_amazon)
WinCompSources.Origin.UBISOFT.name -> "From Ubisoft's install steps for this game"
WinCompSources.Origin.XBOX.name -> "Xbox games install it with the game (${r.args.getOrElse(0) { "VC14" }})" WinCompSources.Origin.XBOX.name -> "Xbox games install it with the game (${r.args.getOrElse(0) { "VC14" }})"
else -> "" else -> ""
} }
+18
View File
@@ -195,10 +195,13 @@ dependencies = [
"libc", "libc",
"lzma-rs", "lzma-rs",
"reqwest", "reqwest",
"ruzstd",
"serde_json", "serde_json",
"sha1", "sha1",
"sha2", "sha2",
"tokio", "tokio",
"tokio-rustls",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -897,6 +900,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ruzstd"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8"
dependencies = [
"twox-hash",
]
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"
@@ -1243,6 +1255,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "twox-hash"
version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.0" version = "1.20.0"
+4 -1
View File
@@ -15,11 +15,14 @@ libc = "0.2"
lzma-rs = { version = "0.3", default-features = false } lzma-rs = { version = "0.3", default-features = false }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] }
tokio = { version = "1", default-features = false, features = ["rt", "net", "time", "sync"] } tokio = { version = "1", default-features = false, features = ["rt", "net", "time", "sync", "io-util"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] } futures-util = { version = "0.3", default-features = false, features = ["std"] }
serde_json = { version = "1", features = ["preserve_order"] } serde_json = { version = "1", features = ["preserve_order"] }
sha1 = "0.10" sha1 = "0.10"
sha2 = "0.10" sha2 = "0.10"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
webpki-roots = "1"
ruzstd = "0.8"
[build-dependencies] [build-dependencies]
cc = "1" cc = "1"
+1
View File
@@ -3,6 +3,7 @@
#![allow(clippy::missing_safety_doc, clippy::result_large_err)] #![allow(clippy::missing_safety_doc, clippy::result_large_err)]
pub mod amazon; pub mod amazon;
pub mod ubisoft;
pub mod ea; pub mod ea;
pub mod epic; pub mod epic;
pub mod fetch_core; pub mod fetch_core;
@@ -0,0 +1,154 @@
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio_rustls::client::TlsStream;
use tokio_rustls::rustls::{self, pki_types::ServerName, ClientConfig, RootCertStore};
use tokio_rustls::TlsConnector;
use super::pb::{self, Msg};
const HOST: &str = "dmx.upc.ubisoft.com";
const PORT: u16 = 443;
const CLIENT_ID: &str = "uplay_pc";
/// The Connect build number Demux expects first on a socket; older ones get ClientOutdatedPush and a closed socket.
pub const CLIENT_VERSION: u32 = 13247;
const READ_TIMEOUT: Duration = Duration::from_secs(20);
const MAX_FRAME: usize = 64 << 20;
pub struct Demux {
tls: TlsStream<TcpStream>,
next_id: u32,
}
impl Demux {
pub async fn connect(ticket: &str) -> Result<Self, String> {
let mut roots = RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| format!("TLS setup: {e}"))?
.with_root_certificates(roots)
.with_no_client_auth();
let tcp = tokio::time::timeout(READ_TIMEOUT, TcpStream::connect((HOST, PORT)))
.await
.map_err(|_| "Ubisoft's download service did not answer".to_string())?
.map_err(|e| format!("Could not reach Ubisoft's download service: {e}"))?;
let name = ServerName::try_from(HOST).map_err(|e| e.to_string())?.to_owned();
let tls = TlsConnector::from(Arc::new(config))
.connect(name, tcp)
.await
.map_err(|e| format!("TLS with Ubisoft's download service failed: {e}"))?;
let mut d = Demux { tls, next_id: 1 };
d.send(Msg::new().msg(2, Msg::new().msg(4, Msg::new().uint(1, CLIENT_VERSION as u64)))).await?;
let id = d.id();
let auth = Msg::new().uint(1, id as u64).msg(
2,
Msg::new()
.msg(1, Msg::new().text(3, ticket))
.text(2, CLIENT_ID)
.flag(4, false),
);
d.send(Msg::new().msg(1, auth)).await?;
let rsp = d.response(id).await?;
let a = pb::fields(pb::sub(&pb::fields(&rsp)?, 2).ok_or("Ubisoft did not answer the sign-in")?)?;
if pb::num(&a, 1) != Some(1) {
let expired = pb::num(&a, 2) == Some(1);
return Err(if expired { "The Ubisoft sign-in expired".into() } else { "Ubisoft refused the sign-in".into() });
}
Ok(d)
}
fn id(&mut self) -> u32 {
let id = self.next_id;
self.next_id += 1;
id
}
async fn send(&mut self, up: Msg) -> Result<(), String> {
let len = (up.0.len() as u32).to_be_bytes();
self.tls.write_all(&len).await.map_err(|e| e.to_string())?;
self.tls.write_all(&up.0).await.map_err(|e| e.to_string())?;
self.tls.flush().await.map_err(|e| e.to_string())
}
async fn frame(&mut self) -> Result<Vec<u8>, String> {
let read = async {
let mut len = [0u8; 4];
self.tls.read_exact(&mut len).await.map_err(|e| format!("Ubisoft closed the connection: {e}"))?;
let n = u32::from_be_bytes(len) as usize;
if n > MAX_FRAME {
return Err(format!("Ubisoft sent a {n}-byte frame"));
}
let mut body = vec![0u8; n];
self.tls.read_exact(&mut body).await.map_err(|e| e.to_string())?;
Ok(body)
};
tokio::time::timeout(READ_TIMEOUT, read).await.map_err(|_| "Ubisoft's download service stopped answering".to_string())?
}
fn check_push(down: &[(u32, pb::Value<'_>)]) -> Result<(), String> {
if let Some(push) = pb::sub(down, 2) {
let p = pb::fields(push)?;
if pb::first(&p, 5).is_some() {
return Err("Ubisoft says this client version is outdated".into());
}
}
Ok(())
}
async fn response(&mut self, id: u32) -> Result<Vec<u8>, String> {
for _ in 0..64 {
let frame = self.frame().await?;
let down = pb::fields(&frame)?;
Self::check_push(&down)?;
if let Some(rsp) = pb::sub(&down, 1) {
if pb::num(&pb::fields(rsp)?, 1) == Some(id as u64) {
return Ok(rsp.to_vec());
}
}
}
Err("Ubisoft never answered the request".into())
}
pub async fn open(&mut self, service: &str) -> Result<u32, String> {
let id = self.id();
self.send(Msg::new().msg(1, Msg::new().uint(1, id as u64).msg(3, Msg::new().text(2, service)))).await?;
let rsp = self.response(id).await?;
let o = pb::fields(pb::sub(&pb::fields(&rsp)?, 3).ok_or("no connection answer")?)?;
if pb::num(&o, 2) != Some(1) {
return Err(format!("Ubisoft refused the {service} connection"));
}
pb::num(&o, 1).map(|c| c as u32).ok_or_else(|| "no connection id".into())
}
/// One service request on a connection; the payload is length-prefixed inside the DataMessage both ways.
pub async fn call(&mut self, connection: u32, payload: Msg) -> Result<Vec<u8>, String> {
let mut framed = (payload.0.len() as u32).to_be_bytes().to_vec();
framed.extend_from_slice(&payload.0);
self.send(Msg::new().msg(2, Msg::new().msg(1, Msg::new().uint(1, connection as u64).bytes(2, &framed)))).await?;
for _ in 0..64 {
let frame = self.frame().await?;
let down = pb::fields(&frame)?;
Self::check_push(&down)?;
let Some(push) = pb::sub(&down, 2) else { continue };
let p = pb::fields(push)?;
if let Some(closed) = pb::sub(&p, 2) {
let c = pb::fields(closed)?;
if pb::num(&c, 1) == Some(connection as u64) {
return Err(format!("Ubisoft closed the service connection (code {})", pb::num(&c, 2).unwrap_or(0)));
}
}
let Some(data) = pb::sub(&p, 1) else { continue };
let d = pb::fields(data)?;
if pb::num(&d, 1) != Some(connection as u64) {
continue;
}
let body = pb::sub(&d, 2).unwrap_or_default();
return Ok(if body.len() >= 4 { body[4..].to_vec() } else { body.to_vec() });
}
Err("Ubisoft never answered the service request".into())
}
}
+269
View File
@@ -0,0 +1,269 @@
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
use jni::sys::{jlong, jstring, JNI_FALSE, JNI_TRUE};
use jni::{JNIEnv, JavaVM};
use serde_json::json;
use super::install;
use crate::amazon::install::{Events, Stop};
fn read_string(env: &mut JNIEnv, value: &JString) -> String {
if value.is_null() {
return String::new();
}
env.get_string(value).map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
}
fn new_string(env: &mut JNIEnv, text: &str) -> jstring {
env.new_string(text).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut())
}
fn owned(ticket: &str, session: &str) -> Result<serde_json::Value, String> {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(async {
let mut d = super::demux::Demux::connect(ticket).await?;
let (mut ownership, mut games) = super::service::Ownership::open(&mut d, ticket, session).await?;
let missing: Vec<u32> = games.iter().filter(|g| g.owned && g.product_type == 0 && g.manifest.is_empty()).map(|g| g.product_id).collect();
let (found, manifest_error) = match ownership.latest_manifests(&mut d, &missing).await {
Ok(m) => (m, String::new()),
Err(e) => (Vec::new(), e),
};
for (product, manifest) in &found {
if let Some(g) = games.iter_mut().find(|g| g.product_id == *product) {
g.manifest = manifest.clone();
}
}
Ok(json!({ "games": games.iter().map(|g| g.to_json()).collect::<Vec<_>>(),
"manifestsAsked": missing.len(), "manifestsFound": found.len(), "manifestError": manifest_error }))
})
}
fn launch(ticket: &str, session: &str, product: u32) -> Result<serde_json::Value, String> {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(async {
let mut d = super::demux::Demux::connect(ticket).await?;
let (mut ownership, games) = super::service::Ownership::open(&mut d, ticket, session).await?;
if !games.iter().any(|g| g.product_id == product && g.owned) {
return Err("Ubisoft says this account does not own the game".into());
}
let pc_ticket = ownership.pc_ticket(&mut d, product, ticket, session).await?;
let owned: Vec<u32> = games.iter().filter(|g| g.in_session()).map(|g| g.product_id).collect();
let products: Vec<_> = games.iter().filter(|g| g.in_session()).map(|g| json!({ "id": g.product_id, "type": g.product_type })).collect();
Ok(json!({ "ticket": pc_ticket, "owned": owned, "products": products }))
})
}
fn unhex(s: &str) -> Result<Vec<u8>, String> {
if s.len() % 2 != 0 {
return Err("odd hex length".into());
}
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string())).collect()
}
fn denuvo(ticket: &str, session: &str, product: u32, challenge: &str) -> Result<serde_json::Value, String> {
let challenge = unhex(challenge).map_err(|e| format!("challenge: {e}"))?;
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(async {
let mut d = super::demux::Demux::connect(ticket).await?;
let (mut ownership, _) = super::service::Ownership::open(&mut d, ticket, session).await?;
let token = ownership.token(&mut d, product).await?;
let game = super::service::denuvo_token(&mut d, &token, &challenge).await?;
Ok(json!({ "token": game.iter().map(|b| format!("{b:02x}")).collect::<String>() }))
})
}
fn call(op: &str, request: &str) -> String {
let parsed: serde_json::Value = serde_json::from_str(request).unwrap_or(serde_json::Value::Null);
let s = |k: &str| parsed.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
let answer = match op {
"owned" => owned(&s("ticket"), &s("sessionId")).unwrap_or_else(|e| json!({ "error": e })),
"denuvo" => denuvo(&s("ticket"), &s("sessionId"), parsed.get("productId").and_then(|v| v.as_u64()).unwrap_or(0) as u32, &s("challenge"))
.unwrap_or_else(|e| json!({ "error": e })),
"chunks" => install::Request::from_json(&parsed)
.and_then(|r| install::chunks(&r))
.map(|m| json!({ "chunks": install::chunks_json(&m.chunks), "setup": install::setup_json(&m) }))
.unwrap_or_else(|e| json!({ "error": e })),
"launch" => launch(&s("ticket"), &s("sessionId"), parsed.get("productId").and_then(|v| v.as_u64()).unwrap_or(0) as u32)
.unwrap_or_else(|e| json!({ "error": e })),
_ => json!({ "error": format!("unknown operation {op}") }),
};
answer.to_string()
}
#[no_mangle]
pub extern "system" fn Java_com_droiddeck_launcher_stores_ubisoft_UbisoftNative_nativeCall(
mut env: JNIEnv,
_class: JClass,
op: JString,
request: JString,
) -> jstring {
let op = read_string(&mut env, &op);
let request = read_string(&mut env, &request);
match catch_unwind(AssertUnwindSafe(|| call(&op, &request))) {
Ok(text) => new_string(&mut env, &text),
Err(_) => std::ptr::null_mut(),
}
}
struct Listener {
vm: JavaVM,
target: GlobalRef,
}
impl Listener {
fn with_env<R>(&self, fallback: R, f: impl FnOnce(&mut JNIEnv) -> R) -> R {
let Ok(mut env) = self.vm.attach_current_thread_as_daemon() else {
return fallback;
};
let out = f(&mut env);
if env.exception_check().unwrap_or(false) {
let _ = env.exception_clear();
}
out
}
fn text(&self, method: &str, text: &str) {
self.with_env((), |env| {
let Ok(s) = env.new_string(text) else { return };
let s = JObject::from(s);
let _ = env.call_method(self.target.as_obj(), method, "(Ljava/lang/String;)V", &[JValue::Object(&s)]);
let _ = env.delete_local_ref(s);
});
}
fn counts(&self, method: &str, a: u64, b: u64, c: u32, d: u32) {
self.with_env((), |env| {
let _ = env.call_method(
self.target.as_obj(),
method,
"(JJII)V",
&[
JValue::Long(a.min(i64::MAX as u64) as i64),
JValue::Long(b.min(i64::MAX as u64) as i64),
JValue::Int(c.min(i32::MAX as u32) as i32),
JValue::Int(d.min(i32::MAX as u32) as i32),
],
);
});
}
fn complete(&self, success: bool, cancelled: bool, error: &str, result: &str) {
self.with_env((), |env| {
let (Ok(e), Ok(r)) = (env.new_string(error), env.new_string(result)) else { return };
let (e, r) = (JObject::from(e), JObject::from(r));
let _ = env.call_method(
self.target.as_obj(),
"onComplete",
"(ZZLjava/lang/String;Ljava/lang/String;)V",
&[
JValue::Bool(if success { JNI_TRUE } else { JNI_FALSE }),
JValue::Bool(if cancelled { JNI_TRUE } else { JNI_FALSE }),
JValue::Object(&e),
JValue::Object(&r),
],
);
let _ = env.delete_local_ref(e);
let _ = env.delete_local_ref(r);
});
}
}
impl Events for Listener {
fn log(&self, line: &str) {
self.text("onLog", &crate::amazon::net::scrub_urls(line));
}
fn status(&self, text: &str) {
self.text("onStatus", text);
}
fn checking(&self, done_bytes: u64, total_bytes: u64, files: u32, files_total: u32) {
self.counts("onCheck", done_bytes, total_bytes, files, files_total);
}
fn sizes(&self, pending_bytes: u64, total_bytes: u64) {
self.with_env((), |env| {
let _ = env.call_method(
self.target.as_obj(),
"onSizes",
"(JJ)V",
&[
JValue::Long(pending_bytes.min(i64::MAX as u64) as i64),
JValue::Long(total_bytes.min(i64::MAX as u64) as i64),
],
);
});
}
fn progress(&self, done: u64, total: u64, files: u32, files_total: u32) {
self.counts("onProgress", done, total, files, files_total);
}
fn before_replace(&self) -> bool {
self.with_env(false, |env| match env.call_method(self.target.as_obj(), "beforeReplace", "()Z", &[]) {
Ok(v) => v.z().unwrap_or(false),
Err(_) => false,
})
}
}
#[no_mangle]
pub extern "system" fn Java_com_droiddeck_launcher_stores_ubisoft_UbisoftNative_nativeInstall(
mut env: JNIEnv,
_class: JClass,
request: JString,
listener: JObject,
) -> jlong {
if listener.is_null() {
return 0;
}
let Ok(vm) = env.get_java_vm() else { return 0 };
let Ok(target) = env.new_global_ref(&listener) else { return 0 };
let listener = Listener { vm, target };
let text = read_string(&mut env, &request);
let parsed = serde_json::from_str::<serde_json::Value>(&text)
.map_err(|_| "The install request could not be read".to_string())
.and_then(|v| install::Request::from_json(&v));
let request = match parsed {
Ok(r) => r,
Err(e) => {
listener.complete(false, false, &e, "");
return 0;
}
};
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = Arc::clone(&cancel);
let spawned = std::thread::Builder::new().name("ubisoft-install".into()).spawn(move || {
crate::priority::background();
let result = catch_unwind(AssertUnwindSafe(|| install::run(&request, &worker_cancel, &listener)))
.unwrap_or_else(|_| Err(Stop::Failed("The Ubisoft install stopped on an internal error".into())));
match result {
Ok(done) => listener.complete(true, false, "", &done.to_json().to_string()),
Err(Stop::Cancelled) => listener.complete(false, true, "cancelled", ""),
Err(Stop::Failed(e)) => listener.complete(false, false, &e, ""),
}
});
if spawned.is_err() {
return 0;
}
Box::into_raw(Box::new(cancel)) as jlong
}
#[no_mangle]
pub extern "system" fn Java_com_droiddeck_launcher_stores_ubisoft_UbisoftNative_nativeCancel(_env: JNIEnv, _class: JClass, handle: jlong) {
let ptr = handle as *const Arc<AtomicBool>;
if let Some(cancel) = unsafe { ptr.as_ref() } {
cancel.store(true, Ordering::SeqCst);
}
}
#[no_mangle]
pub extern "system" fn Java_com_droiddeck_launcher_stores_ubisoft_UbisoftNative_nativeRelease(_env: JNIEnv, _class: JClass, handle: jlong) {
let ptr = handle as *mut Arc<AtomicBool>;
if !ptr.is_null() {
drop(unsafe { Box::from_raw(ptr) });
}
}
@@ -0,0 +1,408 @@
use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use futures_util::stream::{self, StreamExt};
use serde_json::{json, Value as Json};
use sha1::{Digest, Sha1};
use super::demux::Demux;
use super::manifest::{self, File};
use super::service::{Downloads, Ownership};
use crate::amazon::install::{Events, Stop};
const SIGN_BATCH: usize = 200;
const WINDOW: usize = 16;
const TRIES: usize = 4;
pub struct Request {
pub ticket: String,
pub session_id: String,
pub product_id: u32,
pub manifest: String,
pub folder: PathBuf,
pub language: String,
}
impl Request {
pub fn from_json(v: &Json) -> Result<Self, String> {
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
let product_id = v.get("productId").and_then(|x| x.as_u64()).ok_or("no product id")? as u32;
let folder = PathBuf::from(s("folder"));
if s("ticket").is_empty() || folder.as_os_str().is_empty() {
return Err("The install request is incomplete".into());
}
Ok(Request { ticket: s("ticket"), session_id: s("sessionId"), product_id, manifest: s("manifest"), folder, language: s("language") })
}
}
pub struct Done {
pub files: usize,
pub bytes: u64,
pub chunks: Vec<manifest::Chunk>,
pub setup: Json,
}
pub fn chunks_json(chunks: &[manifest::Chunk]) -> Json {
Json::Array(chunks.iter().map(|c| json!({ "id": c.id, "tags": c.tags })).collect())
}
pub fn setup_json(m: &manifest::Manifest) -> Json {
json!({
"installRuns": m.install_runs.iter().map(|(exe, args, what)| json!({ "exe": exe, "arguments": args, "description": what })).collect::<Vec<_>>(),
"registry": m.registry.iter().map(|(key, values)| json!({ "key": key, "values": values.iter().map(|(n, v)| json!({ "name": n, "value": v })).collect::<Vec<_>>() })).collect::<Vec<_>>(),
})
}
impl Done {
pub fn to_json(&self) -> Json {
json!({ "files": self.files, "bytes": self.bytes, "chunks": chunks_json(&self.chunks), "setup": self.setup.clone() })
}
}
pub fn safe_path(root: &Path, name: &str) -> Result<PathBuf, String> {
let rel = Path::new(name.trim_start_matches('/'));
if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
return Err(format!("The manifest names an unsafe path: {name}"));
}
Ok(root.join(rel))
}
fn sha1(b: &[u8]) -> Vec<u8> {
Sha1::digest(b).to_vec()
}
/// The slice as served and as unpacked must both match the manifest, so a bad proxy or a truncated body never lands in the game.
pub fn checked(method: u64, raw: &[u8], s: &manifest::Slice) -> Result<Vec<u8>, String> {
if let Some(n) = s.download_size {
if raw.len() as u32 != n {
return Err(format!("a slice is {} bytes, the manifest says {n}", raw.len()));
}
}
if !s.download_sha1.is_empty() && sha1(raw) != s.download_sha1 {
return Err("a slice failed its download checksum".into());
}
let out = manifest::decompress(method, raw)?;
if s.size != 0 && out.len() as u32 != s.size {
return Err(format!("a slice unpacked to {} bytes, the manifest says {}", out.len(), s.size));
}
if s.content_sha1.len() == 20 && sha1(&out) != s.content_sha1 {
return Err("a slice failed its content checksum".into());
}
Ok(out)
}
/// Whether a file of the right size already holds every slice the manifest lists for it; a file that cannot be checked is fetched again.
fn already_installed(path: &Path, f: &File) -> bool {
if f.slices.iter().any(|s| s.content_sha1.len() != 20 || s.size == 0) {
return false;
}
let Ok(mut file) = std::fs::File::open(path) else { return false };
let mut buf = Vec::new();
f.slices.iter().all(|s| {
buf.resize(s.size as usize, 0);
file.seek(SeekFrom::Start(s.offset)).is_ok() && file.read_exact(&mut buf).is_ok() && sha1(&buf) == s.content_sha1
})
}
fn slice_hash(s: &manifest::Slice) -> String {
manifest::hex_upper(if s.download_sha1.is_empty() { &s.content_sha1 } else { &s.download_sha1 })
}
struct Job {
file: usize,
slice: usize,
path: String,
}
async fn fetch(client: &reqwest::Client, urls: &[String]) -> Result<Vec<u8>, String> {
let mut last = String::from("no URL");
for attempt in 0..TRIES {
for u in urls {
match client.get(u).send().await {
Ok(r) if r.status().is_success() => match r.bytes().await {
Ok(b) => return Ok(b.to_vec()),
Err(e) => last = e.to_string(),
},
Ok(r) => last = format!("HTTP {}", r.status().as_u16()),
Err(e) => last = e.to_string(),
}
}
tokio::time::sleep(Duration::from_millis(500 << attempt)).await;
}
Err(format!("a slice could not be downloaded ({last})"))
}
/// A Demux login holding a download_service connection for one product. Ubisoft drops idle sockets
/// while slices download, so signing reconnects and retries when the connection is gone.
struct Session {
d: Demux,
dl: Downloads,
}
const RECONNECTS: usize = 4;
impl Session {
/// Opens a session, retrying a few times: Demux drops a connection now and then right after another closes.
async fn open(req: &Request) -> Result<(Self, Vec<super::service::Owned>), String> {
let mut last = String::new();
for attempt in 0..3u32 {
if attempt > 0 {
tokio::time::sleep(Duration::from_secs(2 << attempt)).await;
}
match Self::open_once(req).await {
Ok(s) => return Ok(s),
Err(e) => last = e,
}
}
Err(last)
}
async fn open_once(req: &Request) -> Result<(Self, Vec<super::service::Owned>), String> {
let mut d = Demux::connect(&req.ticket).await?;
let (mut own, owned) = Ownership::open(&mut d, &req.ticket, &req.session_id).await?;
let token = own.token(&mut d, req.product_id).await?;
let dl = Downloads::open(&mut d, &token).await?;
Ok((Session { d, dl }, owned))
}
async fn sign(&mut self, req: &Request, paths: &[String], events: &dyn Events) -> Result<Vec<Vec<String>>, String> {
let mut last = String::new();
for attempt in 0..=RECONNECTS {
if attempt > 0 {
events.log(&format!("ubisoft: reconnecting after: {last}"));
tokio::time::sleep(Duration::from_millis(500 << attempt.min(4))).await;
match Session::open(req).await {
Ok((fresh, _)) => *self = fresh,
Err(e) => {
last = e;
continue;
}
}
}
match self.dl.sign(&mut self.d, req.product_id, paths).await {
Ok(urls) => return Ok(urls),
Err(e) if e.contains("does not own") => return Err(e),
Err(e) => last = e,
}
}
Err(last)
}
}
/// The manifest of an install, without downloading the game.
pub fn chunks(req: &Request) -> Result<manifest::Manifest, String> {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(async {
let (mut sess, owned) = Session::open(req).await?;
let manifest_id = if req.manifest.is_empty() {
owned.iter().find(|o| o.product_id == req.product_id).map(|o| o.manifest.clone()).unwrap_or_default()
} else {
req.manifest.clone()
};
if manifest_id.is_empty() {
return Err("Ubisoft lists no download for this game".to_string());
}
let client = reqwest::Client::builder().timeout(Duration::from_secs(120)).build().map_err(|e| e.to_string())?;
struct Quiet;
impl Events for Quiet {
fn log(&self, _: &str) {}
fn status(&self, _: &str) {}
fn checking(&self, _: u64, _: u64, _: u32, _: u32) {}
fn sizes(&self, _: u64, _: u64) {}
fn progress(&self, _: u64, _: u64, _: u32, _: u32) {}
fn before_replace(&self) -> bool { false }
}
let urls = sess.sign(req, &[format!("manifests/{manifest_id}.manifest")], &Quiet).await?;
let raw = fetch(&client, urls.first().map(|v| v.as_slice()).unwrap_or_default()).await?;
let language = if req.language.is_empty() { "en-US" } else { req.language.as_str() };
let mut owned_ids: Vec<u32> = owned.iter().filter(|o| o.owned).map(|o| o.product_id).collect();
owned_ids.push(req.product_id);
Ok(manifest::parse_owned(&manifest::decode(&raw)?, language, &owned_ids)?)
})
}
pub fn run(req: &Request, cancel: &AtomicBool, events: &dyn Events) -> Result<Done, Stop> {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| Stop::Failed(e.to_string()))?;
rt.block_on(install(req, cancel, events))
}
async fn install(req: &Request, cancel: &AtomicBool, events: &dyn Events) -> Result<Done, Stop> {
let fail = Stop::Failed;
events.status("Connecting to Ubisoft");
let (mut sess, owned) = Session::open(req).await.map_err(fail)?;
let manifest_id = if req.manifest.is_empty() {
owned.iter().find(|o| o.product_id == req.product_id).map(|o| o.manifest.clone()).unwrap_or_default()
} else {
req.manifest.clone()
};
if manifest_id.is_empty() {
return Err(Stop::Failed("Ubisoft lists no download for this game".into()));
}
let client = reqwest::Client::builder().timeout(Duration::from_secs(120)).build().map_err(|e| Stop::Failed(e.to_string()))?;
events.status("Reading the game's file list");
let urls = sess.sign(req, &[format!("manifests/{manifest_id}.manifest")], events).await.map_err(fail)?;
let raw = fetch(&client, urls.first().map(|v| v.as_slice()).unwrap_or_default()).await.map_err(fail)?;
let decoded = manifest::decode(&raw).map_err(fail)?;
let language = if req.language.is_empty() { "en-US" } else { req.language.as_str() };
let mut owned_ids: Vec<u32> = owned.iter().filter(|o| o.owned).map(|o| o.product_id).collect();
owned_ids.push(req.product_id);
let m = manifest::parse_owned(&decoded, language, &owned_ids).map_err(fail)?;
events.log(&format!("ubisoft: manifest {manifest_id} files={} chunks={} skipped={} compression={}", m.files.len(), m.chunks.len(), m.skipped.len(), m.compression));
for name in &m.skipped {
if let Ok(path) = safe_path(&req.folder, name) {
if path.is_file() && std::fs::remove_file(&path).is_ok() {
events.log(&format!("ubisoft: removed {name}, which this install leaves out"));
}
}
}
std::fs::create_dir_all(&req.folder).map_err(|e| Stop::Failed(format!("Could not create the game folder: {e}")))?;
let total: u64 = m.files.iter().map(|f| f.size).sum();
let files_total = m.files.len() as u32;
let mut pending: Vec<usize> = Vec::new();
let mut done_bytes = 0u64;
let mut done_files = 0u32;
for (i, f) in m.files.iter().enumerate() {
let path = safe_path(&req.folder, &f.name).map_err(fail)?;
if f.dir {
std::fs::create_dir_all(&path).map_err(|e| Stop::Failed(e.to_string()))?;
done_files += 1;
continue;
}
if std::fs::metadata(&path).map(|md| md.len() == f.size).unwrap_or(false) && already_installed(&path, f) {
done_bytes += f.size;
done_files += 1;
continue;
}
pending.push(i);
}
let pending_bytes: u64 = pending.iter().map(|&i| m.files[i].size).sum();
events.sizes(pending_bytes, total);
events.log(&format!("ubisoft: {} of {} files to fetch, {pending_bytes} bytes", pending.len(), m.files.len()));
let bucketed = match pending.iter().flat_map(|&i| m.files[i].slices.first()).next() {
None => true,
Some(s) => {
let h = slice_hash(s);
let probe = sess.sign(req, &[manifest::slice_path(true, &h), manifest::slice_path(false, &h)], events).await.map_err(fail)?;
let mut v3 = true;
for (layout, list) in [true, false].into_iter().zip(probe.iter()) {
if let Some(u) = list.first() {
if client.get(u).send().await.map(|r| r.status().is_success()).unwrap_or(false) {
v3 = layout;
break;
}
}
}
v3
}
};
let mut parts: HashMap<usize, Mutex<std::fs::File>> = HashMap::new();
let mut remaining: HashMap<usize, usize> = HashMap::new();
let mut jobs = Vec::new();
for &i in &pending {
let f: &File = &m.files[i];
let path = safe_path(&req.folder, &f.name).map_err(fail)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Stop::Failed(e.to_string()))?;
}
let part = path.with_extension(format!("{}part", path.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()));
let file = std::fs::File::create(&part).map_err(|e| Stop::Failed(format!("Could not write {}: {e}", f.name)))?;
file.set_len(f.size).map_err(|e| Stop::Failed(e.to_string()))?;
parts.insert(i, Mutex::new(file));
remaining.insert(i, f.slices.len());
for (si, s) in f.slices.iter().enumerate() {
jobs.push(Job { file: i, slice: si, path: manifest::slice_path(bucketed, &slice_hash(s)) });
}
}
let progress = AtomicU64::new(done_bytes);
let mut finished_files = done_files;
let mut last_report = Instant::now();
events.status("Downloading");
for batch in jobs.chunks(SIGN_BATCH) {
if cancel.load(Ordering::SeqCst) {
return Err(Stop::Cancelled);
}
let paths: Vec<String> = batch.iter().map(|j| j.path.clone()).collect();
let signed = sess.sign(req, &paths, events).await.map_err(fail)?;
let mut slices = stream::iter(batch.iter().zip(signed.into_iter()))
.map(|(job, urls)| {
let client = &client;
let m = &m;
async move {
let s = &m.files[job.file].slices[job.slice];
let raw = fetch(client, &urls).await?;
let out = checked(m.compression, &raw, s).map_err(|e| format!("{}: {e}", m.files[job.file].name))?;
Ok::<_, String>((job.file, job.slice, out))
}
})
.buffer_unordered(WINDOW);
while let Some(r) = slices.next().await {
if cancel.load(Ordering::SeqCst) {
return Err(Stop::Cancelled);
}
let (fi, si, data) = r.map_err(fail)?;
let s = &m.files[fi].slices[si];
{
let mut f = parts[&fi].lock().unwrap();
f.seek(SeekFrom::Start(s.offset)).and_then(|_| f.write_all(&data)).map_err(|e| Stop::Failed(format!("Could not write {}: {e}", m.files[fi].name)))?;
}
progress.fetch_add(data.len() as u64, Ordering::Relaxed);
let left = remaining.get_mut(&fi).unwrap();
*left -= 1;
if *left == 0 {
let f = parts.remove(&fi).unwrap().into_inner().unwrap();
f.sync_all().map_err(|e| Stop::Failed(e.to_string()))?;
drop(f);
let path = safe_path(&req.folder, &m.files[fi].name).map_err(fail)?;
let part = path.with_extension(format!("{}part", path.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()));
std::fs::rename(&part, &path).map_err(|e| Stop::Failed(format!("Could not finish {}: {e}", m.files[fi].name)))?;
finished_files += 1;
}
if last_report.elapsed() > Duration::from_millis(250) {
events.progress(progress.load(Ordering::Relaxed), total, finished_files, files_total);
last_report = Instant::now();
}
}
}
for i in pending.iter().copied().filter(|i| m.files[*i].slices.is_empty()) {
let path = safe_path(&req.folder, &m.files[i].name).map_err(fail)?;
parts.remove(&i);
let part = path.with_extension(format!("{}part", path.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()));
std::fs::rename(&part, &path).map_err(|e| Stop::Failed(e.to_string()))?;
finished_files += 1;
}
events.progress(total, total, finished_files, files_total);
Ok(Done { files: m.files.len(), bytes: total, chunks: m.chunks.clone(), setup: setup_json(&m) })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refuses_paths_that_leave_the_folder() {
let root = Path::new("/games/x");
assert!(safe_path(root, "../evil").is_err());
assert!(safe_path(root, "a/../../evil").is_err());
assert_eq!(safe_path(root, "/bin/game.exe").unwrap(), root.join("bin/game.exe"));
}
#[test]
fn checks_a_slice_as_served_and_unpacked() {
let data = b"slice".to_vec();
let ok = manifest::Slice { size: 5, download_size: Some(5), download_sha1: sha1(&data), content_sha1: Vec::new(), offset: 0 };
assert_eq!(checked(0, &data, &ok).unwrap(), data);
let bad = manifest::Slice { download_sha1: vec![0; 20], ..ok.clone() };
assert!(checked(0, &data, &bad).is_err());
let short = manifest::Slice { download_size: Some(4), ..ok };
assert!(checked(0, &data, &short).is_err());
}
}
@@ -0,0 +1,230 @@
use std::io::Read;
use super::pb;
/// Bytes Ubisoft prepends to a downloaded `.manifest` before the zlib stream.
pub const HEADER: usize = 356;
pub const DEFLATE: u64 = 1;
pub const ZSTD: u64 = 3;
#[derive(Clone, Debug, PartialEq)]
pub struct Slice {
pub size: u32,
pub download_size: Option<u32>,
pub download_sha1: Vec<u8>,
pub content_sha1: Vec<u8>,
pub offset: u64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct File {
pub name: String,
pub size: u64,
pub dir: bool,
pub slices: Vec<Slice>,
}
/// A chunk the install keeps, as the game's launcher session reports it.
#[derive(Clone, Debug, PartialEq)]
pub struct Chunk {
pub id: u32,
pub tags: String,
}
#[derive(Debug)]
pub struct Manifest {
pub compression: u64,
pub files: Vec<File>,
pub chunks: Vec<Chunk>,
/// Files of chunks left out: other languages, and add-ons the account does not own.
pub skipped: Vec<String>,
/// Prerequisite installers Connect runs after an install: exe, arguments, description.
pub install_runs: Vec<(String, String, String)>,
/// Registry keys Connect writes for the game, with their string values as (name, value).
pub registry: Vec<(String, Vec<(String, String)>)>,
}
fn file(b: &[u8]) -> Result<File, String> {
let f = pb::fields(b)?;
let hashes = pb::subs(&f, 4);
let mut offset = 0u64;
let mut slices = Vec::new();
for (i, s) in pb::subs(&f, 7).into_iter().enumerate() {
let s = pb::fields(s)?;
let size = pb::num(&s, 1).unwrap_or(0) as u32;
let at = pb::num(&s, 4).unwrap_or(offset);
slices.push(Slice {
size,
download_size: pb::num(&s, 2).map(|v| v as u32),
download_sha1: pb::sub(&s, 3).unwrap_or_default().to_vec(),
content_sha1: hashes.get(i).map(|h| h.to_vec()).unwrap_or_default(),
offset: at,
});
offset = at + size as u64;
}
if slices.is_empty() {
for h in &hashes {
slices.push(Slice { size: 0, download_size: None, download_sha1: Vec::new(), content_sha1: h.to_vec(), offset: 0 });
}
}
Ok(File {
name: pb::string(&f, 1).unwrap_or_default().replace('\\', "/"),
size: pb::num(&f, 2).unwrap_or(0),
dir: pb::num(&f, 3) == Some(1),
slices,
})
}
/// Files of the chunks to install: those for every language plus the language asked for, of the
/// game itself or an optional add-on in `owned` (every add-on when `owned` is empty).
pub fn parse(decoded: &[u8], language: &str) -> Result<Manifest, String> {
parse_owned(decoded, language, &[])
}
pub fn parse_owned(decoded: &[u8], language: &str, owned: &[u32]) -> Result<Manifest, String> {
let m = pb::fields(decoded)?;
let wanted = language.to_ascii_lowercase();
let prefix = wanted.split(['-', '_']).next().unwrap_or("").to_string();
let mut files = Vec::new();
let mut chunks = Vec::new();
let mut skipped = Vec::new();
for c in pb::subs(&m, 4) {
let c = pb::fields(c)?;
let lang = pb::string(&c, 5).unwrap_or_default().to_ascii_lowercase();
let mut products: Vec<u32> = c.iter().filter_map(|(n, v)| match v { pb::Value::Varint(x) if *n == 8 => Some(*x as u32), _ => None }).collect();
products.extend(pb::num(&c, 4).map(|v| v as u32));
let in_language = lang.is_empty() || lang == wanted || (!prefix.is_empty() && lang.split(['-', '_']).next() == Some(prefix.as_str()));
let optional = pb::num(&c, 2) == Some(1);
let owned_product = !optional || owned.is_empty() || products.is_empty() || products.iter().any(|p| owned.contains(p));
if !(in_language && owned_product) {
for f in pb::subs(&c, 3) {
skipped.push(file(f)?.name);
}
continue;
}
chunks.push(Chunk { id: pb::num(&c, 1).unwrap_or(0) as u32, tags: pb::string(&c, 7).unwrap_or_default() });
for f in pb::subs(&c, 3) {
files.push(file(f)?);
}
}
let kept: std::collections::HashSet<&str> = files.iter().map(|f| f.name.as_str()).collect();
skipped.retain(|n| !kept.contains(n.as_str()));
let mut install_runs = Vec::new();
for r in pb::subs(&m, 2) {
let r = pb::fields(r)?;
install_runs.push((pb::string(&r, 1).unwrap_or_default(), pb::string(&r, 3).unwrap_or_default(), pb::string(&r, 4).unwrap_or_default()));
}
let mut registry = Vec::new();
for r in pb::subs(&m, 5) {
let r = pb::fields(r)?;
let mut values = Vec::new();
for e in pb::subs(&r, 3) {
let e = pb::fields(e)?;
values.push((pb::string(&e, 1).unwrap_or_default(), pb::string(&e, 2).unwrap_or_default()));
}
registry.push((pb::string(&r, 1).unwrap_or_default(), values));
}
Ok(Manifest { compression: pb::num(&m, 21).unwrap_or(0), files, chunks, skipped, install_runs, registry })
}
pub fn decode(downloaded: &[u8]) -> Result<Vec<u8>, String> {
if downloaded.len() <= HEADER {
return Err(format!("the manifest is only {} bytes", downloaded.len()));
}
let mut out = Vec::new();
flate2::read::ZlibDecoder::new(&downloaded[HEADER..]).read_to_end(&mut out).map_err(|e| format!("the manifest did not inflate: {e}"))?;
Ok(out)
}
pub fn hex_upper(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02X}")).collect()
}
/// The CDN bucket of a slice: the first byte of its hash with nibbles swapped, mapped onto base32.
pub fn bucket(hash_upper: &str) -> char {
const BASE32: &[u8; 32] = b"0123456789abcdefghijklmnopqrstuv";
let b = hash_upper.as_bytes();
let swapped = format!("{}{}", *b.get(1).unwrap_or(&b'0') as char, *b.first().unwrap_or(&b'0') as char);
let v = u8::from_str_radix(&swapped, 16).unwrap_or(0);
BASE32[(v / 16) as usize + if v % 2 == 0 { 0 } else { 16 }] as char
}
pub fn slice_path(bucketed: bool, hash_upper: &str) -> String {
if bucketed { format!("slices_v3/{}/{}", bucket(hash_upper), hash_upper) } else { format!("slices/{hash_upper}") }
}
pub fn decompress(method: u64, data: &[u8]) -> Result<Vec<u8>, String> {
match method {
ZSTD => {
let mut out = Vec::new();
ruzstd::decoding::StreamingDecoder::new(data).map_err(|e| format!("zstd: {e}"))?.read_to_end(&mut out).map_err(|e| format!("zstd: {e}"))?;
Ok(out)
}
DEFLATE => {
let zlib = data.len() >= 2 && data[0] == 0x78;
let inflate = |z: bool| {
let mut out = Vec::new();
let r = if z { flate2::read::ZlibDecoder::new(data).read_to_end(&mut out) } else { flate2::read::DeflateDecoder::new(data).read_to_end(&mut out) };
r.map(|_| out)
};
inflate(zlib).or_else(|_| inflate(!zlib)).map_err(|e| format!("deflate: {e}"))
}
_ => Ok(data.to_vec()),
}
}
#[cfg(test)]
mod tests {
use super::super::pb::Msg;
use super::*;
#[test]
fn buckets_follow_the_swapped_first_byte() {
assert_eq!(bucket("10ABCDEF"), 'g');
assert_eq!(bucket("01ABCDEF"), '1');
assert_eq!(slice_path(true, "01AB"), "slices_v3/1/01AB");
assert_eq!(slice_path(false, "01AB"), "slices/01AB");
}
#[test]
fn keeps_shared_and_wanted_language_chunks() {
let f = |name: &str| Msg::new().text(1, name).uint(2, 10).flag(3, false)
.bytes(4, &[0xAA]).msg(7, Msg::new().uint(1, 10).uint(2, 4).bytes(3, &[0xBB]));
let m = Msg::new()
.msg(4, Msg::new().uint(1, 1).uint(2, 0).msg(3, f("Game\\game.exe")))
.msg(4, Msg::new().uint(1, 2).uint(2, 1).msg(3, f("en.pak")).text(5, "en-US"))
.msg(4, Msg::new().uint(1, 3).uint(2, 1).msg(3, f("fr.pak")).text(5, "fr-FR"))
.uint(21, ZSTD);
let parsed = parse(&m.0, "en-US").unwrap();
let names: Vec<_> = parsed.files.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Game/game.exe", "en.pak"]);
assert_eq!(parsed.compression, ZSTD);
assert_eq!(parsed.files[0].slices[0], Slice { size: 10, download_size: Some(4), download_sha1: vec![0xBB], content_sha1: vec![0xAA], offset: 0 });
}
#[test]
fn leaves_out_add_ons_the_account_does_not_own() {
let f = |name: &str| Msg::new().text(1, name).uint(2, 10).flag(3, false);
let m = Msg::new()
.msg(4, Msg::new().uint(1, 1).uint(2, 0).msg(3, f("base.forge")).uint(4, 9999))
.msg(4, Msg::new().uint(1, 2).uint(2, 1).msg(3, f("dlc_24/east.forge")).uint(4, 5450))
.msg(4, Msg::new().uint(1, 3).uint(2, 1).msg(3, f("dlc_25/west.forge")).uint(8, 5451).uint(8, 5452))
.uint(21, ZSTD);
let parsed = parse_owned(&m.0, "en-US", &[5405, 5452]).unwrap();
let names: Vec<_> = parsed.files.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["base.forge", "dlc_25/west.forge"]);
assert_eq!(parsed.skipped, ["dlc_24/east.forge"]);
assert_eq!(parsed.chunks.iter().map(|c| c.id).collect::<Vec<_>>(), [1, 3]);
}
#[test]
fn inflates_both_deflate_framings() {
use std::io::Write;
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
z.write_all(b"hello").unwrap();
assert_eq!(decompress(DEFLATE, &z.finish().unwrap()).unwrap(), b"hello");
let mut r = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
r.write_all(b"hello").unwrap();
assert_eq!(decompress(DEFLATE, &r.finish().unwrap()).unwrap(), b"hello");
}
}
@@ -0,0 +1,6 @@
pub mod demux;
pub mod ffi;
pub mod install;
pub mod manifest;
pub mod pb;
pub mod service;
+104
View File
@@ -0,0 +1,104 @@
pub use crate::amazon::proto::{Fields, Value};
#[derive(Default)]
pub struct Msg(pub Vec<u8>);
impl Msg {
pub fn new() -> Self {
Msg(Vec::new())
}
fn varint(&mut self, mut v: u64) {
loop {
let b = (v & 0x7f) as u8;
v >>= 7;
if v == 0 {
self.0.push(b);
return;
}
self.0.push(b | 0x80);
}
}
fn key(&mut self, field: u32, wire: u8) {
self.varint((field as u64) << 3 | wire as u64);
}
pub fn uint(mut self, field: u32, v: u64) -> Self {
self.key(field, 0);
self.varint(v);
self
}
pub fn flag(self, field: u32, v: bool) -> Self {
self.uint(field, v as u64)
}
pub fn bytes(mut self, field: u32, data: &[u8]) -> Self {
self.key(field, 2);
self.varint(data.len() as u64);
self.0.extend_from_slice(data);
self
}
pub fn text(self, field: u32, s: &str) -> Self {
self.bytes(field, s.as_bytes())
}
pub fn msg(self, field: u32, m: Msg) -> Self {
self.bytes(field, &m.0)
}
}
/// Every field of a message, in wire order; repeated fields appear once per value.
pub fn fields(data: &[u8]) -> Result<Vec<(u32, Value<'_>)>, String> {
let mut out = Vec::new();
let mut f = Fields::new(data);
while let Some(v) = f.next_field()? {
out.push(v);
}
Ok(out)
}
pub fn first<'a>(all: &[(u32, Value<'a>)], field: u32) -> Option<Value<'a>> {
all.iter().find(|(n, _)| *n == field).map(|(_, v)| *v)
}
pub fn sub<'a>(all: &[(u32, Value<'a>)], field: u32) -> Option<&'a [u8]> {
match first(all, field) {
Some(Value::Bytes(b)) => Some(b),
_ => None,
}
}
pub fn subs<'a>(all: &[(u32, Value<'a>)], field: u32) -> Vec<&'a [u8]> {
all.iter().filter_map(|(n, v)| match (n, v) { (n, Value::Bytes(b)) if *n == field => Some(*b), _ => None }).collect()
}
pub fn num(all: &[(u32, Value<'_>)], field: u32) -> Option<u64> {
match first(all, field) {
Some(Value::Varint(v)) | Some(Value::Fixed64(v)) => Some(v),
Some(Value::Fixed32(v)) => Some(v as u64),
_ => None,
}
}
pub fn string(all: &[(u32, Value<'_>)], field: u32) -> Option<String> {
sub(all, field).map(|b| String::from_utf8_lossy(b).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_nested_messages() {
let m = Msg::new().uint(1, 300).msg(2, Msg::new().text(1, "a").text(1, "b")).flag(3, true);
let all = fields(&m.0).unwrap();
assert_eq!(num(&all, 1), Some(300));
assert_eq!(num(&all, 3), Some(1));
let inner = fields(sub(&all, 2).unwrap()).unwrap();
let texts: Vec<_> = subs(&inner, 1).iter().map(|b| String::from_utf8_lossy(b).into_owned()).collect();
assert_eq!(texts, ["a", "b"]);
}
}
@@ -0,0 +1,206 @@
use serde_json::{json, Value as Json};
use super::demux::Demux;
use super::pb::{self, Msg};
pub struct Owned {
pub product_id: u32,
pub space_id: String,
pub manifest: String,
pub product_type: u32,
pub state: u32,
pub owned: bool,
pub configuration: String,
pub app_id: String,
pub partner: u32,
pub activation: u32,
pub locked_by_subscription: bool,
pub associations: Vec<u32>,
pub download_id: u32,
}
impl Owned {
/// Whether Connect lists the product among the session's owned ids: owned games and add-ons that are released.
pub fn in_session(&self) -> bool {
self.owned && self.product_type <= 1 && (self.state == 0 || self.state == 3)
}
pub fn to_json(&self) -> Json {
json!({
"productId": self.product_id, "spaceId": self.space_id, "manifest": self.manifest,
"productType": self.product_type, "state": self.state, "owned": self.owned,
"configuration": self.configuration, "appId": self.app_id,
"partner": self.partner, "activation": self.activation, "lockedBySubscription": self.locked_by_subscription,
"associations": self.associations, "downloadId": self.download_id,
})
}
}
pub struct Ownership {
conn: u32,
next: u64,
}
fn owned_game(b: &[u8]) -> Result<Owned, String> {
let f = pb::fields(b)?;
Ok(Owned {
product_id: pb::num(&f, 1).unwrap_or(0) as u32,
space_id: pb::string(&f, 30).unwrap_or_default(),
manifest: pb::string(&f, 21).unwrap_or_default(),
product_type: pb::num(&f, 8).unwrap_or(0) as u32,
state: pb::num(&f, 13).unwrap_or(0) as u32,
owned: pb::num(&f, 20).map(|v| v == 1).unwrap_or(true),
configuration: pb::string(&f, 17).unwrap_or_default(),
app_id: pb::string(&f, 31).unwrap_or_default(),
partner: pb::num(&f, 38).unwrap_or(0) as u32,
activation: pb::num(&f, 36).unwrap_or(0) as u32,
locked_by_subscription: pb::num(&f, 37) == Some(1),
associations: f.iter().filter_map(|(n, v)| match v { pb::Value::Varint(x) if *n == 16 => Some(*x as u32), _ => None }).collect(),
download_id: pb::num(&f, 5).unwrap_or(0) as u32,
})
}
impl Ownership {
/// Opens ownership_service and initializes it, which also returns the account's products.
pub async fn open(d: &mut Demux, ticket: &str, session_id: &str) -> Result<(Self, Vec<Owned>), String> {
let conn = d.open("ownership_service").await?;
let init = Msg::new().flag(2, true).uint(3, 7);
let mut req = Msg::new().uint(1, 1).msg(2, init).text(30, ticket);
if !session_id.is_empty() {
req = req.text(31, session_id);
}
let raw = d.call(conn, Msg::new().msg(1, req)).await?;
let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no ownership answer")?)?;
let init = pb::fields(pb::sub(&rsp, 2).ok_or("no ownership initialize answer")?)?;
if pb::num(&init, 1) != Some(1) {
return Err("Ubisoft would not list the account's games".into());
}
let mut games = Vec::new();
if let Some(list) = pb::sub(&init, 2) {
for g in pb::subs(&pb::fields(list)?, 1) {
games.push(owned_game(g)?);
}
}
Ok((Ownership { conn, next: 2 }, games))
}
/// The latest manifest of each product that has one, for games whose ownership entry leaves it out.
pub async fn latest_manifests(&mut self, d: &mut Demux, products: &[u32]) -> Result<Vec<(u32, String)>, String> {
if products.is_empty() {
return Ok(Vec::new());
}
let id = self.next;
self.next += 1;
let mut req = Msg::new();
for p in products {
req = req.uint(1, *p as u64);
}
let raw = d.call(self.conn, Msg::new().msg(1, Msg::new().uint(1, id).msg(10, req))).await?;
let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no ownership answer")?)?;
let m = pb::fields(pb::sub(&rsp, 10).ok_or("no manifest answer")?)?;
if pb::num(&m, 1) != Some(1) {
return Err("Ubisoft would not list the latest manifests".into());
}
let mut out = Vec::new();
for entry in pb::subs(&m, 2) {
let e = pb::fields(entry)?;
if pb::num(&e, 1) != Some(1) {
continue;
}
if let (Some(product), Some(manifest)) = (pb::num(&e, 2), pb::string(&e, 3).filter(|s| !s.is_empty())) {
out.push((product as u32, manifest));
}
}
Ok(out)
}
/// The ticket a game receives in its launcher session (`GetUplayPCTicket`).
pub async fn pc_ticket(&mut self, d: &mut Demux, product_id: u32, ticket: &str, session_id: &str) -> Result<String, String> {
let id = self.next;
self.next += 1;
let info = Msg::new().uint(1, product_id as u64).uint(2, 0).uint(3, product_id as u64);
let mut req = Msg::new().uint(1, id).msg(14, info).text(30, ticket);
if !session_id.is_empty() {
req = req.text(31, session_id);
}
let raw = d.call(self.conn, Msg::new().msg(1, req)).await?;
let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no ownership answer")?)?;
let t = pb::fields(pb::sub(&rsp, 13).ok_or("no game ticket answer")?)?;
if pb::num(&t, 1) != Some(1) {
return Err(format!("Ubisoft would not issue a game ticket (code {})", pb::num(&t, 3).unwrap_or(0)));
}
pb::string(&t, 2).filter(|s| !s.is_empty()).ok_or_else(|| "Ubisoft sent an empty game ticket".into())
}
pub async fn token(&mut self, d: &mut Demux, product_id: u32) -> Result<String, String> {
let id = self.next;
self.next += 1;
let raw = d.call(self.conn, Msg::new().msg(1, Msg::new().uint(1, id).msg(28, Msg::new().uint(1, product_id as u64)))).await?;
let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no ownership answer")?)?;
let t = pb::fields(pb::sub(&rsp, 27).ok_or("no ownership token answer")?)?;
if pb::num(&t, 1) != Some(1) {
return Err("Ubisoft says this account does not own the game".into());
}
pb::string(&t, 2).filter(|s| !s.is_empty()).ok_or_else(|| "Ubisoft sent an empty ownership token".into())
}
}
pub struct Downloads {
conn: u32,
next: u64,
}
impl Downloads {
pub async fn open(d: &mut Demux, ownership_token: &str) -> Result<Self, String> {
let conn = d.open("download_service").await?;
let raw = d.call(conn, Msg::new().msg(1, Msg::new().uint(1, 1).msg(2, Msg::new().text(5, ownership_token)))).await?;
let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no download service answer")?)?;
let ok = pb::sub(&rsp, 2).map(pb::fields).transpose()?.and_then(|i| pb::num(&i, 1)) == Some(1);
if !ok {
return Err("Ubisoft's download service refused the ownership token".into());
}
Ok(Downloads { conn, next: 2 })
}
/// Signed CDN URLs for paths under the product's download root, in request order; empty where none was given.
pub async fn sign(&mut self, d: &mut Demux, product_id: u32, paths: &[String]) -> Result<Vec<Vec<String>>, String> {
let id = self.next;
self.next += 1;
let mut request = Msg::new().uint(1, product_id as u64);
for p in paths {
request = request.text(2, p);
}
let raw = d.call(self.conn, Msg::new().msg(1, Msg::new().uint(1, id).msg(3, Msg::new().msg(1, request)))).await?;
let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no download service answer")?)?;
let urls = pb::fields(pb::sub(&rsp, 3).ok_or("no signed URLs")?)?;
let mut out = Vec::new();
for r in pb::subs(&urls, 1) {
let r = pb::fields(r)?;
if pb::num(&r, 1).unwrap_or(0) != 0 {
return Err("Ubisoft says this account does not own the game".into());
}
for group in pb::subs(&r, 2) {
out.push(pb::subs(&pb::fields(group)?, 1).iter().map(|u| String::from_utf8_lossy(u).into_owned()).collect());
}
}
if out.len() != paths.len() {
return Err(format!("Ubisoft signed {} of {} paths", out.len(), paths.len()));
}
Ok(out)
}
}
/// Denuvo's activation for a game the account owns: the game's challenge and the product's ownership
/// token go to `denuvo_service`, which answers with the game token the game's aux DLL waits for.
pub async fn denuvo_token(d: &mut Demux, ownership_token: &str, challenge: &[u8]) -> Result<Vec<u8>, String> {
let conn = d.open("denuvo_service").await?;
let req = Msg::new().uint(1, 1).msg(2, Msg::new().text(1, ownership_token).bytes(2, challenge));
let raw = d.call(conn, Msg::new().msg(1, req)).await?;
let rsp = pb::fields(pb::sub(&pb::fields(&raw)?, 1).ok_or("no denuvo answer")?)?;
let result = pb::num(&rsp, 2).unwrap_or(0);
if result != 0 {
return Err(format!("Ubisoft's activation service refused the game (result {result})"));
}
let t = pb::fields(pb::sub(&rsp, 3).ok_or("no game token answer")?)?;
pb::sub(&t, 1).filter(|b| !b.is_empty()).map(<[u8]>::to_vec).ok_or_else(|| "Ubisoft sent an empty game token".into())
}
@@ -0,0 +1,60 @@
package com.droiddeck.launcher.stores.ubisoft
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreLaunch
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = android.app.Application::class)
class UbisoftTest {
private val steamConfig = "version: 2.0\r\nroot:\r\n name: Assassin's Creed Syndicate\r\n start_game:\r\n steam:\r\n steam_app_id: 368500\r\n" +
" third_party_platform:\r\n name: Steam\r\n override_uplayid: 1875\r\n"
@Test fun aProductUbisoftDeliversThroughSteamNamesItsSteamApp() {
assertEquals("368500", UbisoftLibrary.steamApp(steamConfig))
assertNull(UbisoftLibrary.steamApp("root:\n start_game:\n online:\n executables:\n - path:\n relative: bin/game.exe\n"))
}
@Test fun steamOwnedGamesAreKeyedByTheirSpace() {
val owned = JSONObject().put("games", JSONArray()
.put(JSONObject().put("productId", 1957).put("spaceId", "space-s").put("productType", 0).put("owned", true).put("configuration", steamConfig))
.put(JSONObject().put("productId", 109).put("spaceId", "space-sc").put("productType", 0).put("owned", true).put("manifest", "AB").put("configuration", "root: {}"))
.put(JSONObject().put("productId", 1961).put("spaceId", "space-addon").put("productType", 1).put("owned", true).put("configuration", steamConfig)))
assertEquals(mapOf("space-s" to "368500"), UbisoftLibrary.steamBySpace(owned))
assertEquals(setOf("space-sc"), UbisoftLibrary.ownedBySpace(owned).keys)
}
@Test fun theEntitlementNamesTheGamesApplication() {
val body = JSONObject().put("items", JSONArray()
.put(JSONObject().put("productId", 82).put("applicationId", "app-82"))
.put(JSONObject().put("productId", 109).put("applicationId", "app-109"))).toString()
assertEquals("app-109", UbisoftLaunch.applicationId(body, 109))
assertNull(UbisoftLaunch.applicationId(body, 1651))
}
@Test fun theLauncherStartsTheServiceOnlyWithAFreshLaunchFile() {
val text = StoreLaunch.launcherText(StoreGameSidecar(Store.UBISOFT, "space-sc", "Splinter Cell", "system/SplinterCell.exe"))
val lines = text.trimEnd().split("\r\n")
assertTrue(lines.contains("if not exist \"%~dp0.droiddeck-ubisoft.json\" goto direct"))
assertTrue(lines.contains("\"Z:\\usr\\local\\lib\\droiddeck-ubisoft\\droiddeck-upc.exe\" \"%~dp0.droiddeck-ubisoft.json\""))
assertEquals("\"%~dp0system\\SplinterCell.exe\"", lines.last())
assertTrue(lines.indexOf(":direct") < lines.lastIndex)
}
@Test fun activationPayloadsSurviveTheHexHop() {
val bytes = byteArrayOf(0, 1, 0x7f, -1, -128, 42)
assertEquals("00017fff802a", UbisoftActivation.hex(bytes))
assertArrayEquals(bytes, UbisoftActivation.unhex("00017fff802a"))
assertNull(UbisoftActivation.unhex("abc"))
}
}
+90
View File
@@ -0,0 +1,90 @@
# Ubisoft launch without Ubisoft Connect
A Ubisoft game never holds its own ticket. Its DRM loader opens Ubisoft's API DLL from the Connect
folder, and that DLL asks the running launcher for the session over local named pipes. DroidDeck
answers those pipes with `droiddeck-upc.exe`, a small Windows program that runs in the game's prefix.
The game and every Ubisoft DLL stay unmodified, and there is no Connect client, CEF or background
service. The session the game receives is real: its ticket comes from Ubisoft's ownership service for
the signed-in account.
This file is the specification `tools/ubisoft-launcher` was written from. The facts come from the
protobuf descriptors embedded in Ubisoft's own `uplay_r1.dll` (`proto_uplaydll/UplayDll.proto`) and
from the public notes in [Foxtrot47/Ubisoft-Connect-RE](https://github.com/Foxtrot47/Ubisoft-Connect-RE)
(`docs/06-launch.md`, `docs/10-denuvo.md`). No code was taken from either.
## Pieces
| Piece | Where | Job |
|---|---|---|
| Ubisoft runtime | `C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\` in the shared prefix | Ubisoft's own `uplay_r1(64).dll`, `uplay_aux_r1(64).dll`, `uplay_r2(64).dll`, `ubiorbitapi_r2(64).dll`, `UbisoftGameLauncher(64).exe`, the starter's imports (`discord-rpc.x86/x64.dll`, `steam_api(64).dll`) and `overlay(64).dll`. `droiddeck-store-launch` extracts exactly these 16 files with `7z` from the official `UbisoftConnectInstaller.exe`, then deletes the installer |
| Registry | `HKLM\Software\[Wow6432Node\]Ubisoft\Launcher` | `InstallDir` points at the runtime folder in both views; `Installs\<productId>\InstallDir` points at the game, with forward slashes and a trailing slash, as Connect writes it |
| Shared prefix | `compatdata/2929927072` | Every Ubisoft game runs here (`UbisoftConnect.launchOptions` sets `STEAM_COMPAT_DATA_PATH`), so the runtime is extracted once |
| Launch file | `<game>/.droiddeck-ubisoft.json` | Written by `droiddeck-store-launch` before every launch from the app's `ubisoft-launch` answer: the account, the owned product ids, a fresh PC ticket, the entitlement's application id and the save folder. Removed first, so a failed refresh falls back to starting the game directly |
| Launcher | `<game>/.droiddeck-launch.bat` | Runs `Z:\usr\local\lib\droiddeck-ubisoft\droiddeck-upc.exe <launch file>` when both exist |
| Tickets | app, Rust `ubisoft/service.rs` | Demux `ownership_service`: initialize (owned products), then `GetUplayPCTicketReq` (field 14) answered in field 13 `{1: success, 2: ticket}` |
## Pipes
All pipes are `\\.\pipe\terminal_<session>_<name>`, where `<session>` is
`ProcessIdToSessionId(GetCurrentProcessId())` (1 under Wine). They are byte-mode duplex pipes with
unlimited instances, created before the game starts. Every message is a 4-byte big-endian length
followed by a proto2 message, except on `uplay_service_ipc_pipe_`.
| Pipe | Client | Exchange |
|---|---|---|
| `game_start_ipc_pipe_` | `UbisoftGameLauncher.exe` | `game_starter.Upstream{1:{1:{1: build, 2: product, 10: exe, 11: arguments, ...}}}` → `Downstream{2:{1:{1:0 (overlay off), 2:0, 3: <root>/ + "/" + <exe under root>, 4: <root>/, 8:3, 9:{1:{1,113,0,1,0} 1:{3,123,0,0,0}}}}}` |
| `uplay_api_process_ipc_pipe_` | game | `Req.initProcessReq{1: product, 2: game pid, 5: env set}` → `Rsp.initProcessRsp(13){1:1, 3:0 (overlay off), 4:3, 6:0}`. Kept open. Answering it is what releases the starter (below) |
| `uplay_ipc_pipe_` | game (`uplay_r1.dll`) | `Req.initReq(1)` → `Rsp.initRsp(1)`, answered every time it is asked. `Req.installerReq(13)` with `initInstallerReq` → `Rsp.installerRsp(10){1:{1:1, 2:"", 3: language}}` |
| `uplay_aux_ipc_pipe_` | `uplay_aux_r1(64).dll` (Denuvo) | `{1: rid, 2: init}` → `{1: rid, 2:{1:1, 2: pid, 3:3367, 3:3368, 3:3962}}`; `{1: rid, 3:{2:{2: challenge}}}` → `{1: rid, 3:{2:{2:1, 3:0, 4: game token, 5:""}}}` |
| `uplay_service_ipc_pipe_` | Ubisoft helpers | Server sends `{3:{1:1, 3:{1: <hwscore path>}}}` once, then only reads, unframed |
| `upc_api_process_ipc_pipe_standard`, `uplay_protocol_ipc_pipe_`, `uplay_overlay_ipc_pipe_`, `orbit_ipc_pipe_`, `uplay_crash_reporter_ipc_pipe_` | — | Held open, never answered |
### The session (`InitRsp`)
| Field | Value |
|---|---|
| 1 result | 1 (`InitResult_Success`) |
| 2 account | `{1: userId, 2: username, 3: nameOnPlatform, 4: email, 5: "" (password), 6: country}` |
| 3 connected | 1 |
| 4 gameUplayId | the product |
| 5 uplayIds | every owned product whose ownership entry is owned, of type 0 or 1, in state 0 or 3 |
| 6 gameCdKeys | `{1: product, 2: ""}` |
| 7 isInOfflineMode | 0 |
| 8 upcTicket | the PC ticket from `GetUplayPCTicket`, never the Denuvo token |
| 11, 12, 15, 16, 18, 19, 21, 23 | the values a real launch carries (`{1:0}`, 0, overlay `{1:0, 2:3}`, hardware score `{6211, 22395, 1.0f}`, empty, `{1:1, 2:1}`, `{2:1}`, empty) |
| 17 storage | `{1: "C:/users/steamuser/Saved Games/Ubisoft/<userId>/<product>/"}`; the game appends file names directly, so the trailing slash matters |
| 22 ubiServices | `{1: applicationId}` from the account's entitlements, left out when unknown |
The overlay is reported off in all three places. `uplay_r1.dll` still loads `overlay.dll` from the
runtime folder right after the session arrives, whatever the session says; without that file it gives up
with `UplayAPI [ERROR]: Startup failed: cannot load uplay library` (Assassin's Creed Chronicles: China
then sits on a black screen and aborts with code 3 after about 160 s), so the runtime includes it.
## Start sequence
1. `droiddeck-upc.exe` creates the pipes and an auto-reset event marked inheritable, then starts the
game's own exe with handle inheritance, in the install folder, with no extra arguments.
2. The exe sees it was not started by Connect and starts `UbisoftGameLauncher.exe
-gamelauncher_wait_handle <event> -upc_uplay_id <product> ...` (it exits with code 3; that is normal).
3. The starter asks `game_start` and starts the real game from the reply.
4. The game announces itself on `uplay_api_process`; the answer sets the event, which lets the starter
exit. Without it the starter times out and its job object ends the game after about 15 seconds.
5. `uplay_r1.dll` asks `uplay_ipc` for the session.
6. `droiddeck-upc.exe` waits for the announced process, allows 15 s for a restart, then exits with the
game's code so Steam sees the game end.
A log of every exchange is written to `~/.cache/droiddeck/ubisoft/upc-<product>.log` in the guest.
## Denuvo
For protected titles the aux pipe carries the activation challenge. `droiddeck-upc.exe` writes it to
`~/.cache/droiddeck/ubisoft/bridge-<product>/denuvo-<rid>.req` and waits 90 s for `.rsp`. Answering it
needs `denuvo_service` with an ownership token (`OwnershipTokenReq`, field 28) and is the next step;
titles without Denuvo never open the aux pipe.
## Not covered yet
- R2 (`UPC_*`) titles use `uplay_r2.dll`; their pipe traffic has not been captured, so whether the
same server answers them is unverified.
- Orbit (`ubiorbitapi_r2`) titles.
- Achievements, friends, overlay, cloud saves.
+9
View File
@@ -424,6 +424,15 @@ if ! restore_step comet "${comet_key}" "${comet_out}"; then
DROIDDECK_BUILD_IMAGE="${image_name}" bash tools/comet/build-local.sh DROIDDECK_BUILD_IMAGE="${image_name}" bash tools/comet/build-local.sh
save_step comet "${comet_key}" "${comet_out}" save_step comet "${comet_key}" "${comet_out}"
fi fi
if [[ "${DROIDDECK_SKIP_RUST:-0}" != 1 ]]; then
upc_out="${linuxfs_dir}/usr/local/lib/droiddeck-ubisoft"
upc_key=$(inputs_hash "x86_64-pc-windows-gnu" tools/ubisoft-launcher/src tools/ubisoft-launcher/Cargo.toml tools/ubisoft-launcher/Cargo.lock tools/ubisoft-launcher/build.sh)
if ! restore_step ubisoft "${upc_key}" "${upc_out}"; then
command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 || { echo "x86_64-w64-mingw32-gcc is required for the Ubisoft launcher service (droiddeck-upc.exe)." >&2; exit 1; }
CARGO_TARGET_DIR="${cache_dir}/ubisoft-target" bash tools/ubisoft-launcher/build.sh "${upc_out}"
save_step ubisoft "${upc_key}" "${upc_out}"
fi
fi
python3 tools/powervr/fetch.py python3 tools/powervr/fetch.py
bash tools/packs/build.sh app/build/packs bash tools/packs/build.sh app/build/packs
python3 tools/release/runtime_inputs.py record python3 tools/release/runtime_inputs.py record
@@ -17,12 +17,12 @@ SEED_REDISTS = "/usr/local/bin/droiddeck-seed-redists"
PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC") PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC")
def game_id(env): # Prefixes several games share; the game itself is the Steam app being launched.
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/") SHARED_PREFIXES = {"2929927072"}
if not prefix:
return None
value = prefix.rsplit("/", 1)[-1] def app_number(value):
if not re.fullmatch(r"-?[0-9]+", value): if not re.fullmatch(r"-?[0-9]+", value or ""):
return None return None
number = int(value) number = int(value)
if not -(2**31) <= number < 2**32 or number == 0: if not -(2**31) <= number < 2**32 or number == 0:
@@ -30,6 +30,26 @@ def game_id(env):
return str(number % 2**32) return str(number % 2**32)
def game_id(env):
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
if not prefix:
return None
found = app_number(prefix.rsplit("/", 1)[-1])
if found in SHARED_PREFIXES:
return app_number(env.get("SteamAppId", "")) or shortcut_number(env.get("SteamGameId", "")) or found
return found
def shortcut_number(value):
"""The shortcut appid inside Steam's 64-bit shortcut game id `(appid << 32) | 0x02000000`."""
if not re.fullmatch(r"[0-9]{1,20}", value or ""):
return None
number = int(value)
if number >= 2**64 or number & 0xFFFFFFFF != 0x02000000:
return None
return app_number(str(number >> 32))
DOTNET_FEX = { DOTNET_FEX = {
"FEX_TSOENABLED": "1", "FEX_TSOENABLED": "1",
"FEX_VECTORTSOENABLED": "1", "FEX_VECTORTSOENABLED": "1",
@@ -289,6 +289,218 @@ def exited(argv, code=0):
return 0 return 0
UBISOFT_DIR = "drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher"
UBISOFT_WINDOWS_DIR = "C:\\Program Files (x86)\\Ubisoft\\Ubisoft Game Launcher\\"
UBISOFT_INSTALLER_URL = "https://static3.cdn.ubi.com/orbit/launcher_installer/UbisoftConnectInstaller.exe"
UBISOFT_CACHE = Path("/root/.cache/droiddeck/ubisoft")
UBISOFT_LAUNCH = ".droiddeck-ubisoft.json"
UBISOFT_SERVICE = Path("/usr/local/lib/droiddeck-ubisoft/droiddeck-upc.exe")
UBISOFT_ASK_WAIT = 45.0
UBISOFT_EXTRACT_WAIT = 300
# Ubisoft's own game-side runtime from the Connect installer, unmodified: the API and aux DLLs the
# game's loader opens from InstallDir, the starter it re-executes into and its imports, and the overlay
# DLLs uplay_r1.dll loads even with the overlay off (its startup fails without them).
UBISOFT_RUNTIME = ("uplay_r1.dll", "uplay_r164.dll", "uplay_aux_r1.dll", "uplay_aux_r164.dll",
"uplay_r2.dll", "uplay_r264.dll", "ubiorbitapi_r2.dll", "ubiorbitapi_r264.dll",
"UbisoftGameLauncher.exe", "UbisoftGameLauncher64.exe", "discord-rpc.x86.dll",
"discord-rpc.x64.dll", "steam_api.dll", "steam_api64.dll", "overlay.dll", "overlay64.dll")
UBISOFT_VIEWS = ("[Software\\\\Ubisoft\\\\Launcher%s]", "[Software\\\\Wow6432Node\\\\Ubisoft\\\\Launcher%s]")
MIN_INSTALLER = 1 << 20
def ubisoft_runtime_dir(compat_data):
return Path(compat_data) / "pfx" / UBISOFT_DIR
def ubisoft_runtime_ready(compat_data):
folder = ubisoft_runtime_dir(compat_data)
return all((folder / name).is_file() for name in UBISOFT_RUNTIME)
def fetch_installer(url=UBISOFT_INSTALLER_URL, folder=UBISOFT_CACHE):
target = folder / "UbisoftConnectInstaller.exe"
if target.is_file() and target.stat().st_size >= MIN_INSTALLER:
return target
import urllib.request
folder.mkdir(parents=True, exist_ok=True)
part = target.with_name(target.name + ".part")
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=60) as reply, open(part, "wb") as out:
shutil.copyfileobj(reply, out)
if part.stat().st_size < MIN_INSTALLER:
part.unlink()
raise OSError("installer too small")
os.replace(part, target)
return target
def install_runtime(compat_data, wait=UBISOFT_EXTRACT_WAIT):
if ubisoft_runtime_ready(compat_data):
return "present"
if not (Path(compat_data) / "pfx").is_dir():
return "no-prefix"
try:
installer = fetch_installer()
except OSError as error:
return "download-failed-" + type(error).__name__
target = ubisoft_runtime_dir(compat_data)
target.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="droiddeck-ubisoft-", dir=str(UBISOFT_CACHE)) as tmp:
try:
subprocess.run(["7z", "e", "-y", "-o" + tmp, str(installer)] + list(UBISOFT_RUNTIME), stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=wait)
except subprocess.TimeoutExpired:
return "extract-timeout"
except OSError:
return "no-7z"
missing = [name for name in UBISOFT_RUNTIME if not (Path(tmp) / name).is_file()]
if missing:
return "extract-missing-" + ",".join(missing)
for name in UBISOFT_RUNTIME:
shutil.copyfile(Path(tmp) / name, target / name)
if not ubisoft_runtime_ready(compat_data):
return "copy-failed"
try:
installer.unlink()
except OSError:
pass
return "installed"
def ubisoft_product(sidecar):
extra = sidecar.get("extra") if isinstance(sidecar.get("extra"), dict) else {}
try:
return int(str(extra.get("productId") or ""))
except ValueError:
return None
def ubisoft_install_dir(folder):
return windows_path(str(folder)).replace("\\", "/").rstrip("/") + "/"
def register_ubisoft(folder, product, compat_data):
reg = Path(compat_data) / "pfx" / "system.reg"
if not reg.is_file():
return "no-registry"
if not wait_for_server(reg.parent):
return "server-running"
for view in UBISOFT_VIEWS:
set_value(reg, view % "", "InstallDir", UBISOFT_WINDOWS_DIR)
set_value(reg, view % ("\\\\Installs\\\\%d" % product), "InstallDir", ubisoft_install_dir(folder))
return "registered %d" % product
UBISOFT_HIVES = {"HKEY_LOCAL_MACHINE": "system.reg", "HKEY_CURRENT_USER": "user.reg"}
def manifest_value(values, folder, language):
"""The value Connect writes for a manifest registry entry: the install folder, the entry for the
language being played, or the only entry there is."""
entries = [v for v in values if isinstance(v, dict)]
if not entries:
return None
names = [str(v.get("name") or "") for v in entries]
if "$INSTALL_DIR" in names:
return windows_path(str(folder)).rstrip("\\") + "\\"
for v in entries:
if str(v.get("value") or "").lower() == language.lower():
return str(v.get("value"))
prefix = language.split("-")[0].lower()
for v in entries:
if str(v.get("value") or "").lower().split("-")[0] == prefix:
return str(v.get("value"))
return str(entries[0].get("value") or entries[0].get("name") or "")
def apply_manifest_registry(compat_data, registry, folder, language):
written = 0
for item in registry or []:
if not isinstance(item, dict):
continue
path = str(item.get("key") or "").replace("/", "\\")
hive, _, rest = path.partition("\\")
target = UBISOFT_HIVES.get(hive.upper())
if not target or "\\" not in rest:
continue
subkey, _, name = rest.rpartition("\\")
value = manifest_value(item.get("values"), folder, language)
if value is None or not name:
continue
reg = Path(compat_data) / "pfx" / target
parts = subkey.split("\\")
views = [parts]
if target == "system.reg" and len(parts) > 1 and parts[0].lower() == "software" and parts[1].lower() != "wow6432node":
views.append(parts[:1] + ["Wow6432Node"] + parts[1:])
for view in views:
key = "[" + "\\\\".join(view) + "]"
if set_value(reg, key, name, value):
written += 1
return written
def ubisoft_launch(folder, sidecar, product, compat_data, answer):
"""The launch file droiddeck-upc.exe reads: the game, the account and this launch's ticket."""
account = answer.get("account") if isinstance(answer.get("account"), dict) else {}
user = str(account.get("userId") or "user")
saves = Path(compat_data) / "pfx" / "drive_c/users/steamuser/Saved Games/Ubisoft" / user / str(product)
saves.mkdir(parents=True, exist_ok=True)
exe = str(sidecar.get("exe") or "").replace("\\", "/")
workdir = folder / str(sidecar.get("workdir")) if sidecar.get("workdir") else folder
UBISOFT_CACHE.mkdir(parents=True, exist_ok=True)
return {
"productId": product,
"exe": windows_path(str(folder / exe)),
"args": " ".join(str(a) for a in sidecar.get("args") or []),
"workdir": windows_path(str(workdir)),
"account": account,
"ticket": str(answer.get("ticket") or ""),
"owned": [int(i) for i in answer.get("owned") or [] if str(i).isdigit()],
"products": [p for p in answer.get("products") or [] if isinstance(p, dict) and isinstance(p.get("id"), int)],
"appId": str(answer.get("appId") or ""),
"chunks": [c for c in answer.get("chunks") or [] if isinstance(c, dict) and isinstance(c.get("id"), int)],
"saves": "C:/users/steamuser/Saved Games/Ubisoft/%s/%d/" % (user, product),
"language": str(answer.get("language") or "en-US"),
"bridge": windows_path(str(UBISOFT_CACHE / ("bridge-%d" % product))),
"log": windows_path(str(UBISOFT_CACHE / ("upc-%d.log" % product))),
}
def prepare_ubisoft(folder, sidecar, compat_data, launch_dir, proton="", ask_app=None):
launch = folder / UBISOFT_LAUNCH
try:
launch.unlink()
except OSError:
pass
if not compat_data:
return "no-prefix"
product = ubisoft_product(sidecar)
if product is None:
return "no-product"
if not UBISOFT_SERVICE.is_file():
return "no-service"
made = create_prefix(compat_data, proton) if not (Path(compat_data) / "pfx/user.reg").is_file() else "present"
if made not in ("present", "created"):
return "prefix " + made
runtime = install_runtime(compat_data)
if not ubisoft_runtime_ready(compat_data):
return "runtime " + runtime
registry = register_ubisoft(folder, product, compat_data)
if not launch_dir:
return "runtime %s, %s, no-channel" % (runtime, registry)
answer = (ask_app or ask)(Path(launch_dir) / "stores", {"op": "ubisoft-launch", "store": "ubisoft", "id": str(sidecar.get("id"))},
timeout=UBISOFT_ASK_WAIT)
if not isinstance(answer, dict) or not answer.get("ok") or not answer.get("ticket"):
reason = "timeout" if answer is None else str(answer.get("reason") or "refused")
return "runtime %s, %s, session %s" % (runtime, registry, reason)
written = 0
if answer.get("registry") and wait_for_server(Path(compat_data) / "pfx"):
written = apply_manifest_registry(compat_data, answer.get("registry"), folder, str(answer.get("language") or "en-US"))
tmp = launch.with_name(launch.name + ".tmp")
tmp.write_text(json.dumps(ubisoft_launch(folder, sidecar, product, compat_data, answer)))
os.replace(tmp, launch)
return "runtime %s, %s, %d manifest value(s), session ready" % (runtime, registry, written)
COMET_DIR = Path("/usr/local/lib/droiddeck-comet") COMET_DIR = Path("/usr/local/lib/droiddeck-comet")
@@ -1114,6 +1326,15 @@ def main(argv):
prepare_gog(sidecar, compat_data, launch_dir) prepare_gog(sidecar, compat_data, launch_dir)
log("gog registry: " + apply_gog_registry(folder, compat_data, os.environ.get("BL_STORE_PROTON", ""))) log("gog registry: " + apply_gog_registry(folder, compat_data, os.environ.get("BL_STORE_PROTON", "")))
log("gog support data: " + gog_support_data(folder, compat_data, os.environ.get("BL_STORE_PROTON", ""))) log("gog support data: " + gog_support_data(folder, compat_data, os.environ.get("BL_STORE_PROTON", "")))
if store == "ubisoft":
try:
log("ubisoft %s: %s" % (game, prepare_ubisoft(folder, sidecar, compat_data, launch_dir, os.environ.get("BL_STORE_PROTON", ""))))
except OSError as e:
try:
(folder / UBISOFT_LAUNCH).unlink()
except OSError:
pass
log("ubisoft %s: not prepared (%s), starting directly" % (game, e))
if store == "epic": if store == "epic":
handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file() handled = os.environ.get(EPIC_HANDLED) == "1" and (folder / EPIC_ARGS).is_file()
if not handled: if not handled:
@@ -152,6 +152,13 @@ bl_components() {
else else
cm_app="" cm_app=""
fi fi
if [ "$cm_app" = 2929927072 ] && [[ "${SteamAppId:-}" =~ ^[0-9]{1,10}$ ]] && (( 10#$SteamAppId > 0 && 10#$SteamAppId <= 4294967295 )); then
cm_app=$((10#$SteamAppId))
elif [ "$cm_app" = 2929927072 ] && [[ "${SteamGameId:-}" =~ ^[1-9][0-9]{9,19}$ ]] &&
{ [ ${#SteamGameId} -lt 20 ] || [[ ! "$SteamGameId" > 18446744073709551615 ]]; } &&
(( (10#$SteamGameId & 0xFFFFFFFF) == 0x02000000 && ((10#$SteamGameId >> 32) & 0xFFFFFFFF) != 0 )); then
cm_app=$(( (10#$SteamGameId >> 32) & 0xFFFFFFFF ))
fi
if [ -z "$cm_app" ]; then if [ -z "$cm_app" ]; then
echo "droiddeck-proton: components: invalid game prefix, left as it is" >&2 echo "droiddeck-proton: components: invalid game prefix, left as it is" >&2
return 0 return 0
+21
View File
@@ -1124,6 +1124,23 @@ class GameEnvironmentTest(unittest.TestCase):
scope = MODULE["game_id"](env) scope = MODULE["game_id"](env)
expected = "untouched" if scope is None else scope if scope in {"42", "4294967295", "2147483648"} else "*" expected = "untouched" if scope is None else scope if scope in {"42", "4294967295", "2147483648"} else "*"
self.assertEqual(target.read_text(), expected) self.assertEqual(target.read_text(), expected)
for app, expected in (("42", "42"), ("4294967295", "4294967295"), ("43", "*"), ("", "*"), ("x", "*")):
with self.subTest(shared=app):
env = dict(os.environ, HOME=str(root), STEAM_COMPAT_DATA_PATH="/steam/compatdata/2929927072", SteamAppId=app)
target.write_text("untouched")
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True, capture_output=True)
self.assertEqual(MODULE["game_id"](env), app if app in {"42", "4294967295", "43"} else "2929927072")
self.assertEqual(target.read_text(), expected)
for game, expected in (
(str(42 << 32 | 0x02000000), "42"), (str(4294967295 << 32 | 0x02000000), "4294967295"),
(str(42 << 32 | 0x02000001), "*"), (str(42 << 32 | 0x02000000 | 1 << 64), "*"), ("33554432", "*"), ("x", "*"),
):
with self.subTest(direct=game):
env = dict(os.environ, HOME=str(root), STEAM_COMPAT_DATA_PATH="/steam/compatdata/2929927072", SteamAppId="0", SteamGameId=game)
target.write_text("untouched")
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True, capture_output=True)
self.assertEqual(MODULE["game_id"](env), expected if expected != "*" else "2929927072")
self.assertEqual(target.read_text(), expected)
def test_engine_fixes_follow_the_games_files(self): def test_engine_fixes_follow_the_games_files(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -1628,6 +1645,10 @@ class GameEnvironmentTest(unittest.TestCase):
self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix})) self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix}))
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42/"}), "42") self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42/"}), "42")
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/-1"}), "4294967295") self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/-1"}), "4294967295")
shared = {"STEAM_COMPAT_DATA_PATH": "/a/compatdata/2929927072", "SteamAppId": "2424921676"}
self.assertEqual(MODULE["game_id"](shared), "2424921676")
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/2929927072"}), "2929927072")
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42", "SteamAppId": "7"}), "42")
def test_launch_reads_updates_preserves_argv_and_does_not_execute_values(self): def test_launch_reads_updates_preserves_argv_and_does_not_execute_values(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
+99
View File
@@ -797,3 +797,102 @@ class GogRegistry(unittest.TestCase):
store.main([str(folder / store.LAUNCHER)]) store.main([str(folder / store.LAUNCHER)])
apply.assert_called_once_with(folder, root + '/compat', '/chosen/proton') apply.assert_called_once_with(folder, root + '/compat', '/chosen/proton')
support.assert_called_once_with(folder, root + '/compat', '/chosen/proton') support.assert_called_once_with(folder, root + '/compat', '/chosen/proton')
class Ubisoft(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.compat_data = self.root / 'compatdata/2929927072'
(self.compat_data / 'pfx').mkdir(parents=True)
(self.compat_data / 'pfx/system.reg').write_text('WINE REGISTRY Version 2\n')
(self.compat_data / 'pfx/user.reg').write_text('WINE REGISTRY Version 2\n')
self.folder = self.root / 'Games/Stores/Ubisoft/Splinter Cell'
(self.folder / 'system').mkdir(parents=True)
self.sidecar = {'store': 'ubisoft', 'id': 'space-1', 'exe': 'system/SplinterCell.exe', 'extra': {'productId': '109'}}
service = self.root / 'droiddeck-upc.exe'
service.write_bytes(b'MZ')
for patcher in (patch.object(store, 'UBISOFT_SERVICE', service), patch.object(store, 'UBISOFT_CACHE', self.root / 'cache'),
patch.object(store, 'wait_for_server', return_value=True)):
patcher.start()
self.addCleanup(patcher.stop)
def tearDown(self):
self.tmp.cleanup()
def runtime(self):
folder = store.ubisoft_runtime_dir(self.compat_data)
folder.mkdir(parents=True, exist_ok=True)
for name in store.UBISOFT_RUNTIME:
(folder / name).write_bytes(b'MZ')
def answer(self, *_args, **_kwargs):
return {'ok': True, 'ticket': 'PC', 'owned': [82, 109], 'appId': 'app',
'account': {'userId': 'u1', 'username': 'name', 'nameOnPlatform': 'name', 'email': 'e@x', 'country': 'US'}}
def prepare(self, ask_app):
return store.prepare_ubisoft(self.folder, self.sidecar, str(self.compat_data), str(self.root / 'launch'), '/proton', ask_app=ask_app)
def test_an_extracted_runtime_is_not_extracted_again(self):
self.runtime()
with patch.object(store.subprocess, 'run') as run:
self.assertEqual('present', store.install_runtime(str(self.compat_data)))
run.assert_not_called()
def test_the_runtime_comes_from_the_installer_through_7z(self):
installer = self.root / 'UbisoftConnectInstaller.exe'
def run(command, **_kwargs):
self.assertEqual(['7z', 'e', '-y'], command[:3])
self.assertEqual(str(installer), command[4])
out = Path(command[3][2:])
for name in command[5:]:
(out / name).write_bytes(b'MZ')
(self.root / 'cache').mkdir()
with patch.object(store, 'fetch_installer', return_value=installer), patch.object(store.subprocess, 'run', side_effect=run):
self.assertEqual('installed', store.install_runtime(str(self.compat_data)))
self.assertTrue(store.ubisoft_runtime_ready(str(self.compat_data)))
def test_a_launch_registers_the_game_and_writes_the_session_for_the_service(self):
self.runtime()
result = self.prepare(self.answer)
self.assertEqual('runtime present, registered 109, 0 manifest value(s), session ready', result)
reg = (self.compat_data / 'pfx/system.reg').read_text()
for view in ('[Software\\\\Ubisoft\\\\Launcher]', '[Software\\\\Wow6432Node\\\\Ubisoft\\\\Launcher]'):
self.assertIn(view, reg)
self.assertIn('[Software\\\\Wow6432Node\\\\Ubisoft\\\\Launcher\\\\Installs\\\\109]', reg)
self.assertIn('"InstallDir"="C:\\\\Program Files (x86)\\\\Ubisoft\\\\Ubisoft Game Launcher\\\\"', reg)
launch = json.loads((self.folder / store.UBISOFT_LAUNCH).read_text())
self.assertEqual(109, launch['productId'])
self.assertEqual('PC', launch['ticket'])
self.assertEqual([82, 109], launch['owned'])
self.assertEqual(store.windows_path(str(self.folder / 'system/SplinterCell.exe')), launch['exe'])
self.assertEqual('C:/users/steamuser/Saved Games/Ubisoft/u1/109/', launch['saves'])
self.assertTrue((self.compat_data / 'pfx/drive_c/users/steamuser/Saved Games/Ubisoft/u1/109').is_dir())
def test_without_a_session_the_launch_file_is_removed_and_the_game_starts_as_is(self):
self.runtime()
(self.folder / store.UBISOFT_LAUNCH).write_text('{}')
result = self.prepare(lambda *_a, **_k: {'ok': False, 'reason': 'signed-out'})
self.assertEqual('runtime present, registered 109, session signed-out', result)
self.assertFalse((self.folder / store.UBISOFT_LAUNCH).exists())
def test_a_game_without_a_product_is_left_alone(self):
self.sidecar['extra'] = {}
self.assertEqual('no-product', self.prepare(self.answer))
def test_the_manifest_registry_lands_in_both_views_with_the_install_folder_and_language(self):
self.runtime()
registry = [{'key': r'HKEY_LOCAL_MACHINE\SOFTWARE\Ubisoft\Launcher\Installs\109\Language',
'values': [{'name': 'French', 'value': 'fr-FR'}, {'name': 'English', 'value': 'en-US'}]},
{'key': r'HKEY_LOCAL_MACHINE\SOFTWARE\Ubisoft\Splinter Cell\GamePath', 'values': [{'name': '$INSTALL_DIR', 'value': ''}]}]
answer = dict(self.answer(), registry=registry, language='en-US')
result = self.prepare(lambda *_a, **_k: answer)
self.assertEqual('runtime present, registered 109, 4 manifest value(s), session ready', result)
reg = (self.compat_data / 'pfx/system.reg').read_text()
self.assertIn(r'[SOFTWARE\\Wow6432Node\\Ubisoft\\Launcher\\Installs\\109]', reg)
self.assertIn(r'[SOFTWARE\\Ubisoft\\Launcher\\Installs\\109]', reg)
self.assertIn('"Language"="en-US"', reg)
folder = store.windows_path(str(self.folder)).replace('\\', '\\\\')
self.assertIn('"GamePath"="%s\\\\"' % folder, reg)
+2
View File
@@ -0,0 +1,2 @@
target/
out/
+105
View File
@@ -0,0 +1,105 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "droiddeck-ubisoft-launcher"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "itoa"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "droiddeck-ubisoft-launcher"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-only"
publish = false
[[bin]]
name = "droiddeck-upc"
path = "src/main.rs"
[dependencies]
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
panic = "abort"
strip = true
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Builds droiddeck-upc.exe for Wine and stages it with the guest scripts.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
out=${1:-"$here/../../app/src/main/assets/linuxfs/usr/local/lib/droiddeck-ubisoft"}
target=x86_64-pc-windows-gnu
rustup target add "$target" >/dev/null 2>&1 || true
cargo build --manifest-path "$here/Cargo.toml" --release --locked --target "$target"
mkdir -p "$out"
install -m644 "${CARGO_TARGET_DIR:-$here/target}/$target/release/droiddeck-upc.exe" "$out/droiddeck-upc.exe"
+358
View File
@@ -0,0 +1,358 @@
//! What Ubisoft Connect answers on its local pipes, built from the launch configuration.
//!
//! Field numbers follow `mg.protocol.uplaydll` and `game_starter` as embedded in Ubisoft's own
//! `uplay_r1.dll`; see docs/development/ubisoft-launch.md.
use crate::pb::{self, Msg, Value};
use serde_json::Value as Json;
pub const GAME_START: &str = "game_start_ipc_pipe_";
pub const API_PROCESS: &str = "uplay_api_process_ipc_pipe_";
pub const SESSION: &str = "uplay_ipc_pipe_";
pub const AUX: &str = "uplay_aux_ipc_pipe_";
pub const SERVICE: &str = "uplay_service_ipc_pipe_";
/// Pipes Connect creates that the game may open; they are held open without answers.
pub const QUIET: &[&str] = &[
"upc_api_process_ipc_pipe_standard",
"uplay_protocol_ipc_pipe_",
"uplay_overlay_ipc_pipe_",
"orbit_ipc_pipe_",
"uplay_crash_reporter_ipc_pipe_",
];
/// The add-on list Connect returns from the aux init; the game echoes it back.
const AUX_ADDONS: [u64; 3] = [3367, 3368, 3962];
/// `OverlayInjectionMethod_None`.
const OVERLAY_NONE: u64 = 1;
const HWSCORE: &str = "C:/ProgramData/Ubisoft/Ubisoft Game Launcher/cache//statistics/hwscore";
pub struct Config {
pub product: u32,
pub exe: String,
pub args: String,
pub workdir: String,
pub user_id: String,
pub username: String,
pub name_on_platform: String,
pub email: String,
pub country: String,
pub ticket: String,
pub owned: Vec<u32>,
pub app_id: String,
pub saves: String,
pub language: String,
pub bridge: String,
pub log: String,
/// Installed chunks of the game's manifest: id and tags.
pub chunks: Vec<(u32, String)>,
/// Owned products and their ownership type (0 game, 1 add-on).
pub products: Vec<(u32, u32)>,
}
fn s(j: &Json, k: &str) -> String {
j.get(k).and_then(|v| v.as_str()).unwrap_or_default().to_string()
}
impl Config {
pub fn parse(text: &str) -> Result<Config, String> {
let j: Json = serde_json::from_str(text).map_err(|e| format!("config: {e}"))?;
let product = j.get("productId").and_then(|v| v.as_u64()).ok_or("config: no productId")? as u32;
let account = j.get("account").cloned().unwrap_or(Json::Null);
let mut owned: Vec<u32> = j.get("owned").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_u64()).map(|v| v as u32).collect()).unwrap_or_default();
if !owned.contains(&product) {
owned.push(product);
}
let mut saves = s(&j, "saves").replace('\\', "/");
if !saves.ends_with('/') {
saves.push('/');
}
let c = Config {
product,
exe: s(&j, "exe"),
args: s(&j, "args"),
workdir: s(&j, "workdir"),
user_id: s(&account, "userId"),
username: s(&account, "username"),
name_on_platform: s(&account, "nameOnPlatform"),
email: s(&account, "email"),
country: s(&account, "country"),
ticket: s(&j, "ticket"),
owned,
app_id: s(&j, "appId"),
saves,
language: Some(s(&j, "language")).filter(|l| !l.is_empty()).unwrap_or_else(|| "en-US".into()),
bridge: s(&j, "bridge"),
log: s(&j, "log"),
chunks: j.get("chunks").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|c| {
Some((c.get("id")?.as_u64()? as u32, c.get("tags").and_then(|t| t.as_str()).unwrap_or("").to_string()))
}).collect()).unwrap_or_default(),
products: j.get("products").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|p| {
Some((p.get("id")?.as_u64()? as u32, p.get("type").and_then(|t| t.as_u64()).unwrap_or(0) as u32))
}).collect()).unwrap_or_default(),
};
if c.exe.is_empty() || c.ticket.is_empty() {
return Err("config: exe and ticket are required".into());
}
Ok(c)
}
fn root(&self) -> String {
let mut r = self.workdir.replace('\\', "/");
if !r.ends_with('/') {
r.push('/');
}
r
}
/// The executable the way Connect hands it back: the install root, a slash, then the path under it.
pub fn reply_exe(&self, requested: &str) -> String {
let root = self.root();
let base = root.trim_end_matches('/');
let exe = requested.replace('\\', "/");
let rel = if !base.is_empty() && exe.to_lowercase().starts_with(&(base.to_lowercase() + "/")) { &exe[base.len() + 1..] } else { &exe[..] };
format!("{root}/{rel}")
}
/// `game_starter.Downstream` granting the start the starter asked for.
pub fn game_start(&self, request: &[u8]) -> Vec<u8> {
let requested = pb::find(request, 10, 4).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_else(|| self.exe.clone());
let progress = Msg::new()
.msg(1, Msg::new().uint(1, 1).uint(2, 113).uint(3, 0).uint(4, 1).uint(5, 0))
.msg(1, Msg::new().uint(1, 3).uint(2, 123).uint(3, 0).uint(4, 0).uint(5, 0));
let granted = Msg::new()
.uint(1, 0)
.uint(2, 0)
.text(3, &self.reply_exe(&requested))
.text(4, &self.root())
.uint(8, 3)
.msg(9, progress);
Msg::new().msg(2, Msg::new().msg(1, granted)).0
}
/// `Chunks`: every installed chunk, marked downloaded.
fn chunks(&self) -> Msg {
let mut m = Msg::new();
for (id, tags) in &self.chunks {
m = m.msg(1, Msg::new().uint(1, *id as u64).uint(2, 1).text(3, tags));
}
m
}
/// `Rsp.initProcessRsp` for the game announcing its process. The overlay is reported off
/// everywhere: with it on, `uplay_r1.dll` loads `overlay.dll` and fails its startup without it.
pub fn init_process(&self) -> Vec<u8> {
Msg::new().msg(13, Msg::new().uint(1, 1).uint(3, 0).uint(4, OVERLAY_NONE).uint(6, 0)).0
}
/// `Rsp.initRsp`: the account, its products and the PC ticket.
pub fn session(&self) -> Vec<u8> {
let account = Msg::new()
.text(1, &self.user_id)
.text(2, &self.username)
.text(3, &self.name_on_platform)
.text(4, &self.email)
.text(5, "")
.text(6, &self.country);
let mut body = Msg::new().uint(1, 1).msg(2, account).uint(3, 1).uint(4, self.product as u64);
for id in &self.owned {
body = body.uint(5, *id as u64);
}
body = body
.msg(6, Msg::new().uint(1, self.product as u64).text(2, ""))
.uint(7, 0)
.text(8, &self.ticket)
.msg(14, Msg::new().uint(1, 1).msg(2, self.chunks()).text(3, &self.language))
.msg(11, Msg::new().uint(1, 0))
.uint(12, 0)
.msg(15, Msg::new().uint(1, 0).uint(2, OVERLAY_NONE))
.msg(16, Msg::new().uint(1, 6211).uint(2, 22395).float(3, 1.0))
.msg(17, Msg::new().text(1, &self.saves))
.bytes(18, b"")
.msg(19, Msg::new().uint(1, 1).uint(2, 1))
.msg(21, Msg::new().uint(2, 1));
if !self.app_id.is_empty() {
body = body.msg(22, Msg::new().text(1, &self.app_id));
}
Msg::new().msg(1, body.bytes(23, b"")).0
}
/// `Rsp.installerRsp.initInstallerRsp`.
pub fn installer(&self) -> Vec<u8> {
Msg::new().msg(10, Msg::new().msg(1, Msg::new().uint(1, 1).msg(2, self.chunks()).text(3, &self.language))).0
}
/// `Rsp.productRsp.getProductListRsp`: every owned product as owned, playable and purchased.
pub fn product_list(&self, request_id: u64) -> Vec<u8> {
let mut list = Msg::new().uint(1, request_id).uint(2, 1);
let products: Vec<(u32, u32)> = if self.products.is_empty() { self.owned.iter().map(|id| (*id, 0)).collect() } else { self.products.clone() };
for (id, kind) in products {
list = list.msg(3, Msg::new().uint(1, id as u64).uint(2, if kind == 1 { 2 } else { 1 }).uint(3, 1).uint(4, 3).uint(6, 1));
}
Msg::new().msg(15, Msg::new().msg(1, list)).0
}
pub fn service_hello(&self) -> Vec<u8> {
Msg::new().msg(3, Msg::new().uint(1, 1).msg(3, Msg::new().text(1, HWSCORE))).0
}
}
/// Requests answered with only their id and a result: (`Req` field, its request) to (`Rsp` field, its
/// response, `OperationResult`). 1 is ok and 4 not found.
const ACKNOWLEDGED: &[(u32, u32, u32, u32, u64)] = &[
(8, 1, 5, 1, 1), // achievements: list (none)
(8, 2, 5, 2, 4), // achievements: image
(8, 3, 5, 3, 1), // achievements: earn
(9, 1, 6, 1, 1), // overlay: show
(9, 2, 6, 2, 1), // overlay: shop url
(9, 4, 6, 3, 1), // overlay: browser url
(9, 5, 6, 4, 1), // overlay: notification
(9, 6, 6, 5, 1), // overlay: friend invitation
(9, 7, 6, 6, 1), // overlay: friend selection
(9, 8, 6, 8, 1), // overlay: micro app
(10, 1, 7, 1, 4), // avatar
(20, 1, 14, 1, 1), // store products (none)
(23, 3, 15, 5, 1), // track add-on
(25, 1, 17, 1, 4), // user lookup
(25, 3, 17, 3, 1), // rich presence
(25, 4, 17, 4, 1), // played with
];
/// The id-and-result answer to a request in [ACKNOWLEDGED], with a name for the log.
pub fn acknowledge(body: &[u8]) -> Option<(Vec<u8>, String)> {
let f = pb::fields(body)?;
for &(req, sub, rsp, rsp_sub, result) in ACKNOWLEDGED {
let Some(inner) = pb::sub(&f, req).and_then(pb::fields) else { continue };
let Some(leaf) = pb::sub(&inner, sub).and_then(pb::fields) else { continue };
let id = pb::num(&leaf, 1).unwrap_or(0);
let answer = Msg::new().msg(rsp, Msg::new().msg(rsp_sub, Msg::new().uint(1, id).uint(2, result))).0;
return Some((answer, format!("{req}.{sub} id {id} -> {result}")));
}
None
}
pub enum SessionReq {
Init,
Installer,
ProductList(u64),
Other,
}
pub fn session_request(body: &[u8]) -> SessionReq {
match pb::fields(body) {
Some(f) if pb::sub(&f, 13).and_then(pb::fields).is_some_and(|i| pb::has(&i, 1)) => SessionReq::Installer,
Some(f) if pb::has(&f, 1) => SessionReq::Init,
Some(f) => match pb::sub(&f, 23).and_then(pb::fields).and_then(|p| pb::sub(&p, 1).and_then(pb::fields)) {
Some(list) => SessionReq::ProductList(pb::num(&list, 1).unwrap_or(0)),
None => SessionReq::Other,
},
_ => SessionReq::Other,
}
}
/// The product and game process the game announces on the api-process pipe.
pub fn announced(body: &[u8]) -> Option<(u32, u32)> {
let f = pb::fields(body)?;
let r = pb::fields(pb::sub(&f, 2)?)?;
Some((pb::num(&r, 1)? as u32, pb::num(&r, 2)? as u32))
}
pub enum AuxReq<'a> {
Init(u64),
Activate(u64, &'a [u8]),
Other(u64),
}
/// `UplayAuxDll.Req`: an init, or an activation carrying the game's Denuvo request token.
pub fn aux_request(body: &[u8]) -> AuxReq<'_> {
let Some(f) = pb::fields(body) else { return AuxReq::Other(0) };
let rid = pb::num(&f, 1).unwrap_or(0);
let Some(activate) = pb::sub(&f, 3) else {
return if pb::has(&f, 2) { AuxReq::Init(rid) } else { AuxReq::Other(rid) };
};
let token = pb::fields(activate)
.and_then(|a| pb::sub(&a, 2).and_then(pb::fields))
.and_then(|t| t.iter().find_map(|(n, v)| match v { Value::Bytes(b) if *n == 2 => Some(*b), _ => None }));
match token {
Some(t) => AuxReq::Activate(rid, t),
None => AuxReq::Other(rid),
}
}
pub fn aux_init(rid: u64, pid: u32) -> Vec<u8> {
let mut init = Msg::new().uint(1, 1).uint(2, pid as u64);
for a in AUX_ADDONS {
init = init.uint(3, a);
}
Msg::new().uint(1, rid).msg(2, init).0
}
pub fn aux_token(rid: u64, game_token: &[u8]) -> Vec<u8> {
let answer = Msg::new().uint(2, 1).uint(3, 0).bytes(4, game_token).bytes(5, b"");
Msg::new().uint(1, rid).msg(3, Msg::new().msg(2, answer)).0
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> Config {
Config::parse(r#"{"productId":109,"exe":"Z:/games/sc/system/splintercell.exe","workdir":"Z:/games/sc","ticket":"T",
"account":{"userId":"u1","username":"name","nameOnPlatform":"name","email":"e@x","country":"US"},
"owned":[82],"appId":"a-b","saves":"Z:/saves/u1/109","chunks":[{"id":0,"tags":""},{"id":64,"tags":"custo05"}]}"#).unwrap()
}
#[test]
fn the_owned_list_always_holds_the_game_and_saves_end_with_a_slash() {
let c = config();
assert_eq!(c.owned, vec![82, 109]);
assert_eq!(c.saves, "Z:/saves/u1/109/");
}
#[test]
fn the_start_reply_names_the_exe_under_the_root_with_connects_double_slash() {
let c = config();
assert_eq!(c.reply_exe("Z:\\games\\sc\\system\\splintercell.exe"), "Z:/games/sc//system/splintercell.exe");
let req = Msg::new().msg(1, Msg::new().msg(1, Msg::new().uint(1, 13389).uint(2, 109).text(10, "Z:/games/sc/system/splintercell.exe")));
let rsp = c.game_start(&req.0);
let granted = pb::fields(pb::sub(&pb::fields(pb::sub(&pb::fields(&rsp).unwrap(), 2).unwrap()).unwrap(), 1).unwrap()).unwrap();
assert_eq!(pb::num(&granted, 1), Some(0));
assert_eq!(pb::sub(&granted, 3), Some(&b"Z:/games/sc//system/splintercell.exe"[..]));
assert_eq!(pb::sub(&granted, 4), Some(&b"Z:/games/sc/"[..]));
assert_eq!(pb::num(&granted, 8), Some(3));
}
#[test]
fn the_session_carries_the_ticket_products_and_save_root() {
let rsp = config().session();
let init = pb::fields(pb::sub(&pb::fields(&rsp).unwrap(), 1).unwrap()).unwrap();
assert_eq!(pb::num(&init, 1), Some(1));
assert_eq!(pb::num(&init, 4), Some(109));
let owned: Vec<u64> = init.iter().filter_map(|(n, v)| match v { Value::Int(x) if *n == 5 => Some(*x), _ => None }).collect();
assert_eq!(owned, vec![82, 109]);
assert_eq!(pb::sub(&init, 8), Some(&b"T"[..]));
let saves = pb::fields(pb::sub(&init, 17).unwrap()).unwrap();
assert_eq!(pb::sub(&saves, 1), Some(&b"Z:/saves/u1/109/"[..]));
let account = pb::fields(pb::sub(&init, 2).unwrap()).unwrap();
assert_eq!(pb::sub(&account, 1), Some(&b"u1"[..]));
assert_eq!(pb::sub(&account, 5), Some(&b""[..]));
let install = pb::fields(pb::sub(&init, 14).unwrap()).unwrap();
assert_eq!(pb::num(&install, 1), Some(1));
let chunks = pb::fields(pb::sub(&install, 2).unwrap()).unwrap();
let second = pb::fields(match &chunks[1].1 { Value::Bytes(b) => b, _ => panic!() }).unwrap();
assert_eq!((pb::num(&second, 1), pb::num(&second, 2), pb::sub(&second, 3)), (Some(64), Some(1), Some(&b"custo05"[..])));
}
#[test]
fn requests_are_told_apart() {
assert!(matches!(session_request(&Msg::new().msg(1, Msg::new().uint(1, 109)).0), SessionReq::Init));
assert!(matches!(session_request(&Msg::new().msg(13, Msg::new().bytes(1, b"")).0), SessionReq::Installer));
assert!(matches!(session_request(&[0xba, 0x01, 0x06, 0x0a, 0x04, 0x08, 0x80, 0x80, 0x04]), SessionReq::ProductList(65536)));
let (answer, _) = acknowledge(&[0x42, 0x08, 0x0a, 0x06, 0x08, 0x80, 0x80, 0x08, 0x10, 0x00]).unwrap();
let rsp = pb::fields(pb::sub(&pb::fields(pb::sub(&pb::fields(&answer).unwrap(), 5).unwrap()).unwrap(), 1).unwrap()).unwrap();
assert_eq!((pb::num(&rsp, 1), pb::num(&rsp, 2)), (Some(0x20000), Some(1)));
assert!(acknowledge(&Msg::new().msg(1, Msg::new().uint(1, 1)).0).is_none());
assert_eq!(announced(&Msg::new().msg(2, Msg::new().uint(1, 109).uint(2, 4242).uint(5, 1)).0), Some((109, 4242)));
let activate = Msg::new().uint(1, 2).msg(3, Msg::new().msg(2, Msg::new().bytes(2, b"challenge").uint(3, 0)));
assert!(matches!(aux_request(&activate.0), AuxReq::Activate(2, b"challenge")));
assert!(matches!(aux_request(&Msg::new().uint(1, 1).msg(2, Msg::new().uint(1, 109)).0), AuxReq::Init(1)));
}
}
+331
View File
@@ -0,0 +1,331 @@
//! Stands in for Ubisoft Connect on the local pipes a Ubisoft game talks to, and starts the game.
//!
//! Usage: droiddeck-upc.exe <launch.json>
#![cfg_attr(windows, windows_subsystem = "windows")]
mod launcher;
mod pb;
#[cfg(windows)]
mod win;
#[cfg(not(windows))]
fn main() {
eprintln!("droiddeck-upc runs inside Wine");
std::process::exit(2);
}
#[cfg(windows)]
fn main() {
std::process::exit(service::run());
}
#[cfg(windows)]
mod service {
use crate::launcher::{self, AuxReq, Config, SessionReq};
use crate::win::{self, Handle};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
const MAX_FRAME: usize = 16 << 20;
const ANNOUNCE_WAIT: Duration = Duration::from_secs(120);
const RESTART_WAIT: Duration = Duration::from_secs(15);
const DENUVO_WAIT: Duration = Duration::from_secs(90);
struct State {
cfg: Config,
prefix: String,
log: Mutex<Option<std::fs::File>>,
event: Handle,
game: AtomicU32,
started: Instant,
}
impl State {
fn say(&self, line: &str) {
let text = format!("[{:7.2}] {line}\n", self.started.elapsed().as_secs_f32());
eprint!("droiddeck-upc: {text}");
if let Some(f) = self.log.lock().unwrap().as_mut() {
let _ = f.write_all(text.as_bytes());
}
}
}
struct Frames<'a> {
pipe: &'a Handle,
buf: Vec<u8>,
}
impl<'a> Frames<'a> {
fn new(pipe: &'a Handle) -> Self {
Frames { pipe, buf: Vec::new() }
}
fn fill(&mut self) -> bool {
let mut chunk = vec![0u8; 65536];
match self.pipe.read(&mut chunk) {
Some(n) => {
self.buf.extend_from_slice(&chunk[..n]);
true
}
None => false,
}
}
/// The next message without its 4-byte big-endian length.
fn next(&mut self) -> Option<Vec<u8>> {
while self.buf.len() < 4 {
if !self.fill() {
return None;
}
}
let n = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if n > MAX_FRAME {
return None;
}
while self.buf.len() < 4 + n {
if !self.fill() {
return None;
}
}
let body = self.buf[4..4 + n].to_vec();
self.buf.drain(..4 + n);
Some(body)
}
}
fn send(pipe: &Handle, body: &[u8]) -> bool {
let mut framed = (body.len() as u32).to_be_bytes().to_vec();
framed.extend_from_slice(body);
pipe.write_all(&framed)
}
fn hold(state: &State, name: &str, frames: &mut Frames) {
while let Some(body) = frames.next() {
state.say(&format!("{name}: {} B left unanswered", body.len()));
}
}
fn game_start(state: &State, pipe: Handle) {
let mut frames = Frames::new(&pipe);
let Some(req) = frames.next() else { return };
send(&pipe, &state.cfg.game_start(&req));
state.say("game_start: start granted");
hold(state, "game_start", &mut frames);
}
fn api_process(state: &State, pipe: Handle) {
let mut frames = Frames::new(&pipe);
let Some(req) = frames.next() else { return };
match launcher::announced(&req) {
Some((product, pid)) => {
state.say(&format!("api_process: product {product} is process {pid}"));
if pid != 0 {
state.game.store(pid, Ordering::SeqCst);
}
}
None => state.say(&format!("api_process: {} B announcement not understood", req.len())),
}
send(&pipe, &state.cfg.init_process());
state.event.set();
hold(state, "api_process", &mut frames);
}
fn session(state: &State, pipe: Handle) {
let mut frames = Frames::new(&pipe);
let mut pushes = 0;
while let Some(req) = frames.next() {
match launcher::session_request(&req) {
SessionReq::Init => {
pushes += 1;
send(&pipe, &state.cfg.session());
state.say(&format!("session: init answered ({pushes})"));
}
SessionReq::Installer => {
send(&pipe, &state.cfg.installer());
state.say("session: installer init answered");
}
SessionReq::ProductList(id) => {
send(&pipe, &state.cfg.product_list(id));
state.say(&format!("session: product list {id} answered"));
}
SessionReq::Other => match launcher::acknowledge(&req) {
Some((answer, what)) => {
send(&pipe, &answer);
state.say(&format!("session: {what}"));
}
None => state.say(&format!("session: {} B request {} not handled", req.len(), hex(&req, 24))),
},
}
}
state.say("session: closed");
}
fn denuvo(state: &State, rid: u64, challenge: &[u8]) -> Option<Vec<u8>> {
if state.cfg.bridge.is_empty() {
return None;
}
let dir = PathBuf::from(&state.cfg.bridge);
let _ = std::fs::create_dir_all(&dir);
let rsp = dir.join(format!("denuvo-{rid}.rsp"));
let _ = std::fs::remove_file(&rsp);
let part = dir.join(format!("denuvo-{rid}.req.part"));
std::fs::write(&part, challenge).ok()?;
std::fs::rename(&part, dir.join(format!("denuvo-{rid}.req"))).ok()?;
let deadline = Instant::now() + DENUVO_WAIT;
while Instant::now() < deadline {
if let Ok(token) = std::fs::read(&rsp) {
let _ = std::fs::remove_file(&rsp);
return (!token.is_empty()).then_some(token);
}
thread::sleep(Duration::from_millis(200));
}
None
}
fn aux(state: &State, pipe: Handle) {
let mut frames = Frames::new(&pipe);
while let Some(req) = frames.next() {
match launcher::aux_request(&req) {
AuxReq::Init(rid) => {
send(&pipe, &launcher::aux_init(rid, win::pid()));
state.say("aux: init answered");
}
AuxReq::Activate(rid, challenge) => {
state.say(&format!("aux: activation {rid} with a {} B challenge", challenge.len()));
match denuvo(state, rid, challenge) {
Some(token) => {
send(&pipe, &launcher::aux_token(rid, &token));
state.say(&format!("aux: activation {rid} answered ({} B)", token.len()));
}
None => state.say(&format!("aux: no game token for activation {rid}")),
}
}
AuxReq::Other(rid) => state.say(&format!("aux: request {rid} not handled ({} B)", req.len())),
}
}
}
fn service(state: &State, pipe: Handle) {
send(&pipe, &state.cfg.service_hello());
let mut chunk = vec![0u8; 65536];
while pipe.read(&mut chunk).is_some() {}
}
fn quiet(state: &State, name: &str, pipe: Handle) {
state.say(&format!("{name}: connected"));
let mut chunk = vec![0u8; 65536];
while let Some(n) = pipe.read(&mut chunk) {
state.say(&format!("{name}: {n} B unanswered {}", hex(&chunk[..n], 64)));
}
}
fn serve(state: Arc<State>, name: &'static str) {
thread::spawn(move || {
let full = format!("{}{name}", state.prefix);
loop {
let Some(pipe) = win::accept(&full) else {
state.say(&format!("{name}: cannot serve the pipe (error {})", win::last_error()));
return;
};
let s = state.clone();
thread::spawn(move || match name {
launcher::GAME_START => game_start(&s, pipe),
launcher::API_PROCESS => api_process(&s, pipe),
launcher::SESSION => session(&s, pipe),
launcher::AUX => aux(&s, pipe),
launcher::SERVICE => service(&s, pipe),
_ => quiet(&s, name, pipe),
});
}
});
}
fn hex(b: &[u8], n: usize) -> String {
b.iter().take(n).map(|x| format!("{x:02x}")).collect()
}
fn command_line(cfg: &Config) -> String {
let mut c = format!("\"{}\"", cfg.exe);
if !cfg.args.trim().is_empty() {
c.push(' ');
c.push_str(cfg.args.trim());
}
c
}
pub fn run() -> i32 {
let Some(path) = std::env::args().nth(1) else {
eprintln!("usage: droiddeck-upc <launch.json>");
return 2;
};
let parsed = std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|t| Config::parse(&t));
let _ = std::fs::remove_file(&path);
let cfg = match parsed {
Ok(c) => c,
Err(e) => {
eprintln!("droiddeck-upc: {e}");
return 2;
}
};
let log = (!cfg.log.is_empty()).then(|| OpenOptions::new().create(true).append(true).open(&cfg.log).ok()).flatten();
let state = Arc::new(State {
prefix: format!(r"\\.\pipe\terminal_{}_", win::session_id()),
cfg,
log: Mutex::new(log),
event: win::inheritable_event(),
game: AtomicU32::new(0),
started: Instant::now(),
});
state.say(&format!("product {} on {}*", state.cfg.product, state.prefix));
for name in [launcher::GAME_START, launcher::API_PROCESS, launcher::SESSION, launcher::AUX, launcher::SERVICE]
.into_iter()
.chain(launcher::QUIET.iter().copied())
{
serve(state.clone(), name);
}
thread::sleep(Duration::from_millis(300));
let (child, pid) = match win::spawn(&command_line(&state.cfg), &state.cfg.workdir) {
Ok(c) => c,
Err(e) => {
state.say(&format!("cannot start {} (error {e})", state.cfg.exe));
return 1;
}
};
state.say(&format!("started {} as process {pid}", state.cfg.exe));
while !child.wait(1000) {}
let mut code = child.exit_code();
state.say(&format!("process {pid} exited with 0x{code:08x}"));
let mut waiting = Instant::now();
let mut limit = ANNOUNCE_WAIT;
loop {
let game = state.game.swap(0, Ordering::SeqCst);
if game != 0 {
match win::open_process(game) {
Some(h) => {
while !h.wait(1000) {}
code = h.exit_code();
state.say(&format!("game process {game} exited with 0x{code:08x}"));
}
None => state.say(&format!("game process {game} is already gone")),
}
waiting = Instant::now();
limit = RESTART_WAIT;
continue;
}
if waiting.elapsed() >= limit {
break;
}
thread::sleep(Duration::from_millis(500));
}
state.say("done");
code as i32
}
}
+168
View File
@@ -0,0 +1,168 @@
//! The protobuf wire format, enough to build and read the launcher messages by field number.
pub fn varint(mut n: u64, out: &mut Vec<u8>) {
loop {
let b = (n & 0x7f) as u8;
n >>= 7;
if n == 0 {
out.push(b);
return;
}
out.push(b | 0x80);
}
}
#[derive(Default, Clone)]
pub struct Msg(pub Vec<u8>);
impl Msg {
pub fn new() -> Self {
Msg(Vec::new())
}
fn key(mut self, field: u32, wire: u8) -> Self {
varint(((field as u64) << 3) | wire as u64, &mut self.0);
self
}
pub fn uint(self, field: u32, value: u64) -> Self {
let mut m = self.key(field, 0);
varint(value, &mut m.0);
m
}
pub fn bytes(self, field: u32, value: &[u8]) -> Self {
let mut m = self.key(field, 2);
varint(value.len() as u64, &mut m.0);
m.0.extend_from_slice(value);
m
}
pub fn text(self, field: u32, value: &str) -> Self {
self.bytes(field, value.as_bytes())
}
pub fn msg(self, field: u32, value: Msg) -> Self {
self.bytes(field, &value.0)
}
pub fn float(self, field: u32, value: f32) -> Self {
let mut m = self.key(field, 5);
m.0.extend_from_slice(&value.to_le_bytes());
m
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Value<'a> {
Int(u64),
Bytes(&'a [u8]),
}
fn read_varint(b: &[u8], i: &mut usize) -> Option<u64> {
let mut r = 0u64;
let mut shift = 0;
loop {
let c = *b.get(*i)?;
*i += 1;
if shift >= 64 {
return None;
}
r |= ((c & 0x7f) as u64) << shift;
if c & 0x80 == 0 {
return Some(r);
}
shift += 7;
}
}
/// The top-level fields of a message, or `None` when it does not parse.
pub fn fields(b: &[u8]) -> Option<Vec<(u32, Value<'_>)>> {
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
let key = read_varint(b, &mut i)?;
let field = (key >> 3) as u32;
match key & 7 {
0 => out.push((field, Value::Int(read_varint(b, &mut i)?))),
1 => {
let v = b.get(i..i + 8)?;
i += 8;
out.push((field, Value::Int(u64::from_le_bytes(v.try_into().ok()?))));
}
2 => {
let n = read_varint(b, &mut i)? as usize;
let v = b.get(i..i.checked_add(n)?)?;
i += n;
out.push((field, Value::Bytes(v)));
}
5 => {
let v = b.get(i..i + 4)?;
i += 4;
out.push((field, Value::Int(u32::from_le_bytes(v.try_into().ok()?) as u64)));
}
_ => return None,
}
}
Some(out)
}
pub fn sub<'a>(f: &[(u32, Value<'a>)], field: u32) -> Option<&'a [u8]> {
f.iter().find_map(|(n, v)| match v {
Value::Bytes(b) if *n == field => Some(*b),
_ => None,
})
}
pub fn num(f: &[(u32, Value<'_>)], field: u32) -> Option<u64> {
f.iter().find_map(|(n, v)| match v {
Value::Int(x) if *n == field => Some(*x),
_ => None,
})
}
pub fn has(f: &[(u32, Value<'_>)], field: u32) -> bool {
f.iter().any(|(n, _)| *n == field)
}
/// The first length-delimited `field` at any depth up to `depth`, searching breadth first.
pub fn find(b: &[u8], field: u32, depth: u32) -> Option<&[u8]> {
let f = fields(b)?;
if let Some(v) = sub(&f, field) {
return Some(v);
}
if depth == 0 {
return None;
}
f.iter().find_map(|(_, v)| match v {
Value::Bytes(inner) => find(inner, field, depth - 1),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
let m = Msg::new().uint(1, 300).text(2, "hi").msg(3, Msg::new().uint(4, 1)).float(5, 1.0);
let f = fields(&m.0).unwrap();
assert_eq!(num(&f, 1), Some(300));
assert_eq!(sub(&f, 2), Some(&b"hi"[..]));
assert_eq!(num(&fields(sub(&f, 3).unwrap()).unwrap(), 4), Some(1));
assert_eq!(num(&f, 5), Some(0x3f80_0000));
}
#[test]
fn finds_nested_fields() {
let m = Msg::new().msg(1, Msg::new().msg(1, Msg::new().uint(2, 109).text(10, "C:/game.exe")));
assert_eq!(find(&m.0, 10, 4), Some(&b"C:/game.exe"[..]));
assert_eq!(find(&m.0, 11, 4), None);
}
#[test]
fn rejects_truncated_input() {
assert!(fields(&[0x0a, 0x05, 1, 2]).is_none());
}
}
+183
View File
@@ -0,0 +1,183 @@
//! The handful of kernel32 calls the launcher needs.
#![allow(non_snake_case, clippy::upper_case_acronyms)]
use std::ffi::c_void;
use std::os::windows::ffi::OsStrExt;
pub type HANDLE = *mut c_void;
pub const INVALID: HANDLE = -1isize as HANDLE;
const PIPE_ACCESS_DUPLEX: u32 = 3;
const PIPE_TYPE_BYTE: u32 = 0;
const PIPE_READMODE_BYTE: u32 = 0;
const PIPE_WAIT: u32 = 0;
const PIPE_UNLIMITED_INSTANCES: u32 = 255;
const ERROR_PIPE_CONNECTED: u32 = 535;
const HANDLE_FLAG_INHERIT: u32 = 1;
const SYNCHRONIZE: u32 = 0x0010_0000;
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
pub const WAIT_OBJECT_0: u32 = 0;
const BUFFER: u32 = 65536;
#[repr(C)]
struct STARTUPINFOW {
cb: u32,
lpReserved: *mut u16,
lpDesktop: *mut u16,
lpTitle: *mut u16,
dwX: u32,
dwY: u32,
dwXSize: u32,
dwYSize: u32,
dwXCountChars: u32,
dwYCountChars: u32,
dwFillAttribute: u32,
dwFlags: u32,
wShowWindow: u16,
cbReserved2: u16,
lpReserved2: *mut u8,
hStdInput: HANDLE,
hStdOutput: HANDLE,
hStdError: HANDLE,
}
#[repr(C)]
struct PROCESS_INFORMATION {
hProcess: HANDLE,
hThread: HANDLE,
dwProcessId: u32,
dwThreadId: u32,
}
#[link(name = "kernel32")]
extern "system" {
fn CreateNamedPipeW(name: *const u16, open: u32, mode: u32, max: u32, out: u32, inp: u32, timeout: u32, sa: *mut c_void) -> HANDLE;
fn ConnectNamedPipe(h: HANDLE, ov: *mut c_void) -> i32;
fn ReadFile(h: HANDLE, buf: *mut u8, n: u32, read: *mut u32, ov: *mut c_void) -> i32;
fn WriteFile(h: HANDLE, buf: *const u8, n: u32, written: *mut u32, ov: *mut c_void) -> i32;
fn FlushFileBuffers(h: HANDLE) -> i32;
fn CloseHandle(h: HANDLE) -> i32;
fn GetLastError() -> u32;
fn CreateEventW(sa: *mut c_void, manual: i32, initial: i32, name: *const u16) -> HANDLE;
fn SetEvent(h: HANDLE) -> i32;
fn SetHandleInformation(h: HANDLE, mask: u32, flags: u32) -> i32;
fn CreateProcessW(app: *const u16, cmd: *mut u16, pa: *mut c_void, ta: *mut c_void, inherit: i32, flags: u32,
env: *mut c_void, cwd: *const u16, si: *mut STARTUPINFOW, pi: *mut PROCESS_INFORMATION) -> i32;
fn WaitForSingleObject(h: HANDLE, ms: u32) -> u32;
fn GetExitCodeProcess(h: HANDLE, code: *mut u32) -> i32;
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> HANDLE;
fn ProcessIdToSessionId(pid: u32, session: *mut u32) -> i32;
fn GetCurrentProcessId() -> u32;
}
pub fn wide(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect()
}
pub fn last_error() -> u32 {
unsafe { GetLastError() }
}
pub fn pid() -> u32 {
unsafe { GetCurrentProcessId() }
}
pub fn session_id() -> u32 {
let mut s = 0;
if unsafe { ProcessIdToSessionId(GetCurrentProcessId(), &mut s) } == 0 {
return 1;
}
s
}
pub struct Handle(pub HANDLE);
unsafe impl Send for Handle {}
unsafe impl Sync for Handle {}
impl Drop for Handle {
fn drop(&mut self) {
if !self.0.is_null() && self.0 != INVALID {
unsafe { CloseHandle(self.0) };
}
}
}
impl Handle {
pub fn read(&self, buf: &mut [u8]) -> Option<usize> {
let mut n = 0u32;
let ok = unsafe { ReadFile(self.0, buf.as_mut_ptr(), buf.len() as u32, &mut n, std::ptr::null_mut()) };
(ok != 0 && n > 0).then_some(n as usize)
}
pub fn write_all(&self, mut data: &[u8]) -> bool {
while !data.is_empty() {
let mut n = 0u32;
if unsafe { WriteFile(self.0, data.as_ptr(), data.len() as u32, &mut n, std::ptr::null_mut()) } == 0 || n == 0 {
return false;
}
data = &data[n as usize..];
}
unsafe { FlushFileBuffers(self.0) };
true
}
pub fn wait(&self, ms: u32) -> bool {
unsafe { WaitForSingleObject(self.0, ms) == WAIT_OBJECT_0 }
}
pub fn exit_code(&self) -> u32 {
let mut c = 0;
unsafe { GetExitCodeProcess(self.0, &mut c) };
c
}
pub fn set(&self) {
unsafe { SetEvent(self.0) };
}
}
/// A new instance of the named pipe, waiting for its client; `None` when it cannot be created.
pub fn accept(name: &str) -> Option<Handle> {
let w = wide(name);
let h = unsafe {
CreateNamedPipeW(w.as_ptr(), PIPE_ACCESS_DUPLEX, PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES, BUFFER, BUFFER, 0, std::ptr::null_mut())
};
if h == INVALID {
return None;
}
let pipe = Handle(h);
if unsafe { ConnectNamedPipe(h, std::ptr::null_mut()) } == 0 && last_error() != ERROR_PIPE_CONNECTED {
return None;
}
Some(pipe)
}
/// An auto-reset event the started process inherits.
pub fn inheritable_event() -> Handle {
let h = unsafe { CreateEventW(std::ptr::null_mut(), 0, 0, std::ptr::null()) };
unsafe { SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) };
Handle(h)
}
pub fn spawn(command_line: &str, cwd: &str) -> Result<(Handle, u32), u32> {
let mut cmd = wide(command_line);
let dir = wide(cwd);
let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() };
si.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
let ok = unsafe {
CreateProcessW(std::ptr::null(), cmd.as_mut_ptr(), std::ptr::null_mut(), std::ptr::null_mut(), 1, 0,
std::ptr::null_mut(), if cwd.is_empty() { std::ptr::null() } else { dir.as_ptr() }, &mut si, &mut pi)
};
if ok == 0 {
return Err(last_error());
}
drop(Handle(pi.hThread));
Ok((Handle(pi.hProcess), pi.dwProcessId))
}
pub fn open_process(pid: u32) -> Option<Handle> {
let h = unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
(!h.is_null()).then(|| Handle(h))
}