diff --git a/app/src/main/java/com/example/bd2modmanager/data/model/ModModels.kt b/app/src/main/java/com/example/bd2modmanager/data/model/ModModels.kt index 61ad721de..820ce6d44 100644 --- a/app/src/main/java/com/example/bd2modmanager/data/model/ModModels.kt +++ b/app/src/main/java/com/example/bd2modmanager/data/model/ModModels.kt @@ -2,6 +2,34 @@ package com.example.bd2modmanager.data.model import android.net.Uri +enum class ResolutionState { + KNOWN, + MISC, + UNKNOWN, + INVALID +} + +enum class MatchStrategy { + EXACT, + NORMALIZED, + EXTENSION_MAPPING, + FALLBACK, + NONE +} + +data class ResolvedTarget( + val originalFileName: String, + val normalizedCandidates: List = emptyList(), + val resolvedAssetKey: String? = null, + val resolvedBundleName: String? = null, + val resolvedBundlePath: String? = null, + val assetType: String? = null, + val targetHash: String? = null, + val familyKey: String? = null, + val matchStrategy: MatchStrategy = MatchStrategy.NONE, + val confidence: Float = 0f +) + data class ModInfo( val name: String, val character: String, @@ -10,7 +38,13 @@ data class ModInfo( val isEnabled: Boolean, val uri: Uri, val targetHashedName: String?, - val isDirectory: Boolean + val isDirectory: Boolean, + val resolutionState: ResolutionState = ResolutionState.UNKNOWN, + val targetHash: String? = targetHashedName, + val resolvedFamilyKey: String? = null, + val resolvedTargets: List = emptyList(), + val unresolvedFiles: List = emptyList(), + val errorReason: String? = null ) data class ModCacheInfo( @@ -21,7 +55,12 @@ data class ModCacheInfo( val costume: String, val type: String, val targetHashedName: String?, - val isDirectory: Boolean + val isDirectory: Boolean, + val resolutionState: ResolutionState = ResolutionState.UNKNOWN, + val targetHash: String? = targetHashedName, + val resolvedFamilyKey: String? = null, + val unresolvedFiles: List = emptyList(), + val errorReason: String? = null ) data class CharacterInfo(val character: String, val costume: String, val type: String, val hashedName: String) diff --git a/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt b/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt index e82c0cb8f..537186cd6 100644 --- a/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt +++ b/app/src/main/java/com/example/bd2modmanager/data/repository/CharacterRepository.kt @@ -1,126 +1,126 @@ -package com.example.bd2modmanager.data.repository - -import android.content.Context -import com.chaquo.python.Python -import com.example.bd2modmanager.data.model.CharacterInfo -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONArray -import java.io.File - -class CharacterRepository(private val context: Context) { - - companion object { - private const val CHARACTERS_JSON_FILENAME = "characters.json" - private const val MOD_CACHE_FILENAME = "mod_cache.json" - } - - private var characterLut: Map> = emptyMap() - - suspend fun updateCharacterData(): Boolean { - return withContext(Dispatchers.IO) { - var success = false - try { - if (!Python.isStarted()) { - Python.start(com.chaquo.python.android.AndroidPlatform(context)) - } - val py = Python.getInstance() - val mainScript = py.getModule("main_script") - val result = mainScript.callAttr("update_character_data", context.filesDir.absolutePath).asList() - val status = result[0].toString() // SUCCESS, SKIPPED, FAILED - val message = result[1].toString() - - when (status) { - "SUCCESS" -> { - println("Successfully ran scraper and saved characters.json: $message") - // When a new characters.json is generated, the mod cache becomes invalid. - val cacheFile = File(context.cacheDir, MOD_CACHE_FILENAME) - if (cacheFile.exists()) { - cacheFile.delete() - println("Deleted mod cache to force re-scan.") - } - success = true - } - "SKIPPED" -> { - println("Scraper skipped: $message") - success = true - } - "FAILED" -> { - println("Scraper script failed: $message. Will use local version if available.") - success = false - } - } - } catch (e: Exception) { - e.printStackTrace() - println("Failed to execute scraper python script, will use local version. Error: ${e.message}") - success = false - } - characterLut = parseCharacterJson() - success - } - } - - private suspend fun parseCharacterJson(): Map> { - return withContext(Dispatchers.IO) { - val lut = mutableMapOf>() - val internalFile = File(context.filesDir, CHARACTERS_JSON_FILENAME) - if (!internalFile.exists()) return@withContext emptyMap() - val jsonString = try { internalFile.readText() } catch (e: Exception) { e.printStackTrace(); "" } - if (jsonString.isNotEmpty()) { - try { - // Try parsing new format first - val rootObject = org.json.JSONObject(jsonString) - val jsonArray = rootObject.getJSONArray("characters") - for (i in 0 until jsonArray.length()) { - val obj = jsonArray.getJSONObject(i) - val fileId = obj.getString("file_id").lowercase() - val charInfo = CharacterInfo(obj.getString("character"), obj.getString("costume"), obj.getString("type"), obj.getString("hashed_name")) - lut.getOrPut(fileId) { mutableListOf() }.add(charInfo) - } - } catch (e: org.json.JSONException) { - // Fallback to old format - try { - val jsonArray = JSONArray(jsonString) - for (i in 0 until jsonArray.length()) { - val obj = jsonArray.getJSONObject(i) - val fileId = obj.getString("file_id").lowercase() - val charInfo = CharacterInfo(obj.getString("character"), obj.getString("costume"), obj.getString("type"), obj.getString("hashed_name")) - lut.getOrPut(fileId) { mutableListOf() }.add(charInfo) - } - } catch (e2: Exception) { - e2.printStackTrace() - } - } catch (e: Exception) { - e.printStackTrace() - } - } - lut - } - } - - fun findBestMatch(fileId: String?, fileNames: List): CharacterInfo? { - if (fileId == null) return null - - val candidates = characterLut[fileId] ?: return null - if (candidates.size == 1) return candidates.first() - if (candidates.isEmpty()) return null - - val hasCutsceneKeyword = fileNames.any { it.contains("cutscene", ignoreCase = true) } - if (hasCutsceneKeyword) { - candidates.find { it.type == "cutscene" }?.let { return it } - } - - val validHashCandidates = candidates.filter { !it.hashedName.isNullOrBlank() } - if (validHashCandidates.size == 1) { - return validHashCandidates.first() - } - - candidates.find { it.type == "idle" }?.let { return it } - - return candidates.first() - } - - fun extractFileId(entryName: String): String? { - return "(char\\d{6}|illust_dating\\d+|illust_special\\d+|illust_talk\\d+|npc\\d+|specialillust\\w+|storypack\\w+|\\bRhythmHitAnim\\b)".toRegex(RegexOption.IGNORE_CASE).find(entryName)?.value?.lowercase() - } -} +package com.example.bd2modmanager.data.repository + +import android.content.Context +import com.chaquo.python.Python +import com.example.bd2modmanager.data.model.CharacterInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import java.io.File + +class CharacterRepository(private val context: Context) { + + companion object { + private const val CHARACTERS_JSON_FILENAME = "characters.json" + private const val MOD_CACHE_FILENAME = "mod_cache.json" + } + + private var characterLut: Map> = emptyMap() + + suspend fun updateCharacterData(): Boolean { + return withContext(Dispatchers.IO) { + var success = false + try { + if (!Python.isStarted()) { + Python.start(com.chaquo.python.android.AndroidPlatform(context)) + } + val py = Python.getInstance() + val mainScript = py.getModule("main_script") + val result = mainScript.callAttr("update_character_data", context.filesDir.absolutePath).asList() + val status = result[0].toString() // SUCCESS, SKIPPED, FAILED + val message = result[1].toString() + + when (status) { + "SUCCESS" -> { + println("Successfully ran scraper and saved characters.json: $message") + // When a new characters.json is generated, the mod cache becomes invalid. + val cacheFile = File(context.filesDir, MOD_CACHE_FILENAME) + if (cacheFile.exists()) { + cacheFile.delete() + println("Deleted mod cache to force re-scan.") + } + success = true + } + "SKIPPED" -> { + println("Scraper skipped: $message") + success = true + } + "FAILED" -> { + println("Scraper script failed: $message. Will use local version if available.") + success = false + } + } + } catch (e: Exception) { + e.printStackTrace() + println("Failed to execute scraper python script, will use local version. Error: ${e.message}") + success = false + } + characterLut = parseCharacterJson() + success + } + } + + private suspend fun parseCharacterJson(): Map> { + return withContext(Dispatchers.IO) { + val lut = mutableMapOf>() + val internalFile = File(context.filesDir, CHARACTERS_JSON_FILENAME) + if (!internalFile.exists()) return@withContext emptyMap() + val jsonString = try { internalFile.readText() } catch (e: Exception) { e.printStackTrace(); "" } + if (jsonString.isNotEmpty()) { + try { + // Try parsing new format first + val rootObject = org.json.JSONObject(jsonString) + val jsonArray = rootObject.getJSONArray("characters") + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + val fileId = obj.getString("file_id").lowercase() + val charInfo = CharacterInfo(obj.getString("character"), obj.getString("costume"), obj.getString("type"), obj.getString("hashed_name")) + lut.getOrPut(fileId) { mutableListOf() }.add(charInfo) + } + } catch (e: org.json.JSONException) { + // Fallback to old format + try { + val jsonArray = JSONArray(jsonString) + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + val fileId = obj.getString("file_id").lowercase() + val charInfo = CharacterInfo(obj.getString("character"), obj.getString("costume"), obj.getString("type"), obj.getString("hashed_name")) + lut.getOrPut(fileId) { mutableListOf() }.add(charInfo) + } + } catch (e2: Exception) { + e2.printStackTrace() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + lut + } + } + + fun findBestMatch(fileId: String?, fileNames: List): CharacterInfo? { + if (fileId == null) return null + + val candidates = characterLut[fileId] ?: return null + if (candidates.size == 1) return candidates.first() + if (candidates.isEmpty()) return null + + val hasCutsceneKeyword = fileNames.any { it.contains("cutscene", ignoreCase = true) } + if (hasCutsceneKeyword) { + candidates.find { it.type == "cutscene" }?.let { return it } + } + + val validHashCandidates = candidates.filter { !it.hashedName.isNullOrBlank() } + if (validHashCandidates.size == 1) { + return validHashCandidates.first() + } + + candidates.find { it.type == "idle" }?.let { return it } + + return candidates.first() + } + + fun extractFileId(entryName: String): String? { + return "(char\\d{6}|illust_dating\\d+|illust_special\\d+|illust_talk\\d+|npc\\d+|specialillust\\w+|storypack\\w+|\\bRhythmHitAnim\\b)".toRegex(RegexOption.IGNORE_CASE).find(entryName)?.value?.lowercase() + } +} diff --git a/app/src/main/java/com/example/bd2modmanager/data/repository/ModRepository.kt b/app/src/main/java/com/example/bd2modmanager/data/repository/ModRepository.kt index a6961081d..a44b918d7 100644 --- a/app/src/main/java/com/example/bd2modmanager/data/repository/ModRepository.kt +++ b/app/src/main/java/com/example/bd2modmanager/data/repository/ModRepository.kt @@ -1,153 +1,326 @@ -package com.example.bd2modmanager.data.repository - -import android.content.Context -import android.net.Uri -import androidx.documentfile.provider.DocumentFile -import com.example.bd2modmanager.data.model.ModCacheInfo -import com.example.bd2modmanager.data.model.ModDetails -import com.example.bd2modmanager.data.model.ModInfo -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.util.zip.ZipInputStream - -class ModRepository( - private val context: Context, - private val characterRepository: CharacterRepository -) { - - companion object { - private const val MOD_CACHE_FILENAME = "mod_cache.json" - } - - private val gson = Gson() - - suspend fun scanMods(dirUri: Uri): List { - return withContext(Dispatchers.IO) { - val existingCache = loadModCache() - val newCache = mutableMapOf() - val tempModsList = mutableListOf() - val files = DocumentFile.fromTreeUri(context, dirUri)?.listFiles() ?: emptyArray() - - files.filter { it.isDirectory || it.name?.endsWith(".zip", ignoreCase = true) == true } - .forEach { file -> - val uriString = file.uri.toString() - val lastModified = file.lastModified() - val cachedInfo = existingCache[uriString] - - val modInfo = if (cachedInfo != null && cachedInfo.lastModified == lastModified) { - newCache[uriString] = cachedInfo - ModInfo( - name = cachedInfo.name, - character = cachedInfo.character, - costume = cachedInfo.costume, - type = cachedInfo.type, - isEnabled = false, - uri = file.uri, - targetHashedName = cachedInfo.targetHashedName, - isDirectory = cachedInfo.isDirectory - ) - } else { - val modName = file.name?.removeSuffix(".zip") ?: "" - val isDirectory = file.isDirectory - val modDetails = if (isDirectory) { - extractModDetailsFromDirectory(file.uri) - } else { - extractModDetailsFromUri(file.uri) - } - val bestMatch = characterRepository.findBestMatch(modDetails.fileId, modDetails.fileNames) - - val newCacheInfo = ModCacheInfo( - uriString = uriString, - lastModified = lastModified, - name = modName, - character = bestMatch?.character ?: "Unknown", - costume = bestMatch?.costume ?: "Unknown", - type = bestMatch?.type ?: "idle", - targetHashedName = bestMatch?.hashedName, - isDirectory = isDirectory - ) - newCache[uriString] = newCacheInfo - - ModInfo( - name = modName, - character = bestMatch?.character ?: "Unknown", - costume = bestMatch?.costume ?: "Unknown", - type = bestMatch?.type ?: "idle", - isEnabled = false, - uri = file.uri, - targetHashedName = bestMatch?.hashedName, - isDirectory = isDirectory - ) - } - tempModsList.add(modInfo) - } - - saveModCache(newCache) - tempModsList.sortedBy { it.name } - } - } - - private fun loadModCache(): Map { - val cacheFile = File(context.cacheDir, MOD_CACHE_FILENAME) - if (!cacheFile.exists()) return emptyMap() - - return try { - val json = cacheFile.readText() - val type = object : TypeToken>() {}.type - gson.fromJson(json, type) ?: emptyMap() - } catch (e: Exception) { - e.printStackTrace() - emptyMap() - } - } - - private fun saveModCache(cache: Map) { - try { - val cacheFile = File(context.cacheDir, MOD_CACHE_FILENAME) - val json = gson.toJson(cache) - cacheFile.writeText(json) - } catch (e: Exception) { - e.printStackTrace() - } - } - - private fun extractModDetailsFromUri(zipUri: Uri): ModDetails { - val fileNames = mutableListOf() - var fileId: String? = null - try { - context.contentResolver.openInputStream(zipUri)?.use { - ZipInputStream(it).use { zis -> - var entry = zis.nextEntry - while (entry != null) { - fileNames.add(entry.name) - if (fileId == null) fileId = characterRepository.extractFileId(entry.name) - entry = zis.nextEntry - } - } - } - } catch (e: Exception) { - e.printStackTrace() - } - return ModDetails(fileId, fileNames) - } - - private fun extractModDetailsFromDirectory(dirUri: Uri): ModDetails { - val fileNames = mutableListOf() - var fileId: String? = null - try { - DocumentFile.fromTreeUri(context, dirUri)?.listFiles()?.forEach { file -> - val entryName = file.name ?: "" - fileNames.add(entryName) - if (file.isFile) { - if (fileId == null) fileId = characterRepository.extractFileId(entryName) - } - } - } catch (e: Exception) { - e.printStackTrace() - } - return ModDetails(fileId, fileNames) - } -} +package com.example.bd2modmanager.data.repository + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.chaquo.python.Python +import com.example.bd2modmanager.data.model.MatchStrategy +import com.example.bd2modmanager.data.model.ModCacheInfo +import com.example.bd2modmanager.data.model.ModDetails +import com.example.bd2modmanager.data.model.ModInfo +import com.example.bd2modmanager.data.model.ResolutionState +import com.example.bd2modmanager.data.model.ResolvedTarget +import com.example.bd2modmanager.service.ModdingService +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.zip.ZipInputStream + +class ModRepository( + private val context: Context, + private val characterRepository: CharacterRepository +) { + + companion object { + private const val MOD_CACHE_FILENAME = "mod_cache.json" + } + + private val gson = Gson() + + private data class ScannedModCandidate( + val uriString: String, + val lastModified: Long, + val name: String, + val uri: Uri, + val isDirectory: Boolean, + val modDetails: ModDetails + ) + + suspend fun scanMods(dirUri: Uri): List { + return withContext(Dispatchers.IO) { + val existingCache = loadModCache() + val newCache = mutableMapOf() + val tempModsList = mutableListOf() + val candidates = mutableListOf() + val files = DocumentFile.fromTreeUri(context, dirUri)?.listFiles() ?: emptyArray() + + files.filter { it.isDirectory || it.name?.endsWith(".zip", ignoreCase = true) == true } + .forEach { file -> + val uriString = file.uri.toString() + val lastModified = file.lastModified() + val cachedInfo = existingCache[uriString] + + if (cachedInfo != null && cachedInfo.lastModified == lastModified) { + newCache[uriString] = cachedInfo + tempModsList.add( + ModInfo( + name = cachedInfo.name, + character = cachedInfo.character, + costume = cachedInfo.costume, + type = cachedInfo.type, + isEnabled = false, + uri = file.uri, + targetHashedName = cachedInfo.targetHashedName, + isDirectory = cachedInfo.isDirectory, + resolutionState = cachedInfo.resolutionState, + targetHash = cachedInfo.targetHash, + resolvedFamilyKey = cachedInfo.resolvedFamilyKey, + unresolvedFiles = cachedInfo.unresolvedFiles, + errorReason = cachedInfo.errorReason + ) + ) + } else { + val modName = file.name?.removeSuffix(".zip") ?: "" + val isDirectory = file.isDirectory + val modDetails = if (isDirectory) { + extractModDetailsFromDirectory(file.uri) + } else { + extractModDetailsFromUri(file.uri) + } + candidates.add( + ScannedModCandidate( + uriString = uriString, + lastModified = lastModified, + name = modName, + uri = file.uri, + isDirectory = isDirectory, + modDetails = modDetails + ) + ) + } + } + + if (candidates.isNotEmpty()) { + if (!Python.isStarted()) { + Python.start(com.chaquo.python.android.AndroidPlatform(context)) + } + + val batchPayload = JSONArray().apply { + candidates.forEachIndexed { index, candidate -> + put(JSONObject().apply { + put("id", index) + put("fileNames", JSONArray(candidate.modDetails.fileNames)) + }) + } + } + + val (batchSuccess, batchResults) = ModdingService.resolveModBatch( + batchPayload.toString(), + context.filesDir.absolutePath, + "HD" + ) { } + + val resultsById = mutableMapOf() + if (batchSuccess && batchResults != null) { + for (i in 0 until batchResults.length()) { + val item = batchResults.optJSONObject(i) ?: continue + val id = item.optInt("id", -1) + val result = item.optJSONObject("result") ?: continue + if (id >= 0) resultsById[id] = result + } + } + + candidates.forEachIndexed { index, candidate -> + val resolvePayload = resultsById[index] ?: buildResolverFallback(candidate.modDetails.fileNames) + val resolutionState = parseResolutionState(resolvePayload) + val targetHash = resolvePayload.optString("targetHash").ifBlank { null } + val resolvedFamilyKey = resolvePayload.optString("resolvedFamilyKey").ifBlank { null } + val unresolvedFiles = jsonArrayToStringList(resolvePayload.optJSONArray("unresolvedFiles")) + val errorReason = resolvePayload.optString("errorReason").ifBlank { null } + val resolvedTargets = parseResolvedTargets(resolvePayload.optJSONArray("resolvedTargets")) + val bestMatch = characterRepository.findBestMatch(candidate.modDetails.fileId, candidate.modDetails.fileNames) + + val displayCharacter: String + val displayCostume: String + val displayType: String + when { + resolutionState == ResolutionState.INVALID -> { + displayCharacter = "Invalid Mod" + displayCostume = "Split Required" + displayType = "invalid" + } + resolutionState == ResolutionState.UNKNOWN -> { + displayCharacter = "Unknown" + displayCostume = "Unknown" + displayType = "unknown" + } + bestMatch != null -> { + displayCharacter = bestMatch.character + displayCostume = bestMatch.costume + displayType = bestMatch.type + } + else -> { + displayCharacter = "Other" + displayCostume = "Other" + displayType = "misc" + } + } + + val resolvedHash = targetHash + val newCacheInfo = ModCacheInfo( + uriString = candidate.uriString, + lastModified = candidate.lastModified, + name = candidate.name, + character = displayCharacter, + costume = displayCostume, + type = displayType, + targetHashedName = resolvedHash, + isDirectory = candidate.isDirectory, + resolutionState = resolutionState, + targetHash = resolvedHash, + resolvedFamilyKey = resolvedFamilyKey, + unresolvedFiles = unresolvedFiles, + errorReason = errorReason + ) + newCache[candidate.uriString] = newCacheInfo + + tempModsList.add( + ModInfo( + name = candidate.name, + character = displayCharacter, + costume = displayCostume, + type = displayType, + isEnabled = false, + uri = candidate.uri, + targetHashedName = resolvedHash, + isDirectory = candidate.isDirectory, + resolutionState = resolutionState, + targetHash = resolvedHash, + resolvedFamilyKey = resolvedFamilyKey, + resolvedTargets = resolvedTargets, + unresolvedFiles = unresolvedFiles, + errorReason = errorReason + ) + ) + } + } + + saveModCache(newCache) + tempModsList.sortedBy { it.name } + } + } + + private fun getModCacheFile(): File { + return File(context.filesDir, MOD_CACHE_FILENAME) + } + + private fun loadModCache(): Map { + val cacheFile = getModCacheFile() + if (!cacheFile.exists()) return emptyMap() + + return try { + val json = cacheFile.readText() + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) ?: emptyMap() + } catch (e: Exception) { + e.printStackTrace() + emptyMap() + } + } + + private fun saveModCache(cache: Map) { + try { + val cacheFile = getModCacheFile() + val json = gson.toJson(cache) + cacheFile.writeText(json) + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun extractModDetailsFromUri(zipUri: Uri): ModDetails { + val fileNames = mutableListOf() + var fileId: String? = null + try { + context.contentResolver.openInputStream(zipUri)?.use { + ZipInputStream(it).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + fileNames.add(entry.name) + if (fileId == null) fileId = characterRepository.extractFileId(entry.name) + entry = zis.nextEntry + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return ModDetails(fileId, fileNames) + } + + private fun extractModDetailsFromDirectory(dirUri: Uri): ModDetails { + val fileNames = mutableListOf() + var fileId: String? = null + try { + DocumentFile.fromTreeUri(context, dirUri)?.listFiles()?.forEach { file -> + val entryName = file.name ?: "" + fileNames.add(entryName) + if (file.isFile) { + if (fileId == null) fileId = characterRepository.extractFileId(entryName) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return ModDetails(fileId, fileNames) + } + + private fun buildResolverFallback(fileNames: List): JSONObject { + return JSONObject().apply { + put("resolutionState", ResolutionState.UNKNOWN.name) + put("errorReason", "Resolver failed") + put("unresolvedFiles", JSONArray(fileNames)) + put("resolvedTargets", JSONArray()) + } + } + + private fun parseResolutionState(payload: JSONObject): ResolutionState { + return try { + ResolutionState.valueOf(payload.optString("resolutionState", ResolutionState.UNKNOWN.name)) + } catch (_: Exception) { + ResolutionState.UNKNOWN + } + } + + private fun jsonArrayToStringList(array: JSONArray?): List { + if (array == null) return emptyList() + return buildList { + for (i in 0 until array.length()) { + add(array.optString(i)) + } + } + } + + private fun parseResolvedTargets(array: JSONArray?): List { + if (array == null) return emptyList() + return buildList { + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val candidates = jsonArrayToStringList(obj.optJSONArray("normalizedCandidates")) + val strategy = try { + MatchStrategy.valueOf(obj.optString("matchStrategy", MatchStrategy.NONE.name)) + } catch (_: Exception) { + MatchStrategy.NONE + } + add( + ResolvedTarget( + originalFileName = obj.optString("originalFileName"), + normalizedCandidates = candidates, + resolvedAssetKey = obj.optString("resolvedAssetKey").ifBlank { null }, + resolvedBundleName = obj.optString("resolvedBundleName").ifBlank { null }, + resolvedBundlePath = obj.optString("resolvedBundlePath").ifBlank { null }, + assetType = obj.optString("assetType").ifBlank { null }, + targetHash = obj.optString("targetHash").ifBlank { null }, + familyKey = obj.optString("familyKey").ifBlank { null }, + matchStrategy = strategy, + confidence = obj.optDouble("confidence", 0.0).toFloat() + ) + ) + } + } + } +} diff --git a/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt b/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt index 4d66c9be7..387e1e294 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt @@ -3,6 +3,8 @@ package com.example.bd2modmanager.service import com.chaquo.python.PyObject import com.chaquo.python.Python +import org.json.JSONArray +import org.json.JSONObject object ModdingService { @@ -74,6 +76,50 @@ object ModdingService { } } + fun resolveModFiles(fileNamesJson: String, outputDir: String, quality: String, onProgress: (String) -> Unit): Pair { + return try { + val py = Python.getInstance() + val mainScript = py.getModule("main_script") + + val result = mainScript.callAttr( + "resolve_mod_files", + fileNamesJson, + outputDir, + quality, + PyObject.fromJava(onProgress) + ).asList() + + val success = result[0].toBoolean() + val payload = result[1].toString() + Pair(success, if (success) JSONObject(payload) else null) + } catch (e: Exception) { + e.printStackTrace() + Pair(false, null) + } + } + + fun resolveModBatch(modsJson: String, outputDir: String, quality: String, onProgress: (String) -> Unit): Pair { + return try { + val py = Python.getInstance() + val mainScript = py.getModule("main_script") + + val result = mainScript.callAttr( + "resolve_mod_batch", + modsJson, + outputDir, + quality, + PyObject.fromJava(onProgress) + ).asList() + + val success = result[0].toBoolean() + val payload = result[1].toString() + Pair(success, if (success) JSONArray(payload) else null) + } catch (e: Exception) { + e.printStackTrace() + Pair(false, null) + } + } + fun mergeSpineAssets(modPath: String, onProgress: (String) -> Unit): Pair { return try { val py = Python.getInstance() diff --git a/app/src/main/java/com/example/bd2modmanager/ui/dialogs/AppDialogs.kt b/app/src/main/java/com/example/bd2modmanager/ui/dialogs/AppDialogs.kt index 9b91f06d5..e393b9c83 100644 --- a/app/src/main/java/com/example/bd2modmanager/ui/dialogs/AppDialogs.kt +++ b/app/src/main/java/com/example/bd2modmanager/ui/dialogs/AppDialogs.kt @@ -307,7 +307,7 @@ fun UninstallConfirmationDialog( title = { Text("Confirm Restore") }, text = { Text( - "Are you sure you want to restore the original file for this group?\n\nTarget: ${targetHash.take(12)}...", + "Are you sure you want to restore the original file for this group?\n\nTarget: ${targetHash}", textAlign = TextAlign.Center ) }, diff --git a/app/src/main/java/com/example/bd2modmanager/ui/screens/ModScreen.kt b/app/src/main/java/com/example/bd2modmanager/ui/screens/ModScreen.kt index 99ee90ff4..f4af6ff0b 100644 --- a/app/src/main/java/com/example/bd2modmanager/ui/screens/ModScreen.kt +++ b/app/src/main/java/com/example/bd2modmanager/ui/screens/ModScreen.kt @@ -29,8 +29,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.example.bd2modmanager.data.model.ModInfo +import com.example.bd2modmanager.data.model.ResolutionState import com.example.bd2modmanager.ui.viewmodel.MainViewModel import com.valentinilk.shimmer.shimmer @@ -46,7 +50,14 @@ fun ModScreen( val modSourceDirectoryUri by viewModel.modSourceDirectoryUri.collectAsState() val modsList by viewModel.filteredModsList.collectAsState() val allModsList by viewModel.modsList.collectAsState() - val groupedMods = modsList.groupBy { it.targetHashedName ?: "Unknown" } + val groupedMods = modsList.groupBy { + when (it.resolutionState) { + ResolutionState.KNOWN -> it.targetHash ?: "Unknown" + ResolutionState.MISC -> it.targetHash ?: "Unknown" + ResolutionState.UNKNOWN -> "Unknown" + ResolutionState.INVALID -> "Invalid" + } + } val selectedMods by viewModel.selectedMods.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val showShimmer by viewModel.showShimmer.collectAsState() @@ -120,7 +131,9 @@ fun ModScreen( modifier = Modifier.size(width = 48.dp, height = 40.dp), contentAlignment = Alignment.Center ) { - val allModsCount = allModsList.size + val allModsCount = allModsList.count { + it.resolutionState == ResolutionState.KNOWN + } val selectedModsCount = selectedMods.size val checkboxState = when { selectedModsCount == 0 -> ToggleableState.Off @@ -272,20 +285,56 @@ fun ModScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { - Text( - text = "Target: ${hash.take(12)}...", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f) - ) + when (hash) { + "Unknown" -> { + Text( + text = "Unknown", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + } + "Invalid" -> { + Text( + text = "Invalid", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + } + else -> { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Target: ", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + AutoShrinkText( + text = hash, + maxFontSize = MaterialTheme.typography.titleMedium.fontSize, + minFontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + } + } + } - IconButton(onClick = { onUninstallRequest(hash) }) { - Icon( - Icons.Default.Delete, - contentDescription = "Uninstall", - tint = MaterialTheme.colorScheme.primary - ) + if (hash != "Unknown" && hash != "Invalid") { + IconButton(onClick = { onUninstallRequest(hash) }) { + Icon( + Icons.Default.Delete, + contentDescription = "Uninstall", + tint = MaterialTheme.colorScheme.primary + ) + } } val modsInGroupUris = modsInGroup.map { it.uri }.toSet() @@ -330,6 +379,35 @@ fun ModScreen( } } +@Composable +private fun AutoShrinkText( + text: String, + modifier: Modifier = Modifier, + maxFontSize: TextUnit = 20.sp, + minFontSize: TextUnit = 12.sp, + fontWeight: FontWeight? = null, + color: androidx.compose.ui.graphics.Color = LocalContentColor.current +) { + var currentFontSize by remember(text) { mutableStateOf(maxFontSize) } + + Text( + text = text, + modifier = modifier, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + fontSize = currentFontSize, + fontWeight = fontWeight, + color = color, + style = LocalTextStyle.current.copy(color = color), + onTextLayout = { result -> + if (result.hasVisualOverflow && currentFontSize > minFontSize) { + currentFontSize = (currentFontSize.value - 1f).coerceAtLeast(minFontSize.value).sp + } + } + ) +} + @Composable fun NoSearchResultsScreen(query: String) { Column( @@ -405,6 +483,7 @@ fun EmptyModsScreen() { @OptIn(ExperimentalFoundationApi::class) @Composable fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit, onLongPress: () -> Unit) { + val isSelectable = modInfo.resolutionState == ResolutionState.KNOWN val elevation by animateDpAsState(if (isSelected) 4.dp else 1.dp, label = "elevation") ElevatedCard( elevation = CardDefaults.cardElevation(defaultElevation = elevation), @@ -414,7 +493,7 @@ fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit .clip(CardDefaults.shape) .pointerInput(Unit) { detectTapGestures( - onTap = { onToggleSelection() }, + onTap = { if (isSelectable) onToggleSelection() }, onLongPress = { onLongPress() } ) } @@ -423,7 +502,7 @@ fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - Checkbox(checked = isSelected, onCheckedChange = { onToggleSelection() }) + Checkbox(checked = isSelected, onCheckedChange = { if (isSelectable) onToggleSelection() }, enabled = isSelectable) Spacer(Modifier.width(4.dp)) Column(modifier = Modifier.weight(1f)) { Text( @@ -440,16 +519,25 @@ fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit ) } Spacer(Modifier.width(8.dp)) + val typeIcon = when (modInfo.type.lowercase()) { + "idle" -> Icons.Default.Person + "cutscene" -> Icons.Default.Movie + else -> Icons.Default.Category + } AssistChip( - onClick = { /* No action */ }, - label = { Text(modInfo.type.uppercase(), style = MaterialTheme.typography.labelSmall) }, + onClick = {}, + label = { + Text( + text = modInfo.type.uppercase(), + style = MaterialTheme.typography.labelSmall + ) + }, leadingIcon = { - val icon = when(modInfo.type.lowercase()) { - "idle" -> Icons.Default.Person - "cutscene" -> Icons.Default.Movie - else -> Icons.Default.Category - } - Icon(icon, contentDescription = modInfo.type, Modifier.size(14.dp)) + Icon( + imageVector = typeIcon, + contentDescription = modInfo.type, + modifier = Modifier.size(14.dp) + ) }, modifier = Modifier.heightIn(max = 24.dp) ) diff --git a/app/src/main/java/com/example/bd2modmanager/ui/viewmodel/MainViewModel.kt b/app/src/main/java/com/example/bd2modmanager/ui/viewmodel/MainViewModel.kt index fa2832e4f..b0f2d8d06 100644 --- a/app/src/main/java/com/example/bd2modmanager/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/example/bd2modmanager/ui/viewmodel/MainViewModel.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.withContext @@ -111,8 +112,14 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _moveState = MutableStateFlow(MoveState.Idle) val moveState: StateFlow = _moveState.asStateFlow() + private var initialized = false + private var scanJob: Job? = null + private var pendingScanUri: Uri? = null fun initialize(context: Context) { + if (initialized) return + initialized = true + characterRepository = CharacterRepository(context) modRepository = ModRepository(context, characterRepository) @@ -164,7 +171,10 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( } fun toggleSelectAll() { - val filteredModUris = filteredModsList.value.map { it.uri }.toSet() + val filteredModUris = filteredModsList.value + .filter { it.resolutionState == ResolutionState.KNOWN } + .map { it.uri } + .toSet() val currentSelections = _selectedMods.value val selectedFilteredUris = currentSelections.intersect(filteredModUris) @@ -176,7 +186,13 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( } fun toggleSelectAllForGroup(groupHash: String) { - val modsInGroup = filteredModsList.value.filter { it.targetHashedName == groupHash }.map { it.uri }.toSet() + val modsInGroup = filteredModsList.value + .filter { + it.targetHash == groupHash && + it.resolutionState == ResolutionState.KNOWN + } + .map { it.uri } + .toSet() val currentSelections = _selectedMods.value val groupSelections = currentSelections.intersect(modsInGroup) @@ -191,8 +207,11 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( val allMods = _modsList.value val jobs = _selectedMods.value .mapNotNull { uri -> allMods.find { it.uri == uri } } - .filter { !it.targetHashedName.isNullOrBlank() } - .groupBy { it.targetHashedName!! } + .filter { + !it.targetHash.isNullOrBlank() && + it.resolutionState == ResolutionState.KNOWN + } + .groupBy { it.targetHash!! } .map { (hash, mods) -> RepackJob(hash, mods) } if (jobs.isNotEmpty()) { @@ -232,6 +251,9 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private suspend fun processSingleJob(context: Context, installJob: InstallJob, cacheKey: String) { val job = installJob.job val hashedName = job.hashedName + var originalDataCache: File? = null + var repackedDataCache: File? = null + val modAssetsDir = File(context.cacheDir, "temp_mod_assets_${hashedName}") try { updateJobStatus(hashedName, JobStatus.Downloading("Starting download...")) @@ -242,12 +264,10 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( if (!downloadSuccess) { throw Exception("Download failed: $messageOrPath") } - val originalDataCache = File(messageOrPath) + originalDataCache = File(messageOrPath) val relativePath = originalDataCache.relativeTo(context.cacheDir) - updateJobStatus(hashedName, JobStatus.Installing("Extracting mod files...")) - val modAssetsDir = File(context.cacheDir, "temp_mod_assets_${hashedName}") if (modAssetsDir.exists()) modAssetsDir.deleteRecursively() modAssetsDir.mkdirs() @@ -270,22 +290,18 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( } updateJobStatus(hashedName, JobStatus.Installing("Repacking bundle...")) - val repackedDataCache = File(context.cacheDir, "repacked/${relativePath.path}") + repackedDataCache = File(context.cacheDir, "repacked/${relativePath.path}") repackedDataCache.parentFile?.mkdirs() val (repackSuccess, repackMessage) = ModdingService.repackBundle(originalDataCache.absolutePath, modAssetsDir.absolutePath, repackedDataCache.absolutePath, useAstc.value) { progress -> updateJobStatus(hashedName, JobStatus.Installing(progress)) } - originalDataCache.delete() - modAssetsDir.deleteRecursively() - if (!repackSuccess) { throw Exception("Repack failed: $repackMessage") } val publicUri = saveFileToDownloads(context, repackedDataCache, relativePath.path, "Shared") - repackedDataCache.delete() if (publicUri != null) { updateJobStatus(hashedName, JobStatus.Finished(relativePath.path)) @@ -302,6 +318,16 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( fullError } updateJobStatus(hashedName, JobStatus.Failed(displayMessage = displayError, detailedLog = fullError)) + } finally { + try { + originalDataCache?.takeIf { it.exists() }?.delete() + } catch (_: Exception) {} + try { + repackedDataCache?.takeIf { it.exists() }?.delete() + } catch (_: Exception) {} + try { + if (modAssetsDir.exists()) modAssetsDir.deleteRecursively() + } catch (_: Exception) {} } } @@ -593,19 +619,32 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( fun scanModSourceDirectory(dirUri: Uri) { - _isLoading.value = true - viewModelScope.launch(Dispatchers.IO) { - isUpdatingCharacters.first { !it } // Wait for character data to be ready - try { - val mods = modRepository.scanMods(dirUri) + pendingScanUri = dirUri + if (scanJob?.isActive == true) return + + scanJob = viewModelScope.launch(Dispatchers.IO) { + while (true) { + val currentUri = pendingScanUri ?: break + pendingScanUri = null + withContext(Dispatchers.Main) { - _modsList.value = mods - _selectedMods.value = emptySet() + _isLoading.value = true } - } finally { - withContext(Dispatchers.Main) { - _isLoading.value = false + + isUpdatingCharacters.first { !it } // Wait for character data to be ready + try { + val mods = modRepository.scanMods(currentUri) + withContext(Dispatchers.Main) { + _modsList.value = mods + _selectedMods.value = emptySet() + } + } finally { + withContext(Dispatchers.Main) { + _isLoading.value = false + } } + + if (pendingScanUri == null) break } } } diff --git a/app/src/main/python/catalog_indexer.py b/app/src/main/python/catalog_indexer.py new file mode 100644 index 000000000..02ef054b2 --- /dev/null +++ b/app/src/main/python/catalog_indexer.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +import base64 +import json +import re +import struct +from pathlib import Path + +from catalog_parser import read_int32_from_byte_array, read_object_from_byte_array + + +INDEX_SCHEMA_VERSION = 5 + + +def _normalize_filename(filename: str): + filename = (filename or "").strip().lower() + if not filename: + return [] + + candidates = [filename] + if filename.endswith('.atlas'): + candidates.append(filename[:-6] + '.atlas.txt') + if filename.endswith('.skel'): + candidates.append(filename[:-5] + '.skel.bytes') + if filename.endswith('.skel.txt'): + candidates.append(filename[:-9] + '.skel.bytes') + return list(dict.fromkeys(candidates)) + + +def _extract_family_key(name: str): + if not name: + return None + lowered = name.lower() + stem = Path(lowered).stem + + if stem.endswith('.atlas'): + stem = stem[:-6] + if stem.endswith('.skel'): + stem = stem[:-5] + + stem = re.sub(r'_(\d+)$', '', stem) + return stem + + +def _score_asset_key_for_base(asset_key: str, asset_base: str): + score = 0 + if asset_key == asset_base: + score += 1000 + if '/censorship/' not in asset_key: + score += 100 + if asset_key.endswith('/' + asset_base): + score += 20 + score -= len(asset_key) + return score + + +def _cleanup_stale_indexes(output_dir, quality, keep_version): + pattern = f"asset_index_{quality.lower()}_*.json" + for path in Path(output_dir).glob(pattern): + if path.name != f"asset_index_{quality.lower()}_{keep_version}.json": + try: + path.unlink() + except Exception: + pass + + +def build_asset_index(catalog_content): + if not catalog_content: + return { + 'schemaVersion': INDEX_SCHEMA_VERSION, + 'version': None, + 'strings': [], + 'records': [], + 'assetsByExactKey': {}, + 'assetsByBaseName': {} + } + + bucket_array = base64.b64decode(catalog_content['m_BucketDataString']) + key_array = base64.b64decode(catalog_content['m_KeyDataString']) + extra_data = base64.b64decode(catalog_content['m_ExtraDataString']) + entry_data = base64.b64decode(catalog_content['m_EntryDataString']) + internal_ids = catalog_content.get('m_InternalIds') or [] + + num_buckets = struct.unpack_from('= 0: + bundle_info = read_object_from_byte_array(extra_data, data_index) + if bundle_info: + bundles[m] = { + 'bundle_name': bundle_info.get('m_BundleName'), + 'bundle_hash': bundle_info.get('m_Hash'), + 'bundle_size': bundle_info.get('m_BundleSize'), + 'download_name': str(keys[primary_key_index]) if primary_key_index < len(keys) else '' + } + + def resolve_bundle_info(entry_index): + if entry_index in bundles: + return bundles[entry_index] + if entry_index < 0 or entry_index >= len(entries): + return None + dep_idx = entries[entry_index]['dependency_index'] + if dep_idx < 0 or dep_idx >= len(dependency_map): + return None + deps = dependency_map[dep_idx] or [] + for dep_entry in deps: + info = bundles.get(dep_entry) + if info: + return info + return None + + strings = [] + string_ids = {} + records = [] + record_ids = {} + assets_by_exact = {} + base_candidates = {} + + def intern_string(value): + if value is None: + return -1 + value = str(value) + existing = string_ids.get(value) + if existing is not None: + return existing + idx = len(strings) + strings.append(value) + string_ids[value] = idx + return idx + + def intern_record(asset_key, target_hash, family_key): + asset_key_id = intern_string(asset_key) + target_hash_id = intern_string(target_hash) + family_key_id = intern_string(family_key) if family_key else -1 + record_key = (asset_key_id, target_hash_id, family_key_id) + existing = record_ids.get(record_key) + if existing is not None: + return existing + idx = len(records) + records.append([asset_key_id, target_hash_id, family_key_id]) + record_ids[record_key] = idx + return idx + + def append_unique_ref(mapping, key, record_id): + existing = mapping.get(key) + if existing is None: + mapping[key] = record_id + return + if isinstance(existing, list): + if record_id not in existing: + existing.append(record_id) + return + if existing != record_id: + mapping[key] = [existing, record_id] + + for i in range(len(entries)): + primary_key_index = entries[i]['primary_key_index'] + internal_id_index = entries[i]['internal_id_index'] + raw_key = keys[primary_key_index] if primary_key_index < len(keys) else None + internal_id = internal_ids[internal_id_index] if 0 <= internal_id_index < len(internal_ids) else None + bundle_info = resolve_bundle_info(i) + if not bundle_info: + continue + + target_hash = bundle_info.get('bundle_name') + if not target_hash: + continue + + candidate_keys = [] + if isinstance(raw_key, str) and raw_key: + candidate_keys.append(raw_key.lower()) + if isinstance(internal_id, str) and internal_id: + lowered_internal = internal_id.lower() + if lowered_internal not in candidate_keys: + candidate_keys.append(lowered_internal) + + if not candidate_keys: + continue + + for asset_key in candidate_keys: + asset_base = Path(asset_key).name.lower() + family_key = _extract_family_key(asset_base) + record_id = intern_record(asset_key, target_hash, family_key) + append_unique_ref(assets_by_exact, asset_key, record_id) + + group_key = (asset_base, target_hash, family_key) + candidate = base_candidates.get(group_key) + score = _score_asset_key_for_base(asset_key, asset_base) + if candidate is None or score > candidate[0]: + base_candidates[group_key] = (score, record_id) + + assets_by_base = {} + for (asset_base, _target_hash, _family_key), (_score, record_id) in base_candidates.items(): + append_unique_ref(assets_by_base, asset_base, record_id) + + return { + 'schemaVersion': INDEX_SCHEMA_VERSION, + 'version': None, + 'strings': strings, + 'records': records, + 'assetsByExactKey': assets_by_exact, + 'assetsByBaseName': assets_by_base + } + + +def load_or_build_asset_index(output_dir, quality, version, catalog_content): + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + _cleanup_stale_indexes(output_path, quality, version) + + index_path = output_path.joinpath(f"asset_index_{quality.lower()}_{version}.json") + if index_path.exists(): + try: + with open(index_path, 'r', encoding='utf-8') as f: + index = json.load(f) + if index.get('schemaVersion') == INDEX_SCHEMA_VERSION: + return index + except Exception: + pass + try: + index_path.unlink() + except Exception: + pass + + index = build_asset_index(catalog_content) + index['version'] = version + with open(index_path, 'w', encoding='utf-8') as f: + json.dump(index, f, ensure_ascii=False, separators=(',', ':')) + return index + + +def normalize_filename(filename: str): + return _normalize_filename(filename) diff --git a/app/src/main/python/character_scraper.py b/app/src/main/python/character_scraper.py index 61ffb7751..4313407d7 100644 --- a/app/src/main/python/character_scraper.py +++ b/app/src/main/python/character_scraper.py @@ -3,63 +3,27 @@ import json import sys import os -import threading import catalog_parser -def scrape_and_save(output_dir, version): - """ - Scrapes character data from the browndust2modding.pages.dev website, - then uses the file_id to find the correct bundle_name from the game's catalog, - and saves it as characters.json in the specified output directory. - """ - URL = "https://browndust2modding.pages.dev/characters" - output_filename = "characters.json" - output_path = os.path.join(output_dir, output_filename) - + +def _fetch_character_metadata_map(): + url = "https://browndust2modding.pages.dev/characters" + print("[Python] Fetching character list from website...") try: - response = requests.get(URL, timeout=15) + response = requests.get(url, timeout=15) response.raise_for_status() except requests.exceptions.RequestException as e: print(f"[Python] Error: Failed to retrieve the webpage. {e}", file=sys.stderr) - return False, f"Failed to retrieve webpage: {e}" + return False, f"Failed to retrieve webpage: {e}", None print("[Python] Parsing HTML content...") soup = BeautifulSoup(response.text, 'html.parser') table_body = soup.find('tbody') if not table_body: print("[Python] Error: Could not find the data table (tbody) in the HTML.", file=sys.stderr) - return False, "Could not find data table in HTML" + return False, "Could not find data table in HTML", None - # --- New logic to get bundle names from catalog --- - asset_map = {} - try: - if not version: - raise Exception("Version not provided to scraper.") - - print(f"[Python] Downloading catalog version {version}...") - # These are dummy args for the downloader, which expects a cache and lock - dummy_cache = {} - dummy_lock = threading.Lock() - catalog_content, error = catalog_parser.download_catalog(output_dir, "HD", version, dummy_cache, dummy_lock, lambda msg: print(f"[Python] {msg}")) - if error: - raise Exception(f"Failed to download catalog: {error}") - - print("[Python] Parsing catalog to build asset map...") - asset_map = catalog_parser.parse_catalog_for_bundle_names(catalog_content) - if not asset_map: - raise Exception("Failed to parse catalog or catalog is empty.") - print("[Python] Asset map built successfully.") - - except Exception as e: - print(f"[Python] Error processing game catalog: {e}", file=sys.stderr) - print("[Python] Cannot proceed without catalog data. Aborting.", file=sys.stderr) - return False, f"Error processing game catalog: {e}" - # --- End of new logic --- - - # --- New Refactored Logic --- - # First, scrape the website to get human-readable metadata. - # This data is treated as secondary and is used to "enrich" the primary data from the catalog. print("[Python] Scraping website for character metadata...") metadata_map = {} rows = table_body.find_all('tr') @@ -76,23 +40,49 @@ def scrape_and_save(output_dir, version): character = last_character file_id = cells[0].get_text(strip=True).lower() costume = cells[1].get_text(strip=True) - + if file_id and character and costume: metadata_map[file_id] = {"character": character, "costume": costume} - + print(f"[Python] Found metadata for {len(metadata_map)} file_ids from the website.") - - # Clean up BeautifulSoup object to free memory + del soup import gc gc.collect() + return True, "OK", metadata_map + + +def scrape_and_save_from_catalog(output_dir, version, catalog_content): + """ + Builds characters.json using scraped website metadata plus a caller-provided + game catalog payload. This function never downloads the catalog itself. + """ + output_filename = "characters.json" + output_path = os.path.join(output_dir, output_filename) + + if not version: + return False, "Version not provided to scraper." + if not catalog_content: + return False, "Catalog content is missing." + + success, message, metadata_map = _fetch_character_metadata_map() + if not success: + return False, message - # Now, iterate through the asset_map (from the official catalog) as the source of truth. + print("[Python] Parsing provided catalog to build asset map...") + try: + asset_map = catalog_parser.parse_catalog_for_bundle_names(catalog_content) + if not asset_map: + raise Exception("Failed to parse catalog or catalog is empty.") + except Exception as e: + print(f"[Python] Error processing game catalog: {e}", file=sys.stderr) + return False, f"Error processing game catalog: {e}" + + print("[Python] Asset map built successfully.") all_characters_data = [] print(f"[Python] Generating character list based on {len(asset_map)} file_ids from the game catalog...") for file_id, bundles in asset_map.items(): - # Get metadata from the scraped data, with a fallback for missing entries. metadata = metadata_map.get(file_id, { "character": "Unknown Character", "costume": f"Unknown ({file_id})" @@ -104,14 +94,12 @@ def scrape_and_save(output_dir, version): "costume": metadata["costume"], } - # Create idle entry if it exists in the catalog. if "idle" in bundles and bundles["idle"]: idle_entry = base_entry.copy() idle_entry["type"] = "idle" idle_entry["hashed_name"] = bundles["idle"] all_characters_data.append(idle_entry) - # Create cutscene entry if it exists in the catalog. if "cutscene" in bundles and bundles["cutscene"]: cutscene_entry = base_entry.copy() cutscene_entry["type"] = "cutscene" @@ -120,9 +108,8 @@ def scrape_and_save(output_dir, version): if not all_characters_data: print("[Python] Warning: No character data could be generated from the catalog.", file=sys.stderr) - - print(f"[Python] Saving {len(all_characters_data)} total entries to {output_path}...") + print(f"[Python] Saving {len(all_characters_data)} total entries to {output_path}...") final_data = { "version": version, "characters": all_characters_data @@ -137,3 +124,11 @@ def scrape_and_save(output_dir, version): print(f"[Python] Success! Data saved to {output_path}") return True, "Scraper completed successfully." + + +def scrape_and_save(output_dir, version): + """ + Backward-compatible wrapper. Downloads are handled externally in the new + metadata refresh flow, so this path should be avoided by new callers. + """ + return False, "scrape_and_save without provided catalog_content is deprecated." diff --git a/app/src/main/python/main_script.py b/app/src/main/python/main_script.py index 49abfedcf..8cd53ded8 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -10,6 +10,10 @@ import cdn_downloader from unpacker import unpack_bundle as unpacker_main import spine_merger +import resolver +import catalog_indexer +import json +from pathlib import Path # --- Global Cache for CDN Catalog --- # In-memory cache for the catalog JSON content. @@ -21,22 +25,108 @@ # to ensure that different installation processes use different caches. current_cache_key = None + +def _prune_catalog_cache(keep_version=None): + stale_versions = [version for version in catalog_cache.keys() if version != keep_version] + for version in stale_versions: + catalog_cache.pop(version, None) + + +def _load_valid_local_index(output_dir: str, quality: str, version: str = None): + output_path = Path(output_dir) + pattern = f"asset_index_{quality.lower()}_*.json" + candidates = sorted(output_path.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True) + + for index_path in candidates: + try: + with open(index_path, 'r', encoding='utf-8') as f: + index = json.load(f) + if index.get('schemaVersion') != catalog_indexer.INDEX_SCHEMA_VERSION: + continue + if version and index.get('version') != version: + continue + return index + except Exception: + try: + index_path.unlink() + except Exception: + pass + return None + + +def _refresh_metadata(output_dir: str, quality: str = "HD", progress_callback=None): + def report_progress(message): + if progress_callback: + progress_callback(message) + print(message) + + try: + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + report_progress(f"Fetching CDN version for {quality} quality...") + latest_version = cdn_downloader.get_cdn_version(quality) + if not latest_version: + return False, "Failed to get CDN version.", None, None + + characters_json_path = output_path / "characters.json" + stored_version = None + if characters_json_path.exists(): + try: + with open(characters_json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + stored_version = data.get("version") + except (json.JSONDecodeError, KeyError, TypeError): + stored_version = None + + existing_index = _load_valid_local_index(output_dir, quality, latest_version) + characters_up_to_date = stored_version == latest_version and characters_json_path.exists() + index_up_to_date = existing_index is not None + + if characters_up_to_date and index_up_to_date: + report_progress(f"Metadata already up to date for version {latest_version}.") + return True, "SKIPPED", latest_version, existing_index + + report_progress(f"Refreshing shared metadata for version {latest_version}...") + with catalog_cache_lock: + _prune_catalog_cache(latest_version) + catalog_content, error = cdn_downloader.download_catalog( + output_dir, quality, latest_version, catalog_cache, catalog_cache_lock, progress_callback + ) + if error: + return False, error, None, None + + if not characters_up_to_date: + report_progress("Rebuilding characters.json from shared catalog...") + success, message = character_scraper.scrape_and_save_from_catalog(output_dir, latest_version, catalog_content) + if not success: + return False, message, None, None + + report_progress("Rebuilding/loading asset index from shared catalog...") + index = catalog_indexer.load_or_build_asset_index(output_dir, quality, latest_version, catalog_content) + return True, "SUCCESS", latest_version, index + except Exception as e: + import traceback + error_message = traceback.format_exc() + report_progress(f"A critical error occurred while refreshing metadata: {error_message}") + return False, error_message, None, None + + def unpack_bundle(bundle_path: str, output_dir: str, progress_callback=None): """ Entry point for Kotlin to unpack a bundle. Returns a tuple: (success: Boolean, message: String) """ try: - # The progress callback will handle printing. success, message = unpacker_main( bundle_path=bundle_path, output_dir=output_dir, progress_callback=progress_callback ) - + print(message) return success, message - + except Exception as e: import traceback error_message = traceback.format_exc() @@ -45,6 +135,7 @@ def unpack_bundle(bundle_path: str, output_dir: str, progress_callback=None): progress_callback(f"An error occurred: {e}") return False, error_message + def download_bundle(hashed_name, quality, output_dir, cache_key, progress_callback=None): """ Entry point for Kotlin to download a bundle from the CDN. @@ -52,28 +143,27 @@ def download_bundle(hashed_name, quality, output_dir, cache_key, progress_callba Returns a tuple: (success: Boolean, message_or_path: String) """ global current_cache_key + def report_progress(message): if progress_callback: progress_callback(message) print(message) try: - # --- Cache Management --- - # If the cache key from the client has changed, it signifies a new batch operation. - # We should clear the cache to ensure we fetch the latest catalog for the new batch. with catalog_cache_lock: if current_cache_key != cache_key: report_progress("New batch installation detected, clearing catalog cache.") catalog_cache.clear() current_cache_key = cache_key - + report_progress(f"Fetching CDN version for {quality} quality...") version = cdn_downloader.get_cdn_version(quality) if not version: return False, "Failed to get CDN version." - + report_progress(f"Latest version is {version}. Checking catalog...") - # The download_catalog function will now use the shared cache + with catalog_cache_lock: + _prune_catalog_cache(version) catalog_content, error = cdn_downloader.download_catalog( output_dir, quality, version, catalog_cache, catalog_cache_lock, progress_callback ) @@ -81,7 +171,6 @@ def report_progress(message): return False, error report_progress(f"Searching for bundle {hashed_name} in catalog...") - # find_and_download_bundle now takes the catalog content directly output_file_path, error = cdn_downloader.find_and_download_bundle( catalog_content=catalog_content, version=version, @@ -93,7 +182,7 @@ def report_progress(message): if error: return False, error - + return True, output_file_path except Exception as e: @@ -102,54 +191,84 @@ def report_progress(message): report_progress(f"A critical error occurred: {error_message}") return False, error_message -import json + +def ensure_asset_index(output_dir: str, quality: str = "HD", progress_callback=None): + def report_progress(message): + if progress_callback: + progress_callback(message) + print(message) + + try: + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + index = _load_valid_local_index(output_dir, quality) + if index is None: + return False, "Asset index missing. Refresh metadata first.", None + + version = index.get('version') + report_progress(f"Using local asset index for version {version}.") + return True, version, index + except Exception as e: + import traceback + error_message = traceback.format_exc() + report_progress(f"A critical error occurred while loading asset index: {error_message}") + return False, error_message, None + + +def resolve_mod_files(file_names_json: str, output_dir: str, quality: str = "HD", progress_callback=None): + try: + file_names = json.loads(file_names_json) if isinstance(file_names_json, str) else file_names_json + success, version_or_error, index = ensure_asset_index(output_dir, quality, progress_callback) + if not success: + return False, version_or_error + + result = resolver.resolve_mod_folder(file_names, index) + return True, json.dumps(result) + except Exception as e: + import traceback + return False, traceback.format_exc() + + +def resolve_mod_batch(mods_json: str, output_dir: str, quality: str = "HD", progress_callback=None): + try: + mods = json.loads(mods_json) if isinstance(mods_json, str) else mods_json + success, version_or_error, index = ensure_asset_index(output_dir, quality, progress_callback) + if not success: + return False, version_or_error + + results = [] + for mod in mods or []: + mod_id = mod.get("id") + file_names = mod.get("fileNames") or [] + resolved = resolver.resolve_mod_folder(file_names, index) + results.append({ + "id": mod_id, + "result": resolved + }) + return True, json.dumps(results) + except Exception: + import traceback + return False, traceback.format_exc() + def update_character_data(output_dir: str): """ - Entry point for Kotlin to run the character data scraper. - Checks versions to avoid unnecessary work. + Entry point for Kotlin to refresh all shared metadata. Returns a tuple: (status: String, message: String) Status can be "SUCCESS", "SKIPPED", "FAILED" """ try: - # 1. Get latest CDN version - print("[Python] Getting latest CDN version...") - latest_version = cdn_downloader.get_cdn_version("HD") - if not latest_version: - return "FAILED", "Could not retrieve latest CDN version." - print(f"[Python] Latest CDN version is: {latest_version}") - - # 2. Check local characters.json - characters_json_path = os.path.join(output_dir, "characters.json") - stored_version = None - if os.path.exists(characters_json_path): - try: - with open(characters_json_path, 'r', encoding='utf-8') as f: - data = json.load(f) - stored_version = data.get("version") - print(f"[Python] Found local characters.json with version: {stored_version}") - except (json.JSONDecodeError, KeyError, TypeError) as e: - print(f"[Python] Could not read version from local characters.json: {e}. Will regenerate.") - stored_version = None - - # 3. Compare versions - if stored_version and stored_version == latest_version: - print("[Python] Local characters.json is up to date. Skipping generation.") - return "SKIPPED", "characters.json is already up to date." - - # 4. Run scraper if needed - print("[Python] Local data is outdated or missing. Running scraper...") - success, message = character_scraper.scrape_and_save(output_dir, latest_version) - + success, status, version, _index = _refresh_metadata(output_dir, "HD") if success: - return "SUCCESS", message - else: - return "FAILED", message - + if status == "SKIPPED": + return "SKIPPED", "characters.json and asset index are already up to date." + return "SUCCESS", f"Metadata refreshed for version {version}." + return "FAILED", status except Exception as e: import traceback error_message = traceback.format_exc() - print(f"An error occurred during scraping process: {error_message}") + print(f"An error occurred during metadata refresh process: {error_message}") return "FAILED", error_message @@ -159,7 +278,6 @@ def main(original_bundle_path: str, modded_assets_folder: str, output_path: str, Returns a tuple: (success: Boolean, message: String) """ try: - # The progress callback will handle printing, so we can remove the print statements here. success, message = repack_bundle( original_bundle_path=original_bundle_path, modded_assets_folder=modded_assets_folder, @@ -167,16 +285,17 @@ def main(original_bundle_path: str, modded_assets_folder: str, output_path: str, use_astc=use_astc, progress_callback=progress_callback ) - + print(message) return success, message - + except Exception as e: import traceback error_message = traceback.format_exc() print(f"An error occurred: {error_message}") return False, error_message + def merge_spine_assets(mod_dir_path: str, progress_callback=None): """ Entry point for Kotlin to run the spine merger script. diff --git a/app/src/main/python/repacker/repacker.py b/app/src/main/python/repacker/repacker.py index bf6dd745e..1078e5ac7 100644 --- a/app/src/main/python/repacker/repacker.py +++ b/app/src/main/python/repacker/repacker.py @@ -256,6 +256,7 @@ def sort_key(filename): from utils.file_operations import find_file_case_insensitive + def compress_image_astc(image_bytes, width, height, block_x, block_y): astcenc = _load_astcenc_library() if not astcenc: @@ -370,6 +371,13 @@ def _compress_texture_worker(args): return result +def _asset_objects(asset_map, target_asset_name: str, type_name: str = None): + objects = list(asset_map.get(target_asset_name, [])) + if type_name: + objects = [obj for obj in objects if obj.type.name == type_name] + return objects + + def repack_bundle(original_bundle_path: str, modded_assets_folder: str, output_path: str, use_astc: bool, progress_callback=None): """ Repack a unity bundle with modded assets. @@ -442,8 +450,9 @@ def report_progress(message): for obj in env.objects: try: data = obj.read() - if hasattr(data, 'm_Name'): - asset_map[data.m_Name.lower()] = obj + if hasattr(data, 'm_Name') and data.m_Name: + key = data.m_Name.lower() + asset_map.setdefault(key, []).append(obj) except Exception: pass # Skip objects that can't be read @@ -465,24 +474,20 @@ def report_progress(message): if mod_filename.lower().endswith('.json'): base_name, _ = os.path.splitext(mod_filename) target_asset_name = (base_name + ".skel").lower() - if target_asset_name in asset_map: + if _asset_objects(asset_map, target_asset_name, "TextAsset"): json_files.append((mod_filepath, target_asset_name)) elif mod_filename.lower().endswith('.png'): target_asset_name = os.path.splitext(mod_filename)[0].lower() - if target_asset_name in asset_map: - obj = asset_map[target_asset_name] - if obj.type.name == "Texture2D": - if use_astc: - png_astc_files.append((mod_filepath, target_asset_name)) - else: - png_rgba_files.append((mod_filepath, target_asset_name)) + if _asset_objects(asset_map, target_asset_name, "Texture2D"): + if use_astc: + png_astc_files.append((mod_filepath, target_asset_name)) + else: + png_rgba_files.append((mod_filepath, target_asset_name)) else: target_asset_name = mod_filename.lower() - if target_asset_name in asset_map: - obj = asset_map[target_asset_name] - if obj.type.name == "TextAsset": - text_files.append((mod_filepath, target_asset_name)) + if _asset_objects(asset_map, target_asset_name, "TextAsset"): + text_files.append((mod_filepath, target_asset_name)) report_progress(f" - JSON animations: {len(json_files)}") report_progress(f" - ASTC textures: {len(png_astc_files)}") @@ -499,8 +504,7 @@ def report_progress(message): try: report_progress(f"{current_progress}Converting animation: {mod_filename}") - obj = asset_map[target_asset_name] - data = obj.read() + target_objects = _asset_objects(asset_map, target_asset_name, "TextAsset") with tempfile.NamedTemporaryFile(delete=False, suffix=".skel") as tmp_skel_file: temp_skel_path = tmp_skel_file.name @@ -509,11 +513,13 @@ def report_progress(message): json_to_skel(mod_filepath, temp_skel_path) with open(temp_skel_path, 'rb') as f: skel_binary_data = f.read() - - data.m_Script = skel_binary_data.decode("utf-8", "surrogateescape") - data.save() - edited = True - report_progress(f"{current_progress}Successfully replaced: {mod_filename}") + + for obj in target_objects: + data = obj.read() + data.m_Script = skel_binary_data.decode("utf-8", "surrogateescape") + data.save() + edited = True + report_progress(f"{current_progress}Successfully replaced: {mod_filename} -> {len(target_objects)} object(s)") finally: if os.path.exists(temp_skel_path): os.remove(temp_skel_path) @@ -529,12 +535,15 @@ def report_progress(message): try: report_progress(f"{current_progress}Replacing asset: {mod_filename}") - obj = asset_map[target_asset_name] - data = obj.read() + target_objects = _asset_objects(asset_map, target_asset_name, "TextAsset") with open(mod_filepath, "rb") as f: - data.m_Script = f.read().decode("utf-8", "surrogateescape") - data.save() - edited = True + new_script = f.read().decode("utf-8", "surrogateescape") + + for obj in target_objects: + data = obj.read() + data.m_Script = new_script + data.save() + edited = True except Exception as e: import traceback @@ -583,24 +592,25 @@ def report_progress(message): # 立即寫入 Bundle target_asset_name = result['target_asset_name'] try: - obj = asset_map[target_asset_name] - data = obj.read() - - data.m_TextureFormat = 48 # ASTC_RGB_4x4 - data.image_data = result['compressed_data'] - data.m_CompleteImageSize = len(result['compressed_data']) - data.m_Width = result['width'] - data.m_Height = result['height'] - data.m_MipCount = 1 - - if hasattr(data, 'm_StreamData'): - data.m_StreamData.offset = 0 - data.m_StreamData.size = 0 - data.m_StreamData.path = "" - - data.save() - edited = True - total_success += 1 + target_objects = _asset_objects(asset_map, target_asset_name, "Texture2D") + for obj in target_objects: + data = obj.read() + + data.m_TextureFormat = 48 # ASTC_RGB_4x4 + data.image_data = result['compressed_data'] + data.m_CompleteImageSize = len(result['compressed_data']) + data.m_Width = result['width'] + data.m_Height = result['height'] + data.m_MipCount = 1 + + if hasattr(data, 'm_StreamData'): + data.m_StreamData.offset = 0 + data.m_StreamData.size = 0 + data.m_StreamData.path = "" + + data.save() + edited = True + total_success += len(target_objects) except Exception as e: total_failed += 1 @@ -635,21 +645,22 @@ def report_progress(message): target_asset_name = result['target_asset_name'] mod_filename = os.path.basename(result['mod_filepath']) try: - obj = asset_map[target_asset_name] - data = obj.read() - data.m_TextureFormat = 48 - data.image_data = result['compressed_data'] - data.m_CompleteImageSize = len(result['compressed_data']) - data.m_Width = result['width'] - data.m_Height = result['height'] - data.m_MipCount = 1 - if hasattr(data, 'm_StreamData'): - data.m_StreamData.offset = 0 - data.m_StreamData.size = 0 - data.m_StreamData.path = "" - data.save() - edited = True - total_success += 1 + target_objects = _asset_objects(asset_map, target_asset_name, "Texture2D") + for obj in target_objects: + data = obj.read() + data.m_TextureFormat = 48 + data.image_data = result['compressed_data'] + data.m_CompleteImageSize = len(result['compressed_data']) + data.m_Width = result['width'] + data.m_Height = result['height'] + data.m_MipCount = 1 + if hasattr(data, 'm_StreamData'): + data.m_StreamData.offset = 0 + data.m_StreamData.size = 0 + data.m_StreamData.path = "" + data.save() + edited = True + total_success += len(target_objects) except Exception as e: total_failed += 1 else: @@ -679,23 +690,24 @@ def report_progress(message): pil_img = None try: report_progress(f"{current_progress}Processing: {mod_filename}") - obj = asset_map[target_asset_name] - data = obj.read() - + target_objects = _asset_objects(asset_map, target_asset_name, "Texture2D") pil_img = Image.open(mod_filepath).convert("RGBA") - - data.m_TextureFormat = 4 # RGBA32 - data.image = pil_img - - data.m_MipCount = 1 - - if hasattr(data, 'm_StreamData'): - data.m_StreamData.offset = 0 - data.m_StreamData.size = 0 - data.m_StreamData.path = "" - - data.save() - edited = True + + for obj in target_objects: + data = obj.read() + + data.m_TextureFormat = 4 # RGBA32 + data.image = pil_img + + data.m_MipCount = 1 + + if hasattr(data, 'm_StreamData'): + data.m_StreamData.offset = 0 + data.m_StreamData.size = 0 + data.m_StreamData.path = "" + + data.save() + edited = True except Exception as e: import traceback diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py new file mode 100644 index 000000000..9e7dcf148 --- /dev/null +++ b/app/src/main/python/resolver.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +import re +from pathlib import Path + +from catalog_indexer import normalize_filename + + +def resolve_mod_folder(mod_file_names, asset_index): + unresolved_files = [] + file_matches = [] + + assets_by_base = (asset_index or {}).get('assetsByBaseName', {}) + assets_by_exact = (asset_index or {}).get('assetsByExactKey', {}) + + for file_name in mod_file_names or []: + base_name = Path(file_name).name + lowered_full = (file_name or '').replace('\\', '/').lower() + candidates = _expand_candidates(base_name) + matches = [] + + exact_match = assets_by_exact.get(lowered_full) + if exact_match: + exact_hits = _decode_hits(asset_index, exact_match) + exact_hits = _prefer_primary_hits(exact_hits) + for hit in exact_hits: + matches.append(_build_match(base_name, candidates, hit, 'EXACT', 1.0)) + + for candidate in candidates: + hits = assets_by_base.get(candidate.lower()) or [] + hits = _decode_hits(asset_index, hits) + hits = _prefer_primary_hits(hits) + strategy = 'EXACT' if candidate.lower() == base_name.lower() else 'EXTENSION_MAPPING' + confidence = 1.0 if strategy == 'EXACT' else 0.9 + for hit in hits: + matches.append(_build_match(base_name, candidates, hit, strategy, confidence)) + + matches = _dedupe_matches(matches) + + if matches: + file_matches.append({ + 'fileName': base_name, + 'candidates': candidates, + 'matches': matches, + 'targetHashes': {m.get('targetHash') for m in matches if m.get('targetHash')} + }) + else: + unresolved_files.append(base_name) + + candidate_sets = [entry['targetHashes'] for entry in file_matches if entry['targetHashes']] + + if not candidate_sets: + return { + 'targetHash': None, + 'resolvedFamilyKey': None, + 'resolvedTargets': [], + 'unresolvedFiles': unresolved_files, + 'resolutionState': 'UNKNOWN', + 'errorReason': 'No matching target could be resolved' + } + + intersection = set(candidate_sets[0]) + for target_set in candidate_sets[1:]: + intersection &= target_set + + union = set() + for target_set in candidate_sets: + union |= target_set + + if len(intersection) == 1: + target_hash = next(iter(intersection)) + elif len(union) == 1: + target_hash = next(iter(union)) + else: + return { + 'targetHash': None, + 'resolvedFamilyKey': None, + 'resolvedTargets': _select_representative_matches(file_matches, None), + 'unresolvedFiles': unresolved_files, + 'resolutionState': 'INVALID', + 'errorReason': 'Multiple targets detected in one mod folder' + } + + resolved_targets = _select_representative_matches(file_matches, target_hash) + family_keys = { + _normalize_family_key(match.get('familyKey')) + for match in resolved_targets + if _normalize_family_key(match.get('familyKey')) + } + + return { + 'targetHash': target_hash, + 'resolvedFamilyKey': next(iter(family_keys)) if len(family_keys) == 1 else None, + 'resolvedTargets': resolved_targets, + 'unresolvedFiles': unresolved_files, + 'resolutionState': 'KNOWN', + 'errorReason': None + } + + +def _expand_candidates(base_name: str): + candidates = list(dict.fromkeys(normalize_filename(base_name))) + + bridge_key = _extract_sactx_bridge_key(base_name) + if bridge_key: + lowered = bridge_key.lower() + candidates.extend([ + lowered, + f"{lowered}.spriteatlasv2" + ]) + + return list(dict.fromkeys(candidates)) + + +def _extract_sactx_bridge_key(file_name: str): + lowered = (file_name or '').strip().lower() + if not lowered.startswith('sactx-'): + return None + + stem = Path(lowered).stem + match = re.search(r'-(localpacktitle\d+_[a-z0-9]+)-[0-9a-f]{6,}$', stem, re.IGNORECASE) + if not match: + return None + + return match.group(1) + + +def _decode_hits(asset_index, raw_hits): + if raw_hits is None: + return [] + if not isinstance(raw_hits, list): + raw_hits = [raw_hits] + + decoded = [] + for item in raw_hits: + hit = _decode_hit(asset_index, item) + if hit: + decoded.append(hit) + return decoded + + +def _decode_hit(asset_index, raw_hit): + if isinstance(raw_hit, dict): + return raw_hit + if not isinstance(raw_hit, int): + return None + + records = (asset_index or {}).get('records') or [] + strings = (asset_index or {}).get('strings') or [] + if raw_hit < 0 or raw_hit >= len(records): + return None + + record = records[raw_hit] + if not isinstance(record, list) or len(record) < 3: + return None + + asset_key = _string_at(strings, record[0]) + target_hash = _string_at(strings, record[1]) + family_key = _string_at(strings, record[2]) + + return { + 'assetKey': asset_key, + 'bundleName': None, + 'targetHash': target_hash, + 'familyKey': family_key + } + + +def _string_at(strings, index): + if index is None or index < 0 or index >= len(strings): + return None + return strings[index] + + +def _prefer_primary_hits(hits): + if not hits: + return [] + + primary_hits = [hit for hit in hits if '/censorship/' not in (hit.get('assetKey') or '')] + return primary_hits if primary_hits else hits + + +def _build_match(base_name: str, candidates, matched, strategy: str, confidence: float): + target_hash = matched.get('targetHash') + family_key = _normalize_family_key(matched.get('familyKey')) + return { + 'originalFileName': base_name, + 'normalizedCandidates': candidates, + 'resolvedAssetKey': matched.get('assetKey'), + 'resolvedBundleName': matched.get('bundleName'), + 'resolvedBundlePath': None, + 'assetType': _infer_asset_type(base_name), + 'targetHash': target_hash, + 'familyKey': family_key, + 'matchStrategy': strategy, + 'confidence': confidence + } + + +def _dedupe_matches(matches): + deduped = [] + seen = set() + for match in matches: + key = ( + match.get('resolvedAssetKey'), + match.get('targetHash'), + match.get('familyKey'), + match.get('matchStrategy') + ) + if key in seen: + continue + seen.add(key) + deduped.append(match) + return deduped + + +def _select_representative_matches(file_matches, target_hash): + resolved_targets = [] + for entry in file_matches: + matches = entry['matches'] + chosen = None + + if target_hash: + matching_target = [m for m in matches if m.get('targetHash') == target_hash] + chosen = _prefer_best_match(matching_target) + else: + chosen = _prefer_best_match(matches) + + if chosen: + resolved_targets.append(chosen) + + return resolved_targets + + +def _prefer_best_match(matches): + if not matches: + return None + + strategy_rank = { + 'EXACT': 0, + 'EXTENSION_MAPPING': 1, + 'NONE': 2 + } + + return sorted( + matches, + key=lambda m: ( + strategy_rank.get(m.get('matchStrategy'), 99), + m.get('resolvedAssetKey') or '' + ) + )[0] + + +def _infer_asset_type(file_name: str): + lowered = (file_name or '').lower() + if lowered.endswith('.png'): + return 'Texture2D' + if lowered.endswith('.atlas') or lowered.endswith('.atlas.txt') or lowered.endswith('.skel') or lowered.endswith('.skel.txt') or lowered.endswith('.skel.bytes'): + return 'TextAsset' + if lowered.endswith('.json'): + return 'JsonSkeleton' + return 'Unknown' + + +def _normalize_family_key(value: str): + if not value: + return None + lowered = value.lower() + lowered = re.sub(r'_(\d+)$', '', lowered) + return lowered diff --git a/app/src/main/python/unpacker.py b/app/src/main/python/unpacker.py index 7f78c049d..3741cd305 100644 --- a/app/src/main/python/unpacker.py +++ b/app/src/main/python/unpacker.py @@ -142,6 +142,18 @@ def decompress_astc_ctypes(image_data, width, height, block_x, block_y): 51: (8, 8), 52: (10, 10), 53: (12, 12), } + +def _build_unique_export_path(output_dir, base_name, extension, path_id): + safe_name = (base_name or 'unnamed').replace('/', '_') + filename = f"{safe_name}{extension}" + dest_path = os.path.join(output_dir, filename) + if not os.path.exists(dest_path): + return dest_path + + duplicate_filename = f"{safe_name} #{path_id}{extension}" + return os.path.join(output_dir, duplicate_filename) + + def unpack_bundle(bundle_path, output_dir, progress_callback=print): progress_callback(f"Starting to unpack '{os.path.basename(bundle_path)}'...") @@ -169,15 +181,14 @@ def unpack_bundle(bundle_path, output_dir, progress_callback=print): continue dest_name = data.m_Name.replace('/', '_') - dest_path = os.path.join(output_dir, dest_name) - + path_id = getattr(obj, 'path_id', 'dup') + current_progress = f"Processing asset {i+1}/{total_objects}: {dest_name}" progress_callback(current_progress) if obj.type.name == "Texture2D": - if not dest_path.lower().endswith((".png", ".jpg", ".jpeg")): - dest_path += ".png" - + dest_path = _build_unique_export_path(output_dir, dest_name, ".png", path_id) + img = None try: img = data.image @@ -190,6 +201,8 @@ def unpack_bundle(bundle_path, output_dir, progress_callback=print): # Handle both TextAsset and MonoBehaviour .skel files if obj.type.name == "MonoBehaviour" and ".skel" not in dest_name.lower(): continue # Skip non-skel MonoBehaviours + + dest_path = _build_unique_export_path(output_dir, dest_name, "", path_id) with open(dest_path, "wb") as f: content = data.m_Script