diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8aa70c1ce..a3656517c6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -210,8 +210,8 @@ dependencies { // JavaSteam val localBuild = false // Change to 'true' needed when building JavaSteam manually if (localBuild) { - implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0-11-SNAPSHOT.jar")) - implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0-11-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0-12-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0-12-SNAPSHOT.jar")) implementation(libs.bundles.javasteam.dev) } else { implementation(libs.javasteam) { diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 6860271b0b..fd99e86d8d 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -304,6 +304,13 @@ object PrefManager { setPref(STEAM_OFFLINE_MODE, value) } + private val SHOW_ACHIEVEMENT_POPUPS = booleanPreferencesKey("show_achievement_popups") + var showAchievementPopups: Boolean + get() = getPref(SHOW_ACHIEVEMENT_POPUPS, false) + set(value) { + setPref(SHOW_ACHIEVEMENT_POPUPS, value) + } + private val USE_LEGACY_DRM = booleanPreferencesKey("use_legacy_drm") var useLegacyDRM: Boolean get() = getPref(USE_LEGACY_DRM, false) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 2d8d4c04ed..fbfeec5179 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -95,6 +95,7 @@ import `in`.dragonbra.javasteam.steam.handlers.steamuser.SteamUser import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.LoggedOffCallback import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.LoggedOnCallback import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.PlayingSessionStateCallback +import `in`.dragonbra.javasteam.steam.handlers.steamuserstats.Stats import `in`.dragonbra.javasteam.steam.handlers.steamuserstats.SteamUserStats import `in`.dragonbra.javasteam.steam.handlers.steamworkshop.SteamWorkshop import `in`.dragonbra.javasteam.steam.steamclient.AsyncJobFailedException @@ -157,8 +158,11 @@ import java.util.concurrent.CopyOnWriteArrayList import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.FormBody +import org.json.JSONArray import org.json.JSONObject import com.winlator.container.ContainerManager +import app.gamenative.statsgen.Achievement +import app.gamenative.statsgen.StatsAchievementsGenerator @AndroidEntryPoint class SteamService : Service(), IChallengeUrlChanged { @@ -214,6 +218,7 @@ class SteamService : Service(), IChallengeUrlChanged { private var _steamApps: SteamApps? = null private var _steamFriends: SteamFriends? = null private var _steamCloud: SteamCloud? = null + private var _steamUserStats: SteamUserStats? = null private var _steamFamilyGroups: FamilyGroups? = null private var _loginResult: LoginResult = LoginResult.Failed @@ -2115,7 +2120,7 @@ class SteamService : Service(), IChallengeUrlChanged { } } - suspend fun closeApp(appId: Int, isOffline: Boolean, prefixToPath: (String) -> String) = withContext(Dispatchers.IO) { + suspend fun closeApp(context: Context, appId: Int, isOffline: Boolean, prefixToPath: (String) -> String) = withContext(Dispatchers.IO) { async { if (isOffline || !isConnected) { return@async @@ -2127,6 +2132,12 @@ class SteamService : Service(), IChallengeUrlChanged { } try { + try { + syncAchievementsFromGoldberg(context, appId) + } catch (e: Exception) { + Timber.e(e, "Achievement sync failed for appId=$appId, continuing with cloud save sync") + } + val maxAttempts = 3 for (attempt in 1..maxAttempts) { try { @@ -2600,6 +2611,164 @@ class SteamService : Service(), IChallengeUrlChanged { return emptySet() } } + + suspend fun generateAchievements(appId: Int, configDirectory: String) { + val steamUser = instance!!._steamUser!! + val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await() + val schemaArray = userStats.schema.toByteArray() + val generator = StatsAchievementsGenerator() + val result = generator.generateStatsAchievements(schemaArray, configDirectory) + + val nameToBlockBit = result.nameToBlockBit + Timber.d("nameToBlockBit size=${nameToBlockBit.size} for appId=$appId") + if (nameToBlockBit.isNotEmpty()) { + val configDir = File(configDirectory) + if (!configDir.exists()) configDir.mkdirs() + val mappingJson = JSONObject() + nameToBlockBit.forEach { (name, pair) -> + mappingJson.put(name, JSONArray(listOf(pair.first, pair.second))) + } + File(configDir, "achievement_name_to_block.json").writeText(mappingJson.toString(), Charsets.UTF_8) + } + } + + suspend fun syncAchievementsFromGoldberg(context: Context, appId: Int) { + val imageFs = ImageFs.find(context) + val gseSavesDir = File( + imageFs.rootDir, + "${ImageFs.WINEPREFIX}/drive_c/users/xuser/AppData/Roaming/GSE Saves/$appId" + ) + var goldbergAchFile = File(gseSavesDir, "achievements.json") + if (!goldbergAchFile.exists()) { + Timber.d("No Goldberg achievements.json at ${goldbergAchFile.absolutePath}, checking userdata path") + val accountId = userSteamId?.accountID?.toInt() + if (accountId != null) { + val userdataDir = File( + imageFs.rootDir, + "${ImageFs.WINEPREFIX}/drive_c/Program Files (x86)/Steam/userdata/$accountId/$appId" + ) + goldbergAchFile = File(userdataDir, "achievements.json") + } + if (!goldbergAchFile.exists()) { + Timber.d("No Goldberg achievements.json found for appId=$appId") + return + } + } + + val unlockedNames = mutableSetOf() + try { + val json = JSONObject(goldbergAchFile.readText(Charsets.UTF_8)) + for (name in json.keys()) { + val entry = json.optJSONObject(name) ?: continue + if (entry.optBoolean("earned", false)) { + unlockedNames.add(name) + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse Goldberg achievements.json for appId=$appId") + return + } + + if (unlockedNames.isEmpty()) { + Timber.d("No earned achievements found in Goldberg output for appId=$appId") + return + } + + val configDirectory = findSteamSettingsDir(context, appId) + if (configDirectory == null) { + Timber.w("Could not find steam_settings directory for appId=$appId") + return + } + + Timber.i("Found ${unlockedNames.size} earned achievements for appId=$appId, syncing to Steam") +// val result = storeAchievementUnlocks(appId, configDirectory, unlockedNames) +// result.onSuccess { +// Timber.i("Successfully synced achievements to Steam for appId=$appId") +// }.onFailure { e -> +// Timber.e(e, "Failed to sync achievements to Steam for appId=$appId") +// } + } + + private fun findSteamSettingsDir(context: Context, appId: Int): String? { + val appDirPath = getAppDirPath(appId) + val appDirSettings = File(appDirPath, "steam_settings") + if (File(appDirSettings, "achievement_name_to_block.json").exists()) { + return appDirSettings.absolutePath + } + + val container = ContainerUtils.getContainer(context, "STEAM_$appId") + val coldclientSettings = File( + container.rootDir, + ".wine/drive_c/Program Files (x86)/Steam/steam_settings" + ) + if (File(coldclientSettings, "achievement_name_to_block.json").exists()) { + return coldclientSettings.absolutePath + } + + return null + } + + suspend fun storeAchievementUnlocks( + appId: Int, + configDirectory: String, + unlockedNames: Set + ): Result = runCatching { + val mappingFile = File(configDirectory, "achievement_name_to_block.json") + if (!mappingFile.exists()) { + throw IllegalStateException("achievement_name_to_block.json not found in $configDirectory") + } + val mappingJson = JSONObject(mappingFile.readText(Charsets.UTF_8)) + val nameToBlockBit = mutableMapOf>() + for (key in mappingJson.keys()) { + val arr = mappingJson.optJSONArray(key) ?: continue + if (arr.length() >= 2) { + nameToBlockBit[key] = Pair(arr.getInt(0), arr.getInt(1)) + } + } + if (nameToBlockBit.isEmpty()) return@runCatching + + val steamUser = instance!!._steamUser!! + val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await() + if (userStats.result != EResult.OK) { + throw IllegalStateException("getUserStats failed: ${userStats.result}") + } + val rawBlocks = userStats.achievementBlocks ?: emptyList() + val blockBitmasks = mutableMapOf() + for (block in rawBlocks) { + val blockId = (block.achievementId as? Number)?.toInt() ?: continue + var bitmask = 0 + val unlockTimes = block.unlockTime ?: emptyList() + for (i in unlockTimes.indices) { + val t = unlockTimes[i] + if ((t as? Number)?.toLong() != 0L) bitmask = bitmask or (1 shl i) + } + blockBitmasks[blockId] = bitmask + } + for (name in unlockedNames) { + val (blockId, bitIndex) = nameToBlockBit[name] ?: continue + val current = blockBitmasks.getOrDefault(blockId, 0) + blockBitmasks[blockId] = current or (1 shl bitIndex) + } + val statsToStore = blockBitmasks.map { (id, mask) -> Stats(statId = id, statValue = mask) } + Timber.d("storeUserStats: appId=$appId, crcStats=${userStats.crcStats}, stats=$statsToStore") + val mySteamId = steamUser.steamID!! +// val callback = instance?._steamUserStats!!.storeUserStats( +// appId, statsToStore, mySteamId, mySteamId, userStats.crcStats +// ).await() +// if (callback.result != EResult.OK) { +// throw IllegalStateException("storeUserStats failed: ${callback.result}") +// } +// if (callback.statsOutOfDate) { +// Timber.w("Stats were out of date on server for appId=$appId") +// } +// if (callback.statsFailedValidation.isNotEmpty()) { +// Timber.w("${callback.statsFailedValidation.size} stats failed validation for appId=$appId") +// callback.statsFailedValidation.forEach { f -> +// Timber.w(" statId=${f.statId} reverted to ${f.revertedStatValue}") +// } +// } + } + } override fun onCreate() { @@ -2704,7 +2873,6 @@ class SteamService : Service(), IChallengeUrlChanged { removeHandler(SteamMasterServer::class.java) removeHandler(SteamWorkshop::class.java) removeHandler(SteamScreenshots::class.java) - removeHandler(SteamUserStats::class.java) } // create the callback manager which will route callbacks to function calls @@ -2715,6 +2883,7 @@ class SteamService : Service(), IChallengeUrlChanged { _steamApps = steamClient!!.getHandler(SteamApps::class.java) _steamFriends = steamClient!!.getHandler(SteamFriends::class.java) _steamCloud = steamClient!!.getHandler(SteamCloud::class.java) + _steamUserStats = steamClient!!.getHandler(SteamUserStats::class.java) _unifiedFriends = SteamUnifiedFriends(this) _steamFamilyGroups = steamClient!!.getHandler()!!.createService() diff --git a/app/src/main/java/app/gamenative/statsgen/Models.kt b/app/src/main/java/app/gamenative/statsgen/Models.kt new file mode 100644 index 0000000000..029a6d7a14 --- /dev/null +++ b/app/src/main/java/app/gamenative/statsgen/Models.kt @@ -0,0 +1,44 @@ +package app.gamenative.statsgen + +data class Achievement( + val name: String, + val displayName: Map? = null, + val description: Map? = null, + val hidden: Int = 0, + val icon: String? = null, + val iconGray: String? = null, + val icongray: String? = null, + val progress: Map? = null, + val unlocked: Boolean? = null, + val unlockTimestamp: Int? = null, + val formattedUnlockTime: String? = null +) + +data class Stat( + val id: String, + val name: String, + val type: String, + val default: String = "0", + val global: String = "0", + val min: String? = null +) + +data class ProcessingResult( + val achievements: List, + val stats: List, + val copyDefaultUnlockedImg: Boolean, + val copyDefaultLockedImg: Boolean, + val nameToBlockBit: Map> = emptyMap(), +) + +object StatType { + const val STAT_TYPE_INT = "1" + const val STAT_TYPE_FLOAT = "2" + const val STAT_TYPE_AVGRATE = "3" + const val STAT_TYPE_BITS = "4" + + const val ACHIEVEMENTS = "ACHIEVEMENTS" + const val INT = "INT" + const val FLOAT = "FLOAT" + const val AVGRATE = "AVGRATE" +} diff --git a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt new file mode 100644 index 0000000000..af751a61e4 --- /dev/null +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -0,0 +1,349 @@ +package app.gamenative.statsgen + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +class StatsAchievementsGenerator { + private val vdfParser = VdfParser() + + private fun escapeUnicode(text: String): String { + val sb = StringBuilder() + for (char in text) { + when { + char.code < 32 || char.code > 126 -> { + sb.append(String.format("\\u%04x", char.code)) + } + char == '\\' -> sb.append("\\") + char == '"' -> sb.append("\\\"") + else -> sb.append(char) + } + } + return sb.toString() + } + + fun generateStatsAchievements(schema: ByteArray, configDirectory: String): ProcessingResult { + val parsedSchema = vdfParser.binaryLoads(schema) + val achievementsOut = mutableListOf() + val statsOut = mutableListOf() + val nameToBlockBit = mutableMapOf>() + + for ((appId, appData) in parsedSchema) { + if (appData !is Map<*, *>) continue + val sch = appData as Map + val statInfo = sch["stats"] as? Map ?: continue + + for ((statKey, statData) in statInfo) { + if (statData !is Map<*, *>) continue + val stat = statData as Map + val statType = stat["type"]?.toString() ?: continue + + if (statType == StatType.STAT_TYPE_BITS || statType == StatType.ACHIEVEMENTS) { + val bits = stat["bits"] as? Map ?: continue + for ((achNumKey, achData) in bits) { + if (achData !is Map<*, *>) continue + val ach = achData as Map + val display = ach["display"] as? Map ?: emptyMap() + + val achievementBuilder = mutableMapOf() + achievementBuilder["hidden"] = 0 + + for ((displayKey, displayValue) in display) { + when (displayKey.lowercase()) { + "name" -> { + if (displayValue is Map<*, *>) { + val langMap = mutableMapOf() + for ((lang, text) in displayValue) { + langMap[lang.toString()] = text.toString() + } + achievementBuilder["displayName"] = langMap + } else { + achievementBuilder["displayName"] = mapOf("english" to displayValue.toString()) + } + } + "desc" -> { + if (displayValue is Map<*, *>) { + val langMap = mutableMapOf() + for ((lang, text) in displayValue) { + langMap[lang.toString()] = text.toString() + } + achievementBuilder["description"] = langMap + } else { + achievementBuilder["description"] = mapOf("english" to displayValue.toString()) + } + } + "hidden" -> { + val value = try { + displayValue.toString().toInt() + } catch (e: NumberFormatException) { + displayValue + } + achievementBuilder["hidden"] = value + } + else -> { + achievementBuilder[displayKey] = displayValue + } + } + } + + achievementBuilder["name"] = ach["name"] ?: "" + val achName = ach["name"]?.toString() ?: "" + if (achName.isNotEmpty()) { + val blockId = statKey.toIntOrNull() + val bitIndex = achNumKey.toIntOrNull() + if (blockId != null && bitIndex != null) { + nameToBlockBit[achName] = Pair(blockId, bitIndex) + } + } + if (ach.containsKey("progress")) { + achievementBuilder["progress"] = ach["progress"] as Any + } + + val achievement = Achievement( + name = achievementBuilder["name"]?.toString() ?: "", + displayName = achievementBuilder["displayName"] as? Map, + description = achievementBuilder["description"] as? Map, + hidden = (achievementBuilder["hidden"] as? Number)?.toInt() ?: 0, + icon = achievementBuilder["icon"]?.toString(), + iconGray = achievementBuilder["icon_gray"]?.toString(), + icongray = achievementBuilder["icongray"]?.toString(), + progress = achievementBuilder["progress"] as? Map + ) + achievementsOut.add(achievement) + } + } else { + val statBuilder = mutableMapOf() + statBuilder["id"] = statKey + statBuilder["default"] = "0" + statBuilder["global"] = "0" + statBuilder["name"] = stat["name"] ?: "" + + if (stat.containsKey("min")) { + statBuilder["min"] = stat["min"] as Any + } + + when (statType) { + StatType.INT -> statBuilder["type"] = "int" + StatType.FLOAT -> statBuilder["type"] = "float" + StatType.AVGRATE -> statBuilder["type"] = "avgrate" + StatType.STAT_TYPE_INT -> statBuilder["type"] = "int" + StatType.STAT_TYPE_FLOAT -> statBuilder["type"] = "float" + StatType.STAT_TYPE_AVGRATE -> statBuilder["type"] = "avgrate" + } + + if (stat.containsKey("Default")) { + statBuilder["default"] = stat["Default"] as Any + } else if (stat.containsKey("default")) { + statBuilder["default"] = stat["default"] as Any + } + + val statObj = Stat( + id = statBuilder["id"]?.toString() ?: "", + name = statBuilder["name"]?.toString() ?: "", + type = statBuilder["type"]?.toString() ?: "int", + default = statBuilder["default"]?.toString() ?: "0", + global = statBuilder["global"]?.toString() ?: "0", + min = statBuilder["min"]?.toString() + ) + statsOut.add(statObj) + } + } + } + + var copyDefaultUnlockedImg = false + var copyDefaultLockedImg = false + val outputAchievements = mutableListOf>() + + for (ach in achievementsOut) { + val outputAch = mutableMapOf() + outputAch["name"] = ach.name + outputAch["displayName"] = ach.displayName ?: emptyMap() + outputAch["description"] = ach.description ?: emptyMap() + outputAch["hidden"] = ach.hidden + + val icon = ach.icon + if (!icon.isNullOrEmpty()) { + outputAch["icon"] = "img/$icon" + } else { + outputAch["icon"] = "img/steam_default_icon_unlocked.jpg" + copyDefaultUnlockedImg = true + } + + val iconGray = ach.iconGray + if (!iconGray.isNullOrEmpty()) { + outputAch["icon_gray"] = "img/$iconGray" + } else { + outputAch["icon_gray"] = "img/steam_default_icon_locked.jpg" + copyDefaultLockedImg = true + } + + val icongray = ach.icongray + if (!icongray.isNullOrEmpty()) { + outputAch["icongray"] = icongray + } + + if (ach.progress != null) { + outputAch["progress"] = ach.progress + } + + ach.unlocked?.let { outputAch["unlocked"] = it } + ach.unlockTimestamp?.let { outputAch["unlockTimestamp"] = it } + ach.formattedUnlockTime?.let { outputAch["formattedUnlockTime"] = it } + + outputAchievements.add(outputAch) + } + + val outputStats = mutableListOf>() + for (stat in statsOut) { + val outputStat = mutableMapOf() + outputStat["id"] = stat.id + outputStat["name"] = stat.name + outputStat["type"] = stat.type + + var defaultNum: String + var globalNum: String + + if (stat.type.lowercase() == "int") { + try { + val defaultInt = stat.default.toInt() + val globalInt = stat.global.toInt() + defaultNum = defaultInt.toString() + globalNum = globalInt.toString() + } catch (e: NumberFormatException) { + try { + val defaultFloat = stat.default.toFloat().toInt() + val globalFloat = stat.global.toFloat().toInt() + defaultNum = defaultFloat.toString() + globalNum = globalFloat.toString() + } catch (e2: NumberFormatException) { + if (!stat.min.isNullOrEmpty()) { + defaultNum = stat.min.toInt().toString() + globalNum = "0" + } else { + throw IllegalArgumentException("min not exist in stat and no way to get the data. please report with the appid") + } + } + } + } else { + defaultNum = stat.default.toFloat().toString() + globalNum = stat.global.toFloat().toString() + } + + outputStat["default"] = defaultNum + outputStat["global"] = globalNum + outputStats.add(outputStat) + } + + // Create output directory + val configDir = File(configDirectory) + if (!configDir.exists()) { + configDir.mkdirs() + } + + // Write achievements.json (always; use [] when empty) + val achievementsFile = File(configDir, "achievements.json") + if (achievementsFile.exists()) { + achievementsFile.delete() + } + val achievementsJsonContent = if (outputAchievements.isNotEmpty()) { + val jsonBuilder = StringBuilder() + jsonBuilder.append("[\n") + + val orderedKeys = listOf( + "hidden", "displayName", "description", "icon", "icon_gray", "name", + "unlocked", "unlockTimestamp", "formattedUnlockTime" + ) + + for ((index, ach) in outputAchievements.withIndex()) { + if (index > 0) jsonBuilder.append(",\n") + jsonBuilder.append(" {\n") + + val achMap = ach.toMap() + var firstKey = true + + for (key in orderedKeys) { + val value = achMap[key] + if (value != null) { + if (!firstKey) jsonBuilder.append(",\n") + firstKey = false + + when (key) { + "displayName", "description" -> { + jsonBuilder.append(" \"$key\": ") + if (value is Map<*, *>) { + jsonBuilder.append("{\n") + val langEntries = value.entries.toList() + for ((langIndex, langEntry) in langEntries.withIndex()) { + if (langIndex > 0) jsonBuilder.append(",\n") + val escapedText = escapeUnicode(langEntry.value.toString()) + jsonBuilder.append(" \"${langEntry.key}\": \"$escapedText\"") + } + jsonBuilder.append("\n }") + } else { + val escapedText = escapeUnicode(value.toString()) + jsonBuilder.append("\"$escapedText\"") + } + } + "hidden", "unlockTimestamp" -> { + jsonBuilder.append(" \"$key\": $value") + } + "unlocked" -> { + jsonBuilder.append(" \"$key\": ${value.toString().lowercase()}") + } + else -> { + jsonBuilder.append(" \"$key\": \"${escapeUnicode(value.toString())}\"") + } + } + } + } + jsonBuilder.append("\n }") + } + + jsonBuilder.append("\n]") + jsonBuilder.toString() + } else { + "[]" + } + achievementsFile.writeText(achievementsJsonContent, Charsets.UTF_8) + + // Write stats.json + if (outputStats.isNotEmpty()) { + val statsFile = File(configDir, "stats.json") + if (statsFile.exists()) { + statsFile.delete() + } + + val jsonBuilder = StringBuilder() + jsonBuilder.append("[\n") + + for ((index, stat) in outputStats.withIndex()) { + if (index > 0) jsonBuilder.append(",\n") + jsonBuilder.append(" {\n") + + // Define the desired order of properties + val orderedKeys = listOf("id", "default", "global", "name", "type") + val statMap = stat.toMap() + + for ((keyIndex, key) in orderedKeys.withIndex()) { + val value = statMap[key] + if (value != null) { + if (keyIndex > 0) jsonBuilder.append(",\n") + jsonBuilder.append(" \"$key\": \"$value\"") + } + } + jsonBuilder.append("\n }") + } + + jsonBuilder.append("\n]") + statsFile.writeText(jsonBuilder.toString(), Charsets.UTF_8) + } + + return ProcessingResult( + achievements = achievementsOut, + stats = statsOut, + copyDefaultUnlockedImg = copyDefaultUnlockedImg, + copyDefaultLockedImg = copyDefaultLockedImg, + nameToBlockBit = nameToBlockBit, + ) + } +} diff --git a/app/src/main/java/app/gamenative/statsgen/VdfParser.kt b/app/src/main/java/app/gamenative/statsgen/VdfParser.kt new file mode 100644 index 0000000000..48bbfaa09b --- /dev/null +++ b/app/src/main/java/app/gamenative/statsgen/VdfParser.kt @@ -0,0 +1,105 @@ +package app.gamenative.statsgen + +import java.io.ByteArrayInputStream +import java.io.DataInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class VdfParser { + companion object { + private const val VDF_SUBSECTION = 0x00.toByte() + private const val VDF_STRING = 0x01.toByte() + private const val VDF_INT32 = 0x02.toByte() + private const val VDF_FLOAT32 = 0x03.toByte() + private const val VDF_INT64 = 0x07.toByte() + private const val VDF_UINT64 = 0x0A.toByte() + private const val VDF_END = 0x08.toByte() + } + + fun binaryLoads(data: ByteArray): Map { + val stream = DataInputStream(ByteArrayInputStream(data)) + return parseVdfData(stream) + } + + private fun parseVdfData(stream: DataInputStream): Map { + val result = mutableMapOf() + + while (stream.available() > 0) { + val dataType = try { + stream.readByte() + } catch (e: Exception) { + break + } + + when (dataType) { + VDF_END -> break + VDF_SUBSECTION -> { + val key = readString(stream) + val value = parseVdfData(stream) + result[key] = value + } + VDF_STRING -> { + val key = readString(stream) + val value = readString(stream) + result[key] = value + } + VDF_INT32 -> { + val key = readString(stream) + val value = readInt32(stream) + result[key] = value + } + VDF_FLOAT32 -> { + val key = readString(stream) + val value = readFloat32(stream) + result[key] = value + } + VDF_INT64 -> { + val key = readString(stream) + val value = readInt64(stream) + result[key] = value + } + VDF_UINT64 -> { + val key = readString(stream) + val value = readUInt64(stream) + result[key] = value + } + } + } + + return result + } + + private fun readString(stream: DataInputStream): String { + val bytes = mutableListOf() + while (true) { + val byte = stream.readByte() + if (byte == 0.toByte()) break + bytes.add(byte) + } + return String(bytes.toByteArray(), Charsets.UTF_8) + } + + private fun readInt32(stream: DataInputStream): Int { + val bytes = ByteArray(4) + stream.readFully(bytes) + return ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).int + } + + private fun readFloat32(stream: DataInputStream): Float { + val bytes = ByteArray(4) + stream.readFully(bytes) + return ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).float + } + + private fun readInt64(stream: DataInputStream): Long { + val bytes = ByteArray(8) + stream.readFully(bytes) + return ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).long + } + + private fun readUInt64(stream: DataInputStream): Long { + val bytes = ByteArray(8) + stream.readFully(bytes) + return ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).long + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt index 4dd00f48d6..ab1e9266cf 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt @@ -338,6 +338,13 @@ fun GeneralTabContent( state = config.steamOfflineMode, onCheckedChange = { state.config.value = config.copy(steamOfflineMode = it) }, ) + SettingsSwitch( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.show_achievement_popups)) }, + subtitle = { Text(text = stringResource(R.string.show_achievement_popups_description)) }, + state = config.showAchievementPopups, + onCheckedChange = { state.config.value = config.copy(showAchievementPopups = it) }, + ) SettingsSwitch( colors = settingsTileColorsAlt(), title = { Text(text = stringResource(R.string.launch_steam_client_beta)) }, diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index d10eb333fa..b1c9f77a38 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -522,7 +522,7 @@ class MainViewModel @Inject constructor( } } else { // For Steam games, sync cloud saves - SteamService.closeApp(gameId, isOffline.value) { prefix -> + SteamService.closeApp(context, gameId, isOffline.value) { prefix -> PathType.from(prefix).toAbsPath(context, gameId, SteamService.userSteamId!!.accountID) }.await() } diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 7aba6e4252..3fab06e64f 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -113,6 +113,7 @@ object ContainerUtils { containerVariant = PrefManager.containerVariant, forceDlc = PrefManager.forceDlc, steamOfflineMode = PrefManager.steamOfflineMode, + showAchievementPopups = PrefManager.showAchievementPopups, useLegacyDRM = PrefManager.useLegacyDRM, unpackFiles = PrefManager.unpackFiles, wineVersion = PrefManager.wineVersion, @@ -195,6 +196,7 @@ object ContainerUtils { PrefManager.dinputMapperType = containerData.dinputMapperType.toInt() PrefManager.forceDlc = containerData.forceDlc PrefManager.steamOfflineMode = containerData.steamOfflineMode + PrefManager.showAchievementPopups = containerData.showAchievementPopups PrefManager.useLegacyDRM = containerData.useLegacyDRM PrefManager.unpackFiles = containerData.unpackFiles PrefManager.portraitMode = containerData.portraitMode @@ -242,6 +244,7 @@ object ContainerUtils { val enableD = apiOrdinal == PreferredInputApi.DINPUT.ordinal || apiOrdinal == PreferredInputApi.BOTH.ordinal val mapperType = container.getDinputMapperType() val useSteamInput = container.getExtra("useSteamInput", "false").toBoolean() + val showAchievementPopups = container.getExtra("showAchievementPopups", "false").toBoolean() // Read disable-mouse flag from container val disableMouse = container.isDisableMouseInput() // Read touchscreen-mode flag from container @@ -290,6 +293,7 @@ object ContainerUtils { useSteamInput = useSteamInput, forceDlc = container.isForceDlc, steamOfflineMode = container.isSteamOfflineMode(), + showAchievementPopups = showAchievementPopups, useLegacyDRM = container.isUseLegacyDRM(), unpackFiles = container.isUnpackFiles(), portraitMode = container.isPortraitMode, @@ -466,6 +470,7 @@ object ContainerUtils { container.setExternalDisplaySwap(containerData.externalDisplaySwap) container.setForceDlc(containerData.forceDlc) container.setSteamOfflineMode(containerData.steamOfflineMode) + container.putExtra("showAchievementPopups", containerData.showAchievementPopups) container.setUseLegacyDRM(containerData.useLegacyDRM) container.setUnpackFiles(containerData.unpackFiles) container.setPortraitMode(containerData.portraitMode) @@ -834,6 +839,7 @@ object ContainerUtils { disableMouseInput = PrefManager.disableMouseInput, forceDlc = PrefManager.forceDlc, steamOfflineMode = PrefManager.steamOfflineMode, + showAchievementPopups = PrefManager.showAchievementPopups, useLegacyDRM = PrefManager.useLegacyDRM, unpackFiles = PrefManager.unpackFiles, externalDisplayMode = PrefManager.externalDisplayInputMode, diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 59df11f703..28a490b67b 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -17,6 +17,7 @@ import com.winlator.core.WineRegistryEditor import com.winlator.xenvironment.ImageFs import `in`.dragonbra.javasteam.types.KeyValue import `in`.dragonbra.javasteam.util.HardwareUtils +import kotlinx.coroutines.runBlocking import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -220,6 +221,9 @@ object SteamUtils { // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId) + // Generate achievements.json + generateAchievementsFile(rootPath.resolve("steam_settings"), appId) + MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_REPLACED) } @@ -259,7 +263,9 @@ object SteamUtils { // Get ticket and pass to ensureSteamSettings val ticketBase64 = SteamService.instance?.getEncryptedAppTicketBase64(steamAppId) - ensureSteamSettings(context, File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient.dll").toPath(), appId, ticketBase64, isOffline) + val path = File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient.dll").toPath() + ensureSteamSettings(context, path, appId, ticketBase64, isOffline) + generateAchievementsFile(path, appId) // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId) @@ -949,6 +955,20 @@ object SteamUtils { } } + val showAchievementPopups = container.getExtra("showAchievementPopups", "false").toBoolean() + val overlayIni = settingsDir.resolve("configs.overlay.ini") + if (showAchievementPopups) { + val overlayContent = buildString { + appendLine("[overlay::general]") + appendLine("enable_experimental_overlay=1") + appendLine("hook_delay_sec=3") + appendLine("renderer_detector_timeout_sec=25") + } + if (Files.notExists(overlayIni)) Files.createFile(overlayIni) + overlayIni.toFile().writeText(overlayContent) + } else if (Files.exists(overlayIni)) { + Files.delete(overlayIni) + } // Write supported languages list val supportedLanguagesFile = settingsDir.resolve("supported_languages.txt") @@ -1306,5 +1326,17 @@ object SteamUtils { Timber.e(e, "[${mapping.description}] Failed to create save location symlink") } } + + fun generateAchievementsFile(dllPath: Path, appId: String) { + val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) + val settingsDir = dllPath.parent.resolve("steam_settings") + if (Files.notExists(settingsDir)) { + Files.createDirectories(settingsDir) + } + + runBlocking { + SteamService.generateAchievements(steamAppId, settingsDir.absolutePathString()) + } + } } diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index 866a80f525..a1dadf3a98 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -86,6 +86,7 @@ data class ContainerData( val language: String = "english", val forceDlc: Boolean = false, val steamOfflineMode: Boolean = false, + val showAchievementPopups: Boolean = false, val useLegacyDRM: Boolean = false, val unpackFiles: Boolean = false, val portraitMode: Boolean = false, @@ -147,6 +148,7 @@ data class ContainerData( "language" to state.language, "forceDlc" to state.forceDlc, "steamOfflineMode" to state.steamOfflineMode, + "showAchievementPopups" to state.showAchievementPopups, "useLegacyDRM" to state.useLegacyDRM, "unpackFiles" to state.unpackFiles, "portraitMode" to state.portraitMode, @@ -207,6 +209,7 @@ data class ContainerData( language = (savedMap["language"] as? String) ?: "english", forceDlc = (savedMap["forceDlc"] as? Boolean) ?: false, steamOfflineMode = (savedMap["steamOfflineMode"] as? Boolean) ?: false, + showAchievementPopups = (savedMap["showAchievementPopups"] as? Boolean) ?: false, useLegacyDRM = (savedMap["useLegacyDRM"] as? Boolean) ?: false, unpackFiles = (savedMap["unpackFiles"] as? Boolean) ?: false, portraitMode = (savedMap["portraitMode"] as? Boolean) ?: false, diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index cd2f00ccf8..377bcbf4d8 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -367,6 +367,8 @@ Start Steam-klient (Beta) Steam Offline-tilstand Start Steam-spil i offline-tilstand + Vis præstations-popups + Kan forårsage gengivelsesproblemer eller nedbrud Reducerer ydelse og gør opstart langsommere\nMuliggør online-spil og løser DRM- og controllerproblemer\nIkke alle spil virker Tillad Steam-opdateringer Opdaterer Steam til nyeste version. Reducerer ydelsen markant. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5d8b4e76bc..8feef36e62 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -507,6 +507,8 @@ Steam-Client starten (Beta) Steam Offline-Modus Steam-Spiele im Offlinemodus starten + Erfolgs-Popups anzeigen + Kann Darstellungsprobleme oder Abstürze verursachen Reduziert Performance und verlängert den Start\nErmöglicht Online-Spiel und behebt DRM- und Controller-Probleme\nNicht alle Spiele funktionieren Steam-Updates erlauben Aktualisiert Steam auf die neueste Version. Reduziert die Leistung spürbar. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b85fb07835..7df09d3fdc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -580,6 +580,8 @@ Reduce el rendimiento y ralentiza el inicio.\nPermite el juego en línea y soluciona problemas de DRM y de mando.\nNo todos los juegos funcionan. Modo sin conexión de Steam Inicia juegos de Steam en modo sin conexión. + Mostrar popups de logros + Puede causar problemas de renderizado o bloqueos Permitir actualizaciones de Steam Actualiza Steam a la última versión. Reduce significativamente el rendimiento. Tipo de Steam diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8d77209e2a..3e16c48835 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -530,6 +530,8 @@ Lancer le client Steam (Bêta) Mode Hors Ligne Steam Lancer les jeux Steam en mode hors ligne + Afficher les popups de succès + Peut causer des problèmes de rendu ou des plantages Réduit les performances et ralentit le lancement\nPermet le jeu en ligne et corrige les problèmes de DRM et de contrôleur\nTous les jeux ne fonctionnent pas Autoriser les mises à jour Steam Met à jour Steam vers la dernière version. Réduit considérablement les performances. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0acb1b8f34..3051c3aa7d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -534,6 +534,8 @@ Avvia Client Steam (Beta) Modalità Offline Steam Avvia i giochi Steam in modalità offline + Mostra popup obiettivi + Potrebbe causare problemi di rendering o arresti anomali Riduce le prestazioni e rallenta l\'avvio\nConsente il gioco online e corregge problemi di DRM e controller\nNon tutti i giochi funzionano Consenti aggiornamenti Steam Aggiorna Steam all\'ultima versione. Riduce significativamente le prestazioni. diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 145cb0d5c2..f8b749418a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -544,6 +544,8 @@ Steam 클라이언트 실행(베타) Steam 오프라인 모드 Steam 게임을 오프라인 모드로 실행 + 업적 팝업 표시 + 렌더링 문제 또는 충돌이 발생할 수 있습니다 성능이 저하되고 실행 속도가 느려집니다\n온라인 플레이를 허용하고 DRM 및 컨트롤러 문제를 해결합니다\n모든 게임이 작동하는 것은 아닙니다 Steam 업데이트 허용 Steam을 최신 버전으로 업데이트합니다. 성능이 크게 저하될 수 있습니다. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4a0a60a9c9..ee37e42b37 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -543,6 +543,8 @@ Uruchom Klienta Steam (Beta) Tryb Offline Steam Uruchamiaj gry Steam w trybie offline + Pokaż wyskakujące okna osiągnięć + Może powodować problemy z renderowaniem lub awarie Zmniejsza wydajność i spowalnia uruchamianie\nPozwala na grę online i naprawia problemy z DRM oraz kontrolerem\nNie wszystkie gry działają Zezwól na aktualizacje Steam Aktualizuje Steam do najnowszej wersji. Znacząco zmniejsza wydajność. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 929af874f5..ec30aa6fd3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -367,6 +367,8 @@ Iniciar o cliente Steam (Beta) Modo Offline do Steam Iniciar jogos Steam no modo offline + Mostrar popups de conquistas + Pode causar problemas de renderização ou travamentos Reduz o desempenho e atrasa a inicialização\nPermite jogo online e corrige problemas de DRM e de controle\nNem todos os jogos funcionam Permitir atualizações do Steam Atualiza o Steam para a versão mais recente. Reduz significativamente o desempenho. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index b4185783e0..73b7ba29cd 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -535,6 +535,8 @@ Pornește Steam Client (Beta) Mod Offline Steam Pornește jocurile Steam în mod offline + Afișează popup-uri realizări + Poate cauza probleme de randare sau blocări Reduce performanța și încetinește lansarea\nPermite joc online și rezolvă probleme DRM și controller\nNu toate jocurile funcționează Permite actualizările Steam Actualizează Steam la ultima versiune. Reduce semnificativ performanța. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index ecae1513b4..d2104fbbbe 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -526,6 +526,8 @@ Запустити клієнт Steam (Бета) Режим Офлайн Steam Запускати ігри Steam в офлайн режимі + Показувати спливаючі вікна досягнень + Може спричинити проблеми з рендерингом або збої Зменшує продуктивність та сповільнює запуск.\nДозволяє онлайн гру, а також вирішує проблему DRM та контролеру.\nПрацює не з усіма іграми Дозволити оновлення клієнту Steam Оновлює клієнт Steam до останньої версії. Значно зменшує продуктивність. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f06507f5c6..48138f5d0d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -526,6 +526,8 @@ 启动 Steam 客户端(Beta) Steam 离线模式 以离线模式启动 Steam 游戏 + 显示成就弹窗 + 可能导致渲染问题或崩溃 降低性能并减慢启动速度\n允许在线游玩并修复 DRM 和控制器问题\n并非所有游戏都适用 允许 Steam 更新 将 Steam 更新至最新版本,会显著降低性能 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7c607447fa..222e44264a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -528,6 +528,8 @@ 啟動 Steam 用戶端 (Beta) Steam 離線模式 以離線模式啟動 Steam 遊戲 + 顯示成就彈窗 + 可能導致渲染問題或當機 降低效能並減慢啟動速度\n允許線上遊玩並修復 DRM 和控制器問題\n並非所有遊戲都適用 允許 Steam 更新 將 Steam 更新至最新版本, 會顯著降低效能 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3644382dd8..0d98f1cbf9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -580,6 +580,8 @@ Reduces performance and slows down launch\nAllows online play and fixes DRM and controller issues\nNot all games work Steam Offline Mode Launch Steam games in offline mode + Show Achievement Popups + May cause rendering issues or crashes Allow Steam updates Updates Steam to the latest version. Significantly reduces performance. Steam Type diff --git a/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt b/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt index b9202d6fb7..589263d943 100644 --- a/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt +++ b/app/src/test/java/app/gamenative/utils/SteamUtilsFileSearchTest.kt @@ -23,6 +23,11 @@ import org.junit.Assert.* import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import io.mockk.every +import io.mockk.just +import io.mockk.Runs +import io.mockk.mockkObject +import io.mockk.unmockkObject import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner @@ -130,10 +135,14 @@ class SteamUtilsFileSearchTest { } catch (e: Exception) { fail("Failed to set SteamService.instance: ${e.message}") } + + mockkObject(SteamUtils) + every { SteamUtils.generateAchievementsFile(any(), any()) } just Runs } @After fun tearDown() { + unmockkObject(SteamUtils) // Clean up temp directory tempDir.deleteRecursively() appDir.deleteRecursively() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 36d015c112..834bc94240 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espres feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery play-integrity = "1.6.0" # https://mvnrepository.com/artifact/com.google.android.play/integrity hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose -javasteam = "1.8.0-11-SNAPSHOT" # https://mvnrepository.com/artifact/in.dragonbra/javasteam +javasteam = "1.8.0-12-SNAPSHOT" # https://mvnrepository.com/artifact/in.dragonbra/javasteam json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit