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] 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 46d683bf2..8d9b7a127 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 000000000..44b38b4f9 --- /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 000000000..214ebf276 --- /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 000000000..48bbfaa09 --- /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 9d87e64e7..1707e5f39 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()) + } }