From 0239a748cde10de238266614b91229150cee8f52 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sat, 3 Jan 2026 12:04:02 +0800 Subject: [PATCH 01/15] generate achievements.json and stats.json utf8 string output handling (match gbe format) json formatting, key order --- .../app/gamenative/service/SteamService.kt | 12 +- .../java/app/gamenative/statsgen/Models.kt | 34 ++ .../statsgen/StatsAchievementsGenerator.kt | 319 ++++++++++++++++++ .../java/app/gamenative/statsgen/VdfParser.kt | 105 ++++++ .../java/app/gamenative/utils/SteamUtils.kt | 15 +- 5 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/app/gamenative/statsgen/Models.kt create mode 100644 app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt create mode 100644 app/src/main/java/app/gamenative/statsgen/VdfParser.kt diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 46d683bf2b..8d9b7a1270 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -143,6 +143,7 @@ import android.util.Base64 import app.gamenative.data.DownloadingAppInfo import app.gamenative.db.dao.DownloadingAppInfoDao import app.gamenative.db.dao.EncryptedAppTicketDao +import app.gamenative.statsgen.StatsAchievementsGenerator import kotlinx.coroutines.flow.update import java.io.InputStream import java.io.OutputStream @@ -190,6 +191,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 @@ -2173,6 +2175,14 @@ 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 generator = StatsAchievementsGenerator() + val schemaArray = userStats.schema.toByteArray() + generator.generateStatsAchievements(schemaArray, configDirectory) + } } override fun onCreate() { @@ -2289,7 +2299,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 @@ -2300,6 +2309,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..44b38b4f94 --- /dev/null +++ b/app/src/main/java/app/gamenative/statsgen/Models.kt @@ -0,0 +1,34 @@ +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 +) + +data class Stat( + 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 +) + +object StatType { + const val INT = "1" + const val FLOAT = "2" + const val AVGRATE = "3" + const val BITS = "4" +} 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..214ebf2762 --- /dev/null +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -0,0 +1,319 @@ +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() + + 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.BITS) { + 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"] ?: "" + 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["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" + } + + if (stat.containsKey("Default")) { + statBuilder["default"] = stat["Default"] as Any + } else if (stat.containsKey("default")) { + statBuilder["default"] = stat["default"] as Any + } + + val statObj = Stat( + 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 + } + + outputAchievements.add(outputAch) + } + + val outputStats = mutableListOf>() + for (stat in statsOut) { + val outputStat = mutableMapOf() + 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 + if (outputAchievements.isNotEmpty()) { + val achievementsFile = File(configDir, "achievements.json") + if (achievementsFile.exists()) { + achievementsFile.delete() + } + + val jsonBuilder = StringBuilder() + jsonBuilder.append("[\n") + + for ((index, ach) in outputAchievements.withIndex()) { + if (index > 0) jsonBuilder.append(",\n") + jsonBuilder.append(" {\n") + + // Define the desired order of properties + val orderedKeys = listOf("hidden", "displayName", "description", "icon", "icon_gray", "name") + val achMap = ach.toMap() + + for ((keyIndex, key) in orderedKeys.withIndex()) { + val value = achMap[key] + if (value != null) { + if (keyIndex > 0) jsonBuilder.append(",\n") + + 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" -> { + jsonBuilder.append(" \"$key\": $value") + } + else -> { + jsonBuilder.append(" \"$key\": \"$value\"") + } + } + } + } + jsonBuilder.append("\n }") + } + + jsonBuilder.append("\n]") + achievementsFile.writeText(jsonBuilder.toString(), 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("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 + ) + } +} 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/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 9d87e64e76..1707e5f398 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -192,6 +192,7 @@ object SteamUtils { Timber.i("Replaced $dllName") if (is64Bit) replaced64Count++ else replaced32Count++ ensureSteamSettings(context, path, appId, ticketBase64) + generateAchievementsFile(path, appId) } } @@ -245,7 +246,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) + val path = File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient.dll").toPath() + ensureSteamSettings(context, path, appId, ticketBase64) + generateAchievementsFile(path, appId) MarkerUtils.addMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) } @@ -1048,5 +1051,15 @@ object SteamUtils { fun getSteam3AccountId(): Long? { return SteamService.userSteamId?.accountID?.toLong() } + + suspend 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) + } + + SteamService.generateAchievements(steamAppId, settingsDir.absolutePathString()) + } } From ee1933e5f6d93ba541406df36967eb9ac6e2a96b Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 5 Feb 2026 19:59:28 +0530 Subject: [PATCH 02/15] Create achievements.json on boot correctly --- .../app/gamenative/service/SteamService.kt | 22 ++++++- .../java/app/gamenative/statsgen/Models.kt | 6 +- .../statsgen/StatsAchievementsGenerator.kt | 63 +++++++++++++------ .../java/app/gamenative/utils/SteamUtils.kt | 1 + 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index db36eb944b..39aba65393 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -151,6 +151,7 @@ import android.util.Base64 import app.gamenative.data.DownloadingAppInfo import app.gamenative.db.dao.DownloadingAppInfoDao import app.gamenative.db.dao.EncryptedAppTicketDao +import app.gamenative.statsgen.Achievement import app.gamenative.statsgen.StatsAchievementsGenerator import kotlinx.coroutines.flow.update import java.io.InputStream @@ -2519,9 +2520,26 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun generateAchievements(appId: Int, configDirectory: String) { val steamUser = instance!!._steamUser!! val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await() - val generator = StatsAchievementsGenerator() val schemaArray = userStats.schema.toByteArray() - generator.generateStatsAchievements(schemaArray, configDirectory) + val expandedAchievements = runCatching { + userStats.getExpandedAchievements("english")?.map { block -> + Achievement( + name = block.name?.takeIf { it.isNotEmpty() } ?: "", + displayName = mapOf("english" to (block.displayName ?: "")), + description = mapOf("english" to (block.description ?: "")), + hidden = if (block.hidden == true) 1 else 0, + icon = block.icon, + iconGray = block.iconGray, + icongray = null, + progress = null, + unlocked = block.isUnlocked, + unlockTimestamp = (block.unlockTimestamp as? Number)?.toInt(), + formattedUnlockTime = block.getFormattedUnlockTime() + ) + }?.takeIf { it.isNotEmpty() } + }.getOrNull() + val generator = StatsAchievementsGenerator() + generator.generateStatsAchievements(schemaArray, configDirectory, expandedAchievements) } } diff --git a/app/src/main/java/app/gamenative/statsgen/Models.kt b/app/src/main/java/app/gamenative/statsgen/Models.kt index 44b38b4f94..c1215999d0 100644 --- a/app/src/main/java/app/gamenative/statsgen/Models.kt +++ b/app/src/main/java/app/gamenative/statsgen/Models.kt @@ -8,10 +8,14 @@ data class Achievement( val icon: String? = null, val iconGray: String? = null, val icongray: String? = null, - val progress: Map? = 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", diff --git a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt index 214ebf2762..ee9cffd268 100644 --- a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -22,11 +22,17 @@ class StatsAchievementsGenerator { return sb.toString() } - fun generateStatsAchievements(schema: ByteArray, configDirectory: String): ProcessingResult { + fun generateStatsAchievements( + schema: ByteArray, + configDirectory: String, + expandedAchievements: List? = null + ): ProcessingResult { val parsedSchema = vdfParser.binaryLoads(schema) val achievementsOut = mutableListOf() val statsOut = mutableListOf() + val useExpandedAchievements = !expandedAchievements.isNullOrEmpty() + for ((appId, appData) in parsedSchema) { if (appData !is Map<*, *>) continue val sch = appData as Map @@ -37,7 +43,7 @@ class StatsAchievementsGenerator { val stat = statData as Map val statType = stat["type"]?.toString() ?: continue - if (statType == StatType.BITS) { + if (!useExpandedAchievements && statType == StatType.BITS) { val bits = stat["bits"] as? Map ?: continue for ((achNumKey, achData) in bits) { if (achData !is Map<*, *>) continue @@ -104,6 +110,7 @@ class StatsAchievementsGenerator { } } else { val statBuilder = mutableMapOf() + statBuilder["id"] = statKey statBuilder["default"] = "0" statBuilder["global"] = "0" statBuilder["name"] = stat["name"] ?: "" @@ -125,6 +132,7 @@ class StatsAchievementsGenerator { } val statObj = Stat( + id = statBuilder["id"]?.toString() ?: "", name = statBuilder["name"]?.toString() ?: "", type = statBuilder["type"]?.toString() ?: "int", default = statBuilder["default"]?.toString() ?: "0", @@ -136,6 +144,10 @@ class StatsAchievementsGenerator { } } + if (useExpandedAchievements && expandedAchievements != null) { + achievementsOut.addAll(expandedAchievements) + } + var copyDefaultUnlockedImg = false var copyDefaultLockedImg = false val outputAchievements = mutableListOf>() @@ -172,12 +184,17 @@ class StatsAchievementsGenerator { 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 @@ -221,28 +238,32 @@ class StatsAchievementsGenerator { configDir.mkdirs() } - // Write achievements.json - if (outputAchievements.isNotEmpty()) { - val achievementsFile = File(configDir, "achievements.json") - if (achievementsFile.exists()) { - achievementsFile.delete() - } - + // 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") - // Define the desired order of properties - val orderedKeys = listOf("hidden", "displayName", "description", "icon", "icon_gray", "name") val achMap = ach.toMap() - - for ((keyIndex, key) in orderedKeys.withIndex()) { + var firstKey = true + + for (key in orderedKeys) { val value = achMap[key] if (value != null) { - if (keyIndex > 0) jsonBuilder.append(",\n") + if (!firstKey) jsonBuilder.append(",\n") + firstKey = false when (key) { "displayName", "description" -> { @@ -261,11 +282,14 @@ class StatsAchievementsGenerator { jsonBuilder.append("\"$escapedText\"") } } - "hidden" -> { + "hidden", "unlockTimestamp" -> { jsonBuilder.append(" \"$key\": $value") } + "unlocked" -> { + jsonBuilder.append(" \"$key\": ${value.toString().lowercase()}") + } else -> { - jsonBuilder.append(" \"$key\": \"$value\"") + jsonBuilder.append(" \"$key\": \"${escapeUnicode(value.toString())}\"") } } } @@ -274,8 +298,11 @@ class StatsAchievementsGenerator { } jsonBuilder.append("\n]") - achievementsFile.writeText(jsonBuilder.toString(), Charsets.UTF_8) + jsonBuilder.toString() + } else { + "[]" } + achievementsFile.writeText(achievementsJsonContent, Charsets.UTF_8) // Write stats.json if (outputStats.isNotEmpty()) { @@ -292,7 +319,7 @@ class StatsAchievementsGenerator { jsonBuilder.append(" {\n") // Define the desired order of properties - val orderedKeys = listOf("default", "global", "name", "type") + val orderedKeys = listOf("id", "default", "global", "name", "type") val statMap = stat.toMap() for ((keyIndex, key) in orderedKeys.withIndex()) { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index b135472a56..eb45c3c2e7 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -231,6 +231,7 @@ object SteamUtils { val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val appDirPath = SteamService.getAppDirPath(steamAppId) val container = ContainerUtils.getContainer(context, appId) + generateAchievementsFile(File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient.dll").toPath(), appId) if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) && File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient_loader_x64.dll").exists()) { return From f5dd9e245b072ee18dc77a5cdd031fcafafa2a87 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 5 Feb 2026 21:32:23 +0530 Subject: [PATCH 03/15] store stat mapping for achievements --- .../app/gamenative/service/SteamService.kt | 102 +++++++++++++++--- .../java/app/gamenative/utils/SteamUtils.kt | 1 - 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 39aba65393..7f54594d84 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -146,6 +146,7 @@ import kotlinx.coroutines.flow.asStateFlow import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.FormBody +import org.json.JSONArray import org.json.JSONObject import android.util.Base64 import app.gamenative.data.DownloadingAppInfo @@ -2521,26 +2522,93 @@ class SteamService : Service(), IChallengeUrlChanged { val steamUser = instance!!._steamUser!! val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await() val schemaArray = userStats.schema.toByteArray() - val expandedAchievements = runCatching { - userStats.getExpandedAchievements("english")?.map { block -> - Achievement( - name = block.name?.takeIf { it.isNotEmpty() } ?: "", - displayName = mapOf("english" to (block.displayName ?: "")), - description = mapOf("english" to (block.description ?: "")), - hidden = if (block.hidden == true) 1 else 0, - icon = block.icon, - iconGray = block.iconGray, - icongray = null, - progress = null, - unlocked = block.isUnlocked, - unlockTimestamp = (block.unlockTimestamp as? Number)?.toInt(), - formattedUnlockTime = block.getFormattedUnlockTime() - ) - }?.takeIf { it.isNotEmpty() } - }.getOrNull() + val expandedRaw = runCatching { userStats.getExpandedAchievements("english") }.getOrNull() + val expandedAchievements = expandedRaw?.map { block -> + Achievement( + name = block.name?.takeIf { it.isNotEmpty() } ?: "", + displayName = mapOf("english" to (block.displayName ?: "")), + description = mapOf("english" to (block.description ?: "")), + hidden = if (block.hidden == true) 1 else 0, + icon = block.icon, + iconGray = block.iconGray, + icongray = null, + progress = null, + unlocked = block.isUnlocked, + unlockTimestamp = (block.unlockTimestamp as? Number)?.toInt(), + formattedUnlockTime = block.getFormattedUnlockTime() + ) + }?.takeIf { it.isNotEmpty() } + if (!expandedRaw.isNullOrEmpty()) { + val nameToBlockBit = expandedRaw + .groupBy { (it.achievementId as? Number)?.toInt() ?: -1 } + .filter { it.key >= 0 } + .flatMap { (rawBlockId, list) -> + val blockId = rawBlockId / 100 + list.mapIndexed { index, block -> + (block.name?.takeIf { it.isNotEmpty() } ?: "") to listOf(blockId, index) + } + } + .filter { it.first.isNotEmpty() } + .toMap() + if (nameToBlockBit.isNotEmpty()) { + val configDir = File(configDirectory) + if (!configDir.exists()) configDir.mkdirs() + val mappingJson = JSONObject() + nameToBlockBit.forEach { (name, pair) -> + mappingJson.put(name, JSONArray(pair)) + } + File(configDir, "achievement_name_to_block.json").writeText(mappingJson.toString(), Charsets.UTF_8) + } + } val generator = StatsAchievementsGenerator() generator.generateStatsAchievements(schemaArray, configDirectory, expandedAchievements) } + + 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 updates = blockBitmasks.map { (id, mask) -> id to mask } +// instance?._steamUserStats!!.storeUserStats(appId, steamUser.steamID!!, updates) + } + } override fun onCreate() { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index eb45c3c2e7..b135472a56 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -231,7 +231,6 @@ object SteamUtils { val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val appDirPath = SteamService.getAppDirPath(steamAppId) val container = ContainerUtils.getContainer(context, appId) - generateAchievementsFile(File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient.dll").toPath(), appId) if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) && File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient_loader_x64.dll").exists()) { return From ad035c225b366789518d64326f863f6865724035 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:45:31 +0800 Subject: [PATCH 04/15] fix achievements format and generations --- .../app/gamenative/service/SteamService.kt | 20 +++---------------- .../java/app/gamenative/statsgen/Models.kt | 13 ++++++++---- .../statsgen/StatsAchievementsGenerator.kt | 19 ++++++------------ .../java/app/gamenative/utils/SteamUtils.kt | 11 +++++++--- 4 files changed, 26 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 7f54594d84..6bfd5ef2c2 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2522,22 +2522,10 @@ class SteamService : Service(), IChallengeUrlChanged { val steamUser = instance!!._steamUser!! val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await() val schemaArray = userStats.schema.toByteArray() + val generator = StatsAchievementsGenerator() + generator.generateStatsAchievements(schemaArray, configDirectory) + val expandedRaw = runCatching { userStats.getExpandedAchievements("english") }.getOrNull() - val expandedAchievements = expandedRaw?.map { block -> - Achievement( - name = block.name?.takeIf { it.isNotEmpty() } ?: "", - displayName = mapOf("english" to (block.displayName ?: "")), - description = mapOf("english" to (block.description ?: "")), - hidden = if (block.hidden == true) 1 else 0, - icon = block.icon, - iconGray = block.iconGray, - icongray = null, - progress = null, - unlocked = block.isUnlocked, - unlockTimestamp = (block.unlockTimestamp as? Number)?.toInt(), - formattedUnlockTime = block.getFormattedUnlockTime() - ) - }?.takeIf { it.isNotEmpty() } if (!expandedRaw.isNullOrEmpty()) { val nameToBlockBit = expandedRaw .groupBy { (it.achievementId as? Number)?.toInt() ?: -1 } @@ -2560,8 +2548,6 @@ class SteamService : Service(), IChallengeUrlChanged { File(configDir, "achievement_name_to_block.json").writeText(mappingJson.toString(), Charsets.UTF_8) } } - val generator = StatsAchievementsGenerator() - generator.generateStatsAchievements(schemaArray, configDirectory, expandedAchievements) } suspend fun storeAchievementUnlocks( diff --git a/app/src/main/java/app/gamenative/statsgen/Models.kt b/app/src/main/java/app/gamenative/statsgen/Models.kt index c1215999d0..142e4510cf 100644 --- a/app/src/main/java/app/gamenative/statsgen/Models.kt +++ b/app/src/main/java/app/gamenative/statsgen/Models.kt @@ -31,8 +31,13 @@ data class ProcessingResult( ) object StatType { - const val INT = "1" - const val FLOAT = "2" - const val AVGRATE = "3" - const val BITS = "4" + 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 index ee9cffd268..c92eb37307 100644 --- a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -22,17 +22,11 @@ class StatsAchievementsGenerator { return sb.toString() } - fun generateStatsAchievements( - schema: ByteArray, - configDirectory: String, - expandedAchievements: List? = null - ): ProcessingResult { + fun generateStatsAchievements(schema: ByteArray, configDirectory: String): ProcessingResult { val parsedSchema = vdfParser.binaryLoads(schema) val achievementsOut = mutableListOf() val statsOut = mutableListOf() - val useExpandedAchievements = !expandedAchievements.isNullOrEmpty() - for ((appId, appData) in parsedSchema) { if (appData !is Map<*, *>) continue val sch = appData as Map @@ -43,7 +37,7 @@ class StatsAchievementsGenerator { val stat = statData as Map val statType = stat["type"]?.toString() ?: continue - if (!useExpandedAchievements && statType == StatType.BITS) { + 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 @@ -123,6 +117,9 @@ class StatsAchievementsGenerator { 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")) { @@ -144,10 +141,6 @@ class StatsAchievementsGenerator { } } - if (useExpandedAchievements && expandedAchievements != null) { - achievementsOut.addAll(expandedAchievements) - } - var copyDefaultUnlockedImg = false var copyDefaultLockedImg = false val outputAchievements = mutableListOf>() @@ -321,7 +314,7 @@ class StatsAchievementsGenerator { // 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) { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index b135472a56..7a55bfb89a 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 @@ -188,7 +189,6 @@ object SteamUtils { Timber.i("Replaced $dllName") if (is64Bit) replaced64Count++ else replaced32Count++ ensureSteamSettings(context, path, appId, ticketBase64) - generateAchievementsFile(path, appId) } } @@ -221,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) } @@ -1273,14 +1276,16 @@ object SteamUtils { } } - suspend fun generateAchievementsFile(dllPath: Path, appId: String) { + 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) } - SteamService.generateAchievements(steamAppId, settingsDir.absolutePathString()) + runBlocking { + SteamService.generateAchievements(steamAppId, settingsDir.absolutePathString()) + } } } From 84744a912dabb057a30b154ac0e6f6ef039db994 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:12:07 +0800 Subject: [PATCH 05/15] update JavaSteam for faster file validation (remove duplicated file hash checking) --- app/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a584bc4a53..1a150b5ab7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -214,8 +214,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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 22498cfd2e..f655d13629 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ dataStore = "1.1.2" # https://mvnrepository.com/artifact/androidx.datastore/data espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espresso/espresso-core feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery 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 From fb4dd175f5a9d7d0d389dfdad8a75927a6a06935 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sat, 3 Jan 2026 12:04:02 +0800 Subject: [PATCH 06/15] generate achievements.json and stats.json utf8 string output handling (match gbe format) json formatting, key order --- .../app/gamenative/service/SteamService.kt | 12 +- .../java/app/gamenative/statsgen/Models.kt | 34 ++ .../statsgen/StatsAchievementsGenerator.kt | 319 ++++++++++++++++++ .../java/app/gamenative/statsgen/VdfParser.kt | 105 ++++++ .../java/app/gamenative/utils/SteamUtils.kt | 15 +- 5 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/app/gamenative/statsgen/Models.kt create mode 100644 app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt create mode 100644 app/src/main/java/app/gamenative/statsgen/VdfParser.kt diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 1016c4cb0b..db36eb944b 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -151,6 +151,7 @@ import android.util.Base64 import app.gamenative.data.DownloadingAppInfo import app.gamenative.db.dao.DownloadingAppInfoDao import app.gamenative.db.dao.EncryptedAppTicketDao +import app.gamenative.statsgen.StatsAchievementsGenerator import kotlinx.coroutines.flow.update import java.io.InputStream import java.io.OutputStream @@ -211,6 +212,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 @@ -2513,6 +2515,14 @@ 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 generator = StatsAchievementsGenerator() + val schemaArray = userStats.schema.toByteArray() + generator.generateStatsAchievements(schemaArray, configDirectory) + } } override fun onCreate() { @@ -2618,7 +2628,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 @@ -2629,6 +2638,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..44b38b4f94 --- /dev/null +++ b/app/src/main/java/app/gamenative/statsgen/Models.kt @@ -0,0 +1,34 @@ +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 +) + +data class Stat( + 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 +) + +object StatType { + const val INT = "1" + const val FLOAT = "2" + const val AVGRATE = "3" + const val BITS = "4" +} 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..214ebf2762 --- /dev/null +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -0,0 +1,319 @@ +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() + + 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.BITS) { + 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"] ?: "" + 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["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" + } + + if (stat.containsKey("Default")) { + statBuilder["default"] = stat["Default"] as Any + } else if (stat.containsKey("default")) { + statBuilder["default"] = stat["default"] as Any + } + + val statObj = Stat( + 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 + } + + outputAchievements.add(outputAch) + } + + val outputStats = mutableListOf>() + for (stat in statsOut) { + val outputStat = mutableMapOf() + 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 + if (outputAchievements.isNotEmpty()) { + val achievementsFile = File(configDir, "achievements.json") + if (achievementsFile.exists()) { + achievementsFile.delete() + } + + val jsonBuilder = StringBuilder() + jsonBuilder.append("[\n") + + for ((index, ach) in outputAchievements.withIndex()) { + if (index > 0) jsonBuilder.append(",\n") + jsonBuilder.append(" {\n") + + // Define the desired order of properties + val orderedKeys = listOf("hidden", "displayName", "description", "icon", "icon_gray", "name") + val achMap = ach.toMap() + + for ((keyIndex, key) in orderedKeys.withIndex()) { + val value = achMap[key] + if (value != null) { + if (keyIndex > 0) jsonBuilder.append(",\n") + + 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" -> { + jsonBuilder.append(" \"$key\": $value") + } + else -> { + jsonBuilder.append(" \"$key\": \"$value\"") + } + } + } + } + jsonBuilder.append("\n }") + } + + jsonBuilder.append("\n]") + achievementsFile.writeText(jsonBuilder.toString(), 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("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 + ) + } +} 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/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index e71c8df30b..fa027031b1 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -188,6 +188,7 @@ object SteamUtils { Timber.i("Replaced $dllName") if (is64Bit) replaced64Count++ else replaced32Count++ ensureSteamSettings(context, path, appId, ticketBase64) + generateAchievementsFile(path, appId) } } @@ -259,7 +260,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) + val path = File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient.dll").toPath() + ensureSteamSettings(context, path, appId, ticketBase64) + generateAchievementsFile(path, appId) // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId) @@ -1261,5 +1264,15 @@ object SteamUtils { Timber.e(e, "[${mapping.description}] Failed to create save location symlink") } } + + suspend 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) + } + + SteamService.generateAchievements(steamAppId, settingsDir.absolutePathString()) + } } From 9d0e84c08ea431b96c2700d4f180526095b62cfa Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 5 Feb 2026 19:59:28 +0530 Subject: [PATCH 07/15] Create achievements.json on boot correctly --- .../app/gamenative/service/SteamService.kt | 22 ++++++- .../java/app/gamenative/statsgen/Models.kt | 6 +- .../statsgen/StatsAchievementsGenerator.kt | 63 +++++++++++++------ .../java/app/gamenative/utils/SteamUtils.kt | 1 + 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index db36eb944b..39aba65393 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -151,6 +151,7 @@ import android.util.Base64 import app.gamenative.data.DownloadingAppInfo import app.gamenative.db.dao.DownloadingAppInfoDao import app.gamenative.db.dao.EncryptedAppTicketDao +import app.gamenative.statsgen.Achievement import app.gamenative.statsgen.StatsAchievementsGenerator import kotlinx.coroutines.flow.update import java.io.InputStream @@ -2519,9 +2520,26 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun generateAchievements(appId: Int, configDirectory: String) { val steamUser = instance!!._steamUser!! val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await() - val generator = StatsAchievementsGenerator() val schemaArray = userStats.schema.toByteArray() - generator.generateStatsAchievements(schemaArray, configDirectory) + val expandedAchievements = runCatching { + userStats.getExpandedAchievements("english")?.map { block -> + Achievement( + name = block.name?.takeIf { it.isNotEmpty() } ?: "", + displayName = mapOf("english" to (block.displayName ?: "")), + description = mapOf("english" to (block.description ?: "")), + hidden = if (block.hidden == true) 1 else 0, + icon = block.icon, + iconGray = block.iconGray, + icongray = null, + progress = null, + unlocked = block.isUnlocked, + unlockTimestamp = (block.unlockTimestamp as? Number)?.toInt(), + formattedUnlockTime = block.getFormattedUnlockTime() + ) + }?.takeIf { it.isNotEmpty() } + }.getOrNull() + val generator = StatsAchievementsGenerator() + generator.generateStatsAchievements(schemaArray, configDirectory, expandedAchievements) } } diff --git a/app/src/main/java/app/gamenative/statsgen/Models.kt b/app/src/main/java/app/gamenative/statsgen/Models.kt index 44b38b4f94..c1215999d0 100644 --- a/app/src/main/java/app/gamenative/statsgen/Models.kt +++ b/app/src/main/java/app/gamenative/statsgen/Models.kt @@ -8,10 +8,14 @@ data class Achievement( val icon: String? = null, val iconGray: String? = null, val icongray: String? = null, - val progress: Map? = 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", diff --git a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt index 214ebf2762..ee9cffd268 100644 --- a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -22,11 +22,17 @@ class StatsAchievementsGenerator { return sb.toString() } - fun generateStatsAchievements(schema: ByteArray, configDirectory: String): ProcessingResult { + fun generateStatsAchievements( + schema: ByteArray, + configDirectory: String, + expandedAchievements: List? = null + ): ProcessingResult { val parsedSchema = vdfParser.binaryLoads(schema) val achievementsOut = mutableListOf() val statsOut = mutableListOf() + val useExpandedAchievements = !expandedAchievements.isNullOrEmpty() + for ((appId, appData) in parsedSchema) { if (appData !is Map<*, *>) continue val sch = appData as Map @@ -37,7 +43,7 @@ class StatsAchievementsGenerator { val stat = statData as Map val statType = stat["type"]?.toString() ?: continue - if (statType == StatType.BITS) { + if (!useExpandedAchievements && statType == StatType.BITS) { val bits = stat["bits"] as? Map ?: continue for ((achNumKey, achData) in bits) { if (achData !is Map<*, *>) continue @@ -104,6 +110,7 @@ class StatsAchievementsGenerator { } } else { val statBuilder = mutableMapOf() + statBuilder["id"] = statKey statBuilder["default"] = "0" statBuilder["global"] = "0" statBuilder["name"] = stat["name"] ?: "" @@ -125,6 +132,7 @@ class StatsAchievementsGenerator { } val statObj = Stat( + id = statBuilder["id"]?.toString() ?: "", name = statBuilder["name"]?.toString() ?: "", type = statBuilder["type"]?.toString() ?: "int", default = statBuilder["default"]?.toString() ?: "0", @@ -136,6 +144,10 @@ class StatsAchievementsGenerator { } } + if (useExpandedAchievements && expandedAchievements != null) { + achievementsOut.addAll(expandedAchievements) + } + var copyDefaultUnlockedImg = false var copyDefaultLockedImg = false val outputAchievements = mutableListOf>() @@ -172,12 +184,17 @@ class StatsAchievementsGenerator { 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 @@ -221,28 +238,32 @@ class StatsAchievementsGenerator { configDir.mkdirs() } - // Write achievements.json - if (outputAchievements.isNotEmpty()) { - val achievementsFile = File(configDir, "achievements.json") - if (achievementsFile.exists()) { - achievementsFile.delete() - } - + // 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") - // Define the desired order of properties - val orderedKeys = listOf("hidden", "displayName", "description", "icon", "icon_gray", "name") val achMap = ach.toMap() - - for ((keyIndex, key) in orderedKeys.withIndex()) { + var firstKey = true + + for (key in orderedKeys) { val value = achMap[key] if (value != null) { - if (keyIndex > 0) jsonBuilder.append(",\n") + if (!firstKey) jsonBuilder.append(",\n") + firstKey = false when (key) { "displayName", "description" -> { @@ -261,11 +282,14 @@ class StatsAchievementsGenerator { jsonBuilder.append("\"$escapedText\"") } } - "hidden" -> { + "hidden", "unlockTimestamp" -> { jsonBuilder.append(" \"$key\": $value") } + "unlocked" -> { + jsonBuilder.append(" \"$key\": ${value.toString().lowercase()}") + } else -> { - jsonBuilder.append(" \"$key\": \"$value\"") + jsonBuilder.append(" \"$key\": \"${escapeUnicode(value.toString())}\"") } } } @@ -274,8 +298,11 @@ class StatsAchievementsGenerator { } jsonBuilder.append("\n]") - achievementsFile.writeText(jsonBuilder.toString(), Charsets.UTF_8) + jsonBuilder.toString() + } else { + "[]" } + achievementsFile.writeText(achievementsJsonContent, Charsets.UTF_8) // Write stats.json if (outputStats.isNotEmpty()) { @@ -292,7 +319,7 @@ class StatsAchievementsGenerator { jsonBuilder.append(" {\n") // Define the desired order of properties - val orderedKeys = listOf("default", "global", "name", "type") + val orderedKeys = listOf("id", "default", "global", "name", "type") val statMap = stat.toMap() for ((keyIndex, key) in orderedKeys.withIndex()) { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index fa027031b1..2c15d70d8b 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -231,6 +231,7 @@ object SteamUtils { val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val appDirPath = SteamService.getAppDirPath(steamAppId) val container = ContainerUtils.getContainer(context, appId) + generateAchievementsFile(File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient.dll").toPath(), appId) if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) && File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient_loader_x64.dll").exists()) { return From afa8142c12899e56b3c651b0a893c2a40dda8373 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 5 Feb 2026 21:32:23 +0530 Subject: [PATCH 08/15] store stat mapping for achievements --- .../app/gamenative/service/SteamService.kt | 102 +++++++++++++++--- .../java/app/gamenative/utils/SteamUtils.kt | 1 - 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 39aba65393..7f54594d84 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -146,6 +146,7 @@ import kotlinx.coroutines.flow.asStateFlow import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.FormBody +import org.json.JSONArray import org.json.JSONObject import android.util.Base64 import app.gamenative.data.DownloadingAppInfo @@ -2521,26 +2522,93 @@ class SteamService : Service(), IChallengeUrlChanged { val steamUser = instance!!._steamUser!! val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await() val schemaArray = userStats.schema.toByteArray() - val expandedAchievements = runCatching { - userStats.getExpandedAchievements("english")?.map { block -> - Achievement( - name = block.name?.takeIf { it.isNotEmpty() } ?: "", - displayName = mapOf("english" to (block.displayName ?: "")), - description = mapOf("english" to (block.description ?: "")), - hidden = if (block.hidden == true) 1 else 0, - icon = block.icon, - iconGray = block.iconGray, - icongray = null, - progress = null, - unlocked = block.isUnlocked, - unlockTimestamp = (block.unlockTimestamp as? Number)?.toInt(), - formattedUnlockTime = block.getFormattedUnlockTime() - ) - }?.takeIf { it.isNotEmpty() } - }.getOrNull() + val expandedRaw = runCatching { userStats.getExpandedAchievements("english") }.getOrNull() + val expandedAchievements = expandedRaw?.map { block -> + Achievement( + name = block.name?.takeIf { it.isNotEmpty() } ?: "", + displayName = mapOf("english" to (block.displayName ?: "")), + description = mapOf("english" to (block.description ?: "")), + hidden = if (block.hidden == true) 1 else 0, + icon = block.icon, + iconGray = block.iconGray, + icongray = null, + progress = null, + unlocked = block.isUnlocked, + unlockTimestamp = (block.unlockTimestamp as? Number)?.toInt(), + formattedUnlockTime = block.getFormattedUnlockTime() + ) + }?.takeIf { it.isNotEmpty() } + if (!expandedRaw.isNullOrEmpty()) { + val nameToBlockBit = expandedRaw + .groupBy { (it.achievementId as? Number)?.toInt() ?: -1 } + .filter { it.key >= 0 } + .flatMap { (rawBlockId, list) -> + val blockId = rawBlockId / 100 + list.mapIndexed { index, block -> + (block.name?.takeIf { it.isNotEmpty() } ?: "") to listOf(blockId, index) + } + } + .filter { it.first.isNotEmpty() } + .toMap() + if (nameToBlockBit.isNotEmpty()) { + val configDir = File(configDirectory) + if (!configDir.exists()) configDir.mkdirs() + val mappingJson = JSONObject() + nameToBlockBit.forEach { (name, pair) -> + mappingJson.put(name, JSONArray(pair)) + } + File(configDir, "achievement_name_to_block.json").writeText(mappingJson.toString(), Charsets.UTF_8) + } + } val generator = StatsAchievementsGenerator() generator.generateStatsAchievements(schemaArray, configDirectory, expandedAchievements) } + + 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 updates = blockBitmasks.map { (id, mask) -> id to mask } +// instance?._steamUserStats!!.storeUserStats(appId, steamUser.steamID!!, updates) + } + } override fun onCreate() { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 2c15d70d8b..fa027031b1 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -231,7 +231,6 @@ object SteamUtils { val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val appDirPath = SteamService.getAppDirPath(steamAppId) val container = ContainerUtils.getContainer(context, appId) - generateAchievementsFile(File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient.dll").toPath(), appId) if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) && File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient_loader_x64.dll").exists()) { return From 4b1b5db7ce00a1e45481a3e9203cf420061a01de Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:45:31 +0800 Subject: [PATCH 09/15] fix achievements format and generations --- .../app/gamenative/service/SteamService.kt | 20 +++---------------- .../java/app/gamenative/statsgen/Models.kt | 13 ++++++++---- .../statsgen/StatsAchievementsGenerator.kt | 19 ++++++------------ .../java/app/gamenative/utils/SteamUtils.kt | 11 +++++++--- 4 files changed, 26 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 7f54594d84..6bfd5ef2c2 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2522,22 +2522,10 @@ class SteamService : Service(), IChallengeUrlChanged { val steamUser = instance!!._steamUser!! val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await() val schemaArray = userStats.schema.toByteArray() + val generator = StatsAchievementsGenerator() + generator.generateStatsAchievements(schemaArray, configDirectory) + val expandedRaw = runCatching { userStats.getExpandedAchievements("english") }.getOrNull() - val expandedAchievements = expandedRaw?.map { block -> - Achievement( - name = block.name?.takeIf { it.isNotEmpty() } ?: "", - displayName = mapOf("english" to (block.displayName ?: "")), - description = mapOf("english" to (block.description ?: "")), - hidden = if (block.hidden == true) 1 else 0, - icon = block.icon, - iconGray = block.iconGray, - icongray = null, - progress = null, - unlocked = block.isUnlocked, - unlockTimestamp = (block.unlockTimestamp as? Number)?.toInt(), - formattedUnlockTime = block.getFormattedUnlockTime() - ) - }?.takeIf { it.isNotEmpty() } if (!expandedRaw.isNullOrEmpty()) { val nameToBlockBit = expandedRaw .groupBy { (it.achievementId as? Number)?.toInt() ?: -1 } @@ -2560,8 +2548,6 @@ class SteamService : Service(), IChallengeUrlChanged { File(configDir, "achievement_name_to_block.json").writeText(mappingJson.toString(), Charsets.UTF_8) } } - val generator = StatsAchievementsGenerator() - generator.generateStatsAchievements(schemaArray, configDirectory, expandedAchievements) } suspend fun storeAchievementUnlocks( diff --git a/app/src/main/java/app/gamenative/statsgen/Models.kt b/app/src/main/java/app/gamenative/statsgen/Models.kt index c1215999d0..142e4510cf 100644 --- a/app/src/main/java/app/gamenative/statsgen/Models.kt +++ b/app/src/main/java/app/gamenative/statsgen/Models.kt @@ -31,8 +31,13 @@ data class ProcessingResult( ) object StatType { - const val INT = "1" - const val FLOAT = "2" - const val AVGRATE = "3" - const val BITS = "4" + 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 index ee9cffd268..c92eb37307 100644 --- a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -22,17 +22,11 @@ class StatsAchievementsGenerator { return sb.toString() } - fun generateStatsAchievements( - schema: ByteArray, - configDirectory: String, - expandedAchievements: List? = null - ): ProcessingResult { + fun generateStatsAchievements(schema: ByteArray, configDirectory: String): ProcessingResult { val parsedSchema = vdfParser.binaryLoads(schema) val achievementsOut = mutableListOf() val statsOut = mutableListOf() - val useExpandedAchievements = !expandedAchievements.isNullOrEmpty() - for ((appId, appData) in parsedSchema) { if (appData !is Map<*, *>) continue val sch = appData as Map @@ -43,7 +37,7 @@ class StatsAchievementsGenerator { val stat = statData as Map val statType = stat["type"]?.toString() ?: continue - if (!useExpandedAchievements && statType == StatType.BITS) { + 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 @@ -123,6 +117,9 @@ class StatsAchievementsGenerator { 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")) { @@ -144,10 +141,6 @@ class StatsAchievementsGenerator { } } - if (useExpandedAchievements && expandedAchievements != null) { - achievementsOut.addAll(expandedAchievements) - } - var copyDefaultUnlockedImg = false var copyDefaultLockedImg = false val outputAchievements = mutableListOf>() @@ -321,7 +314,7 @@ class StatsAchievementsGenerator { // 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) { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index fa027031b1..1904cc5a7f 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 @@ -188,7 +189,6 @@ object SteamUtils { Timber.i("Replaced $dllName") if (is64Bit) replaced64Count++ else replaced32Count++ ensureSteamSettings(context, path, appId, ticketBase64) - generateAchievementsFile(path, appId) } } @@ -221,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) } @@ -1265,14 +1268,16 @@ object SteamUtils { } } - suspend fun generateAchievementsFile(dllPath: Path, appId: String) { + 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) } - SteamService.generateAchievements(steamAppId, settingsDir.absolutePathString()) + runBlocking { + SteamService.generateAchievements(steamAppId, settingsDir.absolutePathString()) + } } } From 1142d9a62674a56c62909372d2b78dee6f703cce Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:12:07 +0800 Subject: [PATCH 10/15] update JavaSteam for faster file validation (remove duplicated file hash checking) --- app/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b497d89b5d..9eeb2f9407 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -214,8 +214,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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 22498cfd2e..f655d13629 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ dataStore = "1.1.2" # https://mvnrepository.com/artifact/androidx.datastore/data espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espresso/espresso-core feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery 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 From d41610309609fa2b7d60f489c7fec4a75f6c8ee5 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 7 Mar 2026 12:13:19 -0800 Subject: [PATCH 11/15] Updated code with storeUserStats (which gives InvalidProtocolVer) --- .../app/gamenative/service/SteamService.kt | 122 ++++++++++++++---- .../java/app/gamenative/statsgen/Models.kt | 3 +- .../statsgen/StatsAchievementsGenerator.kt | 12 +- .../app/gamenative/ui/model/MainViewModel.kt | 2 +- 4 files changed, 111 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index c5ba0c80d0..97a781bf9f 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 @@ -2119,12 +2120,18 @@ 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 } + try { + syncAchievementsFromGoldberg(context, appId) + } catch (e: Exception) { + Timber.e(e, "Achievement sync failed for appId=$appId, continuing with cloud save sync") + } + if (!tryAcquireSync(appId)) { Timber.w("Cannot close app when sync already in progress for appId=$appId") return@async @@ -2610,31 +2617,84 @@ class SteamService : Service(), IChallengeUrlChanged { val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await() val schemaArray = userStats.schema.toByteArray() val generator = StatsAchievementsGenerator() - generator.generateStatsAchievements(schemaArray, configDirectory) - - val expandedRaw = runCatching { userStats.getExpandedAchievements("english") }.getOrNull() - if (!expandedRaw.isNullOrEmpty()) { - val nameToBlockBit = expandedRaw - .groupBy { (it.achievementId as? Number)?.toInt() ?: -1 } - .filter { it.key >= 0 } - .flatMap { (rawBlockId, list) -> - val blockId = rawBlockId / 100 - list.mapIndexed { index, block -> - (block.name?.takeIf { it.isNotEmpty() } ?: "") to listOf(blockId, index) - } - } - .filter { it.first.isNotEmpty() } - .toMap() - if (nameToBlockBit.isNotEmpty()) { - val configDir = File(configDirectory) - if (!configDir.exists()) configDir.mkdirs() - val mappingJson = JSONObject() - nameToBlockBit.forEach { (name, pair) -> - mappingJson.put(name, JSONArray(pair)) + 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" + ) + val goldbergAchFile = File(gseSavesDir, "achievements.json") + if (!goldbergAchFile.exists()) { + Timber.d("No Goldberg achievements.json found at ${goldbergAchFile.absolutePath} 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) } - File(configDir, "achievement_name_to_block.json").writeText(mappingJson.toString(), Charsets.UTF_8) } + } 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( @@ -2678,8 +2738,20 @@ class SteamService : Service(), IChallengeUrlChanged { val current = blockBitmasks.getOrDefault(blockId, 0) blockBitmasks[blockId] = current or (1 shl bitIndex) } - val updates = blockBitmasks.map { (id, mask) -> id to mask } -// instance?._steamUserStats!!.storeUserStats(appId, steamUser.steamID!!, updates) + val statsToStore = blockBitmasks.map { (id, mask) -> Stats(statId = id, statValue = mask) } + val callback = instance?._steamUserStats!!.storeUserStats(appId, statsToStore).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}") + } + } } } diff --git a/app/src/main/java/app/gamenative/statsgen/Models.kt b/app/src/main/java/app/gamenative/statsgen/Models.kt index 142e4510cf..029a6d7a14 100644 --- a/app/src/main/java/app/gamenative/statsgen/Models.kt +++ b/app/src/main/java/app/gamenative/statsgen/Models.kt @@ -27,7 +27,8 @@ data class ProcessingResult( val achievements: List, val stats: List, val copyDefaultUnlockedImg: Boolean, - val copyDefaultLockedImg: Boolean + val copyDefaultLockedImg: Boolean, + val nameToBlockBit: Map> = emptyMap(), ) object StatType { diff --git a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt index c92eb37307..af751a61e4 100644 --- a/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt +++ b/app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt @@ -26,6 +26,7 @@ class StatsAchievementsGenerator { val parsedSchema = vdfParser.binaryLoads(schema) val achievementsOut = mutableListOf() val statsOut = mutableListOf() + val nameToBlockBit = mutableMapOf>() for ((appId, appData) in parsedSchema) { if (appData !is Map<*, *>) continue @@ -86,6 +87,14 @@ class StatsAchievementsGenerator { } 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 } @@ -333,7 +342,8 @@ class StatsAchievementsGenerator { achievements = achievementsOut, stats = statsOut, copyDefaultUnlockedImg = copyDefaultUnlockedImg, - copyDefaultLockedImg = copyDefaultLockedImg + copyDefaultLockedImg = copyDefaultLockedImg, + nameToBlockBit = nameToBlockBit, ) } } 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() } From b4f6f9fb05e9c15b84a1fb1f689a48d6e520d1ec Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 7 Mar 2026 21:23:29 -0800 Subject: [PATCH 12/15] added ability to show overlay --- .../main/java/app/gamenative/PrefManager.kt | 7 +++ .../app/gamenative/service/SteamService.kt | 47 ++++++++++++------- .../ui/component/dialog/GeneralTab.kt | 7 +++ .../app/gamenative/utils/ContainerUtils.kt | 6 +++ .../java/app/gamenative/utils/SteamUtils.kt | 12 +++++ .../com/winlator/container/ContainerData.kt | 3 ++ app/src/main/res/values-da/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-ko/strings.xml | 2 + app/src/main/res/values-pl/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-ro/strings.xml | 2 + app/src/main/res/values-uk/strings.xml | 2 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values-zh-rTW/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 19 files changed, 92 insertions(+), 16 deletions(-) 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 97a781bf9f..f44b003459 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2126,18 +2126,18 @@ class SteamService : Service(), IChallengeUrlChanged { return@async } - try { - syncAchievementsFromGoldberg(context, appId) - } catch (e: Exception) { - Timber.e(e, "Achievement sync failed for appId=$appId, continuing with cloud save sync") - } - if (!tryAcquireSync(appId)) { Timber.w("Cannot close app when sync already in progress for appId=$appId") return@async } 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 { @@ -2638,10 +2638,21 @@ class SteamService : Service(), IChallengeUrlChanged { imageFs.rootDir, "${ImageFs.WINEPREFIX}/drive_c/users/xuser/AppData/Roaming/GSE Saves/$appId" ) - val goldbergAchFile = File(gseSavesDir, "achievements.json") + var goldbergAchFile = File(gseSavesDir, "achievements.json") if (!goldbergAchFile.exists()) { - Timber.d("No Goldberg achievements.json found at ${goldbergAchFile.absolutePath} for appId=$appId") - return + 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() @@ -2670,12 +2681,12 @@ class SteamService : Service(), IChallengeUrlChanged { } 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") - } +// 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? { @@ -2739,7 +2750,11 @@ class SteamService : Service(), IChallengeUrlChanged { blockBitmasks[blockId] = current or (1 shl bitIndex) } val statsToStore = blockBitmasks.map { (id, mask) -> Stats(statId = id, statValue = mask) } - val callback = instance?._steamUserStats!!.storeUserStats(appId, statsToStore).await() + 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}") } 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/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 d89f5b3b7a..3a9ea58f0a 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -955,6 +955,18 @@ 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") + } + 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") 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 From edfe64bb61aa7124ab9b32beca86a3dfd673a2c3 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sun, 8 Mar 2026 19:04:24 -0700 Subject: [PATCH 13/15] Attempt at making achievements overlay work more --- app/src/main/java/app/gamenative/utils/SteamUtils.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 3a9ea58f0a..28a490b67b 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -961,6 +961,8 @@ object SteamUtils { 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) From 22152f13176988f19b654a9948bead6cc6efa963 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sun, 8 Mar 2026 19:17:17 -0700 Subject: [PATCH 14/15] commented out code to make it compile --- .../app/gamenative/service/SteamService.kt | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index f44b003459..fbfeec5179 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2752,21 +2752,21 @@ class SteamService : Service(), IChallengeUrlChanged { 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}") - } - } +// 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}") +// } +// } } } From f8f24ab9c90a750d50def58233066dfabbeb18ea Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sun, 8 Mar 2026 19:34:52 -0700 Subject: [PATCH 15/15] fixed tests --- .../app/gamenative/utils/SteamUtilsFileSearchTest.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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()