Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd740e1c8d | ||
|
|
61091f2a1a | ||
|
|
253b821a77 | ||
|
|
79568cbb3a | ||
|
|
45f057787d | ||
|
|
a1e6f14a43 | ||
|
|
f32bbd1108 | ||
|
|
61a03b5957 | ||
|
|
af90926d92 | ||
|
|
51a2ca3f8c | ||
|
|
3c1186c781 | ||
|
|
92a4b4f545 | ||
|
|
1f88f6ea38 | ||
|
|
17bcc1adbf |
No files matched your search
@@ -16,3 +16,9 @@ local.properties
|
||||
app/build/
|
||||
*.apk
|
||||
*.aab
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
app/src/main/jniLibs/arm64-v8a/libgacy_*.so
|
||||
app/src/goAssets/gacy-go-data.zip
|
||||
app/src/debug/jniLibs/x86_64/libgacy_*.so
|
||||
Generated
+3
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
Generated
+1
@@ -0,0 +1 @@
|
||||
GaCy-Android
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+123
@@ -0,0 +1,123 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+11
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+19
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,69 @@
|
||||
# GaCy Android
|
||||
|
||||
[ภาษาไทย](../README.md) | English | [简体中文](./README_ZH.md)
|
||||
|
||||

|
||||
|
||||
GaCy Android is an Android companion and local server manager for the
|
||||
GaC-hkrpg-go project. The
|
||||
port is in progress: the app starts the Go SDK and game servers in a foreground
|
||||
service, or connects to an existing server.
|
||||
|
||||
## Current status
|
||||
|
||||
- An ARM64 debug build was tested on a physical phone: the user confirmed game
|
||||
login, in-game map teleport, and restarting the server after an update.
|
||||
- The Go SDK, shared-data, game-server and KCP tests passed. These checks do not
|
||||
establish that every game feature works on Android.
|
||||
- Character settings from the older ppSR runtime are not yet connected to the
|
||||
Go runtime. `/give` and `/heal` are not implemented by the Go command API.
|
||||
|
||||
## Features
|
||||
|
||||
- Start, stop and restart the local server; connect to a remote server and
|
||||
monitor its SDK health.
|
||||
- View and copy server logs, send GM commands and open the SRTool web app.
|
||||
- Use the game's own map to teleport. There is no separate teleport page in
|
||||
the app navigation.
|
||||
- Submit an Unstuck request. The app reports it as queued only when the server
|
||||
supplies a request ID; this does not confirm the game has processed it.
|
||||
- Configure the host and ports and display the actual app version in Settings.
|
||||
|
||||
`/max` updates avatar level, promotion and rank while preserving other save
|
||||
fields. `/teleport <plane_id> [floor_id]` saves the scene for the next game
|
||||
connection. Both have automated tests but have not been verified in the game.
|
||||
`/clear lineup` is rejected because the active team cannot be empty.
|
||||
|
||||
## Build
|
||||
|
||||
Requirements: Android SDK 35, JDK 17 or 21, Go, Python, and a local checkout of
|
||||
GaC-hkrpg-go. The app requires Android 8.0 (API 26) or newer. Normal debug builds
|
||||
target ARM64.
|
||||
|
||||
```powershell
|
||||
python tools/package_go_runtime.py <path-to-GaC-hkrpg-go>
|
||||
.\gradlew.bat testDebugUnitTest assembleDebug
|
||||
```
|
||||
|
||||
The packaging script uses the current Go source and resources, and seeds
|
||||
player data from committed defaults. The APK is written to
|
||||
`app/build/outputs/apk/debug/app-debug.apk` and copied to the project root as
|
||||
`GaCy-Android-<versionName>-debug.apk`. APK files are ignored by Git.
|
||||
|
||||
For the x86_64 emulator, use `--emulator` when packaging and add
|
||||
`-PgacyEmulator` to the Gradle command. These test executables are not part of
|
||||
the normal ARM64 build.
|
||||
|
||||
## Runtime and network configuration
|
||||
|
||||
The Go runtime stores its files under the app's private `files/gacy-go`
|
||||
directory, separate from old ppSR data. An update preserves player data and
|
||||
existing hotfix entries, while adding new bundled versions. A completely empty
|
||||
cached version does not override available bundled hotfix data.
|
||||
|
||||
Set the Host in Settings to the address the game client uses to reach the
|
||||
phone. Use `127.0.0.1` when both run on the same device. Configure the SDK and
|
||||
game ports as needed; changes apply after restarting the server. Redirecting
|
||||
the game client's dispatch traffic remains a separate setup step. The Settings
|
||||
connection test checks the values currently entered; remote monitoring reports
|
||||
an error after three consecutive failed SDK health checks.
|
||||
@@ -0,0 +1,59 @@
|
||||
# GaCy Android
|
||||
|
||||
[ภาษาไทย](../README.md) | [English](./README_EN.md) | 简体中文
|
||||
|
||||

|
||||
|
||||
GaCy Android 是 GaC-hkrpg-go
|
||||
的 Android 辅助应用和本地服务器管理器。移植仍在进行中:应用可通过前台服务启动
|
||||
Go SDK 和游戏服务器,也可连接已有的远程服务器。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 已在实体手机上测试 ARM64 调试版:用户确认可以登录游戏、使用游戏内地图传送,
|
||||
以及在更新后重新启动服务器。
|
||||
- Go SDK、共享数据、游戏服务器和 KCP 测试已通过;这不代表所有游戏功能均已在
|
||||
Android 客户端验证。
|
||||
- 旧版 ppSR 的角色设置尚未接入 Go 运行时。Go 命令 API 尚不支持 `/give` 和 `/heal`。
|
||||
|
||||
## 功能
|
||||
|
||||
- 启动、停止和重启本地服务器;连接远程服务器并监控 SDK 健康状态。
|
||||
- 查看和复制服务器日志、发送 GM 命令,以及打开 SRTool 网页。
|
||||
- 使用游戏内地图传送;应用导航中没有单独的传送页面。
|
||||
- 提交 Unstuck 请求。只有服务器返回请求 ID 时,应用才显示请求已排队;
|
||||
这不表示游戏已经处理成功。
|
||||
- 在设置中配置主机和端口,并显示应用的实际版本。
|
||||
|
||||
`/max` 修改角色等级、晋阶和星魂,同时保留存档的其他字段。
|
||||
`/teleport <plane_id> [floor_id]` 保存下次连接时使用的场景。
|
||||
两者已通过自动测试,但尚未在游戏内验证。由于当前队伍不能为空,
|
||||
`/clear lineup` 会被拒绝。
|
||||
|
||||
## 构建
|
||||
|
||||
需要 Android SDK 35、JDK 17 或 21、Go、Python,以及本地 GaC-hkrpg-go 源码。
|
||||
应用最低支持 Android 8.0(API 26);普通调试版本面向 ARM64。
|
||||
|
||||
```powershell
|
||||
python tools/package_go_runtime.py <path-to-GaC-hkrpg-go>
|
||||
.\gradlew.bat testDebugUnitTest assembleDebug
|
||||
```
|
||||
|
||||
打包脚本使用当前 Go 源码和资源,并从已提交的默认文件初始化玩家数据。
|
||||
APK 输出至 `app/build/outputs/apk/debug/app-debug.apk`,同时复制到项目根目录,
|
||||
文件名为 `GaCy-Android-<versionName>-debug.apk`。APK 已被 Git 忽略。
|
||||
|
||||
如果使用 x86_64 模拟器,请在打包时添加 `--emulator`,并在 Gradle 命令中添加
|
||||
`-PgacyEmulator`。模拟器测试程序不包含在普通 ARM64 构建中。
|
||||
|
||||
## 运行时与网络配置
|
||||
|
||||
Go 运行时数据保存在应用私有目录 `files/gacy-go`,与旧版 ppSR 数据分开。
|
||||
更新时保留玩家数据和已有 hotfix 条目,同时加入新版本。若之前查询失败留下完全为空的
|
||||
缓存条目,SDK 会优先使用可用的内置 hotfix 数据。
|
||||
|
||||
在设置中将 Host 填为游戏客户端连接手机所使用的地址;两者在同一台设备上运行时可使用
|
||||
`127.0.0.1`。按需配置 SDK 和游戏端口,修改后重启服务器生效。游戏客户端的 dispatch
|
||||
流量重定向仍需单独设置。连接测试使用当前输入的值;远程模式在 SDK 健康检查连续三次
|
||||
失败后报告错误。
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
@@ -1,5 +1,48 @@
|
||||
# 📱 GaCy Android
|
||||
|
||||

|
||||
|
||||
> **Go port in progress:** the local server now launches the SDK and game
|
||||
> processes built from GaCy-go. Teleporting uses the game's own map;
|
||||
> Other features still need validation.
|
||||
|
||||
The Go SDK, shared-data, game-server and KCP package tests have also passed;
|
||||
these automated tests do not establish that every gameplay feature works on
|
||||
the Android client.
|
||||
|
||||
To build the embedded runtime, use Go and Python with a local checkout of
|
||||
GaC-hkrpg-go, then build the app:
|
||||
|
||||
```powershell
|
||||
python tools/package_go_runtime.py <path-to-GaC-hkrpg-go>
|
||||
.\gradlew.bat testDebugUnitTest assembleDebug
|
||||
```
|
||||
|
||||
After a successful debug build, Gradle also copies the APK to the project root
|
||||
as `GaCy-Android-<versionName>-debug.apk`. APK files are excluded by `.gitignore`.
|
||||
|
||||
The packaging script uses the checkout's current source and resources and
|
||||
seeds player data from its committed defaults. Runtime saves are stored under
|
||||
the app's `files/gacy-go` directory; the previous ppSR data remains separate.
|
||||
When runtime data is updated, new entries from bundled `hotfix.json` and
|
||||
`versions.json` are added without replacing entries already on the device.
|
||||
If a previous failed lookup left an entirely empty cached entry, the SDK uses
|
||||
available bundled metadata for that version instead of the empty entry.
|
||||
Set the Host in Settings to the address the game client should use to reach
|
||||
the phone (use `127.0.0.1` when both run on the same device). The Go servers
|
||||
use the configured SDK and game ports. Redirecting the game client's dispatch
|
||||
traffic is still a separate setup step.
|
||||
|
||||
Network settings take effect after restarting the server. Remote mode checks
|
||||
the SDK health endpoint periodically and reports an error after three
|
||||
consecutive failed checks.
|
||||
The connection test clears its previous result when the host, ports or mode
|
||||
change, and tests the values currently entered in Settings.
|
||||
|
||||
For an x86_64 Android emulator only, add `--emulator` to the packaging
|
||||
command and `-PgacyEmulator` to the Gradle command. This adds Linux x86_64
|
||||
test executables to the debug APK; ordinary builds contain Android ARM64 only.
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Modern, High-Performance Mobile Companion & Server Manager for the GaCy Ecosystem**
|
||||
@@ -11,7 +54,7 @@ Built with **Kotlin**, **Jetpack Compose**, and **Material Design 3**.
|
||||
[](https://developer.android.com/ndk)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
[English](#-english) | [ภาษาไทย](#-ภาษาไทย)
|
||||
ภาษาไทย | [English](./Docs/README_EN.md) | [简体中文](./Docs/README_ZH.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -22,24 +65,17 @@ Built with **Kotlin**, **Jetpack Compose**, and **Material Design 3**.
|
||||
### ✨ ฟีเจอร์หลัก (Key Features)
|
||||
|
||||
* **🎮 ระบบจัดการเซิร์ฟเวอร์แบบ Dual-Mode (Local & Remote Server Management):**
|
||||
* **โหมด Local Embedded:** รันเซิร์ฟเวอร์จำลองภายในอุปกรณ์ Android โดยตรงผ่าน Android Foreground Service (`specialUse`) ไม่ถูกระบบฆ่าขณะสลับไปเล่นเกม
|
||||
* **โหมด Local Embedded:** รันเซิร์ฟเวอร์จำลองภายในอุปกรณ์ Android ผ่าน Android Foreground Service (`specialUse`)
|
||||
* **โหมด Remote Host:** เชื่อมต่อและควบคุมเซิร์ฟเวอร์ GaCy-GO บน PC ผ่าน Wi-Fi/LAN พร้อมระบบทดสอบค่าความหน่วง (Ping Latency Test)
|
||||
* **Live Terminal Console:** หน้าต่างแสดงผล Log การทำงานของเซิร์ฟเวอร์แบบ Real-time พร้อมปุ่มคัดลอก (Copy) และล้างหน้าจอ (Clear)
|
||||
* **🗺️ ระบบวาร์ปแผนที่ 8 โลก 86 ฉาก (Escape Penacony Teleport Engine):**
|
||||
* ครอบคลุม **8 ดวงดาว 86+ ฉาก** จากฐานข้อมูล `scenes.json` (Astral Express, Herta Space Station, Jarilo-VI, Xianzhou Luofu, Penacony, และแมพเบต้าล่าสุดอย่าง Amphoreus / Planarcadia Beta `20572`)
|
||||
* ระบบตัวกรองอัจฉริยะ: ⚔️ **Combat Ready** (มีเสา Calyx สำหรับต่อสู้), 🗺️ **Safe Explore** (เดินเล่นปลอดภัย), 🧪 **Beta Exclusive** (ฉากพิเศษสำหรับเบต้า)
|
||||
* ค้นหาฉากได้รวดเร็วทั้งภาษาไทย ภาษาอังกฤษ ภาษาจีน หรือกรอก Plane ID
|
||||
* **Custom Coordinates Teleport:** กรอกพิกัด Plane ID, X, Y, Z, Map Layer เองได้อิสระ
|
||||
* ระบบบันทึกไฟล์คอนฟิก `persistent.json` แบบ **Atomic Operation** ปลอดภัย 100% ไม่สร้างความเสียหายแก่ข้อมูลตัวละคร
|
||||
* **🗺️ วาร์ปในเกม:** ใช้แผนที่ของเกมผ่าน Game server ของ GaCy Go โดยตรง
|
||||
* **⚡ คอนโซลคำสั่ง GM บนมือถือ & เชื่อมต่อ GaCy SRTool:**
|
||||
* **Emergency Unstuck:** ปุ่มปลดบล็อกตัวละครเร่งด่วนผ่าน Heartbeat API (`/api/client/unstuck`) กู้คืนอาการเกมค้าง
|
||||
* **Mobile GM Command Prompt:** พิมพ์และส่งคำสั่ง GM (`/give`, `/avatar`, `/faketime`) ตรงไปยังเซิร์ฟเวอร์ พร้อมปุ่มคีย์ลัดคำสั่งยอดฮิต
|
||||
* **Emergency Unstuck:** ส่งคำขอกู้คืน UI ผ่าน Heartbeat API (`/api/client/unstuck`) แอปแสดงว่าเข้าคิวเมื่อเซิร์ฟเวอร์ตอบกลับพร้อมรหัสคำขอเท่านั้น; การเข้าคิวไม่ได้ยืนยันว่าเกมประมวลผลสำเร็จ
|
||||
* **Mobile GM Command Prompt:** พิมพ์และส่งคำสั่ง GM ไปยังเซิร์ฟเวอร์; ความพร้อมใช้งานขึ้นกับ Go runtime โดย `/give` และ `/heal` ยังไม่รองรับ
|
||||
* **`/max`:** ตั้งตัวละครทั้งหมดเป็นเลเวล 80, ขั้นเลื่อน 6 และแรงก์ 6 โดยแก้เฉพาะข้อมูลตัวละครที่เกี่ยวข้องและเก็บฟิลด์อื่นไว้ ต้องเชื่อมต่อเกมใหม่เพื่อโหลดค่า การทดสอบอัตโนมัติผ่านแล้ว แต่ยังไม่ได้ยืนยันคำสั่งนี้ในเกมจริง
|
||||
* **`/teleport <plane_id> [floor_id]`:** บันทึกค่าฉากลง persistent โดยไม่เขียนไฟล์นำเข้าตัวละครทับ ต้องเชื่อมต่อเกมใหม่ และไม่แทนการวาร์ปผ่านแผนที่ในเกม; ผ่านการทดสอบอัตโนมัติ แต่ยังไม่ได้ยืนยันคำสั่งนี้ในเกมจริง คำสั่ง `/clear lineup` ถูกปฏิเสธเพราะทีมที่ใช้อยู่ต้องไม่ว่าง
|
||||
* **GaCy SRTool Web Integration:** ปุ่มลัดเปิดหน้าจัดการกระเป๋า ตัวละคร และสร้าง Relic ผ่าน [GaCy SRTool Web](https://gacy-srtool.vercel.app/)
|
||||
* **🛠️ เครื่องมือปรับแต่งตัวละครและเซิร์ฟเวอร์ (Character & Server Configurator):**
|
||||
* **🔑 Server Key / Password:** ตั้งรหัสผ่านเซิร์ฟเวอร์เพื่อความปลอดภัย
|
||||
* **👤 Trailblazer Configurator:** สลับเพศผู้บุกเบิก (Stelle หญิง / Caelus ชาย) และเลือก Path (ทำลายล้าง, อนุรักษ์, ประสาน, ลบล้าง, ปิติสุข) ได้ทันที
|
||||
* **⭐ 3 EX Eidolon (Rank) Manager:** สลับเปิด/ปิดสกิลพิเศษ 3 EX สำหรับตัวละครยอดนิยม (JingYuan [1204], DanHeng IL [1213], Acheron [1308])
|
||||
* **⚡ Send Hotfix (Online/Offline Toggle):** เปิดสำหรับดาวน์โหลด Assets ออนไลน์จาก Hoyoverse หรือปิดเพื่อเล่นแบบออฟไลน์ 100% แก้ปัญหาหน้าจอดำ
|
||||
* **⚙️ การตั้งค่า:** ตั้งค่าการเชื่อมต่อและภาษาของแอป; การตั้งค่าตัวละครจาก ppSR รุ่นเก่ายังไม่ได้เชื่อมกับ Go runtime
|
||||
* **🎨 หน้าต่าง Cyberpunk Dark สวยงาม ลื่นไหล:**
|
||||
* ออกแบบด้วย **Material Design 3** สีสันเข้าชุดกับ G-Launcher Desktop และ GaCy-GO
|
||||
* รองรับ 3 ภาษาภายในตัว: **ภาษาไทย (TH)**, **English (EN)**, และ **简体中文 (ZH)**
|
||||
@@ -53,11 +89,9 @@ graph TD
|
||||
subgraph AppLayer [UI Layer: Jetpack Compose]
|
||||
MainUI[MainScreen & Navigation]
|
||||
ServerTab[ServerScreen & Live Terminal]
|
||||
TeleportTab[TeleportScreen & 86 Scenes]
|
||||
SRToolTab[SRToolScreen & GM Console]
|
||||
SettingsTab[SettingsScreen & Network Config]
|
||||
MainUI --> ServerTab
|
||||
MainUI --> TeleportTab
|
||||
MainUI --> SRToolTab
|
||||
MainUI --> SettingsTab
|
||||
end
|
||||
@@ -70,27 +104,22 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph DataRepositories [Data & Repository Layer]
|
||||
SceneRepo[SceneRepository - scenes.json Cache]
|
||||
PersistRepo[PersistentRepository - persistent.json]
|
||||
ApiRepo[GaCyApiRepository - OkHttp Client]
|
||||
PrefsRepo[PreferencesRepository - SharedPreferences]
|
||||
end
|
||||
|
||||
subgraph GameEcosystem [GaCy Ecosystem & Storage]
|
||||
GameServer[GaCy-GO Server / Remote PC]
|
||||
PersistentData[persistent.json / misc Configs]
|
||||
PersistentData[Go runtime saves and resources]
|
||||
SRToolWeb[GaCy SRTool Web Application]
|
||||
StarRailClient[Star Rail Game Client Android]
|
||||
end
|
||||
|
||||
ServerTab --> PersistRepo
|
||||
ServerTab --> ServerFGS
|
||||
TeleportTab --> SceneRepo
|
||||
TeleportTab --> PersistRepo
|
||||
ServerFGS --> PersistentData
|
||||
SRToolTab --> ApiRepo
|
||||
SettingsTab --> PrefsRepo
|
||||
|
||||
PersistRepo --> PersistentData
|
||||
ApiRepo --> GameServer
|
||||
SRToolTab -.-> SRToolWeb
|
||||
StarRailClient -.-> GameServer
|
||||
|
||||
+44
-2
@@ -12,13 +12,21 @@ android {
|
||||
applicationId = "com.gacy.android"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "v1.0.0"
|
||||
versionCode = 7
|
||||
versionName = "v1.0.6"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a")
|
||||
if (providers.gradleProperty("gacyEmulator").isPresent) abiFilters += "x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("src/main/jniLibs")
|
||||
assets.setSrcDirs(listOf("src/goAssets"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +51,13 @@ android {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
keepDebugSymbols += "**/libgacy_*.so"
|
||||
excludes += listOf("**/libchaquopy*.so", "**/libpython*.so", "**/libcrypto_*.so", "**/libsqlite3_*.so", "**/libssl_*.so")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -63,3 +78,30 @@ dependencies {
|
||||
testImplementation("org.json:json:20240303")
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
}
|
||||
|
||||
tasks.named("preBuild") {
|
||||
doFirst {
|
||||
val required = listOf(
|
||||
"src/goAssets/gacy-go-data.zip",
|
||||
"src/main/jniLibs/arm64-v8a/libgacy_sdkserver.so",
|
||||
"src/main/jniLibs/arm64-v8a/libgacy_gameserver.so"
|
||||
)
|
||||
check(required.all { file(it).isFile }) {
|
||||
"GaCy Go runtime is missing. Run: python tools/package_go_runtime.py <path-to-GaC-hkrpg-go>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val copyDebugApkToRoot by tasks.registering {
|
||||
doNotTrackState("Copies the APK into the project root")
|
||||
onlyIf { tasks.named("assembleDebug").get().state.failure == null }
|
||||
doLast {
|
||||
val source = layout.buildDirectory.file("outputs/apk/debug/app-debug.apk").get().asFile
|
||||
check(source.isFile) { "Debug APK was not produced" }
|
||||
source.copyTo(rootProject.file("GaCy-Android-${android.defaultConfig.versionName}-debug.apk"), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "assembleDebug" }.configureEach {
|
||||
finalizedBy(copyDebugApkToRoot)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"assets": {
|
||||
"bootstrap-native/arm64-v8a/_struct.cpython-314-aarch64-linux-android.so": "9b083e7b97e92291f73d80d7495c9cee8351596b",
|
||||
"requirements-arm64-v8a.imy": "b04f3ee8f5e43fa3b162981b50bb72fe1acabb33",
|
||||
"bootstrap-native/arm64-v8a/java/chaquopy.so": "1284a42a1b74c8fc5c8705a4f6f309134ecef4a8",
|
||||
"bootstrap.imy": "6c6d7c85e4764e96924001f9d6290501bfbfc704",
|
||||
"bootstrap-native/arm64-v8a/math.cpython-314-aarch64-linux-android.so": "dc859c93d3fc01f2d087471052895f230f732436",
|
||||
"cacert.pem": "fec7b8c0a17c1c555fd33baee53678f31b7645ee",
|
||||
"bootstrap-native/arm64-v8a/_bz2.cpython-314-aarch64-linux-android.so": "b48b9abf1f87d587a5f5beb4b38bb17acf29c4d3",
|
||||
"bootstrap-native/arm64-v8a/_ctypes.cpython-314-aarch64-linux-android.so": "50a6a36dab6a9ebe4fca5797872e8d2c3538db56",
|
||||
"bootstrap-native/arm64-v8a/mmap.cpython-314-aarch64-linux-android.so": "b571492fdceac1279bedc8163e27ac205e104b14",
|
||||
"app.imy": "fea5d90dd93b0e7264079d53d95d61e7a6f232c0",
|
||||
"stdlib-common.imy": "8715635411fe145000fbf3b7ce4c3a085646d300",
|
||||
"bootstrap-native/arm64-v8a/_random.cpython-314-aarch64-linux-android.so": "1c6c12f9f43546da0e108031f824b7fd6e79a36a",
|
||||
"bootstrap-native/arm64-v8a/_lzma.cpython-314-aarch64-linux-android.so": "6f3f86f607b4d775452c128858fa26d19c7da56a",
|
||||
"bootstrap-native/arm64-v8a/zlib.cpython-314-aarch64-linux-android.so": "2d164f648a0f97c1d8710d41cb66754e9daff44e",
|
||||
"bootstrap-native/arm64-v8a/binascii.cpython-314-aarch64-linux-android.so": "ff4d2c47c081af6518c8a6a6fa07edcc077b1b6b",
|
||||
"stdlib-arm64-v8a.imy": "ebebf5dc919d7e686936904604fe9a29fede81e9",
|
||||
"requirements-common.imy": "b04f3ee8f5e43fa3b162981b50bb72fe1acabb33"
|
||||
},
|
||||
"extract_packages": [],
|
||||
"python_version": "3.14"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
package com.gacy.android
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object CrashTracker {
|
||||
private const val PREF_NAME = "gacy_crash_tracker"
|
||||
private const val KEY_LAST_CRASH = "last_crash"
|
||||
private const val KEY_BREADCRUMBS = "breadcrumbs"
|
||||
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
|
||||
fun init(context: Context) {
|
||||
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
recordCrash(context, thread, throwable)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
defaultHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
fun recordCrash(context: Context, thread: Thread, throwable: Throwable) {
|
||||
try {
|
||||
val sw = StringWriter()
|
||||
throwable.printStackTrace(PrintWriter(sw))
|
||||
val stackTrace = sw.toString()
|
||||
|
||||
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
val breadcrumbs = prefs.getString(KEY_BREADCRUMBS, "None") ?: "None"
|
||||
val fullReport = "Thread: ${thread.name}\nTimestamp: ${timeFormat.format(Date())}\n\nRecent Actions:\n$breadcrumbs\n\nStacktrace:\n$stackTrace"
|
||||
|
||||
prefs.edit().putString(KEY_LAST_CRASH, fullReport).commit()
|
||||
|
||||
// Also write directly to disk file for redundancy
|
||||
val crashFile = File(context.filesDir, "last_crash.txt")
|
||||
crashFile.writeText(fullReport)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun breadcrumb(context: Context, text: String) {
|
||||
try {
|
||||
val timestamp = timeFormat.format(Date())
|
||||
val entry = "[$timestamp] $text"
|
||||
|
||||
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
val current = prefs.getString(KEY_BREADCRUMBS, "") ?: ""
|
||||
val updated = if (current.isBlank()) entry else "$current\n$entry"
|
||||
val lines = updated.lines().takeLast(15).joinToString("\n")
|
||||
prefs.edit().putString(KEY_BREADCRUMBS, lines).apply()
|
||||
|
||||
val breadcrumbFile = File(context.filesDir, "breadcrumbs.txt")
|
||||
breadcrumbFile.appendText("$entry\n")
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun getLastCrash(context: Context): String? {
|
||||
val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
val fromPrefs = prefs.getString(KEY_LAST_CRASH, null)
|
||||
if (!fromPrefs.isNullOrBlank()) return fromPrefs
|
||||
|
||||
val crashFile = File(context.filesDir, "last_crash.txt")
|
||||
if (crashFile.exists() && crashFile.length() > 0) {
|
||||
return crashFile.readText()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun clearLastCrash(context: Context) {
|
||||
try {
|
||||
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(KEY_LAST_CRASH)
|
||||
.apply()
|
||||
val crashFile = File(context.filesDir, "last_crash.txt")
|
||||
if (crashFile.exists()) crashFile.delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,11 @@ class GaCyApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashTracker.init(this)
|
||||
CrashTracker.breadcrumb(this, "GaCyApplication: onCreate")
|
||||
instance = this
|
||||
createNotificationChannel()
|
||||
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
package com.gacy.android
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.gacy.android.ui.screens.MainScreen
|
||||
import com.gacy.android.ui.theme.GaCyTheme
|
||||
|
||||
@@ -16,12 +37,83 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
CrashTracker.breadcrumb(this, "MainActivity: onCreate")
|
||||
|
||||
// ซ่อน Status Bar
|
||||
val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) // ใช้ .systemBars() ถ้าต้องการซ่อน Navigation Bar ด้านล่างด้วย
|
||||
|
||||
enableEdgeToEdge()
|
||||
requestAppPermissions()
|
||||
|
||||
setContent {
|
||||
GaCyTheme {
|
||||
var crashReport by remember { mutableStateOf(CrashTracker.getLastCrash(this@MainActivity)) }
|
||||
|
||||
MainScreen()
|
||||
|
||||
if (crashReport != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
CrashTracker.clearLastCrash(this@MainActivity)
|
||||
crashReport = null
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
"⚠️ GaCy Crash Report",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"A fatal error occurred in the previous session:",
|
||||
fontSize = 13.sp,
|
||||
color = Color.LightGray
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = crashReport ?: "",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.background(Color(0xFF1E1E1E), RoundedCornerShape(8.dp))
|
||||
.padding(8.dp),
|
||||
color = Color(0xFFFF8A80)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
val clip = ClipData.newPlainText("GaCy Crash", crashReport)
|
||||
clipboard?.setPrimaryClip(clip)
|
||||
Toast.makeText(this@MainActivity, "Crash report copied!", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
) {
|
||||
Text("Copy Log")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
CrashTracker.clearLastCrash(this@MainActivity)
|
||||
crashReport = null
|
||||
}
|
||||
) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,40 +9,42 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class GaCyApiRepository {
|
||||
internal fun parseUnstuckResponse(body: String): GmCommandResult {
|
||||
return try {
|
||||
val result = JSONObject(body)
|
||||
val requestId = result.opt("request_id") as? String
|
||||
if (result.optString("state") == "queued" && !requestId.isNullOrBlank()) {
|
||||
GmCommandResult(true, "Unstuck request queued; waiting for the game client to process it.")
|
||||
} else {
|
||||
GmCommandResult(false, "Server did not confirm an unstuck request.")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
GmCommandResult(false, "Server returned an invalid unstuck response.")
|
||||
}
|
||||
}
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
class GaCyApiRepository(private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.writeTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
.build()) {
|
||||
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
suspend fun checkServerHealth(config: ServerConfig): Pair<Boolean, Long> = withContext(Dispatchers.IO) {
|
||||
val start = System.currentTimeMillis()
|
||||
val url = "${config.dispatchUrl}/status"
|
||||
val url = "${config.dispatchUrl}/api/status"
|
||||
try {
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val duration = System.currentTimeMillis() - start
|
||||
Pair(response.isSuccessful, duration)
|
||||
val payload = response.body?.string().orEmpty()
|
||||
Pair(response.isSuccessful && JSONObject(payload).optInt("status") == 200, duration)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Try query_gateway fallback
|
||||
try {
|
||||
val gwUrl = "${config.dispatchUrl}/query_gateway"
|
||||
val request = Request.Builder().url(gwUrl).get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val duration = System.currentTimeMillis() - start
|
||||
Pair(response.isSuccessful || response.code == 400 || response.code == 200, duration)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Pair(false, -1L)
|
||||
}
|
||||
Pair(false, -1L)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,19 +55,19 @@ class GaCyApiRepository {
|
||||
val request = Request.Builder().url(url).post(body).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
GmCommandResult(true, "Unstuck packet sent to game client successfully.")
|
||||
parseUnstuckResponse(response.body?.string().orEmpty())
|
||||
} else {
|
||||
GmCommandResult(false, "Unstuck request failed with HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Exception) {
|
||||
GmCommandResult(false, "Network error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun executeGmCommand(config: ServerConfig, command: String): GmCommandResult = withContext(Dispatchers.IO) {
|
||||
val cleanCmd = command.trim()
|
||||
val url = "${config.dispatchUrl}/api/gm/command"
|
||||
val url = "${config.dispatchUrl}/api/command"
|
||||
try {
|
||||
val payload = JSONObject().apply {
|
||||
put("command", cleanCmd)
|
||||
@@ -75,7 +77,11 @@ class GaCyApiRepository {
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
if (response.isSuccessful) {
|
||||
GmCommandResult(true, responseBody.ifEmpty { "Command executed successfully: $cleanCmd" })
|
||||
val result = JSONObject(responseBody)
|
||||
val success = result.optBoolean("success", false)
|
||||
GmCommandResult(success, result.optString("output").ifBlank {
|
||||
result.optString("error", "Server returned an invalid command result")
|
||||
})
|
||||
} else {
|
||||
GmCommandResult(false, "Server rejected command: HTTP ${response.code} ($responseBody)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.gacy.android.data
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/** Runtime files for the Go server. Never replace the server's mutable data. */
|
||||
class GoRuntimeRepository(private val context: Context) {
|
||||
val directory = File(context.filesDir, "gacy-go")
|
||||
|
||||
fun prepare() = GoRuntimeInstaller(directory) { context.assets.open("gacy-go-data.zip") }.prepare()
|
||||
}
|
||||
|
||||
/** Install resources without replacing existing saves or extracting concurrently. */
|
||||
internal class GoRuntimeInstaller(private val directory: File, private val openArchive: () -> InputStream) {
|
||||
companion object {
|
||||
private val installLock = Any()
|
||||
private val required = listOf("persistent", "freesr-data.json", "server.json", "hotfix.json", "versions.json", "res.json", "teleports.json", "resources/MapEntrance.json")
|
||||
}
|
||||
|
||||
private fun replaceFile(temp: File, target: File) {
|
||||
try {
|
||||
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare() = synchronized(installLock) {
|
||||
val marker = File(directory, ".asset-version")
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
openArchive().use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
// Bump the installer revision when existing archives need a new merge.
|
||||
val version = "2:" + digest.digest().joinToString("") { "%02x".format(it) }
|
||||
if (marker.isFile && marker.readText() == version && required.all { File(directory, it).isFile }) return@synchronized
|
||||
directory.mkdirs()
|
||||
val root = directory.canonicalFile
|
||||
val mutable = setOf("persistent", "freesr-data.json", "server.json", "hotfix.json", "versions.json")
|
||||
ZipInputStream(openArchive().buffered()).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
val target = File(directory, entry.name).canonicalFile
|
||||
require(target.path.startsWith(root.path + File.separator)) { "Invalid runtime asset path" }
|
||||
if (entry.isDirectory) target.mkdirs()
|
||||
else if (entry.name in setOf("hotfix.json", "versions.json") && target.exists()) {
|
||||
// Add new bundled versions without replacing operator overrides.
|
||||
val existing = JSONObject(target.readText())
|
||||
val bundled = JSONObject(zip.readBytes().toString(Charsets.UTF_8))
|
||||
val keys = bundled.keys()
|
||||
var changed = false
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
if (!existing.has(key)) { existing.put(key, bundled.get(key)); changed = true }
|
||||
}
|
||||
if (changed) {
|
||||
val temp = File(target.parentFile, target.name + ".installing")
|
||||
temp.writeText(existing.toString(2))
|
||||
replaceFile(temp, target)
|
||||
}
|
||||
}
|
||||
else if (entry.name !in mutable || !target.exists()) {
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, target.name + ".installing")
|
||||
temp.outputStream().use { zip.copyTo(it) }
|
||||
replaceFile(temp, target)
|
||||
}
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
check(required.all { File(directory, it).isFile }) { "Go runtime archive is incomplete" }
|
||||
marker.writeText(version)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import java.nio.charset.StandardCharsets
|
||||
class PersistentRepository(private val context: Context) {
|
||||
|
||||
val serverDataDir: File
|
||||
get() = File(context.filesDir, "gacy_server")
|
||||
get() = File(context.filesDir, "server/build/build")
|
||||
|
||||
val miscDir: File
|
||||
get() = File(serverDataDir, "misc")
|
||||
@@ -29,7 +29,41 @@ class PersistentRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
fun isServerInstalled(): Boolean {
|
||||
return File(serverDataDir, "gameserver").exists() || File(serverDataDir, "ppsr.pyc").exists()
|
||||
return File(serverDataDir, "ppsr.pyc").exists() && File(serverDataDir, "ppsr.pyc").length() > 0
|
||||
}
|
||||
|
||||
fun ensureValidPersistentJsonSync() {
|
||||
try {
|
||||
ensureDataStructure()
|
||||
if (persistentFile.exists()) {
|
||||
val text = persistentFile.readText(StandardCharsets.UTF_8)
|
||||
val json = JSONObject(text)
|
||||
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) {
|
||||
val defaultObj = JSONObject(DEFAULT_PERSISTENT_JSON_STRING)
|
||||
json.put("scene", defaultObj.getJSONObject("scene"))
|
||||
modified = true
|
||||
}
|
||||
if (!json.has("avatar") || json.optJSONObject("avatar") == null) {
|
||||
val defaultObj = JSONObject(DEFAULT_PERSISTENT_JSON_STRING)
|
||||
json.put("avatar", defaultObj.getJSONObject("avatar"))
|
||||
modified = true
|
||||
}
|
||||
if (modified) {
|
||||
persistentFile.writeText(json.toString(2), StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureDataStructure() {
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
package com.gacy.android.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
data class UpdateInfo(
|
||||
val currentVersion: String,
|
||||
val latestVersion: String,
|
||||
val availableVersions: List<String>,
|
||||
val hasUpdate: Boolean
|
||||
)
|
||||
|
||||
class UpdateRepository(private val context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("gacy_updater_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
companion object {
|
||||
const val BASE_URL = "https://file.garden/aewBviVxx2XDWgM-/"
|
||||
const val VERSIONS_URL = "https://file.garden/aewBviVxx2XDWgM-/VERSIONS"
|
||||
const val PREF_INSTALLED_VERSION = "installed_server_version"
|
||||
const val CONNECT_TIMEOUT_MS = 15000
|
||||
const val READ_TIMEOUT_MS = 15000
|
||||
}
|
||||
|
||||
fun getInstalledVersion(): String {
|
||||
return prefs.getString(PREF_INSTALLED_VERSION, "4.6.53-embedded") ?: "4.6.53-embedded"
|
||||
}
|
||||
|
||||
fun setInstalledVersion(version: String) {
|
||||
prefs.edit().putString(PREF_INSTALLED_VERSION, version).apply()
|
||||
}
|
||||
|
||||
fun isServerInstalled(): Boolean {
|
||||
val ppsrFile = File(context.filesDir, "server/build/build/ppsr.pyc")
|
||||
if (!ppsrFile.exists() || ppsrFile.length() < 16) return false
|
||||
|
||||
// Check if build code matches current APK
|
||||
val installedBuildCode = prefs.getInt("installed_build_code", -1)
|
||||
if (installedBuildCode != com.gacy.android.BuildConfig.VERSION_CODE) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate that python bytecode magic is genuine Python 3.14: [43, 14, 13, 10]
|
||||
try {
|
||||
ppsrFile.inputStream().use { stream ->
|
||||
val magic = ByteArray(4)
|
||||
if (stream.read(magic) == 4) {
|
||||
if (magic[0] != 43.toByte() || magic[1] != 14.toByte() || magic[2] != 13.toByte() || magic[3] != 10.toByte()) {
|
||||
return false // Incompatible bytecode from old install, force refresh!
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun ensureServerInstalled(force: Boolean = false): Boolean = withContext(Dispatchers.IO) {
|
||||
val ppsrFile = File(context.filesDir, "server/build/build/ppsr.pyc")
|
||||
if (!force && isServerInstalled()) {
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
try {
|
||||
com.gacy.android.CrashTracker.breadcrumb(context, "UpdateRepository: ensureServerInstalled start (force=$force)")
|
||||
val stagingDir = File(context.filesDir, "server/staging")
|
||||
val targetServerDir = File(context.filesDir, "server/build/build")
|
||||
|
||||
if (stagingDir.exists()) stagingDir.deleteRecursively()
|
||||
stagingDir.mkdirs()
|
||||
|
||||
// 1. Extract build.zip from bundled assets
|
||||
com.gacy.android.CrashTracker.breadcrumb(context, "UpdateRepository: extracting build.zip")
|
||||
extractAssetZip("build.zip", stagingDir)
|
||||
|
||||
val stagedBuildDir = File(stagingDir, "build")
|
||||
val effectiveStagedDir = if (stagedBuildDir.exists()) stagedBuildDir else stagingDir
|
||||
|
||||
// 2. Extract runtime packages into effective directory
|
||||
try {
|
||||
com.gacy.android.CrashTracker.breadcrumb(context, "UpdateRepository: extracting packages.zip")
|
||||
extractAssetZip("packages.zip", effectiveStagedDir)
|
||||
} catch (e: Exception) {
|
||||
com.gacy.android.CrashTracker.breadcrumb(context, "UpdateRepository: packages.zip notice: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
// Keep the player's settings when refreshing the bundled server.
|
||||
for (name in listOf("persistent.json", "config.json")) {
|
||||
val existing = File(targetServerDir, "misc/$name")
|
||||
if (existing.isFile) {
|
||||
val destination = File(effectiveStagedDir, "misc/$name")
|
||||
destination.parentFile?.mkdirs()
|
||||
existing.copyTo(destination, overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Move/Rename into place
|
||||
com.gacy.android.CrashTracker.breadcrumb(context, "UpdateRepository: moving to target directory")
|
||||
if (targetServerDir.exists()) {
|
||||
targetServerDir.deleteRecursively()
|
||||
}
|
||||
targetServerDir.parentFile?.mkdirs()
|
||||
|
||||
val success = effectiveStagedDir.renameTo(targetServerDir)
|
||||
if (!success) {
|
||||
copyDirectory(effectiveStagedDir, targetServerDir)
|
||||
effectiveStagedDir.deleteRecursively()
|
||||
}
|
||||
if (stagingDir.exists()) stagingDir.deleteRecursively()
|
||||
|
||||
prefs.edit()
|
||||
.putInt("installed_build_code", com.gacy.android.BuildConfig.VERSION_CODE)
|
||||
.putString(PREF_INSTALLED_VERSION, "4.6.53-embedded-py314")
|
||||
.apply()
|
||||
|
||||
setInstalledVersion("4.6.53-embedded-py314")
|
||||
com.gacy.android.CrashTracker.breadcrumb(context, "UpdateRepository: ensureServerInstalled finished success=${ppsrFile.exists()}")
|
||||
return@withContext ppsrFile.exists()
|
||||
} catch (e: Exception) {
|
||||
com.gacy.android.CrashTracker.breadcrumb(context, "UpdateRepository: ensureServerInstalled error: ${e.message}")
|
||||
e.printStackTrace()
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkUpdate(): Result<UpdateInfo> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val versions = fetchAvailableVersions().getOrThrow()
|
||||
if (versions.isEmpty()) {
|
||||
return@withContext Result.failure(IllegalStateException("No versions found from server"))
|
||||
}
|
||||
|
||||
val latestVersion = versions.last()
|
||||
val installed = getInstalledVersion()
|
||||
val hasUpdate = installed.isBlank() || installed != latestVersion
|
||||
|
||||
Result.success(
|
||||
UpdateInfo(
|
||||
currentVersion = installed,
|
||||
latestVersion = latestVersion,
|
||||
availableVersions = versions,
|
||||
hasUpdate = hasUpdate
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAvailableVersions(): Result<List<String>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL(VERSIONS_URL)
|
||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("User-Agent", "GaCy-Updater/1.0")
|
||||
}
|
||||
|
||||
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("HTTP error ${conn.responseCode}: ${conn.responseMessage}")
|
||||
)
|
||||
}
|
||||
|
||||
val rawText = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
val list = rawText.split(Regex("[\\r\\n,]+"))
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
Result.success(list)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildDownloadUrl(version: String, filename: String = "build.zip"): String {
|
||||
val sanitizedVersion = version.trim()
|
||||
if (sanitizedVersion.contains("/") || sanitizedVersion.contains("\\") || sanitizedVersion.contains("..")) {
|
||||
throw IllegalArgumentException("Invalid version format")
|
||||
}
|
||||
return "$BASE_URL$sanitizedVersion/$filename"
|
||||
}
|
||||
|
||||
suspend fun downloadAndInstall(
|
||||
version: String,
|
||||
onProgress: (Float, String) -> Unit
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
val tempZip = File(context.cacheDir, "_temp_build_${System.currentTimeMillis()}.zip")
|
||||
val stagingDir = File(context.filesDir, "server/staging")
|
||||
val targetServerDir = File(context.filesDir, "server/build/build")
|
||||
val miscBackupFile = File(context.filesDir, "server/persistent_backup.json")
|
||||
val configBackupFile = File(context.filesDir, "server/config_backup.json")
|
||||
|
||||
try {
|
||||
onProgress(0.05f, "Connecting to download server...")
|
||||
val downloadUrl = buildDownloadUrl(version, "build.zip")
|
||||
val url = URL(downloadUrl)
|
||||
val conn = (url.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("User-Agent", "GaCy-Updater/1.0")
|
||||
}
|
||||
|
||||
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Failed to download package: HTTP ${conn.responseCode}")
|
||||
)
|
||||
}
|
||||
|
||||
val contentLength = conn.contentLengthLong
|
||||
val inputStream = BufferedInputStream(conn.inputStream)
|
||||
val outputStream = FileOutputStream(tempZip)
|
||||
|
||||
val buffer = ByteArray(8192)
|
||||
var totalBytesRead = 0L
|
||||
var bytesRead: Int
|
||||
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
totalBytesRead += bytesRead
|
||||
if (contentLength > 0) {
|
||||
val progress = 0.1f + (totalBytesRead.toFloat() / contentLength.toFloat()) * 0.5f
|
||||
val mbRead = totalBytesRead / (1024 * 1024f)
|
||||
val mbTotal = contentLength / (1024 * 1024f)
|
||||
onProgress(progress, String.format("Downloading: %.1f MB / %.1f MB", mbRead, mbTotal))
|
||||
} else {
|
||||
onProgress(0.3f, "Downloading package...")
|
||||
}
|
||||
}
|
||||
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
inputStream.close()
|
||||
|
||||
onProgress(0.65f, "Backing up user configuration...")
|
||||
val existingPersistent = File(targetServerDir, "misc/persistent.json")
|
||||
if (existingPersistent.exists()) {
|
||||
existingPersistent.copyTo(miscBackupFile, overwrite = true)
|
||||
}
|
||||
|
||||
val existingConfig = File(targetServerDir, "misc/config.json")
|
||||
if (existingConfig.exists()) {
|
||||
existingConfig.copyTo(configBackupFile, overwrite = true)
|
||||
}
|
||||
|
||||
onProgress(0.70f, "Preparing staging directory...")
|
||||
if (stagingDir.exists()) {
|
||||
stagingDir.deleteRecursively()
|
||||
}
|
||||
stagingDir.mkdirs()
|
||||
|
||||
onProgress(0.75f, "Extracting server archive...")
|
||||
extractZipSafely(tempZip, stagingDir)
|
||||
|
||||
val stagedBuildDir = File(stagingDir, "build")
|
||||
val effectiveStagedDir = if (stagedBuildDir.exists()) stagedBuildDir else stagingDir
|
||||
|
||||
onProgress(0.80f, "Installing server runtime dependencies...")
|
||||
try {
|
||||
extractAssetZip("packages.zip", effectiveStagedDir)
|
||||
} catch (e: Exception) {
|
||||
// Non-fatal if asset missing
|
||||
}
|
||||
|
||||
onProgress(0.85f, "Restoring user data...")
|
||||
val stagedMiscDir = File(effectiveStagedDir, "misc").apply { mkdirs() }
|
||||
|
||||
if (miscBackupFile.exists()) {
|
||||
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()) {
|
||||
configBackupFile.copyTo(File(stagedMiscDir, "config.json"), overwrite = true)
|
||||
}
|
||||
|
||||
onProgress(0.92f, "Finalizing installation...")
|
||||
if (targetServerDir.exists()) {
|
||||
targetServerDir.deleteRecursively()
|
||||
}
|
||||
targetServerDir.parentFile?.mkdirs()
|
||||
|
||||
val success = effectiveStagedDir.renameTo(targetServerDir)
|
||||
if (!success) {
|
||||
copyDirectory(effectiveStagedDir, targetServerDir)
|
||||
effectiveStagedDir.deleteRecursively()
|
||||
}
|
||||
|
||||
setInstalledVersion(version)
|
||||
onProgress(1.0f, "Installation complete!")
|
||||
Result.success(Unit)
|
||||
|
||||
} catch (e: Exception) {
|
||||
onProgress(0f, "Installation error: ${e.message}")
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
if (tempZip.exists()) tempZip.delete()
|
||||
if (stagingDir.exists()) stagingDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
fun extractAssetZip(assetName: String, outputDir: File) {
|
||||
context.assets.open(assetName).use { inputStream ->
|
||||
ZipInputStream(BufferedInputStream(inputStream)).use { zis ->
|
||||
var entry: ZipEntry? = zis.nextEntry
|
||||
while (entry != null) {
|
||||
val newFile = File(outputDir, entry.name)
|
||||
val canonicalDestPath = outputDir.canonicalPath
|
||||
val canonicalNewFilePath = newFile.canonicalPath
|
||||
if (!canonicalNewFilePath.startsWith(canonicalDestPath + File.separator) &&
|
||||
canonicalNewFilePath != canonicalDestPath
|
||||
) {
|
||||
throw SecurityException("Zip Slip detected: ${entry.name}")
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
newFile.mkdirs()
|
||||
} else {
|
||||
newFile.parentFile?.mkdirs()
|
||||
FileOutputStream(newFile).use { fos ->
|
||||
zis.copyTo(fos)
|
||||
}
|
||||
}
|
||||
zis.closeEntry()
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractZipSafely(zipFile: File, outputDir: File) {
|
||||
ZipInputStream(zipFile.inputStream().buffered()).use { zis ->
|
||||
var entry: ZipEntry? = zis.nextEntry
|
||||
while (entry != null) {
|
||||
val newFile = File(outputDir, entry.name)
|
||||
val canonicalDestPath = outputDir.canonicalPath
|
||||
val canonicalNewFilePath = newFile.canonicalPath
|
||||
if (!canonicalNewFilePath.startsWith(canonicalDestPath + File.separator) &&
|
||||
canonicalNewFilePath != canonicalDestPath
|
||||
) {
|
||||
throw SecurityException("Zip Slip detected: ${entry.name}")
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
newFile.mkdirs()
|
||||
} else {
|
||||
newFile.parentFile?.mkdirs()
|
||||
FileOutputStream(newFile).use { fos ->
|
||||
zis.copyTo(fos)
|
||||
}
|
||||
}
|
||||
zis.closeEntry()
|
||||
entry = zis.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDirectory(source: File, target: File) {
|
||||
if (source.isDirectory) {
|
||||
if (!target.exists()) target.mkdirs()
|
||||
source.list()?.forEach { file ->
|
||||
copyDirectory(File(source, file), File(target, file))
|
||||
}
|
||||
} else {
|
||||
source.copyTo(target, overwrite = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.gacy.android.model
|
||||
|
||||
import java.net.URI
|
||||
|
||||
enum class ServerExecutionMode(val displayNameTh: String, val displayNameEn: String) {
|
||||
LOCAL_EMBEDDED("รันในเครื่อง (Local Embedded)", "Local Embedded Runtime"),
|
||||
REMOTE_SERVER("เชื่อมต่อเซิร์ฟเวอร์ภายนอก (Remote Server)", "Remote Server Connection")
|
||||
@@ -9,6 +11,7 @@ enum class ServerStatus {
|
||||
STOPPED,
|
||||
STARTING,
|
||||
RUNNING,
|
||||
STOPPING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
@@ -21,7 +24,19 @@ data class ServerConfig(
|
||||
val sendHotfix: Boolean = true
|
||||
) {
|
||||
val dispatchUrl: String
|
||||
get() = "http://$remoteHost:$dispatchPort"
|
||||
get() = "http://${if (executionMode == ServerExecutionMode.LOCAL_EMBEDDED) "127.0.0.1" else remoteHost}:$dispatchPort"
|
||||
|
||||
/** Parse the host and ports the same way they will be used by the server. */
|
||||
fun withNetworkInput(mode: ServerExecutionMode, host: String, sdkPort: String, gamePort: String): ServerConfig? {
|
||||
val cleanHost = host.trim()
|
||||
val sdk = sdkPort.toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
|
||||
val game = gamePort.toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
|
||||
val address = try { URI("http://$cleanHost") } catch (_: Exception) { return null }
|
||||
if (address.host.isNullOrBlank() || address.port != -1 || address.userInfo != null ||
|
||||
!address.rawPath.isNullOrEmpty() || address.rawQuery != null || address.rawFragment != null ||
|
||||
cleanHost.contains(':') || cleanHost == "0.0.0.0") return null
|
||||
return copy(executionMode = mode, remoteHost = cleanHost, dispatchPort = sdk, gameserverPort = game)
|
||||
}
|
||||
}
|
||||
|
||||
data class GmCommandResult(
|
||||
|
||||
@@ -10,7 +10,9 @@ import java.util.Locale
|
||||
object LogBuffer {
|
||||
|
||||
private const val MAX_LOG_LINES = 500
|
||||
private const val MAX_DIAGNOSTIC_LINES = 100
|
||||
private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
private val diagnostics = ArrayDeque<String>()
|
||||
|
||||
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
||||
val logs: StateFlow<List<String>> = _logs.asStateFlow()
|
||||
@@ -19,6 +21,10 @@ object LogBuffer {
|
||||
fun append(message: String) {
|
||||
val timestamp = timeFormat.format(Date())
|
||||
val formatted = "[$timestamp] $message"
|
||||
if (isDiagnostic(message)) {
|
||||
diagnostics.addLast(formatted)
|
||||
if (diagnostics.size > MAX_DIAGNOSTIC_LINES) diagnostics.removeFirst()
|
||||
}
|
||||
val current = _logs.value.toMutableList()
|
||||
current.add(formatted)
|
||||
if (current.size > MAX_LOG_LINES) {
|
||||
@@ -30,9 +36,18 @@ object LogBuffer {
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
_logs.value = emptyList()
|
||||
diagnostics.clear()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getAllText(): String {
|
||||
return _logs.value.joinToString("\n")
|
||||
val recent = _logs.value
|
||||
val olderDiagnostics = diagnostics.filterNot { it in recent }
|
||||
return (olderDiagnostics + recent).joinToString("\n")
|
||||
}
|
||||
|
||||
private fun isDiagnostic(message: String): Boolean =
|
||||
"[gateway]" in message || "[DISPATCH]" in message ||
|
||||
"[KCP] handshake rejected:" in message || "[KCP] packet rejected:" in message ||
|
||||
"New connection from addr:" in message || "[Server error]" in message
|
||||
}
|
||||
@@ -1,152 +1,177 @@
|
||||
package com.gacy.android.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.gacy.android.GaCyApplication
|
||||
import com.gacy.android.MainActivity
|
||||
import com.gacy.android.R
|
||||
import com.gacy.android.data.GaCyApiRepository
|
||||
import com.gacy.android.data.GoRuntimeRepository
|
||||
import com.gacy.android.data.PreferencesRepository
|
||||
import com.gacy.android.model.ServerConfig
|
||||
import com.gacy.android.model.ServerExecutionMode
|
||||
import com.gacy.android.model.ServerStatus
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ServerService : LifecycleService() {
|
||||
class ServerService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var job: Job? = null
|
||||
private val processes = mutableListOf<Process>()
|
||||
private val lock = Any()
|
||||
@Volatile private var generation = 0
|
||||
|
||||
private var serverJob: Job? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
_serverStatus.value = ServerStatus.STOPPED
|
||||
}
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
val action = intent?.action ?: ACTION_START
|
||||
|
||||
when (action) {
|
||||
when (intent?.action ?: ACTION_START) {
|
||||
ACTION_START -> startServer()
|
||||
ACTION_STOP -> stopServer()
|
||||
ACTION_RESTART -> restartServer()
|
||||
ACTION_RESTART -> { stopServer(false); startServer() }
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startServer() {
|
||||
if (_serverStatus.value == ServerStatus.RUNNING || _serverStatus.value == ServerStatus.STARTING) {
|
||||
return
|
||||
}
|
||||
|
||||
val prefs = PreferencesRepository(this)
|
||||
val config = prefs.getServerConfig()
|
||||
|
||||
if (_serverStatus.value == ServerStatus.STARTING || _serverStatus.value == ServerStatus.RUNNING) return
|
||||
val run = ++generation
|
||||
val config = PreferencesRepository(this).getServerConfig()
|
||||
_serverStatus.value = ServerStatus.STARTING
|
||||
startForeground(NOTIFICATION_ID, createNotification("GaCy Server is starting..."))
|
||||
LogBuffer.append("--- GaCy Server Starting ---")
|
||||
LogBuffer.append("Mode: ${config.executionMode.displayNameEn}")
|
||||
LogBuffer.append("Dispatch Port: ${config.dispatchPort} | Gameserver Port: ${config.gameserverPort}")
|
||||
|
||||
serverJob = lifecycleScope.launch {
|
||||
startForegroundCompat("Starting GaCy Go server")
|
||||
job = scope.launch {
|
||||
try {
|
||||
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
|
||||
LogBuffer.append("Initializing local server runtime...")
|
||||
delay(500)
|
||||
LogBuffer.append("Loading resources and protocol definitions...")
|
||||
delay(500)
|
||||
LogBuffer.append("Dispatch service listening on 0.0.0.0:${config.dispatchPort}")
|
||||
LogBuffer.append("Gateway KCP socket bound on 0.0.0.0:${config.gameserverPort}")
|
||||
_serverStatus.value = ServerStatus.RUNNING
|
||||
updateNotification("GaCy Server is RUNNING on :${config.dispatchPort}")
|
||||
LogBuffer.append("GaCy Server is ready! Connect client via dispatch redirect.")
|
||||
} else {
|
||||
LogBuffer.append("Connecting to remote GaCy host at ${config.remoteHost}...")
|
||||
delay(500)
|
||||
_serverStatus.value = ServerStatus.RUNNING
|
||||
updateNotification("Monitoring Remote GaCy Host at ${config.remoteHost}")
|
||||
LogBuffer.append("Connected to remote GaCy server monitor.")
|
||||
val runtime = GoRuntimeRepository(applicationContext)
|
||||
LogBuffer.append("[Setup] Preparing GaCy Go runtime")
|
||||
runtime.prepare()
|
||||
ensureActive()
|
||||
launchProcess("gameserver", runtime.directory, config, run)
|
||||
launchProcess("sdkserver", runtime.directory, config, run)
|
||||
}
|
||||
val api = GaCyApiRepository()
|
||||
var ready = false
|
||||
repeat(40) {
|
||||
if (!ready) {
|
||||
ensureActive()
|
||||
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) checkProcesses()
|
||||
ready = api.checkServerHealth(config).first
|
||||
if (!ready) delay(500)
|
||||
}
|
||||
}
|
||||
if (!ready) error("SDK server did not respond")
|
||||
if (run != generation) return@launch
|
||||
_serverStatus.value = ServerStatus.RUNNING
|
||||
updateNotification("GaCy Go server is running")
|
||||
LogBuffer.append("[System] SDK server is ready at ${config.dispatchUrl}")
|
||||
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
|
||||
while (isActive && run == generation) {
|
||||
delay(1000)
|
||||
checkProcesses()
|
||||
}
|
||||
} else {
|
||||
var failures = 0
|
||||
while (isActive && run == generation) {
|
||||
delay(5000)
|
||||
val healthy = api.checkServerHealth(config).first
|
||||
ensureActive()
|
||||
failures = if (healthy) 0 else failures + 1
|
||||
if (failures >= 3) error("Remote SDK server stopped responding")
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
LogBuffer.append("Error starting server: ${e.message}")
|
||||
_serverStatus.value = ServerStatus.ERROR
|
||||
updateNotification("GaCy Server encountered an error")
|
||||
if (run == generation) {
|
||||
LogBuffer.append("[Server error] ${e.message}")
|
||||
terminateProcesses()
|
||||
_serverStatus.value = ServerStatus.ERROR
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopServer() {
|
||||
LogBuffer.append("--- GaCy Server Stopping ---")
|
||||
serverJob?.cancel()
|
||||
serverJob = null
|
||||
private fun checkProcesses() {
|
||||
val failed = synchronized(lock) { processes.firstOrNull { !it.isAlive } }
|
||||
if (failed != null) error("A Go server process exited with code ${failed.exitValue()}; see the log")
|
||||
}
|
||||
|
||||
private fun launchProcess(name: String, directory: File, config: ServerConfig, run: Int) {
|
||||
val executable = File(applicationInfo.nativeLibraryDir, "libgacy_${name}.so")
|
||||
check(executable.isFile) { "Missing Go runtime: ${executable.name}" }
|
||||
val builder = ProcessBuilder(executable.absolutePath).directory(directory).redirectErrorStream(true)
|
||||
builder.environment().apply {
|
||||
put("GAC_SDK_PORT", config.dispatchPort.toString())
|
||||
put("GAC_GAME_PORT", config.gameserverPort.toString())
|
||||
put("GAC_PUBLIC_GAME_HOST", config.remoteHost)
|
||||
put("GAC_PUBLIC_SDK_URL", "http://${config.remoteHost}:${config.dispatchPort}")
|
||||
}
|
||||
val process = builder.start()
|
||||
synchronized(lock) {
|
||||
if (run != generation) { process.destroy(); throw CancellationException() }
|
||||
processes.add(process)
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { LogBuffer.append("[$name] $it") }
|
||||
}
|
||||
} catch (e: java.io.IOException) {
|
||||
if (process.isAlive && run == generation) LogBuffer.append("[$name] Log stream error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun terminateProcesses() {
|
||||
val old = synchronized(lock) { processes.toList().also { processes.clear() } }
|
||||
old.forEach { it.destroy() }
|
||||
old.forEach { if (!it.waitFor(2, TimeUnit.SECONDS)) it.destroyForcibly() }
|
||||
}
|
||||
|
||||
private fun stopServer(stopService: Boolean = true) {
|
||||
++generation
|
||||
job?.cancel()
|
||||
job = null
|
||||
_serverStatus.value = ServerStatus.STOPPING
|
||||
terminateProcesses()
|
||||
_serverStatus.value = ServerStatus.STOPPED
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
LogBuffer.append("[System] GaCy server stopped")
|
||||
if (stopService) { stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() }
|
||||
}
|
||||
|
||||
private fun restartServer() {
|
||||
LogBuffer.append("Restarting GaCy Server...")
|
||||
stopServer()
|
||||
lifecycleScope.launch {
|
||||
delay(800)
|
||||
startServer()
|
||||
}
|
||||
private fun notification(text: String): Notification {
|
||||
val channel = NotificationChannel(GaCyApplication.CHANNEL_ID, GaCyApplication.CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW)
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
val pending = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID).setContentTitle("GaCy Android").setContentText(text)
|
||||
.setSmallIcon(R.drawable.app_icon).setContentIntent(pending).setOngoing(true).build()
|
||||
}
|
||||
|
||||
private fun createNotification(contentText: String): Notification {
|
||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val stopIntent = Intent(this, ServerService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
val stopPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
stopIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID)
|
||||
.setContentTitle("GaCy Android")
|
||||
.setContentText(contentText)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent)
|
||||
.build()
|
||||
private fun startForegroundCompat(text: String) {
|
||||
val n = notification(text)
|
||||
if (Build.VERSION.SDK_INT >= 34) startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
else startForeground(NOTIFICATION_ID, n)
|
||||
}
|
||||
|
||||
private fun updateNotification(contentText: String) {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
|
||||
manager.notify(NOTIFICATION_ID, createNotification(contentText))
|
||||
}
|
||||
|
||||
private fun updateNotification(text: String) { getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(text)) }
|
||||
override fun onDestroy() {
|
||||
++generation
|
||||
job?.cancel()
|
||||
terminateProcesses()
|
||||
scope.cancel()
|
||||
if (_serverStatus.value != ServerStatus.ERROR) _serverStatus.value = ServerStatus.STOPPED
|
||||
super.onDestroy()
|
||||
serverJob?.cancel()
|
||||
_serverStatus.value = ServerStatus.STOPPED
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
super.onBind(intent)
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -154,23 +179,10 @@ class ServerService : LifecycleService() {
|
||||
const val ACTION_STOP = "com.gacy.android.ACTION_STOP"
|
||||
const val ACTION_RESTART = "com.gacy.android.ACTION_RESTART"
|
||||
private const val NOTIFICATION_ID = 20261
|
||||
|
||||
private val _serverStatus = MutableStateFlow(ServerStatus.STOPPED)
|
||||
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_START }
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_STOP }
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun restart(context: Context) {
|
||||
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_RESTART }
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
fun start(context: Context) { androidx.core.content.ContextCompat.startForegroundService(context, Intent(context, ServerService::class.java).setAction(ACTION_START)) }
|
||||
fun stop(context: Context) { context.startService(Intent(context, ServerService::class.java).setAction(ACTION_STOP)) }
|
||||
fun restart(context: Context) { androidx.core.content.ContextCompat.startForegroundService(context, Intent(context, ServerService::class.java).setAction(ACTION_RESTART)) }
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ fun StatusBadge(status: ServerStatus, lang: AppLanguage) {
|
||||
val (color, text) = when (status) {
|
||||
ServerStatus.RUNNING -> Pair(GaCyGreen, tr(lang, "RUNNING", "กำลังทำงาน", "运行中"))
|
||||
ServerStatus.STARTING -> Pair(GaCyAmber, tr(lang, "STARTING...", "กำลังเริ่ม...", "启动中..."))
|
||||
ServerStatus.STOPPING -> Pair(GaCyAmber, tr(lang, "STOPPING...", "กำลังหยุด...", "停止中..."))
|
||||
ServerStatus.STOPPED -> Pair(TextMuted, tr(lang, "STOPPED", "หยุดทำงาน", "已停止"))
|
||||
ServerStatus.ERROR -> Pair(GaCyRed, tr(lang, "ERROR", "เกิดข้อผิดพลาด", "错误"))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.gacy.android.data.PersistentRepository
|
||||
import com.gacy.android.data.PreferencesRepository
|
||||
import com.gacy.android.model.AppLanguage
|
||||
import com.gacy.android.service.ServerService
|
||||
@@ -24,7 +23,6 @@ enum class NavTab(
|
||||
val icon: ImageVector
|
||||
) {
|
||||
SERVER("Server", "เซิร์ฟเวอร์", "服务器", Icons.Default.Computer),
|
||||
TELEPORT("Teleport", "วาร์ปแผนที่", "传送地图", Icons.Default.Explore),
|
||||
SRTOOL("SRTool", "เครื่องมือ GM", "GM 工具", Icons.Default.Build),
|
||||
SETTINGS("Settings", "ตั้งค่า", "设置", Icons.Default.Settings);
|
||||
|
||||
@@ -38,7 +36,6 @@ enum class NavTab(
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val context = LocalContext.current
|
||||
val persistentRepo = remember { PersistentRepository(context) }
|
||||
val prefsRepo = remember { PreferencesRepository(context) }
|
||||
|
||||
var selectedTab by remember { mutableStateOf(NavTab.SERVER) }
|
||||
@@ -89,14 +86,8 @@ fun MainScreen() {
|
||||
NavTab.SERVER -> ServerScreen(
|
||||
lang = currentLanguage,
|
||||
serverStatus = serverStatus,
|
||||
persistentRepo = persistentRepo,
|
||||
prefsRepo = prefsRepo
|
||||
)
|
||||
NavTab.TELEPORT -> TeleportScreen(
|
||||
lang = currentLanguage,
|
||||
serverStatus = serverStatus,
|
||||
persistentRepo = persistentRepo
|
||||
)
|
||||
NavTab.SRTOOL -> SRToolScreen(
|
||||
lang = currentLanguage,
|
||||
prefsRepo = prefsRepo
|
||||
|
||||
@@ -39,15 +39,14 @@ fun SRToolScreen(
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val apiRepo = remember { GaCyApiRepository() }
|
||||
val serverConfig = remember { prefsRepo.getServerConfig() }
|
||||
|
||||
var commandInput by remember { mutableStateOf("") }
|
||||
var commandOutput by remember { mutableStateOf("") }
|
||||
var commandSuccess by remember { mutableStateOf(true) }
|
||||
var isExecuting by remember { mutableStateOf(false) }
|
||||
|
||||
val presetCommands = listOf(
|
||||
"/unstuck",
|
||||
"/give 200001 10000",
|
||||
"/avatar 1204 80",
|
||||
"/avatar 1213 80",
|
||||
"/avatar 1308 80",
|
||||
@@ -146,9 +145,10 @@ fun SRToolScreen(
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val res = apiRepo.sendUnstuck(serverConfig)
|
||||
val res = apiRepo.sendUnstuck(prefsRepo.getServerConfig())
|
||||
Toast.makeText(context, res.message, Toast.LENGTH_SHORT).show()
|
||||
commandOutput = "[UNSTUCK] ${res.message}"
|
||||
commandSuccess = res.success
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = GaCyRed),
|
||||
@@ -185,7 +185,7 @@ fun SRToolScreen(
|
||||
value = commandInput,
|
||||
onValueChange = { commandInput = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text(tr(lang, "Enter GM command (e.g. /give 200001 100)", "พิมพ์คำสั่ง GM...", "输入 GM 命令..."), fontSize = 12.sp) },
|
||||
placeholder = { Text(tr(lang, "Enter GM command (e.g. /help)", "พิมพ์คำสั่ง GM...", "输入 GM 命令..."), fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
@@ -201,8 +201,9 @@ fun SRToolScreen(
|
||||
if (commandInput.isNotBlank()) {
|
||||
scope.launch {
|
||||
isExecuting = true
|
||||
val result = apiRepo.executeGmCommand(serverConfig, commandInput)
|
||||
val result = apiRepo.executeGmCommand(prefsRepo.getServerConfig(), commandInput)
|
||||
commandOutput = result.message
|
||||
commandSuccess = result.success
|
||||
isExecuting = false
|
||||
}
|
||||
}
|
||||
@@ -229,7 +230,7 @@ fun SRToolScreen(
|
||||
) {
|
||||
Text(
|
||||
text = commandOutput,
|
||||
color = TerminalGreen,
|
||||
color = if (commandSuccess) TerminalGreen else GaCyRed,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
|
||||
@@ -1,388 +1,41 @@
|
||||
package com.gacy.android.ui.screens
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.gacy.android.data.PersistentRepository
|
||||
import com.gacy.android.data.PreferencesRepository
|
||||
import com.gacy.android.model.*
|
||||
import com.gacy.android.model.AppLanguage
|
||||
import com.gacy.android.model.ServerStatus
|
||||
import com.gacy.android.service.LogBuffer
|
||||
import com.gacy.android.service.ServerService
|
||||
import com.gacy.android.ui.components.*
|
||||
import com.gacy.android.ui.theme.*
|
||||
import kotlinx.coroutines.launch
|
||||
import com.gacy.android.ui.components.TerminalConsole
|
||||
import com.gacy.android.ui.components.tr
|
||||
import com.gacy.android.ui.theme.BackgroundDark
|
||||
|
||||
@Composable
|
||||
fun ServerScreen(
|
||||
lang: AppLanguage,
|
||||
serverStatus: ServerStatus,
|
||||
persistentRepo: PersistentRepository,
|
||||
prefsRepo: PreferencesRepository
|
||||
) {
|
||||
fun ServerScreen(lang: AppLanguage, serverStatus: ServerStatus, prefsRepo: PreferencesRepository) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val logs by LogBuffer.logs.collectAsState()
|
||||
var serverConfig by remember { mutableStateOf(prefsRepo.getServerConfig()) }
|
||||
|
||||
var sendHotfix by remember { mutableStateOf(true) }
|
||||
var serverKey by remember { mutableStateOf("") }
|
||||
var tbConfig by remember { mutableStateOf(TrailblazerConfig()) }
|
||||
var exConfigs by remember { mutableStateOf(PersistentRepository.DEFAULT_EX_RANK_CONFIGS) }
|
||||
|
||||
// Dialog flags
|
||||
var showKeyDialog by remember { mutableStateOf(false) }
|
||||
var showTbDialog by remember { mutableStateOf(false) }
|
||||
var showExDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
sendHotfix = persistentRepo.getSendHotfix()
|
||||
serverKey = persistentRepo.getServerKey()
|
||||
tbConfig = persistentRepo.getTrailblazerConfig()
|
||||
exConfigs = persistentRepo.getExRankConfigs()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(BackgroundDark)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Status Card
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = SurfaceDark,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, BorderDark, RoundedCornerShape(16.dp))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = tr(lang, "GaCy Server Core", "คอร์เซิร์ฟเวอร์ GaCy", "GaCy 核心服务器"),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = tr(lang, "Ports: 21000 (Dispatch) / 23301 (Game)", "พอร์ต: 21000 (Dispatch) / 23301 (Game)", "端口: 21000 (调度) / 23301 (游戏)"),
|
||||
fontSize = 12.sp,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
StatusBadge(serverStatus, lang)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Control Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
val isRunning = serverStatus == ServerStatus.RUNNING
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (isRunning) {
|
||||
ServerService.stop(context)
|
||||
} else {
|
||||
ServerService.start(context)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isRunning) GaCyRed else GaCyPurple
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
if (isRunning) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
if (isRunning) tr(lang, "STOP", "หยุดเซิร์ฟเวอร์", "停止")
|
||||
else tr(lang, "START", "เริ่มเซิร์ฟเวอร์", "启动")
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { ServerService.restart(context) },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = TextPrimary),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(brush = androidx.compose.ui.graphics.SolidColor(BorderDark)),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Restart", modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(tr(lang, "Restart", "รีสตาร์ต", "重启"))
|
||||
}
|
||||
}
|
||||
val config = prefsRepo.getServerConfig()
|
||||
val active = serverStatus == ServerStatus.RUNNING || serverStatus == ServerStatus.STARTING
|
||||
Column(Modifier.fillMaxSize().background(BackgroundDark).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("GaCy Go — ${serverStatus.name}", style = MaterialTheme.typography.titleLarge)
|
||||
Text("SDK: ${config.dispatchPort} | Game: ${config.gameserverPort}")
|
||||
Text(tr(lang, "Set the host in Settings to the address the game uses to reach this device.", "ตั้งค่า Host ในหน้าตั้งค่าเป็น IP ที่เกมใช้เชื่อมต่ออุปกรณ์นี้", "在设置中填写游戏用于连接此设备的地址。"))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { if (active) ServerService.stop(context) else ServerService.start(context) }, enabled = serverStatus != ServerStatus.STOPPING) {
|
||||
Text(if (active) tr(lang, "Stop", "หยุด", "停止") else tr(lang, "Start", "เริ่ม", "启动"))
|
||||
}
|
||||
OutlinedButton(onClick = { ServerService.restart(context) }, enabled = serverStatus != ServerStatus.STARTING && serverStatus != ServerStatus.STOPPING) {
|
||||
Text(tr(lang, "Restart", "เริ่มใหม่", "重启"))
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal Console
|
||||
TerminalConsole(logs = logs, lang = lang)
|
||||
|
||||
// Server Configuration Tools
|
||||
Text(
|
||||
text = tr(lang, "Server & Character Tools", "เครื่องมือเซิร์ฟเวอร์และตัวละคร", "服务器与角色工具"),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
|
||||
QuickActionButton(
|
||||
icon = Icons.Default.VpnKey,
|
||||
title = tr(lang, "Server Password / Key", "รหัสผ่านเซิร์ฟเวอร์ (Server Key)", "服务器密钥 / 密码"),
|
||||
subtitle = if (serverKey.isEmpty()) tr(lang, "No password set", "ไม่ได้ตั้งรหัสผ่าน", "未设置密码") else "Key: $serverKey",
|
||||
color = GaCyPurple
|
||||
) {
|
||||
showKeyDialog = true
|
||||
}
|
||||
|
||||
QuickActionButton(
|
||||
icon = Icons.Default.Person,
|
||||
title = tr(lang, "Edit Trailblazer (Gender & Path)", "ปรับแต่งผู้บุกเบิก (เพศและ Path)", "开拓者设置 (性别与命途)"),
|
||||
subtitle = "${tbConfig.gender} | ${tbConfig.tbPath}",
|
||||
color = GaCyCyan
|
||||
) {
|
||||
showTbDialog = true
|
||||
}
|
||||
|
||||
QuickActionButton(
|
||||
icon = Icons.Default.Star,
|
||||
title = tr(lang, "3 EX Eidolon (Rank) Manager", "จัดการ 3 EX Eidolon (สกิลพิเศษ)", "3 EX 星魂管理"),
|
||||
subtitle = exConfigs.filter { it.active }.joinToString(", ") { it.name }.ifEmpty { tr(lang, "None active", "ปิดอยู่ทั้งหมด", "全部未开启") },
|
||||
color = GaCyAmber
|
||||
) {
|
||||
showExDialog = true
|
||||
}
|
||||
|
||||
// Send Hotfix Toggle Card
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = CardDark,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(GaCyGreen.copy(alpha = 0.2f), RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.CloudSync, contentDescription = null, tint = GaCyGreen, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = tr(lang, "Send Hotfix", "ส่ง Hotfix อัตโนมัติ", "自动发送热更新"),
|
||||
color = TextPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Text(
|
||||
text = if (sendHotfix) tr(lang, "Online Assets Download", "ดาวน์โหลด Assets ออนไลน์", "在线下载资源")
|
||||
else tr(lang, "Offline Mode (Fix Blackscreen)", "โหมดออฟไลน์ (แก้จอดำ)", "离线模式 (修复黑屏)"),
|
||||
color = TextSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = sendHotfix,
|
||||
onCheckedChange = { checked ->
|
||||
sendHotfix = checked
|
||||
scope.launch {
|
||||
persistentRepo.setSendHotfix(checked)
|
||||
Toast.makeText(context, tr(lang, "Saved send_hotfix: $checked", "บันทึก send_hotfix แล้ว", "已保存 send_hotfix"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = TextPrimary,
|
||||
checkedTrackColor = GaCyGreen,
|
||||
uncheckedTrackColor = SurfaceDark
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
if (showKeyDialog) {
|
||||
var tempKey by remember { mutableStateOf(serverKey) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showKeyDialog = false },
|
||||
title = { Text(tr(lang, "Set Server Password", "กำหนดรหัสผ่านเซิร์ฟเวอร์", "设置服务器密码")) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = tempKey,
|
||||
onValueChange = { tempKey = it },
|
||||
label = { Text(tr(lang, "Password (Optional)", "รหัสผ่าน (เว้นว่างได้)", "密码 (可选)")) },
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
serverKey = tempKey
|
||||
scope.launch {
|
||||
persistentRepo.setServerKey(tempKey)
|
||||
showKeyDialog = false
|
||||
Toast.makeText(context, tr(lang, "Server key saved", "บันทึกรหัสผ่านแล้ว", "密码已保存"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyPurple)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showKeyDialog = false }) {
|
||||
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
|
||||
}
|
||||
},
|
||||
containerColor = CardDark
|
||||
)
|
||||
}
|
||||
|
||||
if (showTbDialog) {
|
||||
var gender by remember { mutableStateOf(tbConfig.gender) }
|
||||
var path by remember { mutableStateOf(tbConfig.tbPath) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTbDialog = false },
|
||||
title = { Text(tr(lang, "Trailblazer Settings", "ตั้งค่าตัวเอก (ผู้บุกเบิก)", "开拓者设置")) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(tr(lang, "Gender:", "เพศ:", "性别:"), color = TextSecondary, fontSize = 12.sp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
listOf("Female", "Male").forEach { g ->
|
||||
FilterChip(
|
||||
selected = gender == g,
|
||||
onClick = { gender = g },
|
||||
label = { Text(if (g == "Female") "Stelle (หญิง)" else "Caelus (ชาย)") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(tr(lang, "Path:", "เส้นทาง (Path):", "命途:"), color = TextSecondary, fontSize = 12.sp)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
listOf("Destruction", "Preservation", "Harmony", "Elation", "Remembrance").forEach { p ->
|
||||
FilterChip(
|
||||
selected = path == p,
|
||||
onClick = { path = p },
|
||||
label = { Text(p, fontSize = 11.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val updated = tbConfig.copy(gender = gender, tbPath = path)
|
||||
tbConfig = updated
|
||||
scope.launch {
|
||||
persistentRepo.saveTrailblazerConfig(updated)
|
||||
showTbDialog = false
|
||||
Toast.makeText(context, tr(lang, "Trailblazer saved", "บันทึกตัวเอกแล้ว", "已保存开拓者"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyCyan)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showTbDialog = false }) {
|
||||
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
|
||||
}
|
||||
},
|
||||
containerColor = CardDark
|
||||
)
|
||||
}
|
||||
|
||||
if (showExDialog) {
|
||||
var currentList by remember { mutableStateOf(exConfigs) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExDialog = false },
|
||||
title = { Text(tr(lang, "3 EX Eidolon Config", "เปิด/ปิด 3 EX Eidolon", "3 EX 星魂设置")) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
currentList.forEachIndexed { index, cfg ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("${cfg.name} [ID: ${cfg.avatarId}]", color = TextPrimary, fontSize = 13.sp)
|
||||
Switch(
|
||||
checked = cfg.active,
|
||||
onCheckedChange = { active ->
|
||||
val updated = currentList.toMutableList()
|
||||
updated[index] = cfg.copy(active = active)
|
||||
currentList = updated
|
||||
},
|
||||
colors = SwitchDefaults.colors(checkedTrackColor = GaCyAmber)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
exConfigs = currentList
|
||||
scope.launch {
|
||||
persistentRepo.saveExRankConfigs(currentList)
|
||||
showExDialog = false
|
||||
Toast.makeText(context, tr(lang, "EX config saved", "บันทึก EX แล้ว", "已保存 EX 设置"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyAmber)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExDialog = false }) {
|
||||
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
|
||||
}
|
||||
},
|
||||
containerColor = CardDark
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ fun SettingsScreen(
|
||||
) {
|
||||
RadioButton(
|
||||
selected = mode == m,
|
||||
onClick = { mode = m },
|
||||
onClick = { mode = m; pingStatus = null },
|
||||
colors = RadioButtonDefaults.colors(selectedColor = GaCyPurple)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -112,7 +112,7 @@ fun SettingsScreen(
|
||||
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
onValueChange = { host = it; pingStatus = null },
|
||||
label = { Text(tr(lang, "Host / IP Address", "ที่อยู่ Host / IP", "主机 / IP 地址")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
@@ -125,7 +125,7 @@ fun SettingsScreen(
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = dispatchPort,
|
||||
onValueChange = { dispatchPort = it },
|
||||
onValueChange = { dispatchPort = it; pingStatus = null },
|
||||
label = { Text("Dispatch Port") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
@@ -137,7 +137,7 @@ fun SettingsScreen(
|
||||
|
||||
OutlinedTextField(
|
||||
value = gameserverPort,
|
||||
onValueChange = { gameserverPort = it },
|
||||
onValueChange = { gameserverPort = it; pingStatus = null },
|
||||
label = { Text("Game Port") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
@@ -155,17 +155,15 @@ fun SettingsScreen(
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
val dPort = dispatchPort.toIntOrNull() ?: 21000
|
||||
val gPort = gameserverPort.toIntOrNull() ?: 23301
|
||||
val newCfg = ServerConfig(
|
||||
executionMode = mode,
|
||||
remoteHost = host,
|
||||
dispatchPort = dPort,
|
||||
gameserverPort = gPort
|
||||
)
|
||||
config = newCfg
|
||||
prefsRepo.saveServerConfig(newCfg)
|
||||
Toast.makeText(context, tr(lang, "Configuration saved", "บันทึกการตั้งค่าแล้ว", "设置已保存"), Toast.LENGTH_SHORT).show()
|
||||
val newCfg = config.withNetworkInput(mode, host, dispatchPort, gameserverPort)
|
||||
if (newCfg == null) {
|
||||
Toast.makeText(context, tr(lang, "Enter a valid host and ports from 1 to 65535", "กรอก Host และพอร์ตตั้งแต่ 1 ถึง 65535 ให้ถูกต้อง", "请输入有效主机及 1 至 65535 的端口"), Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
config = newCfg
|
||||
host = newCfg.remoteHost
|
||||
prefsRepo.saveServerConfig(newCfg)
|
||||
Toast.makeText(context, tr(lang, "Saved. Restart the server to apply changes.", "บันทึกแล้ว เริ่มเซิร์ฟเวอร์ใหม่เพื่อใช้ค่า", "已保存,重启服务器后生效。"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
@@ -174,17 +172,22 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
enabled = !isPinging,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val testCfg = config.withNetworkInput(mode, host, dispatchPort, gameserverPort)
|
||||
if (testCfg == null) {
|
||||
pingStatus = tr(lang, "Invalid host or port", "Host หรือพอร์ตไม่ถูกต้อง", "主机或端口无效")
|
||||
return@launch
|
||||
}
|
||||
isPinging = true
|
||||
val dPort = dispatchPort.toIntOrNull() ?: 21000
|
||||
val gPort = gameserverPort.toIntOrNull() ?: 23301
|
||||
val testCfg = ServerConfig(mode, host, dPort, gPort)
|
||||
val (ok, duration) = apiRepo.checkServerHealth(testCfg)
|
||||
pingStatus = if (ok) {
|
||||
"Connected! Latency: ${duration}ms"
|
||||
} else {
|
||||
"Host unreachable or offline"
|
||||
if (config.withNetworkInput(mode, host, dispatchPort, gameserverPort) == testCfg) {
|
||||
pingStatus = if (ok) {
|
||||
"Connected! Latency: ${duration}ms"
|
||||
} else {
|
||||
"Host unreachable or offline"
|
||||
}
|
||||
}
|
||||
isPinging = false
|
||||
}
|
||||
@@ -228,7 +231,7 @@ fun SettingsScreen(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "GaCy Android v1.0.0\nEcosystem: GaCy-GO (Go Server), G-Launcher (PC), GaCy SRTool (Web)",
|
||||
text = "GaCy Android ${com.gacy.android.BuildConfig.VERSION_NAME}\nEcosystem: GaCy-GO (Go Server), G-Launcher (PC), GaCy SRTool (Web)",
|
||||
color = TextSecondary,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import os
|
||||
|
||||
def load_pyc(path, module_name):
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"PYC file not found at absolute path: {path}")
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not load spec for module {module_name} from {path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
@@ -0,0 +1,155 @@
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import tarfile
|
||||
import urllib.request
|
||||
import json
|
||||
import shutil
|
||||
import traceback
|
||||
import re
|
||||
|
||||
# Primary PyPI base URL and mirrors for China fallback
|
||||
PYPI_BASES = [
|
||||
"https://pypi.org/pypi", # Official PyPI
|
||||
"https://pypi.tuna.tsinghua.edu.cn/pypi", # Tsinghua (TUNA)
|
||||
"https://mirrors.aliyun.com/pypi", # Aliyun
|
||||
]
|
||||
|
||||
def _parse_package_spec(package_spec):
|
||||
match = re.match(r"^([a-zA-Z0-9_\-\.]+)(.*)$", package_spec.strip())
|
||||
if match:
|
||||
return match.group(1), match.group(2).strip()
|
||||
return package_spec, ""
|
||||
|
||||
def _fetch_pypi_data(clean_name):
|
||||
"""Attempt to fetch metadata JSON from PyPI and mirrors."""
|
||||
for base_url in PYPI_BASES:
|
||||
url = f"{base_url}/{clean_name}/json"
|
||||
try:
|
||||
print(f"Fetching metadata from: {base_url}…")
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch metadata from {base_url}: {e}")
|
||||
return None
|
||||
|
||||
def _download_file(file_info_urls, dest_path):
|
||||
"""Download package file with mirror URL rewriting if official PyPI fails."""
|
||||
# List of candidate download URLs (original URL + mirror URL replacements)
|
||||
for file_info in file_info_urls:
|
||||
original_url = file_info.get("url")
|
||||
if not original_url:
|
||||
continue
|
||||
|
||||
# Generate candidate download URLs for this file
|
||||
candidate_urls = [original_url]
|
||||
|
||||
# Rewrite official PyPI file URLs to mirror locations if needed
|
||||
if "files.pythonhosted.org" in original_url:
|
||||
candidate_urls.append(
|
||||
original_url.replace("https://files.pythonhosted.org", "https://pypi.tuna.tsinghua.edu.cn")
|
||||
)
|
||||
candidate_urls.append(
|
||||
original_url.replace("https://files.pythonhosted.org", "https://mirrors.aliyun.com/pypi")
|
||||
)
|
||||
|
||||
for dl_url in candidate_urls:
|
||||
try:
|
||||
print(f"Downloading from {dl_url}…")
|
||||
dl_req = urllib.request.Request(dl_url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(dl_req, timeout=30) as dl_resp, open(dest_path, "wb") as out_file:
|
||||
out_file.write(dl_resp.read())
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Download failed from {dl_url}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def install_package(package_name, target_dir):
|
||||
try:
|
||||
clean_name, version_spec = _parse_package_spec(package_name)
|
||||
print(f"Resolving PyPI metadata for {clean_name} (spec: '{version_spec}')…")
|
||||
|
||||
data = _fetch_pypi_data(clean_name)
|
||||
if not data:
|
||||
print(f"Could not retrieve PyPI metadata for {clean_name} from any mirror.")
|
||||
return False
|
||||
|
||||
urls = data.get("urls", [])
|
||||
|
||||
if version_spec.startswith("=="):
|
||||
req_version = version_spec.lstrip("=").strip()
|
||||
releases = data.get("releases", {})
|
||||
if req_version in releases:
|
||||
urls = releases[req_version]
|
||||
else:
|
||||
print(f"Version {req_version} not found on PyPI for {clean_name}")
|
||||
return False
|
||||
|
||||
target_file_infos = []
|
||||
|
||||
# 1. Prefer pure-Python wheels
|
||||
for file_info in urls:
|
||||
filename = file_info.get("filename", "")
|
||||
if filename.endswith("py3-none-any.whl") or filename.endswith("py2.py3-none-any.whl"):
|
||||
target_file_infos.append(file_info)
|
||||
break
|
||||
|
||||
# 2. Fallback to source distribution
|
||||
if not target_file_infos:
|
||||
for file_info in urls:
|
||||
if file_info.get("packagetype") == "sdist":
|
||||
target_file_infos.append(file_info)
|
||||
break
|
||||
|
||||
if not target_file_infos:
|
||||
print(f"No compatible pure-Python release found for {clean_name}")
|
||||
return False
|
||||
|
||||
if not os.path.exists(target_dir):
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
safe_filename = clean_name.replace("/", "_")
|
||||
dest_path = os.path.join(target_dir, f"_dl_{safe_filename}")
|
||||
|
||||
if not _download_file(target_file_infos, dest_path):
|
||||
print(f"Failed to download payload for {clean_name} from all sources.")
|
||||
return False
|
||||
|
||||
# Extract package files
|
||||
if dest_path.endswith(".whl") or dest_path.endswith(".zip") or target_file_infos[0].get("filename", "").endswith((".whl", ".zip")):
|
||||
with zipfile.ZipFile(dest_path, "r") as zip_ref:
|
||||
zip_ref.extractall(target_dir)
|
||||
else:
|
||||
with tarfile.open(dest_path, "r:gz") as tar_ref:
|
||||
tar_ref.extractall(target_dir)
|
||||
|
||||
if os.path.exists(dest_path):
|
||||
os.remove(dest_path)
|
||||
|
||||
# Handle 'src' layout if present
|
||||
for root, dirs, files in os.walk(target_dir):
|
||||
if os.path.basename(root) == "src":
|
||||
for d in dirs:
|
||||
src_pkg = os.path.join(root, d)
|
||||
dest_pkg = os.path.join(target_dir, d)
|
||||
if not os.path.exists(dest_pkg):
|
||||
shutil.move(src_pkg, dest_pkg)
|
||||
|
||||
# Cleanup __pycache__
|
||||
for root, dirs, files in os.walk(target_dir):
|
||||
for d in list(dirs):
|
||||
if d == "__pycache__":
|
||||
shutil.rmtree(os.path.join(root, d), ignore_errors=True)
|
||||
|
||||
if target_dir not in sys.path:
|
||||
sys.path.insert(0, target_dir)
|
||||
|
||||
print(f"Successfully installed {clean_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to install {package_name}: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
@@ -0,0 +1,982 @@
|
||||
import sys
|
||||
import os
|
||||
import builtins
|
||||
import time
|
||||
import _thread
|
||||
import gc
|
||||
import socket
|
||||
import struct
|
||||
import warnings
|
||||
|
||||
# === SYS.EXIT INTERCEPTOR (Prevent Android Process Termination) ===
|
||||
def _safe_sys_exit(code=0):
|
||||
print(f"[Sys] Intercepted sys.exit({code}), prevented Android process exit.")
|
||||
raise SystemExit(code)
|
||||
sys.exit = _safe_sys_exit
|
||||
|
||||
# === GLOBAL WARNING SUPPRESSION & METADATA SHIM (very early) ===
|
||||
try:
|
||||
import importlib.metadata
|
||||
_orig_metadata_version = importlib.metadata.version
|
||||
_orig_metadata_distribution = importlib.metadata.distribution
|
||||
|
||||
_KNOWN_VERSIONS = {
|
||||
"werkzeug": "3.1.9",
|
||||
"flask": "3.1.3",
|
||||
"marshmallow": "3.20.0",
|
||||
"requests": "2.34.2",
|
||||
"urllib3": "2.8.0",
|
||||
"protobuf": "7.36.2",
|
||||
"click": "8.5.0",
|
||||
"jinja2": "3.1.6",
|
||||
"itsdangerous": "2.2.0",
|
||||
"blinker": "1.9.0",
|
||||
"markupsafe": "3.0.4",
|
||||
"dataclasses-json": "0.6.7",
|
||||
"dataclasses_json": "0.6.7",
|
||||
"packaging": "26.3"
|
||||
}
|
||||
|
||||
class _ShimedDistribution:
|
||||
def __init__(self, name, version):
|
||||
self.name = name
|
||||
self.version = version
|
||||
self.metadata = {"Name": name, "Version": version}
|
||||
|
||||
def _safe_metadata_version(name):
|
||||
try:
|
||||
return _orig_metadata_version(name)
|
||||
except Exception:
|
||||
clean = name.lower().replace("-", "_")
|
||||
return _KNOWN_VERSIONS.get(name.lower(), _KNOWN_VERSIONS.get(clean, "1.0.0"))
|
||||
|
||||
def _safe_metadata_distribution(name):
|
||||
try:
|
||||
return _orig_metadata_distribution(name)
|
||||
except Exception:
|
||||
ver = _safe_metadata_version(name)
|
||||
return _ShimedDistribution(name, ver)
|
||||
|
||||
importlib.metadata.version = _safe_metadata_version
|
||||
importlib.metadata.distribution = _safe_metadata_distribution
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
warnings.filterwarnings("ignore", message=".*default.*deprecated.*")
|
||||
warnings.filterwarnings("ignore", message=".*metadata.*deprecated.*")
|
||||
warnings.filterwarnings("ignore", module="marshmallow.*")
|
||||
warnings.filterwarnings("ignore", module="dataclasses_json.*")
|
||||
|
||||
try:
|
||||
from marshmallow.warnings import RemovedInMarshmallow4Warning
|
||||
warnings.filterwarnings("ignore", category=RemovedInMarshmallow4Warning)
|
||||
warnings.simplefilter("ignore", category=RemovedInMarshmallow4Warning)
|
||||
print("[Shim] Global warning suppression activated")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_active_server_instance = None
|
||||
|
||||
# --- 1. Stream Redirector for Android UI ---
|
||||
class AndroidLogStream:
|
||||
def __init__(self, activity_context, original_stream):
|
||||
self.activity_context = activity_context
|
||||
self.original_stream = original_stream
|
||||
self.buffer = ""
|
||||
self._in_write = False
|
||||
|
||||
def write(self, message):
|
||||
if self._in_write:
|
||||
if self.original_stream:
|
||||
try:
|
||||
self.original_stream.write(message)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
self._in_write = True
|
||||
try:
|
||||
if self.original_stream:
|
||||
try:
|
||||
self.original_stream.write(message)
|
||||
except Exception:
|
||||
pass
|
||||
self.buffer += message
|
||||
while "\n" in self.buffer:
|
||||
line, self.buffer = self.buffer.split("\n", 1)
|
||||
clean_line = line.strip()
|
||||
if clean_line and self.activity_context:
|
||||
try:
|
||||
self.activity_context.onServerLog(clean_line)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._in_write = False
|
||||
|
||||
def flush(self):
|
||||
if self.original_stream:
|
||||
try:
|
||||
self.original_stream.flush()
|
||||
except Exception:
|
||||
pass
|
||||
if self.buffer.strip() and self.activity_context:
|
||||
try:
|
||||
self.activity_context.onServerLog(self.buffer.strip())
|
||||
self.buffer = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- 2. Headless Input Mock ---
|
||||
def _mock_input(prompt=""):
|
||||
if prompt:
|
||||
print(f"[CLI]: {prompt}")
|
||||
time.sleep(2)
|
||||
return ""
|
||||
|
||||
builtins.input = _mock_input
|
||||
|
||||
# --- 3. Minimal Marshmallow Info ---
|
||||
try:
|
||||
import marshmallow
|
||||
print(f"[Shim] marshmallow {marshmallow.__version__}")
|
||||
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
|
||||
}
|
||||
],
|
||||
"ex_rank": [
|
||||
{
|
||||
"name": "JingYuan",
|
||||
"avatar_id": 1204,
|
||||
"active": True
|
||||
},
|
||||
{
|
||||
"name": "DanHeng IL",
|
||||
"avatar_id": 1213,
|
||||
"active": True
|
||||
},
|
||||
{
|
||||
"name": "Acheron",
|
||||
"avatar_id": 1308,
|
||||
"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 == 'ex_rank':
|
||||
self['ex_rank'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['ex_rank'])
|
||||
return self['ex_rank']
|
||||
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 "send_hotfix" not in data:
|
||||
data["send_hotfix"] = True
|
||||
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 (send_hotfix=True)")
|
||||
except Exception as e:
|
||||
print(f"[Patch Warning] sanitize persistent: {e}")
|
||||
|
||||
# --- 4. Shutdown Logic ---
|
||||
def kill_server_process():
|
||||
global _active_server_instance
|
||||
print("[Shutdown]: Force-stopping server execution...")
|
||||
|
||||
try:
|
||||
import dispatch.dispatchserver as _ds
|
||||
if hasattr(_ds, 'DispatchServer') and hasattr(_ds.DispatchServer, 'stop'):
|
||||
_ds.DispatchServer.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import game.gameserver as _gs
|
||||
if hasattr(_gs, 'GameServer') and hasattr(_gs.GameServer, 'stop'):
|
||||
_gs.GameServer.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _active_server_instance is not None:
|
||||
try:
|
||||
if hasattr(_active_server_instance, 'shutdown'):
|
||||
_active_server_instance.shutdown()
|
||||
if hasattr(_active_server_instance, 'server_close'):
|
||||
_active_server_instance.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
sock = getattr(_active_server_instance, 'socket', None)
|
||||
if sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0))
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
_active_server_instance = None
|
||||
|
||||
# Do not close unrelated application sockets or interrupt the Android main thread.
|
||||
|
||||
# --- 5. Main Execution Entrypoint ---
|
||||
def run_server(ppsr_path, activity_context=None):
|
||||
if activity_context is not None:
|
||||
sys.stdout = AndroidLogStream(activity_context, sys.__stdout__)
|
||||
sys.stderr = AndroidLogStream(activity_context, sys.__stderr__)
|
||||
|
||||
# Extra safety - re-apply on every start
|
||||
try:
|
||||
warnings.filterwarnings("ignore", category=RemovedInMarshmallow4Warning)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ppsr_dir = os.path.dirname(os.path.abspath(ppsr_path))
|
||||
misc_dir = os.path.join(ppsr_dir, "misc")
|
||||
pkg_zip = os.path.join(ppsr_dir, "packages.zip")
|
||||
|
||||
if os.path.exists(ppsr_dir):
|
||||
os.chdir(ppsr_dir)
|
||||
print(f"Working directory locked: {os.getcwd()}")
|
||||
|
||||
for path in [ppsr_dir, misc_dir, pkg_zip]:
|
||||
if os.path.exists(path) and path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
warnings.filterwarnings("ignore", message=".*default.*deprecated.*")
|
||||
warnings.filterwarnings("ignore", message=".*metadata.*deprecated.*")
|
||||
warnings.filterwarnings("ignore", module="marshmallow.*")
|
||||
warnings.filterwarnings("ignore", module="dataclasses_json.*")
|
||||
|
||||
# Verify and auto-recover critical packages if needed
|
||||
for pkg_name in ["requests", "werkzeug", "flask"]:
|
||||
try:
|
||||
__import__(pkg_name)
|
||||
except ImportError:
|
||||
print(f"[Shim] Package '{pkg_name}' missing, attempting auto-install...")
|
||||
try:
|
||||
import package_fetcher
|
||||
package_fetcher.install_package(pkg_name, ppsr_dir)
|
||||
except Exception as ex:
|
||||
print(f"[Shim Warning] Fallback failed for {pkg_name}: {ex}")
|
||||
|
||||
# --- Werkzeug Patch ---
|
||||
try:
|
||||
import werkzeug.serving
|
||||
if hasattr(werkzeug.serving, "ThreadedWSGIServer"):
|
||||
try:
|
||||
werkzeug.serving.ThreadedWSGIServer._server_version = "Werkzeug/3.1.9"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_orig_make_server = werkzeug.serving.make_server
|
||||
|
||||
def _patched_make_server(*args, **kwargs):
|
||||
global _active_server_instance
|
||||
kwargs.pop('options', None)
|
||||
server = _orig_make_server(*args, **kwargs)
|
||||
|
||||
try:
|
||||
server._server_version = "Werkzeug/3.1.9"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if hasattr(server, 'socket') and server.socket:
|
||||
server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_active_server_instance = server
|
||||
print("[Server] Server instance patched and ready")
|
||||
return server
|
||||
|
||||
werkzeug.serving.make_server = _patched_make_server
|
||||
print("[Patch] Werkzeug make_server patched successfully")
|
||||
|
||||
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}")
|
||||
|
||||
# --- 4.6 Command IDs & Router Patch ---
|
||||
CORE_46 = {
|
||||
"ChangeLineupLeaderCsReq": 798,
|
||||
"ChangeLineupLeaderScRsp": 747,
|
||||
"ClientDownloadDataScNotify": 60,
|
||||
"DressAvatarSkinCsReq": 357,
|
||||
"DressAvatarSkinScRsp": 382,
|
||||
"GetAllLineupDataCsReq": 704,
|
||||
"GetAllLineupDataScRsp": 794,
|
||||
"GetAssistListCsReq": 2962,
|
||||
"GetAssistListScRsp": 2980,
|
||||
"GetAvatarDataCsReq": 374,
|
||||
"GetAvatarDataScRsp": 319,
|
||||
"GetBagCsReq": 574,
|
||||
"GetBagScRsp": 519,
|
||||
"GetBasicInfoCsReq": 46,
|
||||
"GetBasicInfoScRsp": 20,
|
||||
"GetBigDataAllRecommendCsReq": 2437,
|
||||
"GetBigDataAllRecommendScRsp": 2411,
|
||||
"GetCurLineupDataCsReq": 731,
|
||||
"GetCurLineupDataScRsp": 787,
|
||||
"GetCurSceneInfoCsReq": 1459,
|
||||
"GetCurSceneInfoScRsp": 1418,
|
||||
"GetFriendAssistListCsReq": 2978,
|
||||
"GetFriendAssistListScRsp": 2939,
|
||||
"GetFriendListInfoCsReq": 2974,
|
||||
"GetFriendListInfoScRsp": 2919,
|
||||
"GetMissionStatusCsReq": 1204,
|
||||
"GetMissionStatusScRsp": 1294,
|
||||
"GetRechargeGiftInfoCsReq": 8375,
|
||||
"GetRechargeGiftInfoScRsp": 8368,
|
||||
"GetUnreleasedBlockInfoCsReq": 4190,
|
||||
"GetUnreleasedBlockInfoScRsp": 4112,
|
||||
"JoinLineupCsReq": 705,
|
||||
"JoinLineupScRsp": 771,
|
||||
"PVEBattleResultCsReq": 174,
|
||||
"PVEBattleResultScRsp": 119,
|
||||
"PlayerGetTokenCsReq": 5,
|
||||
"PlayerGetTokenScRsp": 71,
|
||||
"PlayerHeartBeatCsReq": 45,
|
||||
"PlayerHeartBeatScRsp": 16,
|
||||
"PlayerLoginCsReq": 74,
|
||||
"PlayerLoginFinishCsReq": 9,
|
||||
"PlayerLoginFinishScRsp": 13,
|
||||
"PlayerLoginScRsp": 19,
|
||||
"PlayerSyncScNotify": 674,
|
||||
"QuitLineupCsReq": 759,
|
||||
"QuitLineupScRsp": 718,
|
||||
"ReplaceLineupCsReq": 782,
|
||||
"ReplaceLineupScRsp": 715,
|
||||
"SceneCastSkillCsReq": 1405,
|
||||
"SceneCastSkillScRsp": 1471,
|
||||
"SceneEntityMoveCsReq": 1474,
|
||||
"SceneEntityMoveScNotify": 1427,
|
||||
"SceneEntityMoveScRsp": 1419,
|
||||
"SceneGroupRefreshScNotify": 1434,
|
||||
"SetAvatarEnhancedIdCsReq": 381,
|
||||
"SetAvatarEnhancedIdScRsp": 322,
|
||||
"StartCocoonStageCsReq": 1444,
|
||||
"StartCocoonStageScRsp": 1481,
|
||||
"SyncLineupNotify": 727,
|
||||
"TakeOffAvatarSkinCsReq": 315,
|
||||
"TakeOffAvatarSkinScRsp": 323
|
||||
}
|
||||
|
||||
try:
|
||||
import json
|
||||
import game.cmdid as _cmdid_mod
|
||||
import game.router as _router_mod
|
||||
import game.packet as _packet_mod
|
||||
|
||||
TABLE_46 = dict(CORE_46)
|
||||
for json_path in [
|
||||
os.path.join(ppsr_dir, "cmd_ids_4_6.json"),
|
||||
os.path.join(misc_dir, "cmd_ids_4_6.json"),
|
||||
r"/data/data/com.gacy.android/files/server/cmd_ids_4_6.json",
|
||||
r"/data/data/com.gacy.android/files/server/build/build/cmd_ids_4_6.json"
|
||||
]:
|
||||
if os.path.exists(json_path):
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as _cjf:
|
||||
_raw = json.load(_cjf)
|
||||
for _k, _v in _raw.items():
|
||||
_name = _k[3:] if _k.startswith("Cmd") else _k
|
||||
TABLE_46[_name] = _v
|
||||
if "GetFriendListInfoScRsp" not in TABLE_46:
|
||||
TABLE_46["GetFriendListInfoScRsp"] = 2919
|
||||
print(f"[Patch] Loaded {len(TABLE_46)} 4.6 commands from {json_path}")
|
||||
break
|
||||
except Exception as _cje:
|
||||
print(f"[Patch Warning] Load cmd_ids_4_6.json: {_cje}")
|
||||
|
||||
_cmdid_mod.cmd_dict = TABLE_46
|
||||
_router_mod.cmd_dict = TABLE_46
|
||||
_router_mod.cmds = list(TABLE_46.keys())
|
||||
_router_mod.cmd_ids = list(TABLE_46.values())
|
||||
|
||||
_handlers_to_wire = [
|
||||
('GetAvatarDataCsReq', 'GetAvatarDataCsReq'),
|
||||
('DressAvatarSkinCsReq', 'DressAvatarSkinCsReq'),
|
||||
('TakeOffAvatarSkinCsReq', 'TakeOffAvatarSkinCsReq'),
|
||||
('SetAvatarEnhancedIdCsReq', 'SetAvatarEnhancedIdCsReq'),
|
||||
('PVEBattleResultCsReq', 'PVEBattleResultCsReq'),
|
||||
('GetBagCsReq', 'GetBagCsReq'),
|
||||
('GetCurLineupDataCsReq', 'GetCurLineupDataCsReq'),
|
||||
('JoinLineupCsReq', 'JoinLineupCsReq'),
|
||||
('QuitLineupCsReq', 'QuitLineupCsReq'),
|
||||
('ChangeLineupLeaderCsReq', 'ChangeLineupLeaderCsReq'),
|
||||
('GetAllLineupDataCsReq', 'GetAllLineupDataCsReq'),
|
||||
('ReplaceLineupCsReq', 'ReplaceLineupCsReq'),
|
||||
('GetUnreleasedBlockInfoCsReq', 'GetUnreleasedBlockInfoCsReq'),
|
||||
('GetMissionStatusCsReq', 'GetMissionStatusCsReq'),
|
||||
('PlayerLoginCsReq', 'PlayerLoginCsReq'),
|
||||
('PlayerGetTokenCsReq', 'PlayerGetTokenCsReq'),
|
||||
('GetBasicInfoCsReq', 'GetBasicInfoCsReq'),
|
||||
('PlayerHeartBeatCsReq', 'PlayerHeartBeatCsReq'),
|
||||
('PlayerLoginFinishCsReq', 'PlayerLoginFinishCsReq'),
|
||||
('GetRechargeGiftInfoCsReq', 'GetRechargeGiftInfoCsReq'),
|
||||
('GetBigDataAllRecommendCsReq', 'GetBigDataAllRecommendCsReq'),
|
||||
('SceneCastSkillCsReq', 'SceneCastSkillCsReq'),
|
||||
('GetCurSceneInfoCsReq', 'GetCurSceneInfoCsReq'),
|
||||
('StartCocoonStageCsReq', 'StartCocoonStageCsReq'),
|
||||
('SceneEntityMoveCsReq', 'SceneEntityMoveCsReq'),
|
||||
]
|
||||
|
||||
_new_handle_funct = {}
|
||||
for req_name, handler_name in _handlers_to_wire:
|
||||
if hasattr(_router_mod, handler_name):
|
||||
h_mod = getattr(_router_mod, handler_name)
|
||||
if hasattr(h_mod, 'handle'):
|
||||
cid = TABLE_46.get(req_name)
|
||||
if cid is not None:
|
||||
_new_handle_funct[cid] = h_mod.handle
|
||||
|
||||
_router_mod.handle_funct = _new_handle_funct
|
||||
|
||||
def _safe_to_rsp(req_name):
|
||||
if req_name.endswith('CsReq'):
|
||||
rsp_name = req_name[:-5] + 'ScRsp'
|
||||
if rsp_name in TABLE_46:
|
||||
return TABLE_46[rsp_name]
|
||||
return -1
|
||||
|
||||
_router_mod.to_rsp = _safe_to_rsp
|
||||
|
||||
def _safe_router_handle(data):
|
||||
try:
|
||||
cmd, head, body = _packet_mod.read_packet(data)
|
||||
if cmd not in _router_mod.cmd_ids:
|
||||
print(f"[Packet Warning] Unhandled cmd_id {cmd}")
|
||||
return b''
|
||||
|
||||
cmd_name = _router_mod.cmds[_router_mod.cmd_ids.index(cmd)]
|
||||
rsp_cmd = _router_mod.to_rsp(cmd_name)
|
||||
|
||||
if cmd not in _router_mod.handle_funct:
|
||||
if rsp_cmd != -1:
|
||||
print(f"[Packet] Recv dummy {cmd_name} ({cmd}) -> Send dummy {rsp_cmd}")
|
||||
return _packet_mod.write_packet(rsp_cmd, b'', b'')
|
||||
else:
|
||||
print(f"[Packet] Recv notify/unknown {cmd_name} ({cmd})")
|
||||
return b''
|
||||
|
||||
handler_funct = _router_mod.handle_funct[cmd]
|
||||
rsp_body = handler_funct(body)
|
||||
|
||||
if cmd == TABLE_46.get('StartCocoonStageCsReq'):
|
||||
try:
|
||||
import game.services.manager as _mgr
|
||||
_mgr.Manager.load_persistent()
|
||||
_mgr.Manager.load_freesr()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[Packet] Recv {cmd_name} ({cmd}) -> Send {rsp_cmd}")
|
||||
return _packet_mod.write_packet(rsp_cmd, b'', rsp_body)
|
||||
|
||||
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(f"[Patch] game.router mapped with {len(_new_handle_funct)} 4.6 handlers and crash-guard")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Patch Warning] 4.6 router patch: {e}")
|
||||
|
||||
# --- SafeHotfixDict & Dispatch Patch (Prevent 1001_3 KeyError & reachable Gateway IP) ---
|
||||
try:
|
||||
import json
|
||||
import base64
|
||||
import dispatch.dispatchserver as _ds_mod
|
||||
|
||||
class SafeHotfixDict(dict):
|
||||
def __getitem__(self, key):
|
||||
if key in self:
|
||||
return super().__getitem__(key)
|
||||
base = str(key).split('_')[0]
|
||||
if base in self:
|
||||
print(f"[SafeHotfix] Version resolved: {key} -> {base}")
|
||||
return super().__getitem__(base)
|
||||
for cand in ['OSBETAAndroid4.6.54', 'OSBETAAndroid4.6.53', 'OSBETAWin4.6.54', 'OSBETAWin4.6.53', 'OSBETAAndroid4.6.51', 'OSBETAWin4.6.51']:
|
||||
if cand in self:
|
||||
print(f"[SafeHotfix] Version fallback: {key} -> {cand}")
|
||||
return super().__getitem__(cand)
|
||||
if len(self) > 0:
|
||||
first_val = next(iter(self.values()))
|
||||
print(f"[SafeHotfix] Unknown version {key}, falling back to default hotfix.")
|
||||
return first_val
|
||||
return {}
|
||||
|
||||
if hasattr(_ds_mod, 'hotfix') and isinstance(_ds_mod.hotfix, dict):
|
||||
_ds_mod.hotfix = SafeHotfixDict(_ds_mod.hotfix)
|
||||
elif os.path.exists(os.path.join(misc_dir, "hotfix.json")):
|
||||
with open(os.path.join(misc_dir, "hotfix.json"), "r", encoding="utf-8") as _hf_file:
|
||||
_ds_mod.hotfix = SafeHotfixDict(json.load(_hf_file))
|
||||
|
||||
if hasattr(_ds_mod, 'check_hotfix'):
|
||||
_orig_check_hotfix = _ds_mod.check_hotfix
|
||||
def _safe_check_hotfix(version, full_path):
|
||||
try:
|
||||
return _orig_check_hotfix(version, full_path)
|
||||
except Exception as ex:
|
||||
print(f"[SafeHotfix Warning] check_hotfix handled safely: {ex}")
|
||||
_ds_mod.check_hotfix = _safe_check_hotfix
|
||||
|
||||
# Keep _ds_mod.HOST = "127.0.0.1" for internal references
|
||||
_ds_mod.HOST = "127.0.0.1"
|
||||
|
||||
def resolve_client_gate_ip():
|
||||
try:
|
||||
from flask import request
|
||||
if request.remote_addr in ('127.0.0.1', '::1', 'localhost'):
|
||||
return '127.0.0.1'
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
|
||||
probe.connect((request.remote_addr, 9))
|
||||
return probe.getsockname()[0]
|
||||
except Exception:
|
||||
pass
|
||||
return '127.0.0.1'
|
||||
|
||||
def _patched_query_gateway():
|
||||
from flask import request
|
||||
version = request.args.get('version')
|
||||
if version is None or request.method == 'HEAD':
|
||||
return '', 200
|
||||
|
||||
from msg import classes_pb2
|
||||
gate_ip = resolve_client_gate_ip()
|
||||
print(f"[Gateway] Resolved gateserver target: {gate_ip}:{_ds_mod.GAME_PORT} for client {request.remote_addr}")
|
||||
|
||||
response = classes_pb2.Gateserver(
|
||||
ip=gate_ip,
|
||||
port=_ds_mod.GAME_PORT,
|
||||
unk1=True, unk2=True, unk3=True, unk4=True, unk5=True,
|
||||
unk6=True, unk7=True, unk8=True
|
||||
)
|
||||
|
||||
# Check if persistent specifies send_hotfix (Default to True for 4.6)
|
||||
should_send_hotfix = True
|
||||
try:
|
||||
import game.services.manager as _mgr_mod
|
||||
if hasattr(_mgr_mod, 'Manager') and hasattr(_mgr_mod.Manager, 'persistent'):
|
||||
should_send_hotfix = bool(_mgr_mod.Manager.persistent.get('send_hotfix', True))
|
||||
except Exception:
|
||||
should_send_hotfix = True
|
||||
|
||||
if should_send_hotfix:
|
||||
clean_ver = version.split('_')[0] if version else version
|
||||
hf = _ds_mod.hotfix.get(version) or _ds_mod.hotfix.get(clean_ver)
|
||||
|
||||
if isinstance(hf, dict) and hf:
|
||||
response.asset_bundle_url = str(hf.get('asset_bundle_url', ''))
|
||||
response.asset_bundle_url_b = str(hf.get('asset_bundle_url_b', ''))
|
||||
response.ex_resource_url = str(hf.get('ex_resource_url', ''))
|
||||
response.lua_url = str(hf.get('lua_url', ''))
|
||||
response.ifix_url = str(hf.get('ifix_url', ''))
|
||||
response.lua_version = str(hf.get('lua_version', '0'))
|
||||
response.ifix_version = str(hf.get('ifix_version', '0'))
|
||||
print(f"[Gateway] Hotfix populated for {version} (asset_bundle_url={response.asset_bundle_url[:35]}..., lua_ver={response.lua_version})")
|
||||
else:
|
||||
print(f"[Gateway Warning] No hotfix metadata found for {version}")
|
||||
return 'Unknown client version', 400
|
||||
else:
|
||||
print(f"[Gateway] Hotfix download bypassed (send_hotfix=False) for client {request.remote_addr}")
|
||||
|
||||
return base64.b64encode(response.SerializeToString())
|
||||
|
||||
def _patched_query_dispatch():
|
||||
from flask import request
|
||||
from msg import classes_pb2
|
||||
gate_ip = resolve_client_gate_ip()
|
||||
print(f"[Dispatch] Query dispatch from {request.remote_addr}, offering gateway at {gate_ip}:{_ds_mod.PORT}")
|
||||
response = classes_pb2.Dispatch(
|
||||
msg='OK',
|
||||
retcode=0,
|
||||
top_server_region_name='pp_sr',
|
||||
region_list=[
|
||||
classes_pb2.RegionInfo(
|
||||
name='ppSR',
|
||||
title='ppSR',
|
||||
dispatch_url=f'http://{gate_ip}:{_ds_mod.PORT}/query_gateway',
|
||||
env_type='9',
|
||||
display_name='ppSR'
|
||||
)
|
||||
]
|
||||
)
|
||||
return base64.b64encode(response.SerializeToString())
|
||||
|
||||
# Patch DispatchServer.run_server to bind 0.0.0.0
|
||||
if hasattr(_ds_mod, 'DispatchServer') and hasattr(_ds_mod.DispatchServer, 'run_server'):
|
||||
@classmethod
|
||||
def _patched_run_server(cls):
|
||||
try:
|
||||
import logging, flask
|
||||
logging.getLogger('werkzeug').disabled = True
|
||||
flask.cli.show_server_banner = lambda *args: None
|
||||
print(f"Dispatch server listening on 0.0.0.0:{_ds_mod.PORT}")
|
||||
print(f"Forwarding to 127.0.0.1:{_ds_mod.GAME_PORT}")
|
||||
_ds_mod.app.run(host="0.0.0.0", port=_ds_mod.PORT, threaded=False, use_reloader=False, debug=False)
|
||||
except Exception as ex:
|
||||
print(f"[DispatchServer Error]: {ex}")
|
||||
_ds_mod.DispatchServer.run_server = _patched_run_server
|
||||
|
||||
_app = _ds_mod.app
|
||||
_app.view_functions['query_gateway'] = _patched_query_gateway
|
||||
_ds_mod.query_gateway = _patched_query_gateway
|
||||
|
||||
_app.view_functions['query_dispatch'] = _patched_query_dispatch
|
||||
_ds_mod.query_dispatch = _patched_query_dispatch
|
||||
|
||||
@_app.before_request
|
||||
def _log_http_req():
|
||||
try:
|
||||
from flask import request as _req
|
||||
if any(k in _req.path for k in ['log', 'Upload', 'report']):
|
||||
body_preview = _req.get_data()[:300].decode('utf-8', errors='ignore')
|
||||
print(f"[HTTP Telemetry] {_req.method} {_req.path}: {body_preview}")
|
||||
else:
|
||||
print(f"[HTTP] {_req.method} {_req.path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@_app.after_request
|
||||
def _log_http_rsp(resp):
|
||||
try:
|
||||
from flask import request as _req
|
||||
print(f"[HTTP] {_req.method} {_req.path} -> {resp.status_code}")
|
||||
resp.headers['Access-Control-Allow-Origin'] = '*'
|
||||
resp.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS, HEAD'
|
||||
resp.headers['Access-Control-Allow-Headers'] = '*'
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
def _handle_login_rsp(*args, **kwargs):
|
||||
try:
|
||||
return _ds_mod.login_by_password(), 200, {'Content-Type': 'application/json'}
|
||||
except Exception as e:
|
||||
print(f"[Login Error]: {e}")
|
||||
return '{"retcode":0,"message":"OK","data":{}}', 200, {'Content-Type': 'application/json'}
|
||||
|
||||
def _handle_load_config(*args, **kwargs):
|
||||
return '{"retcode":0,"message":"OK","data":{"client_custom_config":"","client_saved_config":""}}', 200, {'Content-Type': 'application/json'}
|
||||
|
||||
for _route in [
|
||||
'/<region>/account/ma-passport/api/appLoginByPassword',
|
||||
'/account/ma-passport/api/appLoginByPassword',
|
||||
'/<region>/account/ma-cn-passport/app/loginByPassword',
|
||||
'/account/ma-cn-passport/app/loginByPassword',
|
||||
'/<region>/account/ma-passport/token/verify',
|
||||
'/account/ma-passport/token/verify',
|
||||
'/<region>/account/auth/api/login',
|
||||
'/account/auth/api/login'
|
||||
]:
|
||||
try:
|
||||
_app.add_url_rule(_route, endpoint=f'flex_{_route}', view_func=_handle_login_rsp, methods=['OPTIONS', 'POST', 'GET'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for _cfg_route in [
|
||||
'/<region>/mdk/shield/api/loadConfig',
|
||||
'/mdk/shield/api/loadConfig',
|
||||
'/hkrpg_<region>/mdk/shield/api/loadConfig',
|
||||
'/hkrpg_global/mdk/shield/api/loadConfig'
|
||||
]:
|
||||
try:
|
||||
_app.add_url_rule(_cfg_route, endpoint=f'cfg_{_cfg_route}', view_func=_handle_load_config, methods=['OPTIONS', 'POST', 'GET'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@_app.errorhandler(405)
|
||||
def _handle_405(e):
|
||||
return '{"retcode":0,"message":"OK","data":{}}', 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@_app.errorhandler(404)
|
||||
def _handle_404(e):
|
||||
try:
|
||||
from flask import request as _req
|
||||
print(f"[HTTP 404 Fallback] {_req.method} {_req.path}")
|
||||
if any(k in _req.path for k in ['login', 'passport', 'token', 'auth', 'shield']):
|
||||
return _ds_mod.login_by_password(), 200, {'Content-Type': 'application/json'}
|
||||
if 'combo' in _req.path:
|
||||
return _ds_mod.login_v2(), 200, {'Content-Type': 'application/json'}
|
||||
if 'risky' in _req.path:
|
||||
return _ds_mod.risky_check(), 200, {'Content-Type': 'application/json'}
|
||||
except Exception:
|
||||
pass
|
||||
return '{"retcode":0,"message":"OK","data":{}}', 200, {'Content-Type': 'application/json'}
|
||||
|
||||
print("[Patch] dispatchserver patched with reachable Gateserver IP, 4.6 protocol, and flexible routes")
|
||||
except Exception as e:
|
||||
print(f"[Patch Warning] Dispatch server patch: {e}")
|
||||
|
||||
# --- GameServer address & protected KCP loop patch ---
|
||||
try:
|
||||
import game.gameserver as _gs_mod
|
||||
if hasattr(_gs_mod, 'GameServer'):
|
||||
_gs_mod.GameServer.address = "0.0.0.0"
|
||||
|
||||
@classmethod
|
||||
def _patched_read_loop(cls):
|
||||
print(f"[GameServer] UDP KCP Listener active on {cls.address}:{cls.port}")
|
||||
while cls.running:
|
||||
try:
|
||||
message, address = cls.server_socket.recvfrom(_gs_mod.IKCP_MTU_DEF)
|
||||
if not message:
|
||||
continue
|
||||
|
||||
if len(message) == 20:
|
||||
print(f"[GameServer] Received Handshake ({len(message)} bytes) from {address}")
|
||||
cls.handle_handshake(address, message)
|
||||
continue
|
||||
|
||||
if not hasattr(cls, 'kcp') or cls.kcp is None:
|
||||
print(f"[GameServer Warning] Received {len(message)} bytes from {address} before KCP established, ignoring.")
|
||||
continue
|
||||
|
||||
cls.kcp.input(message)
|
||||
cls.kcp.flush()
|
||||
cls.kcp.update(int(time.time() * 1000))
|
||||
|
||||
packet = cls.kcp.recv()
|
||||
while len(packet) > 0 and cls.running:
|
||||
rsp = _router_mod.handle(packet)
|
||||
if len(rsp) != 0:
|
||||
cls.send(rsp)
|
||||
packet = cls.kcp.recv()
|
||||
|
||||
except Exception as ex:
|
||||
if cls.running:
|
||||
print(f"[GameServer Exception in read_loop]: {ex}")
|
||||
time.sleep(0.01)
|
||||
|
||||
_gs_mod.GameServer.read_loop = _patched_read_loop
|
||||
|
||||
@classmethod
|
||||
def _patched_handle_handshake(cls, address, data):
|
||||
try:
|
||||
from game.handshakeutils import HandShake, HandshakeType
|
||||
handshake = HandShake.parse(data)
|
||||
print(f"[GameServer] Parsed handshake type={handshake} from {address}")
|
||||
if handshake == HandshakeType.CONNECT:
|
||||
cls.establish_kcp()
|
||||
rsp_handshake = HandShake.build_rsp_handshake(cls.conv_id)
|
||||
cls.client_socket = address
|
||||
cls.send_udp(rsp_handshake)
|
||||
print(f"[GameServer] Handshake accepted! Sent response to {address}, conv_id={cls.conv_id}")
|
||||
except Exception as ex:
|
||||
print(f"[GameServer Handshake Error]: {ex}")
|
||||
|
||||
_gs_mod.GameServer.handle_handshake = _patched_handle_handshake
|
||||
|
||||
@classmethod
|
||||
def _patched_send_udp(cls, data):
|
||||
if cls.client_socket is not None:
|
||||
try:
|
||||
cls.server_socket.sendto(data, cls.client_socket)
|
||||
except Exception as ex:
|
||||
print(f"[GameServer send_udp Error]: {ex}")
|
||||
|
||||
_gs_mod.GameServer.send_udp = _patched_send_udp
|
||||
|
||||
_orig_gs_start = _gs_mod.GameServer.start
|
||||
@classmethod
|
||||
def _safe_gs_start(cls):
|
||||
cls.address = "0.0.0.0"
|
||||
try:
|
||||
sock = getattr(cls, 'server_socket', None)
|
||||
if sock is None or getattr(sock, 'fileno', lambda: -1)() == -1:
|
||||
cls.server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
cls.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
_orig_gs_start()
|
||||
print(f"[GameServer] Bound UDP socket on {cls.address}:{cls.port}")
|
||||
except Exception as ex:
|
||||
print(f"[GameServer Warning] start error: {ex}")
|
||||
|
||||
_gs_mod.GameServer.start = _safe_gs_start
|
||||
print("[Patch] GameServer address patched to 0.0.0.0 with protected read_loop and handshake logging")
|
||||
except Exception as e:
|
||||
print(f"[Patch Warning] GameServer patch: {e}")
|
||||
|
||||
ppsr_py = os.path.join(ppsr_dir, "ppsr.py")
|
||||
if os.path.exists(ppsr_py):
|
||||
print(f"Loading source script target: {ppsr_py}...")
|
||||
with open(ppsr_py, "r", encoding="utf-8") as f:
|
||||
code_str = f.read()
|
||||
code_obj = compile(code_str, ppsr_py, "exec")
|
||||
target_file = ppsr_py
|
||||
else:
|
||||
print(f"Loading bytecode target: {ppsr_path}...")
|
||||
with open(ppsr_path, "rb") as f:
|
||||
code_bytes = f.read()
|
||||
try:
|
||||
import marshal
|
||||
code_obj = marshal.loads(code_bytes[16:])
|
||||
except Exception:
|
||||
code_obj = compile(code_bytes, ppsr_path, "exec")
|
||||
target_file = ppsr_path
|
||||
|
||||
main_globals = {
|
||||
"__name__": "__main__",
|
||||
"__file__": target_file,
|
||||
"__doc__": None,
|
||||
"sys": sys,
|
||||
"os": os,
|
||||
"input": _mock_input
|
||||
}
|
||||
|
||||
try:
|
||||
exec(code_obj, main_globals)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
print("[System]: Server loop interrupted cleanly.")
|
||||
except Exception as e:
|
||||
print(f"[System]: Server terminated: {e}")
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.gacy.android
|
||||
|
||||
import com.gacy.android.data.GoRuntimeInstaller
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class GoRuntimeInstallerTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private fun archive(resource: String, hotfix: String, extra: Pair<String, String>? = null, versions: String = "{}"): ByteArray {
|
||||
val entries = linkedMapOf(
|
||||
"persistent" to "original-save", "freesr-data.json" to "original-roster",
|
||||
"server.json" to "server-config", "hotfix.json" to hotfix, "versions.json" to versions,
|
||||
"res.json" to resource, "teleports.json" to "{}", "resources/MapEntrance.json" to "{}"
|
||||
)
|
||||
if (extra != null) entries[extra.first] = extra.second
|
||||
val output = ByteArrayOutputStream()
|
||||
ZipOutputStream(output).use { zip ->
|
||||
entries.forEach { (name, content) ->
|
||||
zip.putNextEntry(ZipEntry(name)); zip.write(content.toByteArray()); zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
@Test fun updatePreservesUserDataAndRepairsMissingResources() {
|
||||
val directory = tmp.newFolder("runtime")
|
||||
val old = archive("old", """{"existing":{"asset":"bundled"}}""")
|
||||
GoRuntimeInstaller(directory) { ByteArrayInputStream(old) }.prepare()
|
||||
File(directory, "persistent").writeText("user-save")
|
||||
File(directory, "freesr-data.json").writeText("user-roster")
|
||||
File(directory, "hotfix.json").writeText("""{"existing":{"asset":"user"}}""")
|
||||
File(directory, "versions.json").writeText("""{"existing":{"asset":"user"}}""")
|
||||
val updated = archive("new", """{"existing":{"asset":"new"},"added":{"asset":"new"}}""", versions = """{"existing":{"asset":"new"},"added":{"asset":"new"}}""")
|
||||
val installer = GoRuntimeInstaller(directory) { ByteArrayInputStream(updated) }
|
||||
installer.prepare()
|
||||
assertEquals("user-save", File(directory, "persistent").readText())
|
||||
assertEquals("user-roster", File(directory, "freesr-data.json").readText())
|
||||
assertEquals("new", File(directory, "res.json").readText())
|
||||
val hotfix = JSONObject(File(directory, "hotfix.json").readText())
|
||||
assertEquals("user", hotfix.getJSONObject("existing").getString("asset"))
|
||||
assertEquals("new", hotfix.getJSONObject("added").getString("asset"))
|
||||
val versions = JSONObject(File(directory, "versions.json").readText())
|
||||
assertEquals("user", versions.getJSONObject("existing").getString("asset"))
|
||||
assertEquals("new", versions.getJSONObject("added").getString("asset"))
|
||||
File(directory, "resources/MapEntrance.json").delete()
|
||||
installer.prepare()
|
||||
assertTrue(File(directory, "resources/MapEntrance.json").isFile)
|
||||
assertEquals("user-save", File(directory, "persistent").readText())
|
||||
}
|
||||
|
||||
@Test fun rejectsPathsOutsideRuntimeDirectory() {
|
||||
val directory = tmp.newFolder("runtime")
|
||||
val zip = archive("data", "{}", "../outside" to "bad")
|
||||
try {
|
||||
GoRuntimeInstaller(directory) { ByteArrayInputStream(zip) }.prepare()
|
||||
fail("Path traversal should be rejected")
|
||||
} catch (_: IllegalArgumentException) { }
|
||||
assertFalse(File(directory.parentFile, "outside").exists())
|
||||
assertFalse(File(directory, ".asset-version").exists())
|
||||
}
|
||||
|
||||
@Test fun installerUpgradeMergesVersionsEvenWhenArchiveIsUnchanged() {
|
||||
val directory = tmp.newFolder("migration")
|
||||
val zip = archive("data", "{}", versions = """{"new":{"asset":"bundle"}}""")
|
||||
val installer = GoRuntimeInstaller(directory) { ByteArrayInputStream(zip) }
|
||||
installer.prepare()
|
||||
File(directory, "versions.json").writeText("""{"custom":{"asset":"user"}}""")
|
||||
val marker = File(directory, ".asset-version")
|
||||
marker.writeText(marker.readText().removePrefix("2:"))
|
||||
installer.prepare()
|
||||
val versions = JSONObject(File(directory, "versions.json").readText())
|
||||
assertEquals("user", versions.getJSONObject("custom").getString("asset"))
|
||||
assertEquals("bundle", versions.getJSONObject("new").getString("asset"))
|
||||
}
|
||||
|
||||
@Test fun simultaneousStartsSerializeExtraction() {
|
||||
val directory = tmp.newFolder("runtime")
|
||||
val zip = archive("data", "{}")
|
||||
val firstOpened = CountDownLatch(1)
|
||||
val release = CountDownLatch(1)
|
||||
val secondOpened = CountDownLatch(1)
|
||||
val opens = AtomicInteger()
|
||||
val installer = GoRuntimeInstaller(directory) {
|
||||
if (opens.incrementAndGet() == 1) {
|
||||
firstOpened.countDown()
|
||||
check(release.await(5, TimeUnit.SECONDS))
|
||||
} else {
|
||||
secondOpened.countDown()
|
||||
}
|
||||
ByteArrayInputStream(zip)
|
||||
}
|
||||
val workers = Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
val first = workers.submit { installer.prepare() }
|
||||
assertTrue(firstOpened.await(5, TimeUnit.SECONDS))
|
||||
val second = workers.submit { installer.prepare() }
|
||||
assertFalse("a second install entered before the first was released", secondOpened.await(200, TimeUnit.MILLISECONDS))
|
||||
release.countDown()
|
||||
first.get(10, TimeUnit.SECONDS); second.get(10, TimeUnit.SECONDS)
|
||||
assertEquals("data", File(directory, "res.json").readText())
|
||||
} finally {
|
||||
release.countDown()
|
||||
workers.shutdownNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.gacy.android
|
||||
|
||||
import com.gacy.android.service.LogBuffer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LogBufferTest {
|
||||
@After fun cleanup() = LogBuffer.clear()
|
||||
|
||||
@Test fun preservesBoundedDiagnosticsWhenRecentLogRollsOver() {
|
||||
LogBuffer.clear()
|
||||
repeat(101) { LogBuffer.append("[gateway] diagnostic-$it") }
|
||||
repeat(501) { LogBuffer.append("ordinary-$it") }
|
||||
val copied = LogBuffer.getAllText()
|
||||
assertFalse(copied.contains("diagnostic-0\n"))
|
||||
assertTrue(copied.contains("diagnostic-1\n"))
|
||||
assertTrue(copied.contains("ordinary-500"))
|
||||
LogBuffer.clear()
|
||||
assertTrue(LogBuffer.getAllText().isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,37 @@
|
||||
package com.gacy.android
|
||||
|
||||
import com.gacy.android.model.SceneConfig
|
||||
import com.gacy.android.model.ServerConfig
|
||||
import com.gacy.android.model.ServerExecutionMode
|
||||
import com.gacy.android.model.TrailblazerConfig
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ModelAndConfigTest {
|
||||
|
||||
@Test
|
||||
fun networkSettingsRejectInvalidAddressesAndPorts() {
|
||||
val original = ServerConfig(serverKey = "keep")
|
||||
assertNull(original.withNetworkInput(ServerExecutionMode.LOCAL_EMBEDDED, "0.0.0.0", "21000", "23301"))
|
||||
assertNull(original.withNetworkInput(ServerExecutionMode.LOCAL_EMBEDDED, "example.com/path", "21000", "23301"))
|
||||
assertNull(original.withNetworkInput(ServerExecutionMode.LOCAL_EMBEDDED, "example.com", "65536", "23301"))
|
||||
assertNull(original.withNetworkInput(ServerExecutionMode.LOCAL_EMBEDDED, "example.com", "abc", "23301"))
|
||||
val valid = original.withNetworkInput(ServerExecutionMode.REMOTE_SERVER, " example.com ", "1234", "5678")!!
|
||||
assertEquals("example.com", valid.remoteHost)
|
||||
assertEquals(1234, valid.dispatchPort)
|
||||
assertEquals("keep", valid.serverKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localApiUsesLoopbackWhileRemoteApiUsesConfiguredHost() {
|
||||
val local = ServerConfig(remoteHost = "192.168.1.42", dispatchPort = 24567)
|
||||
assertEquals("http://127.0.0.1:24567", local.dispatchUrl)
|
||||
assertEquals("http://192.168.1.42:24567", local.copy(executionMode = ServerExecutionMode.REMOTE_SERVER).dispatchUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSceneConfigSerialization() {
|
||||
val config = SceneConfig(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.gacy.android
|
||||
|
||||
import com.gacy.android.data.parseUnstuckResponse
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class UnstuckResponseTest {
|
||||
@Test fun confirmsOnlyQueuedRequestsWithAnId() {
|
||||
assertTrue(parseUnstuckResponse("""{"state":"queued","request_id":"abc"}""").success)
|
||||
assertFalse(parseUnstuckResponse("""{"retcode":0,"message":"OK"}""").success)
|
||||
assertFalse(parseUnstuckResponse("""{"state":"queued"}""").success)
|
||||
assertFalse(parseUnstuckResponse("""{"state":"queued","request_id":null}""").success)
|
||||
assertFalse(parseUnstuckResponse("""{"state":"failed","request_id":"abc"}""").success)
|
||||
assertFalse(parseUnstuckResponse("not json").success)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Build GaCy Go for Android and package its runtime data.
|
||||
|
||||
Usage: python tools/package_go_runtime.py PATH_TO_GAC_GO
|
||||
The source checkout is read only; its current source/resources are packaged.
|
||||
Mutable saves are seeded from the checkout's committed defaults, never from
|
||||
the operator's live working files.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = Path(sys.argv[1]).resolve()
|
||||
if not (source / "go.mod").is_file():
|
||||
raise SystemExit("Expected a GaC-hkrpg-go checkout")
|
||||
|
||||
native = root / "app/src/main/jniLibs/arm64-v8a"
|
||||
native.mkdir(parents=True, exist_ok=True)
|
||||
env = os.environ.copy()
|
||||
env.update(GOOS="android", GOARCH="arm64", CGO_ENABLED="0")
|
||||
for name in ("sdkserver", "gameserver"):
|
||||
subprocess.run(["go", "build", "-o", str(native / f"libgacy_{name}.so"), f"./cmd/{name}"],
|
||||
cwd=source, env=env, check=True)
|
||||
|
||||
# Optional emulator-only binaries use Linux syscalls on the x86_64 emulator.
|
||||
# They are excluded from ordinary ARM64 builds.
|
||||
if "--emulator" in sys.argv[2:]:
|
||||
emulator = root / "app/src/debug/jniLibs/x86_64"
|
||||
emulator.mkdir(parents=True, exist_ok=True)
|
||||
env.update(GOOS="linux", GOARCH="amd64", CGO_ENABLED="0")
|
||||
for name in ("sdkserver", "gameserver"):
|
||||
subprocess.run(["go", "build", "-o", str(emulator / f"libgacy_{name}.so"), f"./cmd/{name}"],
|
||||
cwd=source, env=env, check=True)
|
||||
|
||||
archive = root / "app/src/goAssets/gacy-go-data.zip"
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
resources = ("server.json", "hotfix.json", "versions.json", "res.json",
|
||||
"teleports.json", "challenge_data.json", "mission_unlocks.json")
|
||||
scripts = ("damage_analyst_live.lua", "fps_settings_120.lua", "freecam.lua",
|
||||
"hide_ui.lua", "navigation_profiles.lua", "ping_rainbow.lua")
|
||||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
|
||||
for name in resources:
|
||||
z.write(source / name, name)
|
||||
for path in (source / "resources").rglob("*"):
|
||||
if path.is_file():
|
||||
z.write(path, path.relative_to(source).as_posix())
|
||||
for name in scripts:
|
||||
z.write(source / "scripts" / name, "scripts/" + name)
|
||||
for name in ("persistent", "freesr-data.json"):
|
||||
data = subprocess.check_output(["git", "show", f"HEAD:{name}"], cwd=source)
|
||||
z.writestr(name, data)
|
||||
print(f"Packaged Go runtime: {archive}")
|
||||
Reference in new issue
Block a user