diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8415ebfa..a79dfcc7 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -5,4 +5,12 @@ -keep class com.chaquo.python.runtime.** { *; } # Shizuku --keep class com.example.bd2modmanager.service.ShizukuFileService { *; } \ No newline at end of file +-keep class com.example.bd2modmanager.service.ShizukuFileService { *; } + +# Gson: preserve generic signatures for TypeToken +-keepattributes Signature +-keep class com.google.gson.reflect.TypeToken { *; } +-keep class * extends com.google.gson.reflect.TypeToken + +# Keep data model classes used with Gson serialization +-keep class com.example.bd2modmanager.data.model.** { *; } \ No newline at end of file diff --git a/app/src/main/aidl/com/example/bd2modmanager/IFileService.aidl b/app/src/main/aidl/com/example/bd2modmanager/IFileService.aidl index 12f7e541..38f0d021 100644 --- a/app/src/main/aidl/com/example/bd2modmanager/IFileService.aidl +++ b/app/src/main/aidl/com/example/bd2modmanager/IFileService.aidl @@ -3,5 +3,6 @@ package com.example.bd2modmanager; interface IFileService { boolean copyFile(String sourcePath, String destPath); boolean copyDirectory(String sourceDirPath, String destDirPath); + String listBundleDirectory(String sharedDirPath); void destroy(); } diff --git a/app/src/main/java/com/example/bd2modmanager/MainActivity.kt b/app/src/main/java/com/example/bd2modmanager/MainActivity.kt index 27447a7f..fbb48671 100644 --- a/app/src/main/java/com/example/bd2modmanager/MainActivity.kt +++ b/app/src/main/java/com/example/bd2modmanager/MainActivity.kt @@ -132,6 +132,20 @@ class MainActivity : ComponentActivity() { onDismiss = { viewModel.resetMergeState() } ) } + + val showVersionMismatch by viewModel.showVersionMismatchWarning.collectAsState() + if (showVersionMismatch) { + VersionMismatchWarningDialog( + onDismiss = { viewModel.dismissVersionMismatchWarning() } + ) + } + + val bundleScanState by viewModel.bundleScanState.collectAsState() + BundleScanDialog( + state = bundleScanState, + onConfirm = { viewModel.confirmBundleScan() }, + onDismiss = { viewModel.dismissBundleScan() } + ) } } } 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 820ce6d4..3d8faa39 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 @@ -13,6 +13,7 @@ enum class MatchStrategy { EXACT, NORMALIZED, EXTENSION_MAPPING, + LOCAL_SCAN, FALLBACK, NONE } @@ -123,3 +124,27 @@ sealed class MergeState { data class Finished(val message: String) : MergeState() data class Failed(val error: String) : MergeState() } + +sealed class BundleScanState { + object Idle : BundleScanState() + data class Confirmation(val bundleCount: Int) : BundleScanState() + data class Scanning( + val currentIndex: Int, + val totalCount: Int, + val currentBundle: String, + val progressMessage: String = "" + ) : BundleScanState() + data class Finished( + val scannedCount: Int, + val failedCount: Int, + val message: String + ) : BundleScanState() + data class Failed(val error: String) : BundleScanState() +} + +data class BundleCheckResult( + val bundleListJson: String, + val needsScanJson: String, + val hashMap: Map, + val needsScanCount: Int +) 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 537186cd..05019406 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 @@ -17,20 +17,26 @@ class CharacterRepository(private val context: Context) { private var characterLut: Map> = emptyMap() - suspend fun updateCharacterData(): Boolean { + /** + * Returns the update status: "SUCCESS", "SKIPPED", or "FAILED". + * - "SUCCESS" means characters.json was regenerated with a new version. + * - "SKIPPED" means the local version was already up to date. + * - "FAILED" means the update attempt failed. + */ + suspend fun updateCharacterData(quality: String): String { return withContext(Dispatchers.IO) { - var success = false + var status = "FAILED" 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 result = mainScript.callAttr("update_character_data", context.filesDir.absolutePath, quality).asList() + val resultStatus = result[0].toString() // SUCCESS, SKIPPED, FAILED val message = result[1].toString() - when (status) { + when (resultStatus) { "SUCCESS" -> { println("Successfully ran scraper and saved characters.json: $message") // When a new characters.json is generated, the mod cache becomes invalid. @@ -39,27 +45,31 @@ class CharacterRepository(private val context: Context) { cacheFile.delete() println("Deleted mod cache to force re-scan.") } - success = true + status = "SUCCESS" } "SKIPPED" -> { println("Scraper skipped: $message") - success = true + status = "SKIPPED" } "FAILED" -> { println("Scraper script failed: $message. Will use local version if available.") - success = false + status = "FAILED" } } } catch (e: Exception) { e.printStackTrace() println("Failed to execute scraper python script, will use local version. Error: ${e.message}") - success = false + status = "FAILED" } characterLut = parseCharacterJson() - success + status } } + fun hasLocalCharactersJson(): Boolean { + return File(context.filesDir, CHARACTERS_JSON_FILENAME).exists() + } + private suspend fun parseCharacterJson(): Map> { return withContext(Dispatchers.IO) { val lut = mutableMapOf>() 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 630fbb76..40573b24 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 @@ -2,7 +2,7 @@ package com.example.bd2modmanager.data.repository import android.content.Context import android.net.Uri -import androidx.documentfile.provider.DocumentFile +import android.provider.DocumentsContract import com.chaquo.python.Python import com.example.bd2modmanager.data.model.MatchStrategy import com.example.bd2modmanager.data.model.ModCacheInfo @@ -51,12 +51,36 @@ class ModRepository( 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() + // Single ContentResolver query replaces DocumentFile.listFiles() + per-file queries. + // DocumentFile does O(n*k) queries; this does O(1) regardless of file count. + val treeDocId = DocumentsContract.getTreeDocumentId(dirUri) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(dirUri, treeDocId) + val projection = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED + ) + + context.contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + val modifiedCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + + while (cursor.moveToNext()) { + val documentId = cursor.getString(idCol) ?: continue + val displayName = cursor.getString(nameCol) ?: continue + val mimeType = cursor.getString(mimeCol) ?: "" + val lastModified = cursor.getLong(modifiedCol) + + val isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR + val isZip = displayName.endsWith(".zip", ignoreCase = true) + if (!isDirectory && !isZip) continue + + val fileUri = DocumentsContract.buildDocumentUriUsingTree(dirUri, documentId) + val uriString = fileUri.toString() val cachedInfo = existingCache[uriString] if (cachedInfo != null && cachedInfo.lastModified == lastModified) { @@ -68,7 +92,7 @@ class ModRepository( costume = cachedInfo.costume, type = cachedInfo.type, isEnabled = false, - uri = file.uri, + uri = fileUri, targetHashedName = cachedInfo.targetHashedName, isDirectory = cachedInfo.isDirectory, resolutionState = cachedInfo.resolutionState, @@ -79,25 +103,25 @@ class ModRepository( ) ) } else { - val modName = file.name?.removeSuffix(".zip") ?: "" - val isDirectory = file.isDirectory + val modName = displayName.removeSuffix(".zip") val modDetails = if (isDirectory) { - extractModDetailsFromDirectory(file.uri) + extractModDetailsFromDirectory(fileUri) } else { - extractModDetailsFromUri(file.uri) + extractModDetailsFromUri(fileUri) } candidates.add( ScannedModCandidate( uriString = uriString, lastModified = lastModified, name = modName, - uri = file.uri, + uri = fileUri, isDirectory = isDirectory, modDetails = modDetails ) ) } } + } if (candidates.isNotEmpty()) { if (!Python.isStarted()) { @@ -113,10 +137,13 @@ class ModRepository( } } + val prefs = context.getSharedPreferences("app_settings", android.content.Context.MODE_PRIVATE) + val selectedQuality = prefs.getString("selected_quality", "HD") ?: "HD" + val (batchSuccess, batchResults) = ModdingService.resolveModBatch( batchPayload.toString(), context.filesDir.absolutePath, - "HD" + selectedQuality ) { } val resultsById = mutableMapOf() @@ -217,6 +244,12 @@ class ModRepository( val cacheFile = getModCacheFile() if (!cacheFile.exists()) return emptyMap() + val indexFile = File(context.filesDir, "local_bundle_index.json") + if (indexFile.exists() && indexFile.lastModified() > cacheFile.lastModified()) { + // Unify: If the bundle index updated, all mod cache entries are potentially stale + return emptyMap() + } + return try { val json = cacheFile.readText() val type = object : TypeToken>() {}.type @@ -263,12 +296,24 @@ class ModRepository( val fileNames = mutableListOf() var fileId: String? = null try { - DocumentFile.fromTreeUri(context, dirUri)?.listFiles()?.forEach { file -> - val entryName = file.name ?: "" - if (shouldIgnoreModEntry(entryName)) return@forEach - fileNames.add(entryName) - if (file.isFile) { - if (fileId == null) fileId = characterRepository.extractFileId(entryName) + val docId = DocumentsContract.getDocumentId(dirUri) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(dirUri, docId) + val projection = arrayOf( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + context.contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor -> + val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + while (cursor.moveToNext()) { + val entryName = cursor.getString(nameCol) ?: continue + val mimeType = cursor.getString(mimeCol) ?: "" + if (shouldIgnoreModEntry(entryName)) continue + fileNames.add(entryName) + val isFile = mimeType != DocumentsContract.Document.MIME_TYPE_DIR + if (isFile && fileId == null) { + fileId = characterRepository.extractFileId(entryName) + } } } } catch (e: Exception) { 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 387e1e29..176686c3 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ModdingService.kt @@ -139,4 +139,68 @@ object ModdingService { Pair(false, e.message ?: "An unknown error occurred in Kotlin during spine merge.") } } + + // --- Local bundle scanning (three-step API) --- + + fun checkScanNeeded(outputDir: String, bundleListJson: String, onProgress: (String) -> Unit): String { + return try { + val py = Python.getInstance() + val mainScript = py.getModule("main_script") + + val result = mainScript.callAttr( + "check_scan_needed", + outputDir, + bundleListJson, + PyObject.fromJava(onProgress) + ).toString() + + result + } catch (e: Exception) { + e.printStackTrace() + "[]" + } + } + + fun scanSingleBundle(bundleName: String, bundleHash: String, tempDataPath: String, onProgress: (String) -> Unit): Triple { + return try { + val py = Python.getInstance() + val mainScript = py.getModule("main_script") + + val result = mainScript.callAttr( + "scan_single_bundle", + bundleName, + bundleHash, + tempDataPath, + PyObject.fromJava(onProgress) + ).asList() + + val success = result[0].toBoolean() + val assetCount = result[1].toInt() + val message = result[2].toString() + Triple(success, assetCount, message) + } catch (e: Exception) { + e.printStackTrace() + Triple(false, 0, e.message ?: "Unknown error during bundle scan.") + } + } + + fun finalizeScan(outputDir: String, onProgress: (String) -> Unit): Pair { + return try { + val py = Python.getInstance() + val mainScript = py.getModule("main_script") + + val result = mainScript.callAttr( + "finalize_scan", + outputDir, + PyObject.fromJava(onProgress) + ).asList() + + val success = result[0].toBoolean() + val message = result[1].toString() + Pair(success, message) + } catch (e: Exception) { + e.printStackTrace() + Pair(false, e.message ?: "Unknown error during scan finalization.") + } + } } diff --git a/app/src/main/java/com/example/bd2modmanager/service/ShizukuFileService.kt b/app/src/main/java/com/example/bd2modmanager/service/ShizukuFileService.kt index 4df84315..a21630ef 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ShizukuFileService.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ShizukuFileService.kt @@ -4,6 +4,8 @@ import android.content.Context import com.example.bd2modmanager.IFileService import java.io.File import android.util.Log +import org.json.JSONArray +import org.json.JSONObject class ShizukuFileService(context: Context? = null) : IFileService.Stub() { @@ -59,6 +61,48 @@ class ShizukuFileService(context: Context? = null) : IFileService.Stub() { } } + /** + * List all bundles in the game's Shared/ directory. + * Returns a JSON array string: [{"name":"bundleName","hash":"hashDirName"}, ...] + * + * Expected directory structure: + * Shared/{bundleName}/{hash}/__data + */ + override fun listBundleDirectory(sharedDirPath: String): String { + return try { + val sharedDir = File(sharedDirPath) + if (!sharedDir.isDirectory) { + Log.w("ShizukuFileService", "Shared directory does not exist: $sharedDirPath") + return "[]" + } + + val result = JSONArray() + sharedDir.listFiles()?.forEach { bundleDir -> + if (!bundleDir.isDirectory) return@forEach + val bundleName = bundleDir.name + + // Find the first hash subdirectory containing __data + bundleDir.listFiles()?.forEach inner@{ hashDir -> + if (!hashDir.isDirectory) return@inner + val dataFile = File(hashDir, "__data") + if (dataFile.isFile) { + result.put(JSONObject().apply { + put("name", bundleName) + put("hash", hashDir.name) + }) + return@forEach // Only take the first valid hash directory + } + } + } + + Log.d("ShizukuFileService", "Listed ${result.length()} bundles from $sharedDirPath") + result.toString() + } catch (e: Exception) { + Log.e("ShizukuFileService", "Error listing bundle directory $sharedDirPath", e) + "[]" + } + } + override fun destroy() { System.exit(0) } diff --git a/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt b/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt index f0d99510..b94768c9 100644 --- a/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt +++ b/app/src/main/java/com/example/bd2modmanager/service/ShizukuManager.kt @@ -5,6 +5,7 @@ import android.content.ServiceConnection import android.content.pm.PackageManager import android.os.IBinder import com.example.bd2modmanager.IFileService +import com.example.bd2modmanager.data.model.BundleCheckResult import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -13,6 +14,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import rikka.shizuku.Shizuku import android.util.Log +import org.json.JSONArray +import java.io.File object ShizukuManager { @@ -20,6 +23,8 @@ object ShizukuManager { "/storage/emulated/0/Android/data/com.neowizgames.game.browndust2/files/UnityCache/" private const val DOWNLOAD_SHARED_PATH = "/storage/emulated/0/Download/Shared" + private const val GAME_SHARED_PATH = + "/storage/emulated/0/Android/data/com.neowizgames.game.browndust2/files/UnityCache/Shared" private var fileService: IFileService? = null private val bindMutex = Mutex() @@ -168,6 +173,172 @@ object ShizukuManager { } } + /** + * Phase 1: 列舉遊戲目錄並檢查哪些 bundle 需要掃描。 + * 不執行實際掃描,只回傳結果讓呼叫端決定是否繼續。 + * + * @param outputDir App 可寫入的目錄(通常是 context.filesDir) + * @param onProgress 進度回報 + * @return BundleCheckResult? — null 表示失敗或無 bundle + */ + suspend fun checkLocalBundles( + outputDir: String, + onProgress: (String) -> Unit + ): BundleCheckResult? = withContext(Dispatchers.IO) { + try { + if (!isAvailable()) { + onProgress("Shizuku is not available. Skipping local bundle scan.") + return@withContext null + } + + val service = ensureServiceBound() + if (service == null) { + onProgress("Failed to connect to Shizuku file service.") + return@withContext null + } + + // Step 1: List bundles via Shizuku + onProgress("Listing local game bundles...") + val bundleListJson = service.listBundleDirectory(GAME_SHARED_PATH) + + val bundleList = JSONArray(bundleListJson) + if (bundleList.length() == 0) { + onProgress("No bundles found in game directory.") + return@withContext null + } + + onProgress("Found ${bundleList.length()} bundles. Checking cache...") + + // Step 2: Check which bundles need scanning (Python side) + val needsScanJson = ModdingService.checkScanNeeded(outputDir, bundleListJson, onProgress) + val needsScan = JSONArray(needsScanJson) + + // Build hash lookup from the full bundle list + val hashMap = mutableMapOf() + for (i in 0 until bundleList.length()) { + val obj = bundleList.getJSONObject(i) + hashMap[obj.getString("name")] = obj.getString("hash") + } + + BundleCheckResult( + bundleListJson = bundleListJson, + needsScanJson = needsScanJson, + hashMap = hashMap, + needsScanCount = needsScan.length() + ) + } catch (e: Exception) { + Log.e("ShizukuManager", "Error checking local bundles", e) + onProgress("Error: ${e.message}") + null + } + } + + /** + * Phase 2: 執行實際的 bundle 掃描並儲存索引。 + * 應在使用者確認後呼叫。 + * + * @param outputDir App 可寫入的目錄 + * @param cacheDir App 的暫存目錄 + * @param checkResult Phase 1 回傳的結果 + * @param onBundleProgress 每個 bundle 掃描時的進度 callback + * @return Triple<成功, 掃描數量, 失敗數量> + */ + suspend fun executeBundleScan( + outputDir: String, + cacheDir: String, + checkResult: BundleCheckResult, + onBundleProgress: (currentIndex: Int, total: Int, bundleName: String, message: String) -> Unit + ): Triple = withContext(Dispatchers.IO) { + try { + val service = ensureServiceBound() + ?: return@withContext Triple(false, 0, 0) + + val needsScan = JSONArray(checkResult.needsScanJson) + val total = needsScan.length() + + if (total == 0) { + // Nothing to scan, just finalize with cached data + ModdingService.finalizeScan(outputDir) { } + return@withContext Triple(true, 0, 0) + } + + val tempDir = File(cacheDir, "scan_temp") + tempDir.mkdirs() + val tempDataFile = File(tempDir, "__data") + + var scannedCount = 0 + var failedCount = 0 + + for (i in 0 until total) { + val bundleName = needsScan.getString(i) + val bundleHash = checkResult.hashMap[bundleName] ?: continue + + onBundleProgress(i, total, bundleName, "Copying $bundleName...") + + // Copy __data from game dir to temp via Shizuku + val gamePath = "$GAME_SHARED_PATH/$bundleName/$bundleHash/__data" + val copySuccess = service.copyFile(gamePath, tempDataFile.absolutePath) + + if (!copySuccess) { + Log.w("ShizukuManager", "Failed to copy bundle $bundleName, skipping") + failedCount++ + continue + } + + onBundleProgress(i, total, bundleName, "Scanning $bundleName...") + + // Scan via Python + val (scanSuccess, _, _) = ModdingService.scanSingleBundle( + bundleName, bundleHash, tempDataFile.absolutePath + ) { msg -> onBundleProgress(i, total, bundleName, msg) } + + if (scanSuccess) scannedCount++ else failedCount++ + + // Clean up temp file immediately + tempDataFile.delete() + } + + // Clean up temp directory + tempDir.deleteRecursively() + + // Finalize and save index + onBundleProgress(total, total, "", "Saving index...") + val (finalSuccess, _) = ModdingService.finalizeScan(outputDir) { msg -> + onBundleProgress(total, total, "", msg) + } + + Triple(finalSuccess, scannedCount, failedCount) + } catch (e: Exception) { + Log.e("ShizukuManager", "Error scanning local bundles", e) + Triple(false, 0, 0) + } + } + + /** + * 便捷方法:一次完成列舉 + 掃描 + 儲存。 + * 保留向後兼容,內部使用分段 API。 + */ + suspend fun scanLocalBundles( + outputDir: String, + cacheDir: String, + onProgress: (String) -> Unit + ): Pair = withContext(Dispatchers.IO) { + val checkResult = checkLocalBundles(outputDir, onProgress) + ?: return@withContext Pair(false, "Failed to check local bundles.") + + if (checkResult.needsScanCount == 0) { + onProgress("All bundles are up to date. Finalizing...") + return@withContext ModdingService.finalizeScan(outputDir, onProgress) + } + + onProgress("${checkResult.needsScanCount} bundles need scanning...") + val (success, scanned, failed) = executeBundleScan(outputDir, cacheDir, checkResult) { idx, total, name, msg -> + onProgress("Scanning ${idx + 1}/$total: $name - $msg") + } + + Pair(success, "Scanned: $scanned, Failed: $failed") + } + /** * 解綁 UserService */ 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 e393b9c8..49559883 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 @@ -525,3 +525,182 @@ fun MergeSpineDialog(state: MergeState, onDismiss: () -> Unit) { dismissButton = null ) } + +@Composable +fun VersionMismatchWarningDialog(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.Warning, contentDescription = "Warning", tint = MaterialTheme.colorScheme.error) }, + title = { Text("Game Resources Need Update") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text( + "A new game version has been detected, but the local game resources have not been updated yet.", + style = MaterialTheme.typography.bodyMedium + ) + Text( + "Please update the game and launch it at least once to download the latest resources, then restart this app.", + style = MaterialTheme.typography.bodyMedium + ) + } + }, + confirmButton = { + Button(onClick = onDismiss) { + Text("OK") + } + }, + dismissButton = null + ) +} + +@Composable +fun BundleScanDialog( + state: BundleScanState, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + if (state is BundleScanState.Idle) return + + AlertDialog( + onDismissRequest = { + if (state !is BundleScanState.Scanning) { + onDismiss() + } + }, + icon = { + when (state) { + is BundleScanState.Confirmation -> Icon(Icons.Default.FindInPage, contentDescription = "Scan") + is BundleScanState.Scanning -> Icon(Icons.Default.Search, contentDescription = "Scanning") + is BundleScanState.Finished -> Icon(Icons.Default.CheckCircle, contentDescription = "Done") + is BundleScanState.Failed -> Icon(Icons.Default.Error, contentDescription = "Error") + else -> {} + } + }, + title = { + val text = when (state) { + is BundleScanState.Confirmation -> "Bundle Scan Required" + is BundleScanState.Scanning -> "Scanning Bundles..." + is BundleScanState.Finished -> "Scan Complete" + is BundleScanState.Failed -> "Scan Failed" + else -> "" + } + Text(text) + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + when (state) { + is BundleScanState.Confirmation -> { + Text( + "${state.bundleCount} new or updated bundles have been detected and need to be scanned.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + "This process may take a while depending on the number of bundles. You can skip this and use the cached index instead.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + is BundleScanState.Scanning -> { + Text( + "Scanning ${state.currentIndex + 1} of ${state.totalCount}", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { (state.currentIndex.toFloat()) / state.totalCount.toFloat() }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + if (state.currentBundle.isNotEmpty()) { + Text( + "Scanning: ${state.currentBundle}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } + is BundleScanState.Finished -> { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Success", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.height(16.dp)) + Text( + state.message, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + if (state.failedCount > 0) { + Spacer(Modifier.height(8.dp)) + Text( + "${state.failedCount} bundle(s) failed to scan.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + is BundleScanState.Failed -> { + Icon( + Icons.Default.Error, + contentDescription = "Failed", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.height(16.dp)) + Text( + state.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error + ) + } + else -> {} + } + } + }, + confirmButton = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (state is BundleScanState.Confirmation) { + TextButton(onClick = onDismiss) { + Text("Skip") + } + } else { + Spacer(Modifier.width(1.dp)) + } + when (state) { + is BundleScanState.Confirmation -> { + Button(onClick = onConfirm) { + Text("Start Scan") + } + } + is BundleScanState.Finished, is BundleScanState.Failed -> { + Button(onClick = onDismiss) { + Text("OK") + } + } + else -> {} + } + } + }, + dismissButton = null + ) +} + 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 f4af6ff0..1f156aa3 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 @@ -82,6 +82,35 @@ fun ModScreen( Scaffold( floatingActionButton = { Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) { + val selectedQuality by viewModel.selectedQuality.collectAsState() + var qualityExpanded by remember { mutableStateOf(false) } + + AnimatedVisibility(visible = selectedMods.isNotEmpty()) { + Box { + ExtendedFloatingActionButton( + onClick = { qualityExpanded = true }, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + icon = { Icon(Icons.Default.HighQuality, contentDescription = "Quality") }, + text = { Text(selectedQuality) } + ) + DropdownMenu( + expanded = qualityExpanded, + onDismissRequest = { qualityExpanded = false } + ) { + listOf("SD", "HD", "FHD").forEach { q -> + DropdownMenuItem( + text = { Text(q, fontWeight = if (selectedQuality == q) FontWeight.Bold else FontWeight.Normal) }, + onClick = { + viewModel.setSelectedQuality(q) + qualityExpanded = false + } + ) + } + } + } + } + AnimatedVisibility(visible = selectedMods.size == 1) { ExtendedFloatingActionButton( onClick = onMergeRequest, 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 0bae2912..62a89bb8 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 @@ -7,6 +7,7 @@ import android.content.Intent import android.net.Uri import android.os.Environment import android.provider.MediaStore +import android.util.Log import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -62,7 +63,11 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _selectedMods = MutableStateFlow>(emptySet()) val selectedMods: StateFlow> = _selectedMods.asStateFlow() - val useAstc: StateFlow = savedStateHandle.getStateFlow("use_astc", false) + private val _useAstc = MutableStateFlow(false) + val useAstc: StateFlow = _useAstc.asStateFlow() + + private val _selectedQuality = MutableStateFlow("HD") + val selectedQuality: StateFlow = _selectedQuality.asStateFlow() private val _isSearchActive = MutableStateFlow(false) val isSearchActive: StateFlow = _isSearchActive.asStateFlow() @@ -117,6 +122,16 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( private val _moveState = MutableStateFlow(MoveState.Idle) val moveState: StateFlow = _moveState.asStateFlow() + private val _bundleScanState = MutableStateFlow(BundleScanState.Idle) + val bundleScanState: StateFlow = _bundleScanState.asStateFlow() + + private val _showVersionMismatchWarning = MutableStateFlow(false) + val showVersionMismatchWarning: StateFlow = _showVersionMismatchWarning.asStateFlow() + + // Stored for deferred scan execution after user confirmation + private var pendingCheckResult: BundleCheckResult? = null + private var appContext: Context? = null + private var initialized = false private var scanJob: Job? = null private var pendingScanUri: Uri? = null @@ -125,18 +140,66 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( if (initialized) return initialized = true + appContext = context.applicationContext + val prefs = context.getSharedPreferences("app_settings", Context.MODE_PRIVATE) + _useAstc.value = prefs.getBoolean("use_astc", false) + _selectedQuality.value = prefs.getString("selected_quality", "HD") ?: "HD" + characterRepository = CharacterRepository(context) modRepository = ModRepository(context, characterRepository) viewModelScope.launch { + var requiresDeferredInitialization = false try { _isUpdatingCharacters.value = true - characterRepository.updateCharacterData() - } finally { - _isUpdatingCharacters.value = false + + // Check if local characters.json exists before update + val hadLocalCharacters = characterRepository.hasLocalCharactersJson() + + // Step 1: Update characters.json from CDN (also starts Python runtime) + val updateStatus = characterRepository.updateCharacterData(_selectedQuality.value) + val charactersWereRefreshed = (updateStatus == "SUCCESS" && hadLocalCharacters) + + // Step 2: Check local bundles via Shizuku + // Must run after Step 1 because both use Python, and Python.start() is not thread-safe + if (ShizukuManager.isAvailable()) { + val checkResult = withContext(Dispatchers.IO) { + ShizukuManager.checkLocalBundles( + outputDir = context.filesDir.absolutePath + ) { progress -> + Log.d("MainViewModel", "Bundle check: $progress") + } + } + + if (checkResult != null) { + if (checkResult.needsScanCount > 0) { + // Bundles need scanning — show confirmation dialog + pendingCheckResult = checkResult + _bundleScanState.value = BundleScanState.Confirmation(checkResult.needsScanCount) + requiresDeferredInitialization = true + } else { + // No bundles need scanning — existing local_bundle_index.json + // is already valid from the last session. Skip the expensive + // finalizeScan() which would re-parse the catalog for nothing. + + // If characters.json was just updated but no new bundles, + // the user likely hasn't updated the game yet. + if (charactersWereRefreshed) { + _showVersionMismatchWarning.value = true + } + } + } + } else { + Log.d("MainViewModel", "Shizuku not available, skipping local bundle scan. Using cached index if available.") + } + } catch (e: Exception) { + Log.e("MainViewModel", "Error during initialization", e) } - modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } + if (!requiresDeferredInitialization) { + // Proceed immediately if no scan confirmation is needed + finishInitialization() + } } viewModelScope.launch { @@ -157,7 +220,82 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( } fun setUseAstc(useAstc: Boolean) { - savedStateHandle["use_astc"] = useAstc + _useAstc.value = useAstc + appContext?.getSharedPreferences("app_settings", Context.MODE_PRIVATE)?.edit()?.putBoolean("use_astc", useAstc)?.apply() + } + + fun setSelectedQuality(quality: String) { + _selectedQuality.value = quality + appContext?.getSharedPreferences("app_settings", Context.MODE_PRIVATE)?.edit()?.putString("selected_quality", quality)?.apply() + } + + // --- Bundle Scan Dialog Actions --- + + fun confirmBundleScan() { + val context = appContext ?: return + val checkResult = pendingCheckResult ?: return + + viewModelScope.launch { + try { + // Use externalCacheDir for temp files because Shizuku service runs + // as shell UID and cannot write to app's internal cacheDir (/data/data/...) + val shizukuCacheDir = (context.externalCacheDir ?: context.cacheDir).absolutePath + + val (success, scanned, failed) = withContext(Dispatchers.IO) { + ShizukuManager.executeBundleScan( + outputDir = context.filesDir.absolutePath, + cacheDir = shizukuCacheDir, + checkResult = checkResult + ) { currentIndex, total, bundleName, message -> + viewModelScope.launch(Dispatchers.Main) { + _bundleScanState.value = BundleScanState.Scanning( + currentIndex = currentIndex, + totalCount = total, + currentBundle = bundleName, + progressMessage = message + ) + } + } + } + + if (success) { + _bundleScanState.value = BundleScanState.Finished( + scannedCount = scanned, + failedCount = failed, + message = "Scan complete. $scanned scanned, $failed failed." + ) + } else { + _bundleScanState.value = BundleScanState.Failed("Bundle scan failed.") + } + } catch (e: Exception) { + _bundleScanState.value = BundleScanState.Failed(e.message ?: "Unknown error") + } finally { + pendingCheckResult = null + // Finish initialization and trigger mod rescan with new index + finishInitialization() + } + } + } + + fun dismissBundleScan() { + // User cancelled or dismissed results — don't finalize if cancelled. + val wasPending = pendingCheckResult != null + pendingCheckResult = null + _bundleScanState.value = BundleScanState.Idle + + // If user is skipping the confirmation dialog, we must finish initialization now + if (wasPending) { + finishInitialization() + } + } + + private fun finishInitialization() { + _isUpdatingCharacters.value = false + modSourceDirectoryUri.value?.let { scanModSourceDirectory(it) } + } + + fun dismissVersionMismatchWarning() { + _showVersionMismatchWarning.value = false } fun setSearchActive(isActive: Boolean) { @@ -262,7 +400,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( try { updateJobStatus(hashedName, JobStatus.Downloading("Starting download...")) - val (downloadSuccess, messageOrPath) = ModdingService.downloadBundle(hashedName, "HD", context.cacheDir.absolutePath, cacheKey) { progress -> + val (downloadSuccess, messageOrPath) = ModdingService.downloadBundle(hashedName, selectedQuality.value, context.cacheDir.absolutePath, cacheKey) { progress -> updateJobStatus(hashedName, JobStatus.Downloading(progress)) } @@ -416,7 +554,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( Python.start(com.chaquo.python.android.AndroidPlatform(context)) } val cacheKey = "uninstall_${System.currentTimeMillis()}" - ModdingService.downloadBundle(hashedName, "HD", context.cacheDir.absolutePath, cacheKey) { progress -> + ModdingService.downloadBundle(hashedName, selectedQuality.value, context.cacheDir.absolutePath, cacheKey) { progress -> viewModelScope.launch(Dispatchers.Main) { _uninstallState.value = UninstallState.Downloading(hashedName, progress) } diff --git a/app/src/main/python/local_bundle_indexer.py b/app/src/main/python/local_bundle_indexer.py new file mode 100644 index 00000000..0aadd19a --- /dev/null +++ b/app/src/main/python/local_bundle_indexer.py @@ -0,0 +1,535 @@ +# -*- coding: utf-8 -*- +""" +Scans locally cached game bundles to build an asset-name-to-bundle mapping. + +Provides a three-step API designed for Kotlin/Shizuku integration: + + Step 1: check_scan_needed() — Compare bundle list with cache, return which need scanning + Step 2: scan_single_bundle() — Scan one bundle from a temp file (called per bundle) + Step 3: finalize_scan() — Merge cached + new results, save final index + +This architecture lets the Kotlin layer handle Shizuku-mediated file I/O +(copying __data files from the game's private directory to a temp path) +while Python handles only the UnityPy parsing. +""" +import json +import os +import time +import glob + +# Lazy-loaded UnityPy reference +_UnityPy = None + +INDEX_SCHEMA_VERSION = 3 + +# Only scan object types that carry meaningful m_Name for modding. +# Skipping Transform, GameObject, Material, Shader, Mesh, etc. avoids +# expensive obj.read() calls and dramatically speeds up scanning. +SCAN_TYPES = frozenset({"Texture2D", "TextAsset", "Sprite"}) + +# Extension mapping to match unpacker/repacker conventions. +# Texture2D m_Name has no extension in Unity — we add ".png" to match +# what the unpacker exports and what users expect. +_EXTENSION_MAP = {"Texture2D": ".png", "Sprite": ".png"} + +# Module-level state for an ongoing scan session. +# Populated by check_scan_needed(), updated by scan_single_bundle(), +# consumed and cleared by finalize_scan(). +_scan_state = None + + +def _ensure_unitypy(): + """Lazy-load and configure UnityPy.""" + global _UnityPy + if _UnityPy is not None: + return _UnityPy + + from UnityPy.helpers import TypeTreeHelper + TypeTreeHelper.read_typetree_boost = False + import UnityPy + UnityPy.config.FALLBACK_UNITY_VERSION = '2022.3.22f1' + _UnityPy = UnityPy + return _UnityPy + + +def _get_asset_extension(type_name): + """Return the file extension for an object type, or empty string.""" + return _EXTENSION_MAP.get(type_name, "") + + +def _read_name_fast(obj): + """Read m_Name directly from raw object data, skipping full deserialization. + + For Texture2D, TextAsset, and Sprite, m_Name is the first serialized field. + Unity string format: [int32 length][UTF-8 bytes][padding to 4-byte align] + + By reading only the name, we skip parsing texture image data, script content, + sprite meshes, etc. — which is the vast majority of the data. + """ + try: + obj.reset() # seek reader to object's byte_start + return obj.reader.read_aligned_string() + except Exception: + # Fallback: full deserialization (slow but guaranteed correct) + try: + data = obj.read() + return getattr(data, "m_Name", None) + except Exception: + return None + + +def _scan_bundle_file(file_path): + """ + Scan a single bundle file and return a sorted list of unique asset names. + + Only reads objects of types in SCAN_TYPES to minimize parsing overhead. + Uses fast name extraction (raw binary read) instead of full object + deserialization to avoid loading texture data, scripts, etc. + """ + unitypy = _ensure_unitypy() + env = unitypy.load(file_path) + names = set() + + for obj in env.objects: + if obj.type.name not in SCAN_TYPES: + continue + + raw_name = _read_name_fast(obj) + if not raw_name: + continue + + name = raw_name.strip().lower() + if not name: + continue + + ext = _get_asset_extension(obj.type.name) + if ext and not name.endswith(ext): + name += ext + + names.add(name) + + return sorted(names) + + +def _load_existing_cache(cache_path): + """Load existing index cache from disk. Returns dict or None.""" + try: + if os.path.isfile(cache_path): + with open(cache_path, "r", encoding="utf-8") as f: + data = json.load(f) + if data.get("schemaVersion") == INDEX_SCHEMA_VERSION: + return data + except Exception: + pass + return None + + +# --------------------------------------------------------------------------- +# Three-step scanning API +# --------------------------------------------------------------------------- + +def check_scan_needed(output_dir, bundle_list_json): + """ + Step 1: Compare the current bundle list against the cached index to + determine which bundles need scanning. + + Kotlin should call this first with the directory listing obtained via + Shizuku from the game's Shared/ directory. + + Args: + output_dir: Path where the index cache JSON lives (app-writable dir) + bundle_list_json: JSON string or list of objects: + [{"name": "bundleName", "hash": "hashDirName"}, ...] + + Returns: + JSON string of bundle names that need scanning: ["name1", "name2", ...] + Bundles whose hash matches the cache are skipped. + """ + global _scan_state + + bundle_list = json.loads(bundle_list_json) if isinstance(bundle_list_json, str) else bundle_list_json + + cache_path = os.path.join(output_dir, "local_bundle_index.json") + existing_cache = _load_existing_cache(cache_path) + cached_bundles = existing_cache.get("scannedBundles", {}) if existing_cache else {} + + needs_scan = [] + still_valid = {} + + for item in bundle_list: + name = item["name"] + hash_ = item["hash"] + cached = cached_bundles.get(name) + if cached and cached.get("hash") == hash_: + # Hash unchanged — reuse cached scan result + still_valid[name] = cached + else: + # New or updated — needs scanning + needs_scan.append(name) + + # Initialize/reset scan state + _scan_state = { + "output_dir": output_dir, + "all_bundle_hashes": {item["name"]: item["hash"] for item in bundle_list}, + "cached": still_valid, + "scanned": {}, + } + + return json.dumps(needs_scan) + + +def scan_single_bundle(bundle_name, bundle_hash, temp_data_path, progress_callback=None): + """ + Step 2: Scan a single bundle from a temporary file path. + + Kotlin should: + 1. Copy the __data file from the game directory to a temp path via Shizuku + 2. Call this function with the temp path + 3. Delete the temp file after this function returns + + This is called once per bundle that check_scan_needed() flagged. + + Args: + bundle_name: Bundle identifier (folder name under Shared/) + bundle_hash: Hash directory name (for cache key) + temp_data_path: Path to the __data file in app's accessible cache dir + progress_callback: Optional function(str) + + Returns: + Tuple (success: bool, asset_count: int, message: str) + """ + global _scan_state + + if _scan_state is None: + return False, 0, "No scan in progress. Call check_scan_needed first." + + try: + assets = _scan_bundle_file(temp_data_path) + _scan_state["scanned"][bundle_name] = { + "hash": bundle_hash, + "assets": assets, + } + if progress_callback: + progress_callback(f"Scanned {bundle_name}: {len(assets)} assets") + return True, len(assets), f"OK: {len(assets)} assets" + + except Exception as e: + _scan_state["scanned"][bundle_name] = { + "hash": bundle_hash, + "assets": [], + "error": str(e), + } + if progress_callback: + progress_callback(f"Failed {bundle_name}: {e}") + return False, 0, str(e) + + +def _parse_catalog_data(output_dir): + """ + Parse the catalog to extract: + 1. bundle_name → downloadName mapping + 2. bundle_name → list of asset keys (for reverse-lookup disambiguation) + + Returns: + Tuple (download_names: dict, catalog_bundle_to_keys: dict) + """ + try: + from catalog_parser import read_int32_from_byte_array, read_object_from_byte_array + except ImportError: + return {}, {} + import base64 + + catalogs = glob.glob(os.path.join(output_dir, "catalog_*.json")) + if not catalogs: + return {}, {} + + catalog_path = sorted(catalogs, key=os.path.getmtime, reverse=True)[0] + + try: + with open(catalog_path, 'r', encoding='utf-8') as f: + catalog = json.load(f) + + provider_ids = catalog.get("m_ProviderIds", []) + bundle_provider = "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider" + if bundle_provider not in provider_ids: + return {}, {} + bundle_provider_index = provider_ids.index(bundle_provider) + + bucket_array = base64.b64decode(catalog["m_BucketDataString"]) + key_array = base64.b64decode(catalog["m_KeyDataString"]) + extra_data = base64.b64decode(catalog["m_ExtraDataString"]) + entry_data = base64.b64decode(catalog["m_EntryDataString"]) + + # --- Parse buckets --- + num_buckets = read_int32_from_byte_array(bucket_array, 0) + dependency_map = [None] * num_buckets + data_offsets = [] + idx = 4 + for i in range(num_buckets): + data_offsets.append(read_int32_from_byte_array(bucket_array, idx)) + idx += 4 + num_entries = read_int32_from_byte_array(bucket_array, idx) + idx += 4 + entries_list = [] + for _ in range(num_entries): + entries_list.append(read_int32_from_byte_array(bucket_array, idx)) + idx += 4 + dependency_map[i] = entries_list + + keys = [read_object_from_byte_array(key_array, offset) for offset in data_offsets] + + # --- Parse entries --- + number_of_entries = read_int32_from_byte_array(entry_data, 0) + idx = 4 + bundle_entries = {} # entry_index → bundle_name + download_names = {} # bundle_name → downloadName + all_entries = [] + + for m in range(number_of_entries): + # internal_id + idx += 4 + # provider_index + provider_index = read_int32_from_byte_array(entry_data, idx) + idx += 4 + # dependency_key_index + dependency_key_index = read_int32_from_byte_array(entry_data, idx) + idx += 4 + # dependency_hash + idx += 4 + # data_index + data_index = read_int32_from_byte_array(entry_data, idx) + idx += 4 + # primary_key_index + primary_key_index = read_int32_from_byte_array(entry_data, idx) + idx += 4 + # resource_type + idx += 4 + + all_entries.append({ + 'dependency_index': dependency_key_index, + 'primary_key_index': primary_key_index, + }) + + if provider_index == bundle_provider_index and data_index >= 0: + bundle_info = read_object_from_byte_array(extra_data, data_index) + if bundle_info and bundle_info.get("m_BundleName"): + bundle_name = str(bundle_info["m_BundleName"]) + bundle_entries[m] = bundle_name + dn = str(keys[primary_key_index]) if primary_key_index < len(keys) else "" + download_names[bundle_name] = dn + + # --- Resolve asset → bundle via dependency chain --- + def resolve_bundle(entry_index): + if entry_index in bundle_entries: + return bundle_entries[entry_index] + if entry_index < 0 or entry_index >= len(all_entries): + return None + dep_idx = all_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: + if dep_entry in bundle_entries: + return bundle_entries[dep_entry] + return None + + # --- Build bundle → asset keys mapping --- + catalog_bundle_to_keys = {} + for i in range(len(all_entries)): + if i in bundle_entries: + continue # Skip bundle entries themselves + + pki = all_entries[i]['primary_key_index'] + if pki < 0 or pki >= len(keys): + continue + asset_key = keys[pki] + if not isinstance(asset_key, str): + continue + + bundle_name = resolve_bundle(i) + if not bundle_name: + continue + + if bundle_name not in catalog_bundle_to_keys: + catalog_bundle_to_keys[bundle_name] = [] + catalog_bundle_to_keys[bundle_name].append(asset_key.lower()) + + return download_names, catalog_bundle_to_keys + + except Exception as e: + print(f"Error parsing catalog data: {e}") + return {}, {} + + +def _score_bundle_for_asset(asset_name, catalog_keys): + """ + Score how well a bundle's catalog asset keys match a scanned m_Name. + + Returns an integer score (higher = better match, 0 = no match). + """ + # Strip extension from m_Name for stem matching + stem = asset_name.rsplit('.', 1)[0] if '.' in asset_name else asset_name + + best_score = 0 + for key in catalog_keys: + # Exact filename match (highest priority) + key_filename = key.rsplit('/', 1)[-1] + if key_filename == asset_name: + return 100 # Perfect match, return immediately + + # Stem appears in the key's filename portion + if stem in key_filename: + best_score = max(best_score, 50 + len(stem)) + + # Stem appears anywhere in the full key path + elif stem in key: + best_score = max(best_score, 10 + len(stem)) + + return best_score + + +def finalize_scan(output_dir, progress_callback=None): + """ + Step 3: Merge cached + newly scanned results and save the final index. + + Should be called after all scan_single_bundle() calls are complete + (or after deciding to stop scanning early — partial results are fine). + + Args: + output_dir: Path to save the index cache JSON + progress_callback: Optional function(str) + + Returns: + Tuple (success: bool, message: str) + """ + global _scan_state + + def report(msg): + if progress_callback: + progress_callback(msg) + print(msg) + + if _scan_state is None: + return False, "No scan in progress. Call check_scan_needed first." + + try: + # Merge cached and newly scanned bundles + all_bundles = {} + all_bundles.update(_scan_state["cached"]) + all_bundles.update(_scan_state["scanned"]) + + cached_count = len(_scan_state["cached"]) + scanned_count = len(_scan_state["scanned"]) + failed_count = sum( + 1 for info in _scan_state["scanned"].values() if info.get("error") + ) + + # Build the assetToBundles lookup table (scan-based, may have duplicates) + asset_to_bundles = {} + for bundle_name, info in all_bundles.items(): + for asset_name in info.get("assets", []): + if asset_name not in asset_to_bundles: + asset_to_bundles[asset_name] = [] + if bundle_name not in asset_to_bundles[asset_name]: + asset_to_bundles[asset_name].append(bundle_name) + + # Parse catalog for downloadNames and bundle→keys mapping + download_names, catalog_bundle_to_keys = _parse_catalog_data(output_dir) + for b_name, b_info in all_bundles.items(): + b_info["downloadName"] = download_names.get(b_name, "") + + # Build catalogAssetToBundle by cross-referencing: + # For each ambiguous asset (multiple bundles), score each bundle's + # catalog asset keys against the m_Name and pick the best match. + catalog_asset_to_bundle = {} + ambiguous_count = 0 + resolved_count = 0 + for asset_name, bundle_list in asset_to_bundles.items(): + if len(bundle_list) <= 1: + continue # Only one bundle — no ambiguity + ambiguous_count += 1 + + best_bundle = None + best_score = 0 + for bundle_name in bundle_list: + cat_keys = catalog_bundle_to_keys.get(bundle_name, []) + score = _score_bundle_for_asset(asset_name, cat_keys) + if score > best_score: + best_score = score + best_bundle = bundle_name + + if best_bundle: + catalog_asset_to_bundle[asset_name] = best_bundle + resolved_count += 1 + + report(f"Catalog: {len(download_names)} downloadNames, " + f"{ambiguous_count} ambiguous assets, {resolved_count} resolved") + + # Save index to disk + os.makedirs(output_dir, exist_ok=True) + index = { + "schemaVersion": INDEX_SCHEMA_VERSION, + "scannedAt": int(time.time()), + "bundleCount": len(all_bundles), + "assetCount": len(asset_to_bundles), + "assetToBundles": asset_to_bundles, + "catalogAssetToBundle": catalog_asset_to_bundle, + "scannedBundles": all_bundles, + } + + cache_path = os.path.join(output_dir, "local_bundle_index.json") + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(index, f, ensure_ascii=False, separators=(",", ":")) + + msg = ( + f"Index saved: {len(all_bundles)} bundles, {len(asset_to_bundles)} assets " + f"(cached: {cached_count}, scanned: {scanned_count}, failed: {failed_count})" + ) + report(msg) + return True, msg + + except Exception as e: + import traceback + error_msg = traceback.format_exc() + report(f"Error finalizing scan: {error_msg}") + return False, error_msg + + finally: + # Always clear state, even on error + _scan_state = None + + +# --------------------------------------------------------------------------- +# Index loading (used by resolver) +# --------------------------------------------------------------------------- + +# In-memory cache: avoids re-reading and re-parsing the large JSON on every resolve call. +_index_cache = None # cached dict +_index_cache_mtime = 0 # mtime of the file when it was cached + +def load_local_index(output_dir): + """ + Load the local bundle index from disk cache, with in-memory caching. + + Returns: + The index dict, or None if no valid cache exists. + """ + global _index_cache, _index_cache_mtime + + cache_path = os.path.join(output_dir, "local_bundle_index.json") + + try: + current_mtime = os.path.getmtime(cache_path) + except OSError: + return None + + # Return cached version if file hasn't changed + if _index_cache is not None and current_mtime == _index_cache_mtime: + return _index_cache + + data = _load_existing_cache(cache_path) + if data is not None: + _index_cache = data + _index_cache_mtime = current_mtime + + return data diff --git a/app/src/main/python/main_script.py b/app/src/main/python/main_script.py index 8cd53ded..ba6763ad 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -11,7 +11,7 @@ from unpacker import unpack_bundle as unpacker_main import spine_merger import resolver -import catalog_indexer +import local_bundle_indexer import json from pathlib import Path @@ -32,29 +32,15 @@ def _prune_catalog_cache(keep_version=None): 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 - +# --------------------------------------------------------------------------- +# Character metadata (characters.json) — still uses CDN catalog +# --------------------------------------------------------------------------- -def _refresh_metadata(output_dir: str, quality: str = "HD", progress_callback=None): +def _refresh_character_data(output_dir, quality="HD", progress_callback=None): + """ + Download the catalog and rebuild characters.json only. + The asset index is now handled separately by local bundle scanning. + """ def report_progress(message): if progress_callback: progress_callback(message) @@ -67,7 +53,7 @@ def report_progress(message): 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 + return False, "Failed to get CDN version.", None characters_json_path = output_path / "characters.json" stored_version = None @@ -79,144 +65,187 @@ def report_progress(message): 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 + if stored_version == latest_version and characters_json_path.exists(): + report_progress(f"characters.json already up to date for version {latest_version}.") + return True, "SKIPPED", latest_version - report_progress(f"Refreshing shared metadata for version {latest_version}...") + report_progress(f"Refreshing character data 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 + return False, error, 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 characters.json from catalog...") + success, message = character_scraper.scrape_and_save_from_catalog(output_dir, latest_version, catalog_content) + if not success: + return False, message, 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 + return True, "SUCCESS", latest_version 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 + report_progress(f"Error refreshing character data: {error_message}") + return False, error_message, None -def unpack_bundle(bundle_path: str, output_dir: str, progress_callback=None): +def update_character_data(output_dir, quality="HD"): """ - Entry point for Kotlin to unpack a bundle. - Returns a tuple: (success: Boolean, message: String) + Entry point for Kotlin to refresh character metadata (characters.json). + Returns a tuple: (status: String, message: String) + Status can be "SUCCESS", "SKIPPED", "FAILED" """ try: - success, message = unpacker_main( - bundle_path=bundle_path, - output_dir=output_dir, - progress_callback=progress_callback - ) - - print(message) - return success, message - + success, status, version = _refresh_character_data(output_dir, quality) + if success: + if status == "SKIPPED": + return "SKIPPED", "characters.json is already up to date." + return "SUCCESS", f"Character data refreshed for version {version}." + return "FAILED", status except Exception as e: import traceback error_message = traceback.format_exc() - print(f"An error occurred during unpack: {error_message}") - if progress_callback: - progress_callback(f"An error occurred: {e}") - return False, error_message + print(f"An error occurred during character data refresh: {error_message}") + return "FAILED", error_message -def download_bundle(hashed_name, quality, output_dir, cache_key, progress_callback=None): +# --------------------------------------------------------------------------- +# Local bundle scanning — three-step API for Kotlin/Shizuku +# --------------------------------------------------------------------------- +# +# Usage from Kotlin: +# +# // Step 1: List Shared/ via Shizuku, build bundle list, check which need scanning +# val bundleListJson = buildBundleListJson() // [{"name":"...", "hash":"..."}, ...] +# val needsScanJson = mainScript.callAttr("check_scan_needed", outputDir, bundleListJson).toString() +# val needsScan = JSONArray(needsScanJson) +# +# // Step 2: For each bundle that needs scanning +# for (bundleName in needsScan) { +# val hash = bundleHashes[bundleName] +# val tempPath = copyBundleViaShizuku(bundleName, hash) // Copy __data to temp +# mainScript.callAttr("scan_single_bundle", bundleName, hash, tempPath, callback) +# File(tempPath).delete() // Clean up temp file +# } +# +# // Step 3: Finalize and save index +# mainScript.callAttr("finalize_scan", outputDir, callback) + + +def check_scan_needed(output_dir, bundle_list_json, progress_callback=None): """ - Entry point for Kotlin to download a bundle from the CDN. - Manages a shared in-memory cache for the catalog file to avoid redundant downloads. - Returns a tuple: (success: Boolean, message_or_path: String) - """ - global current_cache_key + Step 1: Check which bundles need scanning. - def report_progress(message): + Kotlin should call this with the directory listing from Shared/, + obtained via Shizuku. + + Args: + output_dir: App-writable directory for the index cache + bundle_list_json: JSON string of [{"name": "bundleName", "hash": "hashDir"}, ...] + progress_callback: Optional progress reporting function + + Returns: + JSON string of bundle names that need scanning: '["name1", "name2", ...]' + """ + def report(msg): if progress_callback: - progress_callback(message) - print(message) + progress_callback(msg) + print(msg) try: - 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 + result = local_bundle_indexer.check_scan_needed(output_dir, bundle_list_json) + needs_scan = json.loads(result) + report(f"Scan check complete: {len(needs_scan)} bundles need scanning.") + return result + except Exception as e: + import traceback + error_msg = traceback.format_exc() + report(f"Error checking scan: {error_msg}") + return json.dumps([]) - 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...") - 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 - ) - if error: - return False, error +def scan_single_bundle(bundle_name, bundle_hash, temp_data_path, progress_callback=None): + """ + Step 2: Scan one bundle from a temporary file. - report_progress(f"Searching for bundle {hashed_name} in catalog...") - output_file_path, error = cdn_downloader.find_and_download_bundle( - catalog_content=catalog_content, - version=version, - quality=quality, - hashed_name=hashed_name, - output_dir=output_dir, - progress_callback=progress_callback - ) + Kotlin should: + 1. Copy __data from game dir to a temp path via Shizuku + 2. Call this function + 3. Delete the temp file - if error: - return False, error + Args: + bundle_name: Bundle identifier (folder name under Shared/) + bundle_hash: Hash directory name + temp_data_path: Path to __data in app's cache dir + progress_callback: Optional progress reporting function - return True, output_file_path + Returns: + Tuple (success: Boolean, asset_count: int, message: String) + """ + return local_bundle_indexer.scan_single_bundle( + bundle_name, bundle_hash, temp_data_path, progress_callback + ) - except Exception as e: - import traceback - error_message = traceback.format_exc() - report_progress(f"A critical error occurred: {error_message}") - return False, error_message +def finalize_scan(output_dir, progress_callback=None): + """ + Step 3: Save the final index after all bundles have been scanned. + + Args: + output_dir: App-writable directory for the index cache + progress_callback: Optional progress reporting function + + Returns: + Tuple (success: Boolean, message: String) + """ + return local_bundle_indexer.finalize_scan(output_dir, progress_callback) + + +# --------------------------------------------------------------------------- +# Mod resolution — uses local bundle index +# --------------------------------------------------------------------------- -def ensure_asset_index(output_dir: str, quality: str = "HD", progress_callback=None): +def ensure_asset_index(output_dir, quality="HD", progress_callback=None): + """ + Load the local bundle index from disk. + + Returns a tuple: (success: Boolean, message_or_error: String, index: dict or 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) + index = local_bundle_indexer.load_local_index(output_dir) if index is None: - return False, "Asset index missing. Refresh metadata first.", None + return False, "Local bundle index not found. Please scan local bundles first.", None - version = index.get('version') - report_progress(f"Using local asset index for version {version}.") - return True, version, index + asset_count = index.get('assetCount', 0) + bundle_count = index.get('bundleCount', 0) + report_progress(f"Local bundle index loaded: {bundle_count} bundles, {asset_count} assets.") + return True, "Local index loaded.", 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}") + report_progress(f"Error loading local 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): +def resolve_mod_files(file_names_json, output_dir, quality="HD", progress_callback=None): + """ + Resolve mod files against the local bundle index. + + Args: + file_names_json: JSON string or list of mod file names + output_dir: Path where the local index cache is stored + quality: Ignored (kept for backward compatibility) + progress_callback: Optional progress reporting function + + Returns a tuple: (success: Boolean, result_json_or_error: String) + """ 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) @@ -230,7 +259,18 @@ def resolve_mod_files(file_names_json: str, output_dir: str, quality: str = "HD" return False, traceback.format_exc() -def resolve_mod_batch(mods_json: str, output_dir: str, quality: str = "HD", progress_callback=None): +def resolve_mod_batch(mods_json, output_dir, quality="HD", progress_callback=None): + """ + Resolve a batch of mods against the local bundle index. + + Args: + mods_json: JSON string or list of mod objects with 'id' and 'fileNames' + output_dir: Path where the local index cache is stored + quality: Ignored (kept for backward compatibility) + progress_callback: Optional progress reporting function + + Returns a tuple: (success: Boolean, results_json_or_error: String) + """ 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) @@ -252,27 +292,95 @@ def resolve_mod_batch(mods_json: str, output_dir: str, quality: str = "HD", prog return False, traceback.format_exc() -def update_character_data(output_dir: str): +# --------------------------------------------------------------------------- +# CDN bundle download — kept for backward compatibility +# --------------------------------------------------------------------------- + +def download_bundle(hashed_name, quality, output_dir, cache_key, progress_callback=None): """ - Entry point for Kotlin to refresh all shared metadata. - Returns a tuple: (status: String, message: String) - Status can be "SUCCESS", "SKIPPED", "FAILED" + Entry point for Kotlin to download a bundle from the CDN. + Manages a shared in-memory cache for the catalog file to avoid redundant downloads. + 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: - success, status, version, _index = _refresh_metadata(output_dir, "HD") - if success: - 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 + 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...") + 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 + ) + if error: + return False, error + + report_progress(f"Searching for bundle {hashed_name} in catalog...") + output_file_path, error = cdn_downloader.find_and_download_bundle( + catalog_content=catalog_content, + version=version, + quality=quality, + hashed_name=hashed_name, + output_dir=output_dir, + progress_callback=progress_callback + ) + + if error: + return False, error + + return True, output_file_path + except Exception as e: import traceback error_message = traceback.format_exc() - print(f"An error occurred during metadata refresh process: {error_message}") - return "FAILED", error_message + report_progress(f"A critical error occurred: {error_message}") + return False, error_message + + +# --------------------------------------------------------------------------- +# Unpacking, repacking, spine merge — unchanged +# --------------------------------------------------------------------------- + +def unpack_bundle(bundle_path, output_dir, progress_callback=None): + """ + Entry point for Kotlin to unpack a bundle. + Returns a tuple: (success: Boolean, message: String) + """ + try: + 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() + print(f"An error occurred during unpack: {error_message}") + if progress_callback: + progress_callback(f"An error occurred: {e}") + return False, error_message -def main(original_bundle_path: str, modded_assets_folder: str, output_path: str, use_astc: bool, progress_callback=None): +def main(original_bundle_path, modded_assets_folder, output_path, use_astc, progress_callback=None): """ Main entry point to be called from Kotlin. Returns a tuple: (success: Boolean, message: String) @@ -296,7 +404,7 @@ def main(original_bundle_path: str, modded_assets_folder: str, output_path: str, return False, error_message -def merge_spine_assets(mod_dir_path: str, progress_callback=None): +def merge_spine_assets(mod_dir_path, progress_callback=None): """ Entry point for Kotlin to run the spine merger script. Returns a tuple: (success: Boolean, message: String) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index a3cf0ed0..0cb6ee8d 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -1,450 +1,247 @@ # -*- coding: utf-8 -*- +""" +Resolves mod file names against the local bundle index. + +This is a simplified replacement for the catalog-based resolver. Instead of +parsing catalog addressable keys and using complex bridge/prefab strategies, +it directly looks up asset names (m_Name) in the local bundle index. + +The matching is straightforward: + - Mod file "char000104.png" → looks up "char000104.png" in index + - Mod file "char000104.json" → also tries "char000104.skel" (JSON→skel conversion) + - Mod file "char000104.atlas.txt" → also tries "char000104.atlas" + +The return format is kept compatible with the Kotlin layer. +""" import re from pathlib import Path -from catalog_indexer import normalize_filename +def resolve_mod_folder(mod_file_names, local_index): + """ + Resolve a list of mod file names against the local bundle index. -def resolve_mod_folder(mod_file_names, asset_index): - unresolved_files = [] - file_matches = [] + Finds which bundle(s) contain the target assets, then identifies + the single common bundle that all mod files belong to. + + Uses the catalog-based authoritative mapping (catalogAssetToBundle) + as primary lookup. Falls back to scan-based assetToBundles if the + catalog doesn't have a mapping for a given asset. - assets_by_base = (asset_index or {}).get('assetsByBaseName', {}) - assets_by_exact = (asset_index or {}).get('assetsByExactKey', {}) + Args: + mod_file_names: list of mod file paths/names + local_index: dict with 'assetToBundles' and 'catalogAssetToBundle' keys + + Returns: + A resolution result dict compatible with the Kotlin layer: + { + 'targetHash': str or None (bundle name), + 'resolvedFamilyKey': str or None, + 'resolvedTargets': list of match dicts, + 'unresolvedFiles': list of unmatched file names, + 'resolutionState': 'KNOWN' | 'UNKNOWN' | 'INVALID', + 'errorReason': str or None + } + """ + asset_to_bundles = (local_index or {}).get("assetToBundles", {}) + catalog_asset_to_bundle = (local_index or {}).get("catalogAssetToBundle", {}) + + unresolved = [] + file_matches = [] 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)) + matched_candidate = None + matched_bundles = set() + match_strategy = 'LOCAL_SCAN' 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) - matches = _filter_bridge_noise(base_name, matches) - - if matches: + bundles = asset_to_bundles.get(candidate) + if bundles: + if matched_candidate is None: + matched_candidate = candidate + matched_bundles.update(bundles) + + # Use catalog to narrow down if multiple bundles matched + if len(matched_bundles) > 1: + for candidate in candidates: + catalog_bundle = catalog_asset_to_bundle.get(candidate) + if catalog_bundle and catalog_bundle in matched_bundles: + matched_bundles = {catalog_bundle} + match_strategy = 'CATALOG_FILTERED' + break + + if matched_candidate and matched_bundles: file_matches.append({ 'fileName': base_name, 'candidates': candidates, - 'matches': matches, - 'targetHashes': {m.get('targetHash') for m in matches if m.get('targetHash')} + 'candidate': matched_candidate, + 'bundles': matched_bundles, + 'matchStrategy': match_strategy, }) else: - unresolved_files.append(base_name) - - _inherit_png_targets_from_spine_pairs(file_matches) + unresolved.append(base_name) - candidate_sets = [entry['targetHashes'] for entry in file_matches if entry['targetHashes']] - - if not candidate_sets: + # No matches at all + if not file_matches: return { 'targetHash': None, 'resolvedFamilyKey': None, 'resolvedTargets': [], - 'unresolvedFiles': unresolved_files, + 'unresolvedFiles': unresolved, 'resolutionState': 'UNKNOWN', - 'errorReason': 'No matching target could be resolved' + 'errorReason': 'No matching bundle found in local index' } + # Find common bundle across all matched files + candidate_sets = [entry['bundles'] for entry in file_matches] + intersection = set(candidate_sets[0]) - for target_set in candidate_sets[1:]: - intersection &= target_set + for s in candidate_sets[1:]: + intersection &= s union = set() - for target_set in candidate_sets: - union |= target_set + for s in candidate_sets: + union |= s + target_bundle = None if len(intersection) == 1: - target_hash = next(iter(intersection)) + target_bundle = next(iter(intersection)) + elif len(intersection) > 1: + # Multiple common bundles — pick alphabetically + target_bundle = sorted(intersection)[0] elif len(union) == 1: - target_hash = next(iter(union)) - else: + # No strict intersection but only one bundle total + target_bundle = next(iter(union)) + + if target_bundle is None: + # Files point to different bundles — can't determine a single target return { 'targetHash': None, 'resolvedFamilyKey': None, - 'resolvedTargets': _select_representative_matches(file_matches, None), - 'unresolvedFiles': unresolved_files, + 'resolvedTargets': [ + _build_target(entry) for entry in file_matches + ], + 'unresolvedFiles': unresolved, 'resolutionState': 'INVALID', - 'errorReason': 'Multiple targets detected in one mod folder' + 'errorReason': 'Mod files map to different bundles' } - 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')) - } + resolved_targets = [ + _build_target(entry, target_bundle) for entry in file_matches + ] + family_key = _compute_family_key(file_matches) return { - 'targetHash': target_hash, - 'resolvedFamilyKey': next(iter(family_keys)) if len(family_keys) == 1 else None, + 'targetHash': target_bundle, + 'resolvedFamilyKey': family_key, 'resolvedTargets': resolved_targets, - 'unresolvedFiles': unresolved_files, + 'unresolvedFiles': unresolved, 'resolutionState': 'KNOWN', 'errorReason': None } -def _expand_candidates(base_name: str): - candidates = list(dict.fromkeys(normalize_filename(base_name))) - - lowered_base = (base_name or '').strip().lower() - stem = _mod_asset_stem(lowered_base) - if stem: - if re.fullmatch(r'illust_dating\d+', stem, re.IGNORECASE): - candidates.extend([ - f'{stem}.prefab', - f'char/datingillust/{stem}.prefab', - ]) - - if re.fullmatch(r'char\d{6}', stem, re.IGNORECASE): - candidates.extend([ - f'illust_{stem}_01.prefab', - f'illust_{stem}_1.prefab', - ]) - - if re.fullmatch(r'npc\d{6}', stem, re.IGNORECASE): - candidates.extend([ - f'illust_{stem}_01.prefab', - f'illust_{stem}_1.prefab', - f'illust_{stem}_2.prefab', - ]) - - if _is_rhythm_hit_anim_stem(stem): - candidates.extend(_rhythm_bridge_candidates()) - - 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 _expand_candidates(base_name): + """ + Expand a mod filename to possible asset names in the bundle. -def _mod_asset_stem(asset_name: str): - lowered = (asset_name or '').strip().lower() + Handles extension mappings: + .json → also try .skel (mod JSON animation → bundle skel TextAsset) + .skel.bytes → also try .skel (catalog convention → actual m_Name) + .atlas.txt → also try .atlas (catalog convention → actual m_Name) + """ + lowered = (base_name or "").strip().lower() if not lowered: - return None - - for suffix in ('.skel.bytes', '.atlas.txt'): - if lowered.endswith(suffix): - return lowered[:-len(suffix)] - - return Path(lowered).stem - - - -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 _is_rhythm_hit_anim_stem(stem: str): - if not stem: - return False - return re.fullmatch(r'rhythmhitanim(?:_\d+)?', stem, re.IGNORECASE) is not None - - -def _rhythm_bridge_candidates(): - return [ - f'rhythm_char{n:03d}.prefab' - for n in range(1, 6) - ] - - -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 + candidates = [lowered] -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 - } - + # JSON animation → skel (user provides .json, bundle has .skel TextAsset) + if lowered.endswith('.json'): + candidates.append(lowered[:-5] + '.skel') -def _string_at(strings, index): - if index is None or index < 0 or index >= len(strings): - return None - return strings[index] + # .skel.bytes → .skel (catalog uses .skel.bytes, bundle m_Name is .skel) + if lowered.endswith('.skel.bytes'): + candidates.append(lowered[:-6]) # strip '.bytes' + # .atlas.txt → .atlas + if lowered.endswith('.atlas.txt'): + candidates.append(lowered[:-4]) # strip '.txt' -def _prefer_primary_hits(hits): - if not hits: - return [] + return list(dict.fromkeys(candidates)) - 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_target(entry, target_bundle=None): + """Build a resolved target dict for Kotlin compatibility.""" + bundle_name = target_bundle or (sorted(entry['bundles'])[0] if entry['bundles'] else None) + family_key = _extract_stem(entry['fileName']) -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, + 'originalFileName': entry['fileName'], + 'normalizedCandidates': entry.get('candidates', []), + 'resolvedAssetKey': entry['candidate'], + 'resolvedBundleName': bundle_name, + 'assetType': _infer_asset_type(entry['fileName']), + 'targetHash': bundle_name, '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 _filter_bridge_noise(base_name: str, matches): - if not matches: - return matches - - lowered = (base_name or '').strip().lower() - stem = _mod_asset_stem(lowered) - if not stem: - return matches - - if _is_rhythm_hit_anim_stem(stem): - pattern = re.compile(r'(^|.*/)rhythm_char\d{3}\.prefab$', re.IGNORECASE) - rhythm_matches = [] - for match in matches: - asset_key = (match.get('resolvedAssetKey') or '').lower() - if pattern.search(asset_key): - rhythm_matches.append(match) - - if not rhythm_matches: - return matches - - exact_char001 = [ - match for match in rhythm_matches - if (match.get('resolvedAssetKey') or '').lower().endswith('rhythm_char001.prefab') - ] - if exact_char001: - return exact_char001 - - return sorted( - rhythm_matches, - key=lambda match: (match.get('resolvedAssetKey') or '').lower() - )[:1] - - if not ( - lowered.endswith('.skel') - or lowered.endswith('.skel.bytes') - or lowered.endswith('.json') - or lowered.endswith('.atlas') - or lowered.endswith('.atlas.txt') - ): - return matches - - preferred = [] - - if re.fullmatch(r'char\d{6}', stem, re.IGNORECASE): - pattern = re.compile(rf'(^|.*/)illust_{re.escape(stem)}_\d+\.prefab$', re.IGNORECASE) - for match in matches: - asset_key = (match.get('resolvedAssetKey') or '').lower() - if pattern.search(asset_key): - preferred.append(match) - return preferred if preferred else matches - - if re.fullmatch(r'npc\d{6}', stem, re.IGNORECASE): - pattern = re.compile(rf'(^|.*/)illust_{re.escape(stem)}_\d+\.prefab$', re.IGNORECASE) - for match in matches: - asset_key = (match.get('resolvedAssetKey') or '').lower() - if pattern.search(asset_key): - preferred.append(match) - return preferred if preferred else matches - - return matches - - -def _inherit_png_targets_from_spine_pairs(file_matches): - if not file_matches: - return - - anchor_by_stem = {} - for entry in file_matches: - file_name = entry.get('fileName') or '' - lowered = file_name.lower() - if lowered.endswith('.png'): - continue - if not ( - lowered.endswith('.skel') - or lowered.endswith('.skel.bytes') - or lowered.endswith('.json') - or lowered.endswith('.atlas') - or lowered.endswith('.atlas.txt') - ): - continue - - target_hashes = list(entry.get('targetHashes') or []) - if len(target_hashes) != 1: - continue - - representative = _prefer_best_match(entry.get('matches') or []) - if not representative: - continue - - stem = _mod_asset_stem(lowered) - if not stem: - continue - - anchor_by_stem[stem] = { - 'targetHash': target_hashes[0], - 'match': representative, - } - - for entry in file_matches: - file_name = entry.get('fileName') or '' - lowered = file_name.lower() - if not lowered.endswith('.png'): - continue - - stem = _mod_asset_stem(lowered) - anchor = anchor_by_stem.get(stem) - if not anchor: - continue - - anchor_target_hash = anchor['targetHash'] - existing_matches = entry.get('matches') or [] - matching_target = [m for m in existing_matches if m.get('targetHash') == anchor_target_hash] - if matching_target: - entry['matches'] = matching_target - entry['targetHashes'] = {anchor_target_hash} - continue - - inherited = dict(anchor['match']) - inherited['originalFileName'] = file_name - inherited['assetType'] = _infer_asset_type(file_name) - inherited['matchStrategy'] = 'EXTENSION_MAPPING' - inherited['confidence'] = min(float(inherited.get('confidence') or 0.9), 0.9) - entry['matches'] = [inherited] - entry['targetHashes'] = {anchor_target_hash} - - - -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 + 'matchStrategy': entry.get('matchStrategy', 'LOCAL_SCAN'), + 'confidence': 1.0, } - 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() +def _infer_asset_type(file_name): + """Infer the asset type from a mod file extension.""" + lowered = (file_name or "").lower() if lowered.endswith('.png'): return 'Texture2D' if lowered.endswith('.json'): return 'JsonSkeleton' - if lowered.endswith('.atlas') or lowered.endswith('.atlas.txt') or lowered.endswith('.skel') or lowered.endswith('.skel.txt') or lowered.endswith('.skel.bytes'): + if any(lowered.endswith(ext) for ext in ('.atlas', '.atlas.txt', '.skel', '.skel.txt', '.skel.bytes')): return 'TextAsset' return 'Unknown' -def _normalize_family_key(value: str): - if not value: +def _compute_family_key(file_matches): + """ + Compute a family key from the matched files. + The family key groups related assets (e.g., "char000104"). + """ + stems = set() + for entry in file_matches: + stem = _extract_stem(entry['fileName']) + if stem: + stems.add(stem) + + if len(stems) == 1: + return next(iter(stems)) + return None + + +def _extract_stem(file_name): + """ + Extract the base stem from a filename, removing extensions and numbered suffixes. + Examples: + "char000104.png" → "char000104" + "char000104_2.png" → "char000104" + "char000104.skel" → "char000104" + "char000104.skel.bytes" → "char000104" + "cutscene_char061002.atlas" → "cutscene_char061002" + """ + lowered = (file_name or "").strip().lower() + if not lowered: return None - lowered = value.lower() - lowered = re.sub(r'_(\d+)$', '', lowered) - return lowered + + # Remove known compound extensions first + for ext in ('.skel.bytes', '.atlas.txt', '.skel.txt'): + if lowered.endswith(ext): + lowered = lowered[:-len(ext)] + break + else: + lowered = Path(lowered).stem + + # Remove numbered suffix like _2, _3 + stem = re.sub(r'_\d+$', '', lowered) + return stem if stem else None