diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ccb7ba..1b65ed6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.catlas.escapepenacony" minSdk = 26 targetSdk = 35 - versionCode = 6 - versionName = "v1.1.3" + versionCode = 7 + versionName = "v1.1.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/assets/chaquopy/app.imy b/app/src/main/assets/chaquopy/app.imy index 0c3b9e9..0c675c4 100644 Binary files a/app/src/main/assets/chaquopy/app.imy and b/app/src/main/assets/chaquopy/app.imy differ diff --git a/app/src/main/java/com/catlas/escapepenacony/data/PersistentRepository.kt b/app/src/main/java/com/catlas/escapepenacony/data/PersistentRepository.kt index d008d2e..a69de97 100644 --- a/app/src/main/java/com/catlas/escapepenacony/data/PersistentRepository.kt +++ b/app/src/main/java/com/catlas/escapepenacony/data/PersistentRepository.kt @@ -44,13 +44,128 @@ class PersistentRepository(private val context: Context) { return File(serverBuildDir, "ppsr.pyc").exists() } + companion object { + val DEFAULT_PERSISTENT_JSON_STRING = """ + { + "send_hotfix": true, + "scene": { + "plane_id": 20313, + "x": 40748, + "y": 192819, + "z": 439218, + "calyx_x": 40748, + "calyx_y": 192810, + "calyx_z": 441000, + "map_layer": 2, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_entity_id": 1337, + "calyx_prop_id": 808 + }, + "avatar": { + "tb_gender": "Female", + "tb_path": "Elation", + "march_path": "Preservation", + "skin": { + "1001": 1100101, + "1310": 1131001, + "1415": 1141501, + "1407": 1140701, + "1501": 1150101 + }, + "lineup": [ + 1511, + 1502, + 1506, + 1503 + ], + "global_passive": [ + { + "name": "Castorice Global Passive", + "id": 140703, + "active": true + }, + { + "name": "SW999 Global Passsive", + "id": 150602, + "active": true + }, + { + "name": "Aha Global Passive", + "id": 151104, + "active": true + } + ], + "buff": [ + 1000121 + ], + "leader": 0 + }, + "key": "" + } + """.trimIndent() + } + + fun createDefaultPersistentJson(): JSONObject { + return JSONObject(DEFAULT_PERSISTENT_JSON_STRING) + } + + fun ensureValidPersistentJsonSync(): JSONObject { + try { + miscDir.mkdirs() + val defaultTemplate = createDefaultPersistentJson() + if (!persistentFile.exists()) { + persistentFile.writeText(defaultTemplate.toString(4), StandardCharsets.UTF_8) + return defaultTemplate + } + val content = persistentFile.readText(StandardCharsets.UTF_8) + val json = JSONObject(content) + var modified = false + + if (!json.has("send_hotfix")) { + json.put("send_hotfix", true) + modified = true + } + if (!json.has("key")) { + json.put("key", "") + modified = true + } + if (!json.has("scene") || json.optJSONObject("scene") == null) { + json.put("scene", defaultTemplate.getJSONObject("scene")) + modified = true + } + val avatar = json.optJSONObject("avatar") + if (avatar == null) { + json.put("avatar", defaultTemplate.getJSONObject("avatar")) + modified = true + } else { + val defaultAvatar = defaultTemplate.getJSONObject("avatar") + val keys = listOf("tb_gender", "tb_path", "march_path", "skin", "lineup", "global_passive", "buff", "leader") + for (k in keys) { + if (!avatar.has(k) || avatar.isNull(k)) { + avatar.put(k, defaultAvatar.get(k)) + modified = true + } + } + } + + if (modified) { + persistentFile.writeText(json.toString(4), StandardCharsets.UTF_8) + } + return json + } catch (e: Exception) { + val fallback = createDefaultPersistentJson() + try { + persistentFile.writeText(fallback.toString(4), StandardCharsets.UTF_8) + } catch (_: Exception) {} + return fallback + } + } + suspend fun readPersistentJson(): Result = withContext(Dispatchers.IO) { try { - if (!persistentFile.exists()) { - return@withContext Result.failure(FileNotFoundException("persistent.json not found")) - } - val text = persistentFile.readText(StandardCharsets.UTF_8) - Result.success(JSONObject(text)) + val json = ensureValidPersistentJsonSync() + Result.success(json) } catch (e: Exception) { Result.failure(e) } @@ -59,6 +174,25 @@ class PersistentRepository(private val context: Context) { suspend fun writePersistentJson(json: JSONObject): Result = withContext(Dispatchers.IO) { try { miscDir.mkdirs() + val defaultTemplate = createDefaultPersistentJson() + if (!json.has("send_hotfix")) json.put("send_hotfix", true) + if (!json.has("key")) json.put("key", "") + if (!json.has("scene") || json.optJSONObject("scene") == null) { + json.put("scene", defaultTemplate.getJSONObject("scene")) + } + val avatar = json.optJSONObject("avatar") + if (avatar == null) { + json.put("avatar", defaultTemplate.getJSONObject("avatar")) + } else { + val defaultAvatar = defaultTemplate.getJSONObject("avatar") + val keys = listOf("tb_gender", "tb_path", "march_path", "skin", "lineup", "global_passive", "buff", "leader") + for (k in keys) { + if (!avatar.has(k) || avatar.isNull(k)) { + avatar.put(k, defaultAvatar.get(k)) + } + } + } + val backupFile = File(miscDir, "persistent.json.bak") if (persistentFile.exists()) { persistentFile.copyTo(backupFile, overwrite = true) @@ -85,7 +219,7 @@ class PersistentRepository(private val context: Context) { } suspend fun setSendHotfix(enabled: Boolean): Result { - val json = readPersistentJson().getOrElse { JSONObject() } + val json = readPersistentJson().getOrElse { createDefaultPersistentJson() } json.put("send_hotfix", enabled) return writePersistentJson(json) } @@ -95,7 +229,7 @@ class PersistentRepository(private val context: Context) { } suspend fun setServerKey(key: String): Result { - val json = readPersistentJson().getOrElse { JSONObject() } + val json = readPersistentJson().getOrElse { createDefaultPersistentJson() } json.put("key", key) return writePersistentJson(json) } @@ -105,13 +239,13 @@ class PersistentRepository(private val context: Context) { val avatar = json?.optJSONObject("avatar") return TrailblazerConfig( gender = avatar?.optString("tb_gender", "Female") ?: "Female", - tbPath = avatar?.optString("tb_path", "Destruction") ?: "Destruction", + tbPath = avatar?.optString("tb_path", "Elation") ?: "Elation", marchPath = avatar?.optString("march_path", "Preservation") ?: "Preservation" ) } suspend fun setTrailblazerConfig(config: TrailblazerConfig): Result { - val json = readPersistentJson().getOrElse { JSONObject() } + val json = readPersistentJson().getOrElse { createDefaultPersistentJson() } val avatar = json.optJSONObject("avatar") ?: JSONObject().also { json.put("avatar", it) } avatar.put("tb_gender", config.gender) avatar.put("tb_path", config.tbPath) @@ -120,7 +254,7 @@ class PersistentRepository(private val context: Context) { } suspend fun applySceneTeleport(payload: SceneTeleportPayload): Result { - val json = readPersistentJson().getOrElse { JSONObject() } + val json = readPersistentJson().getOrElse { createDefaultPersistentJson() } val scene = json.optJSONObject("scene") ?: JSONObject().also { json.put("scene", it) } scene.put("plane_id", payload.planeId) diff --git a/app/src/main/java/com/catlas/escapepenacony/data/UpdateRepository.kt b/app/src/main/java/com/catlas/escapepenacony/data/UpdateRepository.kt index 5b8f4c3..178dff8 100644 --- a/app/src/main/java/com/catlas/escapepenacony/data/UpdateRepository.kt +++ b/app/src/main/java/com/catlas/escapepenacony/data/UpdateRepository.kt @@ -11,6 +11,7 @@ import java.net.HttpURLConnection import java.net.URL import java.util.zip.ZipEntry import java.util.zip.ZipInputStream +import org.json.JSONObject data class UpdateInfo( val currentVersion: String, @@ -187,7 +188,28 @@ class UpdateRepository(private val context: Context) { val stagedMiscDir = File(effectiveStagedDir, "misc").apply { mkdirs() } if (miscBackupFile.exists()) { - miscBackupFile.copyTo(File(stagedMiscDir, "persistent.json"), overwrite = true) + try { + val defaultTemplate = JSONObject(PersistentRepository.DEFAULT_PERSISTENT_JSON_STRING) + val backedUpText = miscBackupFile.readText() + val backedUpJson = JSONObject(backedUpText) + if (backedUpJson.has("send_hotfix")) defaultTemplate.put("send_hotfix", backedUpJson.getBoolean("send_hotfix")) + if (backedUpJson.has("key")) defaultTemplate.put("key", backedUpJson.getString("key")) + if (backedUpJson.has("scene") && backedUpJson.optJSONObject("scene") != null) { + defaultTemplate.put("scene", backedUpJson.getJSONObject("scene")) + } + val backedUpAvatar = backedUpJson.optJSONObject("avatar") + if (backedUpAvatar != null) { + val avatar = defaultTemplate.getJSONObject("avatar") + val keys = backedUpAvatar.keys() + while (keys.hasNext()) { + val k = keys.next() + avatar.put(k, backedUpAvatar.get(k)) + } + } + File(stagedMiscDir, "persistent.json").writeText(defaultTemplate.toString(4)) + } catch (e: Exception) { + miscBackupFile.copyTo(File(stagedMiscDir, "persistent.json"), overwrite = true) + } } if (configBackupFile.exists()) { diff --git a/app/src/main/java/com/catlas/escapepenacony/service/ServerService.kt b/app/src/main/java/com/catlas/escapepenacony/service/ServerService.kt index 0f15f97..934532b 100644 --- a/app/src/main/java/com/catlas/escapepenacony/service/ServerService.kt +++ b/app/src/main/java/com/catlas/escapepenacony/service/ServerService.kt @@ -161,6 +161,13 @@ class ServerService : Service() { } } + try { + val persistentRepo = com.catlas.escapepenacony.data.PersistentRepository(this) + persistentRepo.ensureValidPersistentJsonSync() + } catch (e: Exception) { + appendLog("[Warning] Persistent validation notice: ${e.message}") + } + appendLog("[System] Launching ppSR Python 3.14 Server...") // Start readiness check in background diff --git a/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt b/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt index 81ea54f..7918ece 100644 --- a/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt +++ b/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt @@ -129,6 +129,7 @@ fun MainScreen() { } if (isServerInstalled) { + persistentRepo.ensureValidPersistentJsonSync() sendHotfix = persistentRepo.getSendHotfix() serverKey = persistentRepo.getServerKey() trailblazerConfig = persistentRepo.getTrailblazerConfig() diff --git a/app/src/main/python/run_ppsr.py b/app/src/main/python/run_ppsr.py index 1cf8f3f..03294bd 100644 --- a/app/src/main/python/run_ppsr.py +++ b/app/src/main/python/run_ppsr.py @@ -136,6 +136,145 @@ try: except Exception: print("[Shim] marshmallow not available") +# --- 3.1 Persistent Template & SafeDict Protection --- +DEFAULT_PERSISTENT = { + "send_hotfix": True, + "scene": { + "plane_id": 20313, + "x": 40748, + "y": 192819, + "z": 439218, + "calyx_x": 40748, + "calyx_y": 192810, + "calyx_z": 441000, + "map_layer": 2, + "calyx_group_id": 186, + "calyx_inst_id": 300001, + "calyx_entity_id": 1337, + "calyx_prop_id": 808 + }, + "avatar": { + "tb_gender": "Female", + "tb_path": "Elation", + "march_path": "Preservation", + "skin": { + "1001": 1100101, + "1310": 1131001, + "1415": 1141501, + "1407": 1140701, + "1501": 1150101 + }, + "lineup": [ + 1511, + 1502, + 1506, + 1503 + ], + "global_passive": [ + { + "name": "Castorice Global Passive", + "id": 140703, + "active": True + }, + { + "name": "SW999 Global Passsive", + "id": 150602, + "active": True + }, + { + "name": "Aha Global Passive", + "id": 151104, + "active": True + } + ], + "buff": [ + 1000121 + ], + "leader": 0 + }, + "key": "" +} + +class SafeDict(dict): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for k, v in list(self.items()): + if isinstance(v, dict) and not isinstance(v, SafeDict): + self[k] = SafeDict(v) + + def __getitem__(self, key): + import copy + if key not in self: + if key == 'avatar': + self['avatar'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['avatar'])) + return self['avatar'] + elif key == 'scene': + self['scene'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['scene'])) + return self['scene'] + elif key == 'skin': + self['skin'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['avatar']['skin'])) + return self['skin'] + elif key == 'lineup': + self['lineup'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['lineup']) + return self['lineup'] + elif key == 'buff': + self['buff'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['buff']) + return self['buff'] + elif key == 'global_passive': + self['global_passive'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['global_passive']) + return self['global_passive'] + elif key == 'tb_gender': + return 'Female' + elif key == 'tb_path': + return 'Elation' + elif key == 'march_path': + return 'Preservation' + elif key == 'leader': + return 0 + elif key == 'send_hotfix': + return True + elif key == 'key': + return '' + val = super().__getitem__(key) + if isinstance(val, dict) and not isinstance(val, SafeDict): + val = SafeDict(val) + self[key] = val + return val + +def _sanitize_persistent_file(persistent_file): + try: + import json, os, copy + os.makedirs(os.path.dirname(os.path.abspath(persistent_file)), exist_ok=True) + data = {} + if os.path.exists(persistent_file): + try: + with open(persistent_file, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception: + data = {} + + modified = False + if not isinstance(data, dict): + data = {} + modified = True + + for k, v in DEFAULT_PERSISTENT.items(): + if k not in data: + data[k] = copy.deepcopy(v) + modified = True + elif isinstance(v, dict) and isinstance(data[k], dict): + for sub_k, sub_v in v.items(): + if sub_k not in data[k]: + data[k][sub_k] = copy.deepcopy(sub_v) + modified = True + + if modified or not os.path.exists(persistent_file): + with open(persistent_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + print("[Patch] persistent.json validated and auto-healed with complete avatar/scene defaults") + except Exception as e: + print(f"[Patch Warning] sanitize persistent: {e}") + # --- 4. Shutdown Logic --- def kill_server_process(): global _active_server_instance @@ -264,6 +403,45 @@ def run_server(ppsr_path, activity_context=None): except Exception as e: print(f"[Patch Warning] Werkzeug patch: {e}") + # --- Persistent & Avatar Auto-healing Patch --- + _persistent_file = os.path.join(misc_dir, "persistent.json") + _sanitize_persistent_file(_persistent_file) + + try: + import game.services.manager as _mgr_mod + _orig_load_persistent = _mgr_mod.Manager.load_persistent + + @classmethod + def _patched_load_persistent(cls): + _orig_load_persistent() + if hasattr(cls, 'persistent') and isinstance(cls.persistent, dict): + cls.persistent = SafeDict(cls.persistent) + + _mgr_mod.Manager.load_persistent = _patched_load_persistent + if hasattr(_mgr_mod.Manager, 'persistent') and isinstance(_mgr_mod.Manager.persistent, dict): + _mgr_mod.Manager.persistent = SafeDict(_mgr_mod.Manager.persistent) + print("[Patch] Manager.load_persistent patched with SafeDict avatar protection") + except Exception as e: + print(f"[Patch Warning] Manager persistent patch: {e}") + + try: + import game.router as _router_mod + _orig_router_handle = _router_mod.handle + + def _safe_router_handle(data): + try: + return _orig_router_handle(data) + except Exception as e: + import traceback + print(f"[Router Warning] Handled exception in router.handle: {e}") + traceback.print_exc() + return b'' + + _router_mod.handle = _safe_router_handle + print("[Patch] game.router.handle wrapped with crash-guard") + except Exception as e: + print(f"[Patch Warning] Router crash-guard: {e}") + print(f"Loading bytecode target: {ppsr_path}...") with open(ppsr_path, "rb") as f: