14 Commits
72 changed files with 9772 additions and 575 deletions

No files matched your search

+6
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
Generated
+1
View File
@@ -0,0 +1 @@
GaCy-Android
+6
View File
@@ -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>
+123
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+11
View File
@@ -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>
+13
View File
@@ -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>
+19
View File
@@ -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>
+10
View File
@@ -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>
+10
View File
@@ -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>
+17
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+69
View File
@@ -0,0 +1,69 @@
# GaCy Android
[ภาษาไทย](../README.md) | English | [简体中文](./README_ZH.md)
![GaCy GO](../GaCyGO.png)
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.
+59
View File
@@ -0,0 +1,59 @@
# GaCy Android
[ภาษาไทย](../README.md) | [English](./README_EN.md) | 简体中文
![GaCy GO](../GaCyGO.png)
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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

+53 -24
View File
@@ -1,5 +1,48 @@
# 📱 GaCy Android
![GaCy GO](./GaCyGO.png)
> **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**.
[![Target ABI](https://img.shields.io/badge/ABI-arm64--v8a-blue?style=flat)](https://developer.android.com/ndk)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
View File
@@ -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.
Binary file not shown.
+23
View File
@@ -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.
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.
+16
View File
@@ -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
+155
View File
@@ -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
+982
View File
@@ -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)
}
}
+54
View File
@@ -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}")