Skip to content
Merged

Dev #38

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5d5c8a2
Add resolver-first target grouping foundation
kevin930321 Mar 29, 2026
f452a01
Batch mod resolution to reduce scan latency
kevin930321 Mar 29, 2026
46f4d81
Refine invalid detection for unresolved and spine pages
kevin930321 Mar 29, 2026
f8a8282
Prevent duplicate scan initialization
kevin930321 Mar 29, 2026
9ef1514
Tighten target header layout and simplify type chip
kevin930321 Mar 29, 2026
8b6dbfa
Fix Compose Icon modifier syntax
kevin930321 Mar 29, 2026
04feac5
Simplify AssistChip type rendering
kevin930321 Mar 29, 2026
2001432
Normalize specialillust variants for resolver matching
kevin930321 Mar 29, 2026
9d192be
Revert "Normalize specialillust variants for resolver matching"
kevin930321 Mar 29, 2026
a5ef158
Resolve mod targets by candidate-set intersection
kevin930321 Mar 29, 2026
235ab0f
Expand specialillust candidates and prefer non-censorship hits
kevin930321 Mar 29, 2026
1e8be26
Keep non-censorship preference without specialillust expansion
kevin930321 Mar 29, 2026
3d26c5f
Compress asset index and prune stale cache files
kevin930321 Mar 29, 2026
709bb3d
Require canonical asset names for target resolution
kevin930321 Mar 29, 2026
2704e73
Tighten resolver state semantics and cleanup temp cache
kevin930321 Mar 29, 2026
9c0bd84
Revert repacker extension mapping to match main
kevin930321 Mar 29, 2026
bc3a73e
Restore internal id matching in resolver index
kevin930321 Mar 29, 2026
7f95b3d
Support sactx LocalPackTitle resolver bridge
kevin930321 Mar 29, 2026
d26fd0f
Merge pull request #36 from Ark-Repoleved/feat/resolver-first-target-…
kevin930321 Mar 29, 2026
3c56727
Handle duplicate-named cutscene assets
kevin930321 Mar 29, 2026
37254c0
Merge pull request #37 from Ark-Repoleved/feat/cutscene-char004201-du…
kevin930321 Mar 29, 2026
866fbd0
fix: persist mod cache and avoid scan-time catalog refresh
kevin930321 Mar 30, 2026
25428e9
fix: update mod_cache cleanup path to filesDir in CharacterRepository
kevin930321 Mar 30, 2026
5bbea68
fix: store resolver asset index in filesDir
kevin930321 Mar 30, 2026
aecda8b
refactor: centralize metadata refresh and disable resolver downloads
kevin930321 Mar 30, 2026
a803d87
fix: use local asset index without CDN version check
kevin930321 Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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,
Expand All @@ -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<ResolvedTarget> = emptyList(),
val unresolvedFiles: List<String> = emptyList(),
val errorReason: String? = null
)

data class ModCacheInfo(
Expand All @@ -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<String> = emptyList(),
val errorReason: String? = null
)

data class CharacterInfo(val character: String, val costume: String, val type: String, val hashedName: String)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, List<CharacterInfo>> = 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<String, List<CharacterInfo>> {
return withContext(Dispatchers.IO) {
val lut = mutableMapOf<String, MutableList<CharacterInfo>>()
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<String>): 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<String, List<CharacterInfo>> = 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<String, List<CharacterInfo>> {
return withContext(Dispatchers.IO) {
val lut = mutableMapOf<String, MutableList<CharacterInfo>>()
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<String>): 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()
}
}
Loading
Loading