From 5d5c8a2a0fc700156cf9ae701866ad5a7730faf3 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 07:00:08 +0000 Subject: [PATCH 01/24] Add resolver-first target grouping foundation --- .../bd2modmanager/data/model/ModModels.kt | 43 ++++- .../data/repository/ModRepository.kt | 148 +++++++++++++-- .../bd2modmanager/service/ModdingService.kt | 23 +++ .../bd2modmanager/ui/dialogs/AppDialogs.kt | 2 +- .../bd2modmanager/ui/screens/ModScreen.kt | 77 +++++--- .../ui/viewmodel/MainViewModel.kt | 20 +- app/src/main/python/catalog_indexer.py | 179 ++++++++++++++++++ app/src/main/python/main_script.py | 46 ++++- app/src/main/python/repacker/repacker.py | 18 +- app/src/main/python/resolver.py | 90 +++++++++ 10 files changed, 601 insertions(+), 45 deletions(-) create mode 100644 app/src/main/python/catalog_indexer.py create mode 100644 app/src/main/python/resolver.py 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/ModRepository.kt b/app/src/main/java/com/example/bd2modmanager/data/repository/ModRepository.kt index a6961081d..01cb2a4f3 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 @@ -3,13 +3,20 @@ 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 @@ -47,7 +54,12 @@ class ModRepository( isEnabled = false, uri = file.uri, targetHashedName = cachedInfo.targetHashedName, - isDirectory = cachedInfo.isDirectory + 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") ?: "" @@ -57,29 +69,79 @@ class ModRepository( } else { extractModDetailsFromUri(file.uri) } + + if (!Python.isStarted()) { + Python.start(com.chaquo.python.android.AndroidPlatform(context)) + } + + val resolveResult = resolveModDetails(modDetails) val bestMatch = characterRepository.findBestMatch(modDetails.fileId, modDetails.fileNames) + val resolutionState = resolveResult.first + val targetHash = resolveResult.second.optString("targetHash").ifBlank { null } + val resolvedFamilyKey = resolveResult.second.optString("resolvedFamilyKey").ifBlank { null } + val unresolvedFiles = jsonArrayToStringList(resolveResult.second.optJSONArray("unresolvedFiles")) + val errorReason = resolveResult.second.optString("errorReason").ifBlank { null } + val resolvedTargets = parseResolvedTargets(resolveResult.second.optJSONArray("resolvedTargets")) + + 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 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 + character = displayCharacter, + costume = displayCostume, + type = displayType, + targetHashedName = targetHash ?: bestMatch?.hashedName, + isDirectory = isDirectory, + resolutionState = resolutionState, + targetHash = targetHash ?: bestMatch?.hashedName, + resolvedFamilyKey = resolvedFamilyKey, + unresolvedFiles = unresolvedFiles, + errorReason = errorReason ) newCache[uriString] = newCacheInfo ModInfo( name = modName, - character = bestMatch?.character ?: "Unknown", - costume = bestMatch?.costume ?: "Unknown", - type = bestMatch?.type ?: "idle", + character = displayCharacter, + costume = displayCostume, + type = displayType, isEnabled = false, uri = file.uri, - targetHashedName = bestMatch?.hashedName, - isDirectory = isDirectory + targetHashedName = targetHash ?: bestMatch?.hashedName, + isDirectory = isDirectory, + resolutionState = resolutionState, + targetHash = targetHash ?: bestMatch?.hashedName, + resolvedFamilyKey = resolvedFamilyKey, + resolvedTargets = resolvedTargets, + unresolvedFiles = unresolvedFiles, + errorReason = errorReason ) } tempModsList.add(modInfo) @@ -150,4 +212,68 @@ class ModRepository( } return ModDetails(fileId, fileNames) } + + private fun resolveModDetails(modDetails: ModDetails): Pair { + val fileNamesJson = gson.toJson(modDetails.fileNames) + val (success, payload) = ModdingService.resolveModFiles( + fileNamesJson, + context.cacheDir.absolutePath, + "HD" + ) { } + + if (!success || payload == null) { + val fallback = JSONObject() + fallback.put("resolutionState", ResolutionState.UNKNOWN.name) + fallback.put("errorReason", "Resolver failed") + fallback.put("unresolvedFiles", JSONArray(modDetails.fileNames)) + fallback.put("resolvedTargets", JSONArray()) + return ResolutionState.UNKNOWN to fallback + } + + val state = try { + ResolutionState.valueOf(payload.optString("resolutionState", ResolutionState.UNKNOWN.name)) + } catch (_: Exception) { + ResolutionState.UNKNOWN + } + + return state to payload + } + + 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..bd7abc043 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,7 @@ package com.example.bd2modmanager.service import com.chaquo.python.PyObject import com.chaquo.python.Python +import org.json.JSONObject object ModdingService { @@ -74,6 +75,28 @@ 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 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..2fb458618 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 @@ -31,6 +31,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp 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 +47,13 @@ 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, 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 +127,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 || it.resolutionState == ResolutionState.MISC + } val selectedModsCount = selectedMods.size val checkboxState = when { selectedModsCount == 0 -> ToggleableState.Off @@ -273,19 +282,25 @@ fun ModScreen( modifier = Modifier.fillMaxWidth() ) { Text( - text = "Target: ${hash.take(12)}...", + text = when (hash) { + "Unknown" -> "Unknown" + "Invalid" -> "Invalid" + else -> "Target: $hash" + }, style = MaterialTheme.typography.titleMedium, 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() @@ -405,6 +420,7 @@ fun EmptyModsScreen() { @OptIn(ExperimentalFoundationApi::class) @Composable fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit, onLongPress: () -> Unit) { + val isSelectable = modInfo.resolutionState == ResolutionState.KNOWN || modInfo.resolutionState == ResolutionState.MISC val elevation by animateDpAsState(if (isSelected) 4.dp else 1.dp, label = "elevation") ElevatedCard( elevation = CardDefaults.cardElevation(defaultElevation = elevation), @@ -414,7 +430,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 +439,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,19 +456,32 @@ fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit ) } Spacer(Modifier.width(8.dp)) - AssistChip( - onClick = { /* No action */ }, - label = { 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)) - }, - modifier = Modifier.heightIn(max = 24.dp) - ) + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) { + AssistChip( + onClick = { /* No action */ }, + label = { 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)) + }, + modifier = Modifier.heightIn(max = 24.dp) + ) + val stateLabel = when (modInfo.resolutionState) { + ResolutionState.KNOWN -> "KNOWN" + ResolutionState.MISC -> "MISC" + ResolutionState.UNKNOWN -> "UNKNOWN" + ResolutionState.INVALID -> "INVALID" + } + AssistChip( + onClick = { /* No action */ }, + label = { Text(stateLabel, style = MaterialTheme.typography.labelSmall) }, + 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..03f3eceb4 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 @@ -164,7 +164,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 || it.resolutionState == ResolutionState.MISC } + .map { it.uri } + .toSet() val currentSelections = _selectedMods.value val selectedFilteredUris = currentSelections.intersect(filteredModUris) @@ -176,7 +179,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 || it.resolutionState == ResolutionState.MISC) + } + .map { it.uri } + .toSet() val currentSelections = _selectedMods.value val groupSelections = currentSelections.intersect(modsInGroup) @@ -191,8 +200,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 || it.resolutionState == ResolutionState.MISC) + } + .groupBy { it.targetHash!! } .map { (hash, mods) -> RepackJob(hash, mods) } if (jobs.isNotEmpty()) { diff --git a/app/src/main/python/catalog_indexer.py b/app/src/main/python/catalog_indexer.py new file mode 100644 index 000000000..242fe2d76 --- /dev/null +++ b/app/src/main/python/catalog_indexer.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +import base64 +import json +import struct +from pathlib import Path + +from catalog_parser import read_int32_from_byte_array, read_object_from_byte_array + + +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] + return stem + + +def build_asset_index(catalog_content): + if not catalog_content: + return { + 'version': None, + 'assetsByExactKey': {}, + 'assetsByBaseName': {}, + 'bundlesByTargetHash': {} + } + + 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']) + + 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 + + assets_by_exact = {} + assets_by_base = {} + bundles_by_hash = {} + + for i in range(len(entries)): + primary_key_index = entries[i]['primary_key_index'] + raw_key = keys[primary_key_index] if primary_key_index < len(keys) else None + if not isinstance(raw_key, str): + continue + + asset_key = raw_key.lower() + asset_base = Path(asset_key).name.lower() + bundle_info = resolve_bundle_info(i) + if not bundle_info: + continue + + target_hash = bundle_info.get('bundle_name') + family_key = _extract_family_key(asset_base) + asset_info = { + 'assetKey': asset_key, + 'baseName': asset_base, + 'bundleName': bundle_info.get('bundle_name'), + 'bundleHash': bundle_info.get('bundle_hash'), + 'bundleSize': bundle_info.get('bundle_size'), + 'downloadName': bundle_info.get('download_name'), + 'targetHash': target_hash, + 'familyKey': family_key + } + + assets_by_exact[asset_key] = asset_info + assets_by_base.setdefault(asset_base, []).append(asset_info) + if target_hash: + bundles_by_hash[target_hash] = { + 'bundleName': bundle_info.get('bundle_name'), + 'bundleHash': bundle_info.get('bundle_hash'), + 'bundleSize': bundle_info.get('bundle_size'), + 'downloadName': bundle_info.get('download_name') + } + + return { + 'version': None, + 'assetsByExactKey': assets_by_exact, + 'assetsByBaseName': assets_by_base, + 'bundlesByTargetHash': bundles_by_hash + } + + +def load_or_build_asset_index(output_dir, quality, version, catalog_content): + index_path = Path(output_dir).joinpath(f"asset_index_{quality.lower()}_{version}.json") + if index_path.exists(): + with open(index_path, 'r', encoding='utf-8') as f: + return json.load(f) + + 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) + return index + + +def normalize_filename(filename: str): + return _normalize_filename(filename) diff --git a/app/src/main/python/main_script.py b/app/src/main/python/main_script.py index 49abfedcf..410035d66 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -10,6 +10,9 @@ import cdn_downloader from unpacker import unpack_bundle as unpacker_main import spine_merger +import resolver +import catalog_indexer +import json # --- Global Cache for CDN Catalog --- # In-memory cache for the catalog JSON content. @@ -102,7 +105,48 @@ 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: + 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.", None + + report_progress(f"Latest version is {version}. Checking catalog...") + catalog_content, error = cdn_downloader.download_catalog( + output_dir, quality, version, catalog_cache, catalog_cache_lock, progress_callback + ) + if error: + return False, error, None + + report_progress("Building/loading asset index...") + index = catalog_indexer.load_or_build_asset_index(output_dir, quality, version, catalog_content) + return True, version, index + except Exception as e: + import traceback + error_message = traceback.format_exc() + report_progress(f"A critical error occurred while building 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 update_character_data(output_dir: str): """ diff --git a/app/src/main/python/repacker/repacker.py b/app/src/main/python/repacker/repacker.py index bf6dd745e..aa6d80f9a 100644 --- a/app/src/main/python/repacker/repacker.py +++ b/app/src/main/python/repacker/repacker.py @@ -256,6 +256,19 @@ def sort_key(filename): from utils.file_operations import find_file_case_insensitive + +def normalize_mod_filename(filename: str): + filename = (filename or '').strip() + lowered = filename.lower() + if lowered.endswith('.atlas'): + return lowered[:-6] + '.atlas.txt' + if lowered.endswith('.skel'): + return lowered[:-5] + '.skel.bytes' + if lowered.endswith('.skel.txt'): + return lowered[:-9] + '.skel.bytes' + return lowered + + def compress_image_astc(image_bytes, width, height, block_x, block_y): astcenc = _load_astcenc_library() if not astcenc: @@ -461,10 +474,11 @@ def report_progress(message): for mod_filepath in mod_files: mod_filename = os.path.basename(mod_filepath) + normalized_filename = normalize_mod_filename(mod_filename) if mod_filename.lower().endswith('.json'): base_name, _ = os.path.splitext(mod_filename) - target_asset_name = (base_name + ".skel").lower() + target_asset_name = normalize_mod_filename(base_name + ".skel") if target_asset_name in asset_map: json_files.append((mod_filepath, target_asset_name)) @@ -478,7 +492,7 @@ def report_progress(message): else: png_rgba_files.append((mod_filepath, target_asset_name)) else: - target_asset_name = mod_filename.lower() + target_asset_name = normalized_filename if target_asset_name in asset_map: obj = asset_map[target_asset_name] if obj.type.name == "TextAsset": diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py new file mode 100644 index 000000000..387112907 --- /dev/null +++ b/app/src/main/python/resolver.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +from pathlib import Path + +from catalog_indexer import normalize_filename + + +def resolve_mod_folder(mod_file_names, asset_index): + resolved_targets = [] + unresolved_files = [] + target_hashes = set() + family_keys = set() + + 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 = normalize_filename(base_name) + matched = None + strategy = 'NONE' + + if lowered_full in assets_by_exact: + matched = assets_by_exact[lowered_full] + strategy = 'EXACT' + else: + for candidate in candidates: + hits = assets_by_base.get(candidate.lower()) or [] + if hits: + matched = hits[0] + strategy = 'EXACT' if candidate.lower() == base_name.lower() else 'EXTENSION_MAPPING' + break + + if matched: + target_hash = matched.get('targetHash') + family_key = matched.get('familyKey') + if target_hash: + target_hashes.add(target_hash) + if family_key: + family_keys.add(family_key) + resolved_targets.append({ + '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': 1.0 if strategy == 'EXACT' else 0.9 + }) + else: + unresolved_files.append(base_name) + + if len(target_hashes) > 1 or len(family_keys) > 1: + state = 'INVALID' + error_reason = 'Multiple targets detected in one mod folder' + target_hash = None + family_key = None + elif len(target_hashes) == 1: + target_hash = next(iter(target_hashes)) + family_key = next(iter(family_keys)) if family_keys else None + state = 'KNOWN' + error_reason = None + else: + target_hash = None + family_key = None + state = 'UNKNOWN' + error_reason = 'No matching target could be resolved' + + return { + 'targetHash': target_hash, + 'resolvedFamilyKey': family_key, + 'resolvedTargets': resolved_targets, + 'unresolvedFiles': unresolved_files, + 'resolutionState': state, + 'errorReason': error_reason + } + + +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' From f452a01758667346e275f8f323b42a062fc19537 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 07:26:57 +0000 Subject: [PATCH 02/24] Batch mod resolution to reduce scan latency --- .../data/repository/ModRepository.kt | 225 +++++++++++------- .../bd2modmanager/service/ModdingService.kt | 23 ++ app/src/main/python/main_script.py | 22 ++ 3 files changed, 179 insertions(+), 91 deletions(-) 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 01cb2a4f3..86b4fde70 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 @@ -31,11 +31,21 @@ class ModRepository( 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 } @@ -44,22 +54,24 @@ class ModRepository( val lastModified = file.lastModified() val cachedInfo = existingCache[uriString] - val modInfo = if (cachedInfo != null && cachedInfo.lastModified == lastModified) { + 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, - resolutionState = cachedInfo.resolutionState, - targetHash = cachedInfo.targetHash, - resolvedFamilyKey = cachedInfo.resolvedFamilyKey, - unresolvedFiles = cachedInfo.unresolvedFiles, - errorReason = cachedInfo.errorReason + 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") ?: "" @@ -69,83 +81,123 @@ class ModRepository( } else { extractModDetailsFromUri(file.uri) } + candidates.add( + ScannedModCandidate( + uriString = uriString, + lastModified = lastModified, + name = modName, + uri = file.uri, + isDirectory = isDirectory, + modDetails = modDetails + ) + ) + } + } - if (!Python.isStarted()) { - Python.start(com.chaquo.python.android.AndroidPlatform(context)) - } + if (candidates.isNotEmpty()) { + if (!Python.isStarted()) { + Python.start(com.chaquo.python.android.AndroidPlatform(context)) + } - val resolveResult = resolveModDetails(modDetails) - val bestMatch = characterRepository.findBestMatch(modDetails.fileId, modDetails.fileNames) + 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.cacheDir.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 + } + } - val resolutionState = resolveResult.first - val targetHash = resolveResult.second.optString("targetHash").ifBlank { null } - val resolvedFamilyKey = resolveResult.second.optString("resolvedFamilyKey").ifBlank { null } - val unresolvedFiles = jsonArrayToStringList(resolveResult.second.optJSONArray("unresolvedFiles")) - val errorReason = resolveResult.second.optString("errorReason").ifBlank { null } - val resolvedTargets = parseResolvedTargets(resolveResult.second.optJSONArray("resolvedTargets")) + 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 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 newCacheInfo = ModCacheInfo( - uriString = uriString, - lastModified = lastModified, - name = modName, - character = displayCharacter, - costume = displayCostume, - type = displayType, - targetHashedName = targetHash ?: bestMatch?.hashedName, - isDirectory = isDirectory, - resolutionState = resolutionState, - targetHash = targetHash ?: bestMatch?.hashedName, - resolvedFamilyKey = resolvedFamilyKey, - unresolvedFiles = unresolvedFiles, - errorReason = errorReason - ) - newCache[uriString] = newCacheInfo + val resolvedHash = targetHash ?: bestMatch?.hashedName + 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 = modName, + name = candidate.name, character = displayCharacter, costume = displayCostume, type = displayType, isEnabled = false, - uri = file.uri, - targetHashedName = targetHash ?: bestMatch?.hashedName, - isDirectory = isDirectory, + uri = candidate.uri, + targetHashedName = resolvedHash, + isDirectory = candidate.isDirectory, resolutionState = resolutionState, - targetHash = targetHash ?: bestMatch?.hashedName, + targetHash = resolvedHash, resolvedFamilyKey = resolvedFamilyKey, resolvedTargets = resolvedTargets, unresolvedFiles = unresolvedFiles, errorReason = errorReason ) - } - tempModsList.add(modInfo) + ) } + } saveModCache(newCache) tempModsList.sortedBy { it.name } @@ -213,30 +265,21 @@ class ModRepository( return ModDetails(fileId, fileNames) } - private fun resolveModDetails(modDetails: ModDetails): Pair { - val fileNamesJson = gson.toJson(modDetails.fileNames) - val (success, payload) = ModdingService.resolveModFiles( - fileNamesJson, - context.cacheDir.absolutePath, - "HD" - ) { } - - if (!success || payload == null) { - val fallback = JSONObject() - fallback.put("resolutionState", ResolutionState.UNKNOWN.name) - fallback.put("errorReason", "Resolver failed") - fallback.put("unresolvedFiles", JSONArray(modDetails.fileNames)) - fallback.put("resolvedTargets", JSONArray()) - return ResolutionState.UNKNOWN to fallback + 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()) } + } - val state = try { + private fun parseResolutionState(payload: JSONObject): ResolutionState { + return try { ResolutionState.valueOf(payload.optString("resolutionState", ResolutionState.UNKNOWN.name)) } catch (_: Exception) { ResolutionState.UNKNOWN } - - return state to payload } private fun jsonArrayToStringList(array: JSONArray?): List { 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 bd7abc043..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,7 @@ 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 { @@ -97,6 +98,28 @@ object ModdingService { } } + 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/python/main_script.py b/app/src/main/python/main_script.py index 410035d66..7a6d8b6d2 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -148,6 +148,28 @@ 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): + 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. From 46f4d8127f8d7a7b8512c55bdbcc828840c1640e Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 07:46:45 +0000 Subject: [PATCH 03/24] Refine invalid detection for unresolved and spine pages --- app/src/main/python/catalog_indexer.py | 5 +++++ app/src/main/python/resolver.py | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/src/main/python/catalog_indexer.py b/app/src/main/python/catalog_indexer.py index 242fe2d76..29ecd6f2e 100644 --- a/app/src/main/python/catalog_indexer.py +++ b/app/src/main/python/catalog_indexer.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import base64 import json +import re import struct from pathlib import Path @@ -27,10 +28,14 @@ def _extract_family_key(name: str): return None lowered = name.lower() stem = Path(lowered).stem + if stem.endswith('.atlas'): stem = stem[:-6] if stem.endswith('.skel'): stem = stem[:-5] + + # Normalize common spine multi-page suffixes like _2, _3 so they map to the same family. + stem = re.sub(r'_(\d+)$', '', stem) return stem diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index 387112907..451166c25 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import re from pathlib import Path from catalog_indexer import normalize_filename @@ -33,7 +34,7 @@ def resolve_mod_folder(mod_file_names, asset_index): if matched: target_hash = matched.get('targetHash') - family_key = matched.get('familyKey') + family_key = _normalize_family_key(matched.get('familyKey')) if target_hash: target_hashes.add(target_hash) if family_key: @@ -53,7 +54,9 @@ def resolve_mod_folder(mod_file_names, asset_index): else: unresolved_files.append(base_name) - if len(target_hashes) > 1 or len(family_keys) > 1: + # INVALID should be based only on successfully resolved technical targets. + # Unknown/unmatched files stay in unresolvedFiles and do not make the folder invalid by themselves. + if len(target_hashes) > 1 or (len(target_hashes) == 1 and len(family_keys) > 1): state = 'INVALID' error_reason = 'Multiple targets detected in one mod folder' target_hash = None @@ -88,3 +91,11 @@ def _infer_asset_type(file_name: str): 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 From f8a82824357adb6458803161312dbbebb259e19f Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:17:52 +0000 Subject: [PATCH 04/24] Prevent duplicate scan initialization --- .../ui/viewmodel/MainViewModel.kt | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) 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 03f3eceb4..1eb78d5ad 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) @@ -605,19 +612,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 } } } From 9ef15144a5af4b50ecba3dfe9f309a5e4325e49c Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:32:03 +0000 Subject: [PATCH 05/24] Tighten target header layout and simplify type chip --- .../bd2modmanager/ui/screens/ModScreen.kt | 121 ++++++++++++------ 1 file changed, 85 insertions(+), 36 deletions(-) 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 2fb458618..3c5c5dd2e 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,7 +29,10 @@ 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 @@ -281,17 +284,47 @@ fun ModScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { - Text( - text = when (hash) { - "Unknown" -> "Unknown" - "Invalid" -> "Invalid" - else -> "Target: $hash" - }, - 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) + ) + } + } + } if (hash != "Unknown" && hash != "Invalid") { IconButton(onClick = { onUninstallRequest(hash) }) { @@ -345,6 +378,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( @@ -456,31 +518,18 @@ fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit ) } Spacer(Modifier.width(8.dp)) - Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) { - AssistChip( - onClick = { /* No action */ }, - label = { 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)) - }, - modifier = Modifier.heightIn(max = 24.dp) - ) - val stateLabel = when (modInfo.resolutionState) { - ResolutionState.KNOWN -> "KNOWN" - ResolutionState.MISC -> "MISC" - ResolutionState.UNKNOWN -> "UNKNOWN" - ResolutionState.INVALID -> "INVALID" - } - AssistChip( - onClick = { /* No action */ }, - label = { Text(stateLabel, style = MaterialTheme.typography.labelSmall) }, - modifier = Modifier.heightIn(max = 24.dp) - ) + AssistChip( + onClick = { /* No action */ }, + label = { 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)) + }, + modifier = Modifier.heightIn(max = 24.dp) } } } From 8b6dbfab70fac7ce35d8e73c2aabbce2794d5e76 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:38:44 +0000 Subject: [PATCH 06/24] Fix Compose Icon modifier syntax --- .../main/java/com/example/bd2modmanager/ui/screens/ModScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3c5c5dd2e..ef4c25fbb 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 @@ -527,7 +527,7 @@ fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit "cutscene" -> Icons.Default.Movie else -> Icons.Default.Category } - Icon(icon, contentDescription = modInfo.type, Modifier.size(14.dp)) + Icon(icon, contentDescription = modInfo.type, modifier = Modifier.size(14.dp)) }, modifier = Modifier.heightIn(max = 24.dp) } From 04feac5e0c151830f2d10ceffaca5abd4f94ab6c Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 08:44:58 +0000 Subject: [PATCH 07/24] Simplify AssistChip type rendering --- .../bd2modmanager/ui/screens/ModScreen.kt | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) 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 ef4c25fbb..dcf030877 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 @@ -518,19 +518,28 @@ 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 = Modifier.size(14.dp)) + Icon( + imageVector = typeIcon, + contentDescription = modInfo.type, + modifier = Modifier.size(14.dp) + ) }, modifier = Modifier.heightIn(max = 24.dp) - } + ) } } } From 200143254b94b5f61be2d2a13e664590c795c1c5 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 09:14:53 +0000 Subject: [PATCH 08/24] Normalize specialillust variants for resolver matching --- .../bd2modmanager/data/repository/CharacterRepository.kt | 2 +- app/src/main/python/catalog_indexer.py | 4 ++-- app/src/main/python/catalog_parser.py | 2 +- app/src/main/python/resolver.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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..426be5978 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 @@ -121,6 +121,6 @@ class CharacterRepository(private val context: Context) { } 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() + return "(char\\d{6}|illust_dating\\d+|illust_special\\d+|illust_talk\\d+|npc\\d+|specialillust[a-z0-9_-]+|storypack[a-z0-9_-]+|\\bRhythmHitAnim\\b)".toRegex(RegexOption.IGNORE_CASE).find(entryName)?.value?.lowercase() } } diff --git a/app/src/main/python/catalog_indexer.py b/app/src/main/python/catalog_indexer.py index 29ecd6f2e..91c7fab85 100644 --- a/app/src/main/python/catalog_indexer.py +++ b/app/src/main/python/catalog_indexer.py @@ -34,8 +34,8 @@ def _extract_family_key(name: str): if stem.endswith('.skel'): stem = stem[:-5] - # Normalize common spine multi-page suffixes like _2, _3 so they map to the same family. - stem = re.sub(r'_(\d+)$', '', stem) + # Normalize common page / variant suffixes like _2, _3, -1, -2 so they map to the same family. + stem = re.sub(r'([_-])(\d+)$', '', stem) return stem diff --git a/app/src/main/python/catalog_parser.py b/app/src/main/python/catalog_parser.py index 7672fea58..654f89696 100644 --- a/app/src/main/python/catalog_parser.py +++ b/app/src/main/python/catalog_parser.py @@ -209,7 +209,7 @@ def resolve_bundle_info(entry_index): continue # Extract file_id like 'char000104' from asset_key like 'assets/asset/character/char000104/char000104.skel.bytes' - match = re.search(r'(cutscene_char\d{6}|char\d{6}|illust_dating\d+|illust_special\d+|illust_talk\d+|npc\d+|specialillust\w+|storypack\w+|\bRhythmHitAnim\b)', asset_key, re.IGNORECASE) + match = re.search(r'(cutscene_char\d{6}|char\d{6}|illust_dating\d+|illust_special\d+|illust_talk\d+|npc\d+|specialillust[a-z0-9_-]+|storypack[a-z0-9_-]+|\bRhythmHitAnim\b)', asset_key, re.IGNORECASE) if not match: continue diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index 451166c25..d77c527e6 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -97,5 +97,5 @@ def _normalize_family_key(value: str): if not value: return None lowered = value.lower() - lowered = re.sub(r'_(\d+)$', '', lowered) + lowered = re.sub(r'([_-])(\d+)$', '', lowered) return lowered From 9d192be488fa93a291a33259e27c4d7b38e3d082 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 09:36:04 +0000 Subject: [PATCH 09/24] Revert "Normalize specialillust variants for resolver matching" This reverts commit 200143254b94b5f61be2d2a13e664590c795c1c5. --- .../bd2modmanager/data/repository/CharacterRepository.kt | 2 +- app/src/main/python/catalog_indexer.py | 4 ++-- app/src/main/python/catalog_parser.py | 2 +- app/src/main/python/resolver.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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 426be5978..e82c0cb8f 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 @@ -121,6 +121,6 @@ class CharacterRepository(private val context: Context) { } fun extractFileId(entryName: String): String? { - return "(char\\d{6}|illust_dating\\d+|illust_special\\d+|illust_talk\\d+|npc\\d+|specialillust[a-z0-9_-]+|storypack[a-z0-9_-]+|\\bRhythmHitAnim\\b)".toRegex(RegexOption.IGNORE_CASE).find(entryName)?.value?.lowercase() + 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/python/catalog_indexer.py b/app/src/main/python/catalog_indexer.py index 91c7fab85..29ecd6f2e 100644 --- a/app/src/main/python/catalog_indexer.py +++ b/app/src/main/python/catalog_indexer.py @@ -34,8 +34,8 @@ def _extract_family_key(name: str): if stem.endswith('.skel'): stem = stem[:-5] - # Normalize common page / variant suffixes like _2, _3, -1, -2 so they map to the same family. - stem = re.sub(r'([_-])(\d+)$', '', stem) + # Normalize common spine multi-page suffixes like _2, _3 so they map to the same family. + stem = re.sub(r'_(\d+)$', '', stem) return stem diff --git a/app/src/main/python/catalog_parser.py b/app/src/main/python/catalog_parser.py index 654f89696..7672fea58 100644 --- a/app/src/main/python/catalog_parser.py +++ b/app/src/main/python/catalog_parser.py @@ -209,7 +209,7 @@ def resolve_bundle_info(entry_index): continue # Extract file_id like 'char000104' from asset_key like 'assets/asset/character/char000104/char000104.skel.bytes' - match = re.search(r'(cutscene_char\d{6}|char\d{6}|illust_dating\d+|illust_special\d+|illust_talk\d+|npc\d+|specialillust[a-z0-9_-]+|storypack[a-z0-9_-]+|\bRhythmHitAnim\b)', asset_key, re.IGNORECASE) + match = re.search(r'(cutscene_char\d{6}|char\d{6}|illust_dating\d+|illust_special\d+|illust_talk\d+|npc\d+|specialillust\w+|storypack\w+|\bRhythmHitAnim\b)', asset_key, re.IGNORECASE) if not match: continue diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index d77c527e6..451166c25 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -97,5 +97,5 @@ def _normalize_family_key(value: str): if not value: return None lowered = value.lower() - lowered = re.sub(r'([_-])(\d+)$', '', lowered) + lowered = re.sub(r'_(\d+)$', '', lowered) return lowered From a5ef15852243d8723d21b0a5af94d546f591051e Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 09:44:28 +0000 Subject: [PATCH 10/24] Resolve mod targets by candidate-set intersection --- app/src/main/python/resolver.py | 197 +++++++++++++++++++++++--------- 1 file changed, 144 insertions(+), 53 deletions(-) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index 451166c25..507c7314f 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -6,10 +6,8 @@ def resolve_mod_folder(mod_file_names, asset_index): - resolved_targets = [] unresolved_files = [] - target_hashes = set() - family_keys = set() + file_matches = [] assets_by_base = (asset_index or {}).get('assetsByBaseName', {}) assets_by_exact = (asset_index or {}).get('assetsByExactKey', {}) @@ -18,69 +16,162 @@ def resolve_mod_folder(mod_file_names, asset_index): base_name = Path(file_name).name lowered_full = (file_name or '').replace('\\', '/').lower() candidates = normalize_filename(base_name) - matched = None - strategy = 'NONE' + matches = [] - if lowered_full in assets_by_exact: - matched = assets_by_exact[lowered_full] - strategy = 'EXACT' - else: - for candidate in candidates: - hits = assets_by_base.get(candidate.lower()) or [] - if hits: - matched = hits[0] - strategy = 'EXACT' if candidate.lower() == base_name.lower() else 'EXTENSION_MAPPING' - break - - if matched: - target_hash = matched.get('targetHash') - family_key = _normalize_family_key(matched.get('familyKey')) - if target_hash: - target_hashes.add(target_hash) - if family_key: - family_keys.add(family_key) - resolved_targets.append({ - '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': 1.0 if strategy == 'EXACT' else 0.9 + exact_match = assets_by_exact.get(lowered_full) + if exact_match: + matches.append(_build_match(base_name, candidates, exact_match, 'EXACT', 1.0)) + + for candidate in candidates: + hits = assets_by_base.get(candidate.lower()) or [] + 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) - # INVALID should be based only on successfully resolved technical targets. - # Unknown/unmatched files stay in unresolvedFiles and do not make the folder invalid by themselves. - if len(target_hashes) > 1 or (len(target_hashes) == 1 and len(family_keys) > 1): - state = 'INVALID' - error_reason = 'Multiple targets detected in one mod folder' - target_hash = None - family_key = None - elif len(target_hashes) == 1: - target_hash = next(iter(target_hashes)) - family_key = next(iter(family_keys)) if family_keys else None - state = 'KNOWN' - error_reason = None + 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: - target_hash = None - family_key = None - state = 'UNKNOWN' - error_reason = 'No matching target could be resolved' + 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')) + } + + if len(family_keys) > 1: + return { + 'targetHash': None, + 'resolvedFamilyKey': None, + 'resolvedTargets': resolved_targets, + 'unresolvedFiles': unresolved_files, + 'resolutionState': 'INVALID', + 'errorReason': 'Multiple targets detected in one mod folder' + } return { 'targetHash': target_hash, - 'resolvedFamilyKey': family_key, + 'resolvedFamilyKey': next(iter(family_keys)) if family_keys else None, 'resolvedTargets': resolved_targets, 'unresolvedFiles': unresolved_files, - 'resolutionState': state, - 'errorReason': error_reason + 'resolutionState': 'KNOWN', + 'errorReason': None + } + + +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() From 235ab0fb7f48a29534f30415f8e05f1e27c4184f Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:08:44 +0000 Subject: [PATCH 11/24] Expand specialillust candidates and prefer non-censorship hits --- app/src/main/python/resolver.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index 507c7314f..e6fcdfacf 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -15,7 +15,7 @@ def resolve_mod_folder(mod_file_names, asset_index): for file_name in mod_file_names or []: base_name = Path(file_name).name lowered_full = (file_name or '').replace('\\', '/').lower() - candidates = normalize_filename(base_name) + candidates = _expand_candidates(base_name) matches = [] exact_match = assets_by_exact.get(lowered_full) @@ -24,6 +24,7 @@ def resolve_mod_folder(mod_file_names, asset_index): for candidate in candidates: hits = assets_by_base.get(candidate.lower()) or [] + 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: @@ -102,6 +103,32 @@ def resolve_mod_folder(mod_file_names, asset_index): } +def _expand_candidates(base_name: str): + candidates = list(normalize_filename(base_name)) + lowered = (base_name or '').lower() + stem = Path(lowered).stem + + if stem.endswith('.atlas'): + stem = stem[:-6] + if stem.endswith('.skel'): + stem = stem[:-5] + + # specialillust files are cataloged through prefab / vp asset keys rather than direct png names. + if lowered.endswith('.png') and stem.startswith('specialillust'): + candidates.append(f'{stem}.prefab') + candidates.append(f'vp_{stem}.asset') + + return list(dict.fromkeys(candidates)) + + +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')) From 1e8be267ae94178bc4b8b71d9cd1c4aa44a75440 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:30:29 +0000 Subject: [PATCH 12/24] Keep non-censorship preference without specialillust expansion --- app/src/main/python/resolver.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index e6fcdfacf..d5eeeea0f 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -104,21 +104,7 @@ def resolve_mod_folder(mod_file_names, asset_index): def _expand_candidates(base_name: str): - candidates = list(normalize_filename(base_name)) - lowered = (base_name or '').lower() - stem = Path(lowered).stem - - if stem.endswith('.atlas'): - stem = stem[:-6] - if stem.endswith('.skel'): - stem = stem[:-5] - - # specialillust files are cataloged through prefab / vp asset keys rather than direct png names. - if lowered.endswith('.png') and stem.startswith('specialillust'): - candidates.append(f'{stem}.prefab') - candidates.append(f'vp_{stem}.asset') - - return list(dict.fromkeys(candidates)) + return list(dict.fromkeys(normalize_filename(base_name))) def _prefer_primary_hits(hits): From 3d26c5fb333523c3fd3dc43dc16bcd0e8ca2b99c Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:54:11 +0000 Subject: [PATCH 13/24] Compress asset index and prune stale cache files --- app/src/main/python/catalog_indexer.py | 155 ++++++++++++++++++++----- app/src/main/python/main_script.py | 10 ++ app/src/main/python/resolver.py | 53 ++++++++- 3 files changed, 189 insertions(+), 29 deletions(-) diff --git a/app/src/main/python/catalog_indexer.py b/app/src/main/python/catalog_indexer.py index 29ecd6f2e..9e7dc3782 100644 --- a/app/src/main/python/catalog_indexer.py +++ b/app/src/main/python/catalog_indexer.py @@ -8,6 +8,9 @@ from catalog_parser import read_int32_from_byte_array, read_object_from_byte_array +INDEX_SCHEMA_VERSION = 3 + + def _normalize_filename(filename: str): filename = (filename or "").strip().lower() if not filename: @@ -34,15 +37,39 @@ def _extract_family_key(name: str): if stem.endswith('.skel'): stem = stem[:-5] - # Normalize common spine multi-page suffixes like _2, _3 so they map to the same family. 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': {}, 'bundlesByTargetHash': {} @@ -52,6 +79,7 @@ def build_asset_index(catalog_content): 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: @@ -120,47 +150,103 @@ def resolve_bundle_info(entry_index): return info return None + strings = [] + string_ids = {} + records = [] + record_ids = {} assets_by_exact = {} - assets_by_base = {} + base_candidates = {} bundles_by_hash = {} + 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 - if not isinstance(raw_key, str): - continue - - asset_key = raw_key.lower() - asset_base = Path(asset_key).name.lower() + 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') - family_key = _extract_family_key(asset_base) - asset_info = { - 'assetKey': asset_key, - 'baseName': asset_base, - 'bundleName': bundle_info.get('bundle_name'), - 'bundleHash': bundle_info.get('bundle_hash'), - 'bundleSize': bundle_info.get('bundle_size'), - 'downloadName': bundle_info.get('download_name'), - 'targetHash': target_hash, - 'familyKey': family_key - } + if not target_hash: + continue - assets_by_exact[asset_key] = asset_info - assets_by_base.setdefault(asset_base, []).append(asset_info) - if target_hash: + if target_hash not in bundles_by_hash: bundles_by_hash[target_hash] = { - 'bundleName': bundle_info.get('bundle_name'), 'bundleHash': bundle_info.get('bundle_hash'), 'bundleSize': bundle_info.get('bundle_size'), 'downloadName': bundle_info.get('download_name') } + 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, 'bundlesByTargetHash': bundles_by_hash @@ -168,15 +254,28 @@ def resolve_bundle_info(entry_index): def load_or_build_asset_index(output_dir, quality, version, catalog_content): - index_path = Path(output_dir).joinpath(f"asset_index_{quality.lower()}_{version}.json") + 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(): - with open(index_path, 'r', encoding='utf-8') as f: - return json.load(f) + 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) + json.dump(index, f, ensure_ascii=False, separators=(',', ':')) return index diff --git a/app/src/main/python/main_script.py b/app/src/main/python/main_script.py index 7a6d8b6d2..2037c6d76 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -24,6 +24,12 @@ # 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 unpack_bundle(bundle_path: str, output_dir: str, progress_callback=None): """ Entry point for Kotlin to unpack a bundle. @@ -76,6 +82,8 @@ def report_progress(message): return False, "Failed to get CDN version." report_progress(f"Latest version is {version}. Checking catalog...") + with catalog_cache_lock: + _prune_catalog_cache(version) # The download_catalog function will now use the shared cache catalog_content, error = cdn_downloader.download_catalog( output_dir, quality, version, catalog_cache, catalog_cache_lock, progress_callback @@ -118,6 +126,8 @@ def report_progress(message): return False, "Failed to get CDN version.", None 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 ) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index d5eeeea0f..c16a44c62 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -20,10 +20,14 @@ def resolve_mod_folder(mod_file_names, asset_index): exact_match = assets_by_exact.get(lowered_full) if exact_match: - matches.append(_build_match(base_name, candidates, exact_match, 'EXACT', 1.0)) + 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 @@ -107,6 +111,53 @@ def _expand_candidates(base_name: str): return list(dict.fromkeys(normalize_filename(base_name))) +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': target_hash, + '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 [] From 709bb3dea54eb0cfbdd8ef3b615daa9e2bfd8cd9 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:14:36 +0000 Subject: [PATCH 14/24] Require canonical asset names for target resolution --- app/src/main/python/catalog_indexer.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/app/src/main/python/catalog_indexer.py b/app/src/main/python/catalog_indexer.py index 9e7dc3782..1e1dab5aa 100644 --- a/app/src/main/python/catalog_indexer.py +++ b/app/src/main/python/catalog_indexer.py @@ -8,7 +8,7 @@ from catalog_parser import read_int32_from_byte_array, read_object_from_byte_array -INDEX_SCHEMA_VERSION = 3 +INDEX_SCHEMA_VERSION = 4 def _normalize_filename(filename: str): @@ -79,7 +79,6 @@ def build_asset_index(catalog_content): 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: @@ -197,9 +194,7 @@ def append_unique_ref(mapping, key, 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 @@ -218,10 +213,6 @@ def append_unique_ref(mapping, key, record_id): 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 From 2704e73c60e84701dd8a20ce375dd0cd9400c2c4 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:27:38 +0000 Subject: [PATCH 15/24] Tighten resolver state semantics and cleanup temp cache --- .../data/repository/ModRepository.kt | 2 +- .../bd2modmanager/ui/screens/ModScreen.kt | 7 +++-- .../ui/viewmodel/MainViewModel.kt | 29 ++++++++++++------- app/src/main/python/catalog_indexer.py | 14 ++------- app/src/main/python/resolver.py | 2 +- 5 files changed, 26 insertions(+), 28 deletions(-) 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 86b4fde70..9d6b54d75 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 @@ -160,7 +160,7 @@ class ModRepository( } } - val resolvedHash = targetHash ?: bestMatch?.hashedName + val resolvedHash = targetHash val newCacheInfo = ModCacheInfo( uriString = candidate.uriString, lastModified = candidate.lastModified, 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 dcf030877..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 @@ -52,7 +52,8 @@ fun ModScreen( val allModsList by viewModel.modsList.collectAsState() val groupedMods = modsList.groupBy { when (it.resolutionState) { - ResolutionState.KNOWN, ResolutionState.MISC -> it.targetHash ?: "Unknown" + ResolutionState.KNOWN -> it.targetHash ?: "Unknown" + ResolutionState.MISC -> it.targetHash ?: "Unknown" ResolutionState.UNKNOWN -> "Unknown" ResolutionState.INVALID -> "Invalid" } @@ -131,7 +132,7 @@ fun ModScreen( contentAlignment = Alignment.Center ) { val allModsCount = allModsList.count { - it.resolutionState == ResolutionState.KNOWN || it.resolutionState == ResolutionState.MISC + it.resolutionState == ResolutionState.KNOWN } val selectedModsCount = selectedMods.size val checkboxState = when { @@ -482,7 +483,7 @@ fun EmptyModsScreen() { @OptIn(ExperimentalFoundationApi::class) @Composable fun ModCard(modInfo: ModInfo, isSelected: Boolean, onToggleSelection: () -> Unit, onLongPress: () -> Unit) { - val isSelectable = modInfo.resolutionState == ResolutionState.KNOWN || modInfo.resolutionState == ResolutionState.MISC + 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), 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 1eb78d5ad..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 @@ -172,7 +172,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( fun toggleSelectAll() { val filteredModUris = filteredModsList.value - .filter { it.resolutionState == ResolutionState.KNOWN || it.resolutionState == ResolutionState.MISC } + .filter { it.resolutionState == ResolutionState.KNOWN } .map { it.uri } .toSet() val currentSelections = _selectedMods.value @@ -189,7 +189,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( val modsInGroup = filteredModsList.value .filter { it.targetHash == groupHash && - (it.resolutionState == ResolutionState.KNOWN || it.resolutionState == ResolutionState.MISC) + it.resolutionState == ResolutionState.KNOWN } .map { it.uri } .toSet() @@ -209,7 +209,7 @@ class MainViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel( .mapNotNull { uri -> allMods.find { it.uri == uri } } .filter { !it.targetHash.isNullOrBlank() && - (it.resolutionState == ResolutionState.KNOWN || it.resolutionState == ResolutionState.MISC) + it.resolutionState == ResolutionState.KNOWN } .groupBy { it.targetHash!! } .map { (hash, mods) -> RepackJob(hash, mods) } @@ -251,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...")) @@ -261,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() @@ -289,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)) @@ -321,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) {} } } diff --git a/app/src/main/python/catalog_indexer.py b/app/src/main/python/catalog_indexer.py index 1e1dab5aa..278978a68 100644 --- a/app/src/main/python/catalog_indexer.py +++ b/app/src/main/python/catalog_indexer.py @@ -71,8 +71,7 @@ def build_asset_index(catalog_content): 'strings': [], 'records': [], 'assetsByExactKey': {}, - 'assetsByBaseName': {}, - 'bundlesByTargetHash': {} + 'assetsByBaseName': {} } bucket_array = base64.b64decode(catalog_content['m_BucketDataString']) @@ -153,7 +152,6 @@ def resolve_bundle_info(entry_index): record_ids = {} assets_by_exact = {} base_candidates = {} - bundles_by_hash = {} def intern_string(value): if value is None: @@ -203,13 +201,6 @@ def append_unique_ref(mapping, key, record_id): if not target_hash: continue - if target_hash not in bundles_by_hash: - bundles_by_hash[target_hash] = { - 'bundleHash': bundle_info.get('bundle_hash'), - 'bundleSize': bundle_info.get('bundle_size'), - 'downloadName': bundle_info.get('download_name') - } - candidate_keys = [] if isinstance(raw_key, str) and raw_key: candidate_keys.append(raw_key.lower()) @@ -239,8 +230,7 @@ def append_unique_ref(mapping, key, record_id): 'strings': strings, 'records': records, 'assetsByExactKey': assets_by_exact, - 'assetsByBaseName': assets_by_base, - 'bundlesByTargetHash': bundles_by_hash + 'assetsByBaseName': assets_by_base } diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index c16a44c62..24f89fa0f 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -146,7 +146,7 @@ def _decode_hit(asset_index, raw_hit): return { 'assetKey': asset_key, - 'bundleName': target_hash, + 'bundleName': None, 'targetHash': target_hash, 'familyKey': family_key } From 9c0bd844776ad654f05aa0dab617eb4541c37f81 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:50:10 +0000 Subject: [PATCH 16/24] Revert repacker extension mapping to match main --- app/src/main/python/repacker/repacker.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/app/src/main/python/repacker/repacker.py b/app/src/main/python/repacker/repacker.py index aa6d80f9a..45320b128 100644 --- a/app/src/main/python/repacker/repacker.py +++ b/app/src/main/python/repacker/repacker.py @@ -257,18 +257,6 @@ def sort_key(filename): from utils.file_operations import find_file_case_insensitive -def normalize_mod_filename(filename: str): - filename = (filename or '').strip() - lowered = filename.lower() - if lowered.endswith('.atlas'): - return lowered[:-6] + '.atlas.txt' - if lowered.endswith('.skel'): - return lowered[:-5] + '.skel.bytes' - if lowered.endswith('.skel.txt'): - return lowered[:-9] + '.skel.bytes' - return lowered - - def compress_image_astc(image_bytes, width, height, block_x, block_y): astcenc = _load_astcenc_library() if not astcenc: @@ -474,11 +462,10 @@ def report_progress(message): for mod_filepath in mod_files: mod_filename = os.path.basename(mod_filepath) - normalized_filename = normalize_mod_filename(mod_filename) if mod_filename.lower().endswith('.json'): base_name, _ = os.path.splitext(mod_filename) - target_asset_name = normalize_mod_filename(base_name + ".skel") + target_asset_name = (base_name + ".skel").lower() if target_asset_name in asset_map: json_files.append((mod_filepath, target_asset_name)) @@ -492,7 +479,7 @@ def report_progress(message): else: png_rgba_files.append((mod_filepath, target_asset_name)) else: - target_asset_name = normalized_filename + target_asset_name = mod_filename.lower() if target_asset_name in asset_map: obj = asset_map[target_asset_name] if obj.type.name == "TextAsset": From bc3a73ee937cf82de03d41ba4cae3dfd53b47af9 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:51:41 +0000 Subject: [PATCH 17/24] Restore internal id matching in resolver index --- app/src/main/python/catalog_indexer.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/python/catalog_indexer.py b/app/src/main/python/catalog_indexer.py index 278978a68..02ef054b2 100644 --- a/app/src/main/python/catalog_indexer.py +++ b/app/src/main/python/catalog_indexer.py @@ -8,7 +8,7 @@ from catalog_parser import read_int32_from_byte_array, read_object_from_byte_array -INDEX_SCHEMA_VERSION = 4 +INDEX_SCHEMA_VERSION = 5 def _normalize_filename(filename: str): @@ -78,6 +78,7 @@ def build_asset_index(catalog_content): 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: @@ -192,7 +195,9 @@ def append_unique_ref(mapping, key, 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 @@ -204,6 +209,10 @@ def append_unique_ref(mapping, key, record_id): 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 From 7f95b3d94549c730bb00568d0c3f56e6ff3a232e Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:14:53 +0000 Subject: [PATCH 18/24] Support sactx LocalPackTitle resolver bridge --- app/src/main/python/resolver.py | 37 ++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/app/src/main/python/resolver.py b/app/src/main/python/resolver.py index 24f89fa0f..9e7dcf148 100644 --- a/app/src/main/python/resolver.py +++ b/app/src/main/python/resolver.py @@ -87,19 +87,9 @@ def resolve_mod_folder(mod_file_names, asset_index): if _normalize_family_key(match.get('familyKey')) } - if len(family_keys) > 1: - return { - 'targetHash': None, - 'resolvedFamilyKey': None, - 'resolvedTargets': resolved_targets, - 'unresolvedFiles': unresolved_files, - 'resolutionState': 'INVALID', - 'errorReason': 'Multiple targets detected in one mod folder' - } - return { 'targetHash': target_hash, - 'resolvedFamilyKey': next(iter(family_keys)) if family_keys else None, + 'resolvedFamilyKey': next(iter(family_keys)) if len(family_keys) == 1 else None, 'resolvedTargets': resolved_targets, 'unresolvedFiles': unresolved_files, 'resolutionState': 'KNOWN', @@ -108,7 +98,30 @@ def resolve_mod_folder(mod_file_names, asset_index): def _expand_candidates(base_name: str): - return list(dict.fromkeys(normalize_filename(base_name))) + 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): From 3c56727dab8ecc80c5071dc81b4a2382d19e684d Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:12:55 +0000 Subject: [PATCH 19/24] Handle duplicate-named cutscene assets --- app/src/main/python/repacker/repacker.py | 161 ++++++++++++----------- app/src/main/python/unpacker.py | 23 +++- 2 files changed, 104 insertions(+), 80 deletions(-) diff --git a/app/src/main/python/repacker/repacker.py b/app/src/main/python/repacker/repacker.py index bf6dd745e..6f00589d0 100644 --- a/app/src/main/python/repacker/repacker.py +++ b/app/src/main/python/repacker/repacker.py @@ -370,6 +370,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 +449,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 +473,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 +503,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 +512,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 +534,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 +591,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 +644,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 +689,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/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 From 866fbd0499e809a6dab232365aa2b10a54b8f35b Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:06:55 +0000 Subject: [PATCH 20/24] fix: persist mod cache and avoid scan-time catalog refresh --- .../data/repository/ModRepository.kt | 648 +++++++++--------- app/src/main/python/main_script.py | 22 +- 2 files changed, 347 insertions(+), 323 deletions(-) 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 9d6b54d75..9b2d402bd 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,322 +1,326 @@ -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.cacheDir.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 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) - } - - 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() - ) - ) - } - } - } -} +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.cacheDir.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/python/main_script.py b/app/src/main/python/main_script.py index 2037c6d76..d54d2c51f 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -13,6 +13,7 @@ import resolver import catalog_indexer import json +from pathlib import Path # --- Global Cache for CDN Catalog --- # In-memory cache for the catalog JSON content. @@ -120,7 +121,26 @@ def report_progress(message): print(message) try: - report_progress(f"Fetching CDN version for {quality} quality...") + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + pattern = f"asset_index_{quality.lower()}_*.json" + local_indexes = sorted(output_path.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True) + + for index_path in local_indexes: + try: + with open(index_path, 'r', encoding='utf-8') as f: + index = json.load(f) + if index.get('schemaVersion') == catalog_indexer.INDEX_SCHEMA_VERSION: + version = index.get('version') + report_progress(f"Using local asset index {index_path.name} without CDN refresh.") + return True, version, index + except Exception: + try: + index_path.unlink() + except Exception: + pass + + report_progress(f"No valid local asset index found for {quality}. Fetching CDN version...") version = cdn_downloader.get_cdn_version(quality) if not version: return False, "Failed to get CDN version.", None From 25428e979d44b8d2c530680590ffd494bff56593 Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:18:12 +0000 Subject: [PATCH 21/24] fix: update mod_cache cleanup path to filesDir in CharacterRepository --- .../data/repository/CharacterRepository.kt | 252 +++++++++--------- 1 file changed, 126 insertions(+), 126 deletions(-) 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() + } +} From 5bbea68e37952c1892fc8b623c20901fef5cd6ad Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:35:24 +0000 Subject: [PATCH 22/24] fix: store resolver asset index in filesDir --- .../com/example/bd2modmanager/data/repository/ModRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9b2d402bd..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 @@ -110,7 +110,7 @@ class ModRepository( val (batchSuccess, batchResults) = ModdingService.resolveModBatch( batchPayload.toString(), - context.cacheDir.absolutePath, + context.filesDir.absolutePath, "HD" ) { } From aecda8b6010fe707aad2c7cd62b9b36808eb38eb Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:53:04 +0000 Subject: [PATCH 23/24] refactor: centralize metadata refresh and disable resolver downloads --- app/src/main/python/character_scraper.py | 101 ++++++------- app/src/main/python/main_script.py | 183 +++++++++++++---------- 2 files changed, 152 insertions(+), 132 deletions(-) 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 d54d2c51f..5bcb389da 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -31,22 +31,102 @@ def _prune_catalog_cache(keep_version=None): 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() @@ -55,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. @@ -62,30 +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...") with catalog_cache_lock: _prune_catalog_cache(version) - # The download_catalog function will now use the shared cache catalog_content, error = cdn_downloader.download_catalog( output_dir, quality, version, catalog_cache, catalog_cache_lock, progress_callback ) @@ -93,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, @@ -105,7 +182,7 @@ def report_progress(message): if error: return False, error - + return True, output_file_path except Exception as e: @@ -114,6 +191,7 @@ def report_progress(message): report_progress(f"A critical error occurred: {error_message}") return False, error_message + def ensure_asset_index(output_dir: str, quality: str = "HD", progress_callback=None): def report_progress(message): if progress_callback: @@ -123,44 +201,20 @@ def report_progress(message): try: output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) - pattern = f"asset_index_{quality.lower()}_*.json" - local_indexes = sorted(output_path.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True) - - for index_path in local_indexes: - try: - with open(index_path, 'r', encoding='utf-8') as f: - index = json.load(f) - if index.get('schemaVersion') == catalog_indexer.INDEX_SCHEMA_VERSION: - version = index.get('version') - report_progress(f"Using local asset index {index_path.name} without CDN refresh.") - return True, version, index - except Exception: - try: - index_path.unlink() - except Exception: - pass - - report_progress(f"No valid local asset index found for {quality}. Fetching CDN version...") version = cdn_downloader.get_cdn_version(quality) if not version: return False, "Failed to get CDN version.", None - 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, None + index = _load_valid_local_index(output_dir, quality, version) + if index is None: + return False, "Asset index missing or stale. Refresh metadata first.", None - report_progress("Building/loading asset index...") - index = catalog_indexer.load_or_build_asset_index(output_dir, quality, version, catalog_content) + 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 building asset index: {error_message}") + report_progress(f"A critical error occurred while loading asset index: {error_message}") return False, error_message, None @@ -202,50 +256,21 @@ def resolve_mod_batch(mods_json: str, output_dir: str, quality: str = "HD", prog 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 @@ -255,7 +280,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, @@ -263,16 +287,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. From a803d87744a4ebdb8f107572f2c8c0a4734430ab Mon Sep 17 00:00:00 2001 From: kevin930321 <78043580+kevin930321@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:05:33 +0000 Subject: [PATCH 24/24] fix: use local asset index without CDN version check --- app/src/main/python/main_script.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/main/python/main_script.py b/app/src/main/python/main_script.py index 5bcb389da..8cd53ded8 100644 --- a/app/src/main/python/main_script.py +++ b/app/src/main/python/main_script.py @@ -201,14 +201,12 @@ def report_progress(message): try: output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) - version = cdn_downloader.get_cdn_version(quality) - if not version: - return False, "Failed to get CDN version.", None - index = _load_valid_local_index(output_dir, quality, version) + index = _load_valid_local_index(output_dir, quality) if index is None: - return False, "Asset index missing or stale. Refresh metadata first.", 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: