fix: sync current handlers, repair relic data and restore visible launcher consoles

This commit is contained in:
CAtlas0784 committed 2026-10-16 22:19:52 +07:00
1 parent 163d4433e6
commit 74f3c4411b
46 files changed
+4565 -1368

No files matched your search

+7
View File
@@ -3,3 +3,10 @@ gameserver.log
server_debug.log server_debug.log
*.log *.log
*.exe~ *.exe~
battle_last.json
server.local.json
*.next.exe
battle_last.bin
battle_last_response.bin
battle_last_capture.json
battle_diagnostics/
+16
View File
@@ -0,0 +1,16 @@
# Repository rules
## ห้าม hardcode ค่าที่เปลี่ยนตามการใช้งาน
- ห้ามใส่ path ของเครื่อง, ชื่อผู้ใช้, Desktop, ไดรฟ์ติดตั้ง, path ของเกม/Proxy/Go, IP, port, URL ของ deployment หรือเวอร์ชัน Client ลงใน source code หรือ startup scripts
- ค่ารันระบบต้องมาจาก `server.json` โดย override ผ่าน `server.local.json` หรือ environment ได้ ใช้ตัวโหลดร่วมกันและตรวจค่าก่อนเริ่มทำงาน ห้ามมี fallback ที่ซ่อนค่าคงที่ไว้ในโค้ด
- ค่าเฉพาะเครื่องต้องอยู่ในไฟล์ local ที่ถูก ignore หรือ environment ห้าม commit ข้อมูลเฉพาะเครื่อง ห้ามเดาตำแหน่งโปรแกรมจากชื่อโฟลเดอร์หรือเลขเวอร์ชัน
- ค่าจากระบบเกม เช่น StageID, MonsterID, floor, group, anchor, level, buff และรายการปลดล็อก ต้องอ่านจาก resource/config ที่ตรงกับ Client เมื่อมีตารางรองรับ ห้ามเพิ่มการแก้บัคแบบเจาะจง ID โดยไม่มีข้อมูลรองรับ
- main affix ของ relic ต้องตรวจในกลุ่ม MainAffixGroup ของชิ้นนั้น ห้ามใช้ ID ข้ามกลุ่มหรือเดาจากเลข stat ทั่วไป และห้ามแก้ converter ให้บังคับเปลี่ยน ID เฉพาะตัวละครเพื่อกลบข้อมูล inventory ที่ผิด
- ค่าคงที่ที่เป็นนิยามของ protocol, enum, schema, โครงสร้างโปรเจกต์ และขอบเขตของชนิดข้อมูลใช้ได้เมื่อมีที่มา ตรวจสอบได้ และระบุชื่อที่สื่อความหมาย ห้ามใช้ข้อยกเว้นนี้แทนค่าตั้งระบบ
- ห้ามอ้างว่าเอา hardcode ออกทั้ง repository หากตรวจหรือแก้เพียงบางส่วน ต้องบอกขอบเขตและส่วนที่ยังเหลือจริง
- เมื่อแก้ startup/config ต้องตรวจว่าทั้ง SDK dispatch, game gateway และ launcher ใช้ค่าเดียวกัน พร้อมอัปเดต README และทดสอบ config override/ค่าผิดก่อน build
- ห้ามอ้างว่า build/test ผ่านแล้วแปลว่า Client เข้า battle หรือโหลดฉากได้ ต้องยืนยันจาก Client หรือรายงานว่ายังรอตรวจ
- handler ต้อง resolve CmdID จากชื่อในตาราง protocol ที่มี revision อ้างอิง ห้ามรับ alias เลขจากหลายเวอร์ชันหรือส่ง response หลายเลขเพื่อเดา callback; ถ้า schema ยัง obfuscated ต้องบันทึกข้อสันนิษฐานและข้อจำกัดไว้
- อย่าเปลี่ยนค่า protocol เช่น ClientDownloadData Lua format ตามเลข patch ของ Client โดยไม่มีต้นฉบับรองรับ และอย่าเปลี่ยนแค่ข้อความเวอร์ชันเพื่ออ้างว่าอัปเดต handler แล้ว
- SDK และ game server ที่เปิดผ่าน launcher ต้องแสดงคอนโซลและ log ให้ผู้ใช้เห็น ห้ามซ่อนหน้าต่างหรือ redirect จนคอนโซลว่าง เว้นแต่ผู้ใช้ขอเอง เมนูแบบ interactive ปิดหลังทำรายการเสร็จ 5 วินาที โดยไม่ปิดเซิร์ฟเวอร์ตามไปด้วย
+181 -82
View File
@@ -1,102 +1,201 @@
# GaC hkrpg GO — StarRail 4.6.51 Private Server (ภาษา Go) # GaC hkrpg GO
โอนย้าย (port) จากโปรเจกต์ Rust `GaC-hkrpg-ps` มาเป็นภาษา Go ทั้งหมด เซิร์ฟเวอร์จำลอง Honkai: Star Rail ภาษา Go พอร์ตจาก `GaC-hkrpg-ps` และอ้างอิงการทำงานจาก [PearlSR](https://git.xeondev.com/HonkaiSlopRail/pearl-sr) ประกอบด้วย game server, SDK/dispatch server และ REST API สำหรับ GaC-Srtool
พร้อม **แก้บัคกล้องหลุดจากตัวละครตอนกดตี (SceneCastSkill)** ตั้งแต่ต้นทาง
--- โปรเจกต์อยู่ระหว่างพัฒนา โดยกำลังตรวจการใช้งานกับ Client **4.6.53 OS** ตารางคำสั่งและ handler ที่แก้ล่าสุดอ้างอิง PearlSR commit `005d2c12db4781071f4b5b68e8f364587886a710` รายละเอียดอยู่ใน [บันทึกอัปเดต protocol](docs/protocol-update.md) การตรงกันของ schema ไม่ได้ยืนยันว่าเกมทุกระบบทำงานครบ
## บัคกล้องหลุด (SceneCastSkill) — สาเหตุและวิธีแก้ ## เริ่มใช้งานบน Windows
อาการ: กดตีใน overworld แล้วกล้องหลุดออกจากตัวละคร 1. ติดตั้ง **Go 1.27.0 ขึ้นไป** ตาม `go.mod` และตรวจด้วย `go version`
2. เตรียม Client, launcher/proxy และการ redirect dispatch ให้เชื่อมต่อเซิร์ฟเวอร์ที่กำหนดใน `server.json`
3. เก็บไฟล์ข้อมูลและโฟลเดอร์ `resources` ไว้ใน root ของโปรเจกต์ และตั้งค่า launcher ตามหัวข้อ config ด้านล่าง
4. เปิด `run.bat` แล้วเลือกเมนูที่ต้องการ
สาเหตุ (จาก `gameserver/src/net/handlers/battle.rs` เดิม): | เมนู | การทำงาน |
handler ของ `SceneCastSkillCsReq` ตอบ `battle_info` + `monster_battle_info` ให้ client | --- | --- |
เข้าสู่การต่อสู้กับ target ที่ผ่าน filter แบบหลวม: | `1` หรือ Enter | Build ทั้งสองเซิร์ฟเวอร์ แล้วเปิดเซิร์ฟเวอร์และเกม |
| `2` | เปิดทั้งสองเซิร์ฟเวอร์จาก `.exe` ที่มีอยู่ |
| `3` | เปิดเฉพาะ SDK server |
| `4` | เปิดเฉพาะ game server |
| `5` | เปิดทั้งสองเซิร์ฟเวอร์จาก `.exe` ที่มีอยู่ และเรียก Gay Proxy |
| `6` | Build ทั้งสอง `.exe` อย่างเดียว |
| `7` | ออก |
| `8` หรือ `stop` | หยุดเซิร์ฟเวอร์ของโปรเจกต์นี้ |
``` เมนู 1 และ 6 หยุดเมื่อ build ไม่ผ่าน ส่วนเมนู 2–5 ใช้ binary ที่มีอยู่ หลังแก้ซอร์สจึงต้อง build ใหม่ก่อนใช้เมนูเหล่านั้น
filter เดิม: id > 30_000 (monster) || id < 1_000 (avatar entity ของผู้เล่นเอง!)
`run.bat` เรียก `run.ps1` ผ่าน PowerShell โดยอ่าน path เกม/Proxy จาก config ไม่มีการเดา Desktop, ไดรฟ์ หรือชื่อโฟลเดอร์ตามเวอร์ชัน ต้องตั้ง `launcher.client_path` เป็นไฟล์ launcher/เกมที่จะเปิด และ `launcher.proxy_path` เป็นไฟล์ Proxy ที่ต้องการ ถ้าเว้นว่างจะเปิดเซิร์ฟเวอร์และแสดงวิธีตั้งค่า
SDK และ game server เปิดหน้าต่างคอนโซลแยกกันให้เห็น log โดยตรง หลังทำเมนูที่เลือกเสร็จ หน้าต่าง `run.bat` จะปิดเองใน 5 วินาที ส่วนหน้าต่างเซิร์ฟเวอร์ยังเปิดอยู่ ใช้ `run.bat stop` เพื่อหยุด หรือปิดหน้าต่างเซิร์ฟเวอร์ที่ต้องการ สคริปต์หยุดเฉพาะ process ที่ path executable ตรงกับ root นี้ ก่อนเริ่มใหม่หรือแทนที่ binary ที่ build สำเร็จ การเรียกด้วย argument เช่น `run.bat build` จะคืนค่าทันทีเมื่อทำงานเสร็จโดยไม่รอ 5 วินาที
## Config และกฎห้าม hardcode
`AGENTS.md` กำหนดให้ค่าที่เปลี่ยนตามเครื่อง/deployment/เวอร์ชันต้องมาจาก config, environment หรือ resource ห้ามฝัง path, IP, port หรือ fallback เฉพาะเครื่องใน source และ startup scripts
ลำดับความสำคัญ: **environment > local config > config หลัก**
- `server.json`: ค่าเครือข่ายและเวอร์ชันที่แชร์กันระหว่าง launcher, game server และ SDK server
- `server.local.json`: override เฉพาะเครื่อง ไฟล์นี้ถูก ignore โดย Git คัดลอก `server.local.example.json` แล้วใส่ path ของเครื่องได้
- `GAC_CONFIG`: เลือกไฟล์หลักอื่น โดย local override ใช้ชื่อเดียวกันลงท้าย `.local.json`
- path แบบ relative ใน launcher อ้างอิง root ของโปรเจกต์
| ค่าใน config | Environment override | ใช้สำหรับ |
| --- | --- | --- |
| `client_version` | `GAC_CLIENT_VERSION` | เวอร์ชันที่แสดงใน log/API |
| `game_host`, `game_port` | `GAC_GAME_HOST`, `GAC_GAME_PORT` | ที่อยู่ bind ของ game server |
| `sdk_host`, `sdk_port` | `GAC_SDK_HOST`, `GAC_SDK_PORT` | ที่อยู่ bind ของ SDK server |
| `public_game_host` | `GAC_PUBLIC_GAME_HOST` | ที่อยู่ game server ที่ gateway แจ้ง Client |
| `public_sdk_url` | `GAC_PUBLIC_SDK_URL` | Base URL ที่ dispatch แจ้ง Client |
| `launcher.client_path` | `GAC_CLIENT_PATH` | ไฟล์ launcher/เกมที่จะเปิด |
| `launcher.proxy_path` | `GAC_PROXY_PATH` | ไฟล์ Proxy ที่จะเปิด |
| `launcher.go_command` | `GAC_GO_COMMAND` | คำสั่ง Go จาก PATH หรือ path ที่กำหนด |
เมื่อเปลี่ยนเครือข่ายให้ตั้งทั้ง bind และค่าที่ประกาศให้ Client เข้าถึงได้ โดยเฉพาะ port ใน `public_sdk_url` เมื่อ SDK รับการเชื่อมต่อโดยตรง ค่าเหล่านี้มีค่าเริ่มต้นอยู่ในไฟล์ config และไม่มี fallback เครือข่ายในโค้ด ต้องรีสตาร์ตเซิร์ฟเวอร์หลังเปลี่ยน config การเปลี่ยน `client_version` ไม่ได้แปลง protocol หรือทรัพยากรของเกมให้อัตโนมัติ
ตรวจ config โดยไม่เปิดเกมหรือเซิร์ฟเวอร์:
```powershell
.\run.bat check
``` ```
เมื่อ client ส่ง hit target เป็น prop / NPC / ตัวผู้เล่นเอง server จะสั่งเข้า battle ## ทำไมมี executable สองตัว
กับ "monster" ที่ไม่มีอยู่จริง → battle transition โหลดไม่สำเร็จ → กล้อง battle
ไม่มี target ให้ตาม → กล้องหลุดจากตัวละคร นอกจากนี้ถ้าอยู่ใน arena
(entry >= 3000000) โค้ดเดิมยัง "หลอก" target เป็น 30001 เสมอ
วิธีแก้ (ใน `internal/gameserver/handlers_battle.go`): | ไฟล์ | หน้าที่ | พอร์ต |
1. ตอนโหลดฉาก server เก็บรายการ **monster entity จริง** ไว้ใน `Session.MonsterEntities` | --- | --- | --- |
(entity_id → event_id) | `gameserver-go.exe` | รับการเชื่อมต่อเกมผ่าน KCP และจัดการผู้เล่น ฉาก และ battle | UDP ตาม `game_port` |
2. `SceneCastSkill` ยอมรับเฉพาะ hit target ที่เป็น monster จริงเท่านั้น | `sdkserver-go.exe` | Dispatch, login, ข้อมูล hotfix และ REST API | HTTP ตาม `sdk_port` |
3. ถ้าไม่มี monster จริงถูกตี → ตอบ `retcode 0 + cast_entity_id` เฉยๆ **ไม่เข้า battle**
client เล่นแอนิเมชันตีปกติ กล้องค้างกับตัวละคร
4. ตีโดน monster จริง (รวมบอสใน arena 30001) → เข้า battle ตามปกติ
ยืนยันด้วย test อัตโนมัติ: `internal/gameserver/smoke_test.go` การเปิดระบบครบต้องรันทั้งสองตัว `.exe` ที่ build เสร็จจะอยู่ใน root ของโปรเจกต์ ให้เปิดจาก root เพื่อให้เซิร์ฟเวอร์หาไฟล์ข้อมูลและบันทึก log ได้ถูกตำแหน่ง
(จำลอง client เต็มรูปแบบ: handshake → login → กดตี ทั้งกรณี ผ่าน UDP จริง)
## สถาปัตยกรรม ## Build และรันเอง
``` รันคำสั่งจาก root ของโปรเจกต์:
cmd/gameserver เกมเซิร์ฟเวอร์ (UDP :23301, KCP)
cmd/sdkserver dispatch + login + SRTools (HTTP :21000)
protocol/proto StarRail.pb.go — generate จาก proto 4.6.51 (4,932 messages)
protocol/proto/cmdid.go — ตาราง CmdID (2,114 ids) generate จาก "// CmdID:" comments
pkg/kcp KCP สาย mihoyo (header 28 ไบต์: conv+token) — port จาก kcp.rs 1:1
internal/common FreesrData / persistent / res.json / teleports.json + proto converters
internal/gameserver gateway, packet framing, session, dispatch, handlers ทั้งหมด
internal/sdkserver query_dispatch / query_gateway / auth / srtools / versions.json
```
ไฟล์ข้อมูลใช้ชุดเดิมกับ Rust PS ทุกไฟล์ (freesr-data.json, persistent, res.json, ```powershell
teleports.json, challenge_data.json, mission_unlocks.json, versions.json) — save เดิมใช้ได้ทันที
## การใช้งาน
```
run_go.bat เมนู 1/2/3/4 เหมือนเดิม
gameserver-go.exe เกมเซิร์ฟเวอร์
sdkserver-go.exe SDK/dispatch
```
ต้องแก้ hosts ของเครื่อง client แบบเดียวกับ Rust PS (หรือใช้ Fiddler/proxy เดิม)
## Build จากซอร์ส
ต้องมี Go 1.27+ และ protoc + protoc-gen-go (เฉพาะตอนแก้ proto):
```
go build -o gameserver-go.exe ./cmd/gameserver go build -o gameserver-go.exe ./cmd/gameserver
go build -o sdkserver-go.exe ./cmd/sdkserver go build -o sdkserver-go.exe ./cmd/sdkserver
go test ./... # รวม smoke test จำลอง client จริง
``` ```
## สิ่งที่พอร์ตครบแล้ว เปิดคนละ terminal:
- Network: mihoyo KCP (conv+token), NetOperation handshake (0xFF/0x145/0x194), ```powershell
packet framing (0x9D74C714/0xD7A152C8), freesr-data.json hot-reload watcher .\sdkserver-go.exe
- Login flow ครบ (GetToken/PlayerLogin/GetBasicInfo/PlayerLoginFinish + ContentPackage) ```
- ฉาก: GetCurSceneInfo/EnterScene (โหลด props/NPCs/monsters จาก res.json, teleport
resolution, fallback จุดเกิด), SceneEntityMove (บันทึกตำแหน่ง), InteractProp,
GetNpcTakenReward, GetSceneMapInfo, GetEnteredScene, GetUnlockTeleport
- ทีม: GetAllLineupData/GetCurLineupData/JoinLineup/ReplaceLineup/SwapLineup/
SwitchLineupIndex/ChangeLineupLeader + SceneGroupRefresh/SyncLineup notify
- ตัวละคร/ไอเทม: GetAvatarData (+skin), สวม/ถอด lightcone/relic, RankUpAvatar, GetBag
- ภารกิจ: GetMissionStatus (curversion finished), GetMissionData, GetQuestData,
GetMainMissionCustomValue, UpdateTrackMainMission (จาก mission_unlocks.json)
- บทเรียน: ปดล็อคครบ 1249 tutorials + 855 guides (กันกล้อง/อินพุตค้าง)
- ต่อสู้: SceneCastSkill (**แก้บัคกล้องหลุดแล้ว**), StartCocoonStage,
StartQuickCocoonStage, SceneEnterStage, PVEBattleResult, GetCurBattleInfo,
SceneCastSkillCostMp, บัฟ global (Castorice/SW/Aha 151104)
- Challenge: GetChallenge, GetCurChallenge, GetChallengePeakData, GetCurChallengePeak,
GetActivityScheduleConfig, StartChallengePeak (cmd 8948 → arena 3000101),
StartChallenge → 1787 layout 4.6.51, Tierce (8973) + ทุก raw reply ที่ตอบค้าง
(1708/1716/1781/1704/8997/8999/8923/8920/... ครบตาม packet.rs เดิม)
- Dummy ปลอดภัย: ตอบว่างเฉพาะรายการที่ปลอดภัย ที่เหลือ drop เงียบ
(บทเรียนจาก Rust: ตอบว่างมั่วทำให้ client NRE จอดำ)
## ต่างจาก Rust ที่ควรรู้ ```powershell
.\gameserver-go.exe
```
- นาฬิกา KCP เป็นมิลลิวินาที (Rust ใช้วินาที — ทำให้ RTT/เวลา retransmit เพี้ยน) ซอร์สมีไฟล์ protobuf ที่ generate แล้ว การ build ปกติไม่ต้องใช้ `protoc` หากแก้ schema จึงต้องติดตั้ง `protoc` และ `protoc-gen-go` เพื่อ generate ใหม่
- มี ticker ขับ KCP ทุก 50ms ต่อ session (retransmission ทำงานจริง)
- ชื่อ message ใช้ตาม proto 4.6.51 จริง (แก้ปัญหาชื่อเก่าที่หายไป) สำหรับนักพัฒนาที่ต้องการรันชุดทดสอบ:
- StartChallenge ยังไม่รองรับ challenge_id ที่อยู่นอก challenge_data.json
(fallback arena 3014101 ทำงานเหมือนเดิม) ```powershell
go test ./...
```
ผลทดสอบฝั่ง Go และการ build สำเร็จไม่ได้ยืนยันว่า Client โหลดฉากหรือเข้า battle สำเร็จ ต้องตรวจในเกมและอ่าน `Player.log` ประกอบด้วย
## ไฟล์ข้อมูล
| ไฟล์หรือโฟลเดอร์ | ใช้สำหรับ |
| --- | --- |
| `freesr-data.json` | ตัวละคร, lightcone, relic และ `battle_config` |
| `persistent` | ทีม, ตำแหน่ง, ฉาก และค่าผู้เล่นที่บันทึกไว้ |
| `res.json` | ข้อมูลฉากหลักในรูปแบบที่ Go loader ใช้ |
| `resources/` | ข้อมูลฉากและตารางประกอบ เช่น MapEntrance, MazePlane, Anchor และ StageConfig |
| `teleports.json` | ตำแหน่ง teleport แยกตาม floor |
| `challenge_data.json` | ข้อมูล challenge |
| `mission_unlocks.json` | ข้อมูลภารกิจและการปลดล็อก |
| `hotfix.json`, `versions.json` | ข้อมูลเวอร์ชันและทรัพยากร Client สำหรับ SDK server |
| `scripts/` | Lua ที่ส่งให้ Client ผ่าน heartbeat |
สำรอง `freesr-data.json` และ `persistent` ก่อนเปลี่ยนชุดข้อมูล `res.json` ใน root กับ `resources/res.json` มีโครงสร้างต่างกันและ loader ใช้ร่วมกัน จึงควรเก็บไว้ตามตำแหน่งเดิม
**Calyx ใช้ `battle_config` ใน `freesr-data.json` เป็นข้อมูลจำลองการต่อสู้** จึงอาจได้ StageID และมอนสเตอร์ต่างจากดอกไม้ที่กดในเกม
## REST API / GaC-Srtool
เมื่อ SDK server ทำงาน เปิด URL ที่ตั้งใน `public_sdk_url` เพื่อดูรายการ endpoint หน้า root และ `/dashboard` ตอบข้อมูล JSON ของ API gateway
| Method | Endpoint | หน้าที่ |
| --- | --- | --- |
| GET | `/api/status` | อ่านสถานะและข้อมูลผู้เล่น |
| GET, POST | `/api/freesr` | อ่านหรืออัปเดต FreeSR data |
| GET, POST | `/api/freesr/avatars` | อ่านหรืออัปเดตข้อมูลตัวละคร |
| GET, POST | `/api/persistent` | อ่านหรืออัปเดตข้อมูล persistent |
| POST | `/api/exrank/toggle` | สลับการปลดล็อก EX rank |
| POST | `/api/avatar/rank` | เปลี่ยน rank ตัวละคร |
| POST | `/api/avatar/max_all` | ปรับข้อมูลตัวละครตามการทำงานของ handler |
| POST | `/api/teleport` | อัปเดตข้อมูลจุดหมาย teleport |
รูปแบบ request และเงื่อนไขการอัปเดตอยู่ใน `internal/sdkserver/api.go` การอัปเดตข้อมูลผ่าน API ยังต้องตรวจผลการโหลดข้อมูลของ Client ด้วย
## สถานะและปัญหาที่กำลังตรวจ
มี handler สำหรับ login, ทีม, ตัวละคร, ไอเทม, ฉาก, teleport, ภารกิจ, Calyx และ challenge แต่ความครบของ handler ไม่ได้หมายถึงรองรับทุกกรณีใน Client
| รายการ | สถานะ |
| --- | --- |
| Error 2000 ตอน login | แก้ `GetCurBattleInfo` ให้ตอบสำเร็จพร้อมข้อมูลว่างเมื่อไม่มี battle; รอยืนยันในเกม |
| Calyx เข้า fight ไม่ได้ | ยังอยู่ระหว่างตรวจ `NullReferenceException` ใน `CreateBattleServerCheckResult` |
| ตีมอนแล้วค้างหน้า Weakness | พบ stack trace ในฟังก์ชันตรวจ battle เดียวกับ Calyx; ยังไม่ยืนยันว่าแก้แล้ว |
| วาร์ปแมพใหม่ | ปรับลำดับ response/notify และข้อมูล scene ตาม upstream; ยังต้องยืนยันปลายทางในเกม |
| `SceneCastSkill` | ใช้รายการ monster entity ที่ server สร้างเพื่อตรวจเป้าหมายก่อนส่ง battle |
ข้อมูลล่าสุดที่ตรวจในซอร์สและเอกสาร: **16 ตุลาคม 2026**
## เก็บข้อมูลเมื่อพบปัญหา
| ไฟล์ | ข้อมูลที่ช่วยวิเคราะห์ |
| --- | --- |
| `gameserver.log` | คำขอและแพ็กเก็ตตอบกลับ |
| `server_debug.log` | รายละเอียด battle, scene และ teleport |
| `battle_last.json` | Payload battle ล่าสุดที่สร้างสำหรับ Calyx หรือการตีมอน |
| `battle_last_response.bin`, `battle_last.bin` | ไบต์ response ที่ส่งจริง และ nested battle_info ที่ตัดจาก response นั้น |
| `battle_last_capture.json`, `battle_diagnostics/` | CmdID, เวลา, SHA-256 และ snapshot ของแต่ละรอบ |
| `Player.log` ของ Client | Exception และจุดที่ Client โหลดข้อมูลไม่สำเร็จ |
ตำแหน่ง `Player.log` บน Windows สำหรับ Client ที่ใช้ในงานนี้:
```text
%USERPROFILE%\AppData\LocalLow\Cognosphere\Star Rail\Player.log
```
เมื่อรายงานปัญหาให้ระบุเวอร์ชัน Client, เวลาที่เกิด, การกระทำก่อนค้าง และรหัส Cocoon/Entry/Teleport ถ้ามี พร้อม log ช่วงเดียวกันทั้ง server และ Client ข้อความ `sent packet` ยืนยันเพียงว่า server ส่งแพ็กเก็ตแล้ว ส่วนการโหลดสำเร็จต้องดูฝั่ง Client
สำหรับการตรวจ battle ทีละส่วน ดู [บันทึกเทียบ upstream](docs/battle-investigation.md) ค่า `battle_diagnostics.client_battle_type` ใน local config เปิด probe ที่อ่าน payload ด้วย protobuf parser ของ Client และเขียน `[GAC_BATTLE_WIRE]` ลง Player.log ชื่อ class ต้องอ้างอิง stack trace ของ Client รุ่นนั้น ตั้งค่าว่างเพื่อปิด probe
ผลตรวจล่าสุดพบ relic `63095` ของ Dan Heng IL ใช้ main affix `11` ที่ไม่มีในตารางของลูกแก้ว จึงแก้ inventory เป็น `10` (Imaginary DMG) ใน `freesr-data.json` แล้ว ต้องเปิดเกมใหม่เพื่อโหลดข้อมูลและยืนยันการเข้า battle ผล build ไม่ใช่การยืนยันว่าอาการค้างหายแล้ว
## โครงสร้างซอร์ส
```text
cmd/gameserver/ จุดเริ่ม game server
cmd/sdkserver/ จุดเริ่ม SDK/dispatch server
internal/common/ โมเดลข้อมูล, resource loader และ protobuf converters
internal/gameserver/ Gateway, session, dispatch และ game handlers
internal/sdkserver/ HTTP dispatch, login และ REST API
protocol/proto/ Schema, generated protobuf และตาราง CmdID
pkg/kcp/ KCP transport
resources/ ข้อมูลประกอบ
scripts/ Client Lua
```
## อัปเดต protocol และ handler
ใช้ checkout ของ PearlSR ที่ตรวจ revision แล้วเป็นแหล่งข้อมูล ไม่แก้ generated Go ด้วยมือ:
```powershell
python tools/sync_protocol_commands.py "<path-to-pearl-sr-checkout>"
protoc --go_out=. --go_opt=paths=source_relative protocol/proto/StarRail.proto protocol/proto/current_handlers.proto
python tools/audit_protocol_handlers.py "<path-to-pearl-sr-checkout>"
```
คำสั่ง sync อัปเดตตารางชื่อ/CmdID ส่วนการแก้ field ต้องตรวจจาก schema ต้นฉบับแล้วแก้ `.proto` ก่อน generate ใหม่ ตัว audit ตรวจ CmdID, คู่ request/response และชุดหมายเลข field ของ message ที่มีชื่อร่วมกัน ไม่ตรวจชนิด field, default values, asset หรือผลใน Client
`ClientDownloadDataLuaVersion = 51` เป็นค่า version ของ payload ที่ upstream ปัจจุบันยังใช้ส่ง Lua จึงไม่เปลี่ยนเป็น `53` ตาม `client_version` โดยไม่มีหลักฐานรองรับ ส่วน Tutorial อ่าน ID จาก `resources/TutorialData.json` และ `resources/TutorialGuideGroup.json` หลังเปลี่ยน resource ให้รีสตาร์ต server
## ที่มา
พอร์ตจาก `GaC-hkrpg-ps` ภาษา Rust โดยใช้แนวทางและข้อมูลประกอบจาก [HonkaiSlopRail/pearl-sr](https://git.xeondev.com/HonkaiSlopRail/pearl-sr) ภาษา Zig การแก้ใน Go ต้องตรวจเทียบทั้ง handler, payload และเวอร์ชัน Client ที่ใช้งานจริง
+14 -6
View File
@@ -2,23 +2,31 @@ package main
import ( import (
"fmt" "fmt"
"os"
"gac-hkrpg-go/internal/common"
"gac-hkrpg-go/internal/gameserver" "gac-hkrpg-go/internal/gameserver"
) )
func main() { func main() {
cfg, err := common.LoadRuntimeConfig()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("============================================================") fmt.Println("============================================================")
fmt.Println(" [GaC hkrpg GO] Gameserver (4.6.51) - ONLINE") fmt.Printf(" [GaC hkrpg GO] Gameserver (Client %s)\n", cfg.ClientVersion)
fmt.Println(" Listening on UDP port 23301 (KCP Gateway)") fmt.Printf(" Listening on UDP %s:%d (KCP Gateway)\n", cfg.GameHost, cfg.GamePort)
fmt.Println(" Ready for StarRail client connection...") fmt.Println(" Ready for StarRail client connection...")
fmt.Println("============================================================") fmt.Println("============================================================")
gw, err := gameserver.NewGateway("0.0.0.0", 23301) gw, err := gameserver.NewGateway(cfg.GameHost, cfg.GamePort)
if err != nil { if err != nil {
fmt.Println("failed to bind gateway:", err) fmt.Fprintln(os.Stderr, "failed to bind gateway:", err)
return os.Exit(1)
} }
if err := gw.Listen(); err != nil { if err := gw.Listen(); err != nil {
fmt.Println("gateway error:", err) fmt.Fprintln(os.Stderr, "gateway error:", err)
os.Exit(1)
} }
} }
+3 -2
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"os"
"gac-hkrpg-go/internal/sdkserver" "gac-hkrpg-go/internal/sdkserver"
) )
@@ -9,10 +10,10 @@ import (
func main() { func main() {
fmt.Println("============================================================") fmt.Println("============================================================")
fmt.Println(" [GaC hkrpg GO] SDK/Dispatch server - ONLINE") fmt.Println(" [GaC hkrpg GO] SDK/Dispatch server - ONLINE")
fmt.Println(" Listening on HTTP port 21000")
fmt.Println("============================================================") fmt.Println("============================================================")
if err := sdkserver.Listen(); err != nil { if err := sdkserver.Listen(); err != nil {
fmt.Println("sdkserver error:", err) fmt.Fprintln(os.Stderr, "sdkserver error:", err)
os.Exit(1)
} }
} }
+77
View File
@@ -0,0 +1,77 @@
# Battle investigation — 2026-10-16
## หลักฐานที่ใช้อ้างอิง
- Upstream: [HonkaiSlopRail/pearl-sr](https://git.xeondev.com/HonkaiSlopRail/pearl-sr)
- ตรวจ remote ใหม่แล้วที่ commit `005d2c12db4781071f4b5b68e8f364587886a710`
- ไฟล์หลัก: `gameserver/src/services/battle.zig`, `gameserver/src/manager/battle_mgr.zig`, `gameserver/src/data/game_config.zig`, `protocol/src/protocol.pb.zig`
- Client ล่าสุดยัง NRE ที่ `CreateBattleServerCheckResult` ผ่านตัวแปลง BattleAvatar/รายการ relic และ `NMBMHCNGCFG.BDNFEMGIEIH`
- Payload ที่บันทึกเวลา 21:34 มีตัวละคร 4 ตัว แต่ละตัวมี relic 6 ชิ้นและ sub-affix 24 ค่า แม้เลิกส่ง inventory UniqueId ใน BattleRelic แล้ว Client ก็ยังล้ม
## เทียบทีละส่วน
| ส่วน | สิ่งที่ตรวจพบ | ข้อสรุปที่ใช้ได้ |
| --- | --- | --- |
| Quick Calyx response | Field numbers: retcode=1, battle_info=5, wave=8, cocoon_id=11 ตรง upstream | ยังไม่พบ field-number mismatch ใน wrapper |
| SceneBattleInfo | ตรวจชุด field numbers ของ 20 fields ตรงกัน | ชื่อ `JAOIOKBBMFC` ของ Go คือ field 7 ซึ่ง upstream เรียก monster_wave_length |
| BattleAvatar | ตรวจชุด field numbers ของ 18 fields ตรงกัน | ยังต้องดูค่าที่ parser ของ Client ถอดได้จริง |
| Relic/lightcone | BattleRelic, RelicAffix และ BattleEquipment มีชุด field numbers ตรงกัน | จำนวน sub-affix ฝั่ง Go อย่างเดียวไม่ยืนยันว่า Client ถอดได้ครบ |
| Monster/wave | SceneMonster, SceneMonsterWave, SceneMonsterWaveParam มีชุด field numbers ตรงกัน | ยังไม่มีหลักฐานให้สลับ WaveParam เป็น nil อีก |
| Buff | Go ใช้ชื่อ OwnerId แต่ upstream ใช้ owner_index บน field 3 เหมือนกัน | ชื่อ Go ไม่ใช่หลักฐานว่า field บน wire ผิด |
| แหล่งข้อมูล | Go ใช้ freesr-data.json/persistent; PearlSR ใช้ config.json/selected avatars | Payload ทั้งสองยังไม่เท่ากันในระดับค่าข้อมูล |
| EX rank | Upstream append รายการ ExRankAvatarID ให้ทุก battle avatar; Go ส่งรายการตามตัวละครที่ปลดล็อก | เป็นข้อแตกต่างที่บันทึกไว้ ยังไม่ชี้ว่าเป็นสาเหตุ NRE |
| Global/technique buffs | เจ้าของบัฟ, target list และ dynamic values บางตัวต่างกัน | ต้องตรวจแยกหลังระบุตำแหน่งที่ parser/ตัวตรวจล้ม |
การตรวจ field numbers ข้างต้นไม่ได้ยืนยัน default values, Client schema ของรุ่นที่รันจริง หรือผลการคำนวณค่าสถานะใน Client
## ขั้นแรกที่เพิ่มในรอบนี้: เก็บ wire และอ่านกลับบน Client
บันทึกหลังส่ง response สำเร็จจาก Session.Send แทนการบันทึกระหว่างสร้าง battle:
- `battle_last_response.bin`: protobuf body ของ response ที่ส่งจริง
- `battle_last.bin`: bytes ของ nested battle_info ที่ตัดออกจาก response เดิมโดยตรง ไม่ marshal ใหม่
- `battle_last.json`: ข้อมูลสำหรับอ่านด้วยตา
- `battle_last_capture.json`: CmdID, ชื่อ response, StageID, เวลา UTC และ SHA-256
- `battle_diagnostics/`: snapshot ของแต่ละรอบ เก็บทั้งข้อมูลเดิมและรอบต่อมา
เปิด read-only Client probe ด้วย `battle_diagnostics.client_battle_type` ใน local config ชื่อ class ต้องเอาจาก stack trace ของ Client รุ่นที่กำลังใช้งาน ไม่ฝังชื่อ class ที่เปลี่ยนตามเวอร์ชันใน Go/Lua
Probe ถูกส่งครั้งเดียวใน heartbeat หลัง response battle โดยใช้ protobuf parser ของ Client อ่าน bytes เดียวกับ capture จากนั้นเขียน `[GAC_BATTLE_WIRE]` ลง Player.log พร้อม decoded JSON และ descriptor field numbers ไม่แก้ตัวละคร relic บัฟ หรือ callback การต่อสู้
หาก parser/Reflection ไม่ถูกเปิดให้ Lua ใช้ จะมี `probe_error` ใน Player.log แทน ห้ามตีความว่า capture ฝั่ง Go ผ่านแล้วแปลว่า Client decode ผ่าน
## ข้อสรุปที่ถอนออก
Stack trace เดิมเพียงอย่างเดียวไม่ได้พิสูจน์ว่า WaveParam=nil หรือ SceneMonster.ExtraInfo=nil เป็นสาเหตุ การใส่ ExtraInfo หรือการลบ UniqueId ยังไม่ได้ทำให้การเข้า battle สำเร็จ จึงไม่ถือเป็นการแก้ที่ยืนยันผลแล้ว
## ผลตรวจรอบ 21:47: main affix ที่ไม่มีในตาราง
Calyx และ overworld ตายที่ stack เดียวกัน ส่วน probe รุ่นแรกทำงานแล้วแต่รายงาน `protobuf parser property not found` จึงไม่มี Client readback และยังใช้ผลของ probe ยืนยันการ decode ไม่ได้
แก้การอ่านตำแหน่ง exception: `0xc4eb77c` เป็น return address หลังคำสั่ง `call` ที่ `0xc4eb777` เช่นเดียวกับ frame `0x166b23d2` ที่อยู่หลัง `call 0xc4eb0c0` ดังนั้นจุดนี้มาจาก branch หลัง lookup **main affix** ไม่พบ (`0xc4eb2b0`–`0xc4eb2b3`) ไม่ใช่ branch ตรวจ sub-affix array ที่กระโดดไป `0xc4eb77c` แล้วมี return address `0xc4eb781` การตีความ sub-affix array เป็น null ในรอบก่อนจึงถูกถอนออก
ตรวจ payload จริงและ inventory พบรายการนี้:
| Inventory UID | Avatar | Relic | main_affix_id เดิม | ผลตรวจ |
| --- | --- | --- | --- | --- |
| 483 | 1213 (Dan Heng IL) | 63095 | 11 | ไม่มีใน main affix group 55 ของลูกแก้ว |
เทียบกับ [relics.json](https://github.com/Mar-7th/StarRailRes/blob/dbe8cdfcfb0bf657b9fe1cf92d5537f118ccb487/index_new/en/relics.json) และ [relic_main_affixes.json](https://github.com/Mar-7th/StarRailRes/blob/dbe8cdfcfb0bf657b9fe1cf92d5537f118ccb487/index_new/en/relic_main_affixes.json): group 55 มี ID 1–10 โดย ID 10 คือ `ImaginaryAddedRatio` และ `resources/AvatarConfig.json` ระบุ Avatar 1213 เป็น Imaginary
แก้ **ข้อมูล inventory เท่านั้น** จาก 11 เป็น 10 ใน `freesr-data.json` ไม่มีเงื่อนไขพิเศษสำหรับ Avatar/Relic นี้ใน converter สำรองข้อมูลและ log ก่อนแก้ไว้ใน `battle_diagnostics/main-affix-repair/` แล้ว และปิด probe ใน local config ขณะลองผลการแก้ข้อมูล
ข้อจำกัดของชุดข้อมูลอ้างอิง: ไม่พบ relic ใหม่ 63295/63296 ของ Avatar 1511 จึงไม่ได้รับรอง inventory ทุกชิ้น แต่ชิ้นที่อยู่ในทีมของ payload รอบนี้มีครบและพบ main affix ผิดหนึ่งชิ้นข้างต้น
## ขั้นต่อไปหลังแก้ main affix
1. เปิดเกมใหม่ให้ Client โหลด inventory ที่แก้แล้ว และลอง Calyx/overworld ด้วยทีมเดิม
2. หากยังล้ม ให้เก็บ stack ใหม่และเทียบ capture ว่าส่ง main affix 10 แล้ว ก่อนเปลี่ยนส่วนอื่น
## แนวทางตรวจเพิ่มเติมหากยังล้ม
1. เทียบ SHA-256 ให้ตรงกับรอบที่ Client ล้ม
2. เทียบ schema/ข้อมูล BattleAvatar และ nested relic ที่ Client ถอดได้กับ capture ฝั่ง Go
3. หากข้อมูลตรงกัน ให้แยกตัวละครและอุปกรณ์ที่ทำให้ตัวตรวจค่าสถานะล้มโดยมี control payload ก่อนแก้ resource หรือสูตรบัฟ
4. เปลี่ยนเฉพาะส่วนที่มีหลักฐาน แล้วตรวจ Calyx และ overworld battle แยกกัน
สถานะ: ยังไม่มีการยืนยันว่าเข้า fight ได้
+40
View File
@@ -0,0 +1,40 @@
# Protocol และ handler — 2026-10-16
เป้าหมาย Client: 4.6.53 OS ตาม config ที่ใช้อยู่
แหล่งอ้างอิง: [PearlSR commit 005d2c12db4781071f4b5b68e8f364587886a710](https://git.xeondev.com/HonkaiSlopRail/pearl-sr/commit/005d2c12db4781071f4b5b68e8f364587886a710) โดยตรวจ `protocol/src/root.zig`, `protocol/src/protocol.pb.zig` และ `gameserver/src/services/{challenge,misc,avatar}.zig` หลัง fetch remote ใหม่
## สิ่งที่เปลี่ยน
| ส่วน | การแก้ |
| --- | --- |
| ตารางคำสั่ง | Generate ชื่อ/CmdID 2,690 รายการจาก upstream เก็บ revision และตรวจ conflict กับ Go schema เมื่อเริ่มโปรแกรม |
| `handle` / raw dispatch | เลิกระบุเวอร์ชันเก่าในข้อความ log และให้ raw handler resolve request ตามชื่อจากตารางปัจจุบัน |
| alias เก่า | นำเลข request หลายเวอร์ชันและ fixed-body replies ที่ไม่มีชื่อปัจจุบันรองรับออก บางเลขเคยชี้ไปยัง response/notification เช่น 8909, 8924, 8935 |
| Tierce lineup | ใช้ request 8992 → response 8979; challenge_id field 4 และ stage_info_list field 5 ตาม upstream เพิ่ม generated message ที่ Go เดิมขาด |
| Tierce data | ตอบ GetChallengeTierceDataScRsp ครั้งเดียว เลิกส่งสำเนาไป cmd 8994 และเลิกรับ response 8979/8981 เป็น request |
| Challenge reward | รับ request 1747 → response 1748; อ่าน group_id field 10 และส่ง group_id field 12 / retcode field 9 ด้วย generated type แทน request 1739 → cmd 1704 ที่เป็นคนละระบบ |
| Challenge statistics | อ่าน request ด้วย generated type และตอบ group เดิมตาม upstream; ไม่ส่งคะแนนประวัติที่แต่งขึ้น ปรับ statistics สามชนิดเป็น oneof ตาม schema |
| ออกจาก Challenge | ส่ง response ตาม mode ที่ร้องขอเพียงชนิดเดียว ล้าง battle แล้วใช้ฉาก overworld ที่เก็บไว้ของผู้เล่น เลิกบังคับไปฉาก/พิกัดคงที่ |
| Peak | ลงทะเบียน StartChallengePeak ที่มี implementation อยู่แล้ว และ LeaveChallengePeak ด้วยชื่อปัจจุบัน; GetCurChallengePeak ตอบสถานะว่างแบบ upstream แทน PeakID 1 ที่แต่งขึ้น |
| Endgame / SwitchHand | ส่ง generated response โดย resolve CmdID ตามชื่อ แทน raw bytes และเลขที่ฝังใน handler |
| Avatar cmd 400 | อ่าน field จาก generated HIGLJHLNODG แทนการสแกนเลขทุก field และเลิกยิง response 5 หมายเลข; ใช้ LPOLGDLAADM ซึ่งอยู่คู่ติดกันใน schema ชื่อและความสัมพันธ์เชิงความหมายยังเป็น obfuscated จึงยังต้องยืนยัน callback ใน Client |
| ReplaceLineup | เพิ่ม field 14 `OMDLCGEMBLA` ที่ Go schema ขาดตาม upstream โดยไม่เดาค่าให้ field นี้ |
| Tutorial | เลิกใช้รายการ ID จาก Rust 4.6.51; อ่าน TutorialID/GroupID จาก resource JSON ที่ติดตั้งแทน และรายงาน error หากไฟล์หาย/ไม่มี ID |
| Activity | นำ generated list เก่าที่ไม่ได้ใช้แล้วออก ไม่เปิด activity panel เพิ่มก่อนมี implementation รองรับข้อมูลของมัน |
| Lua heartbeat | คงค่า version ของ ClientDownloadData ไว้ที่ 51 เพราะ `services/misc.zig` ของ upstream revision นี้ยังส่งค่านี้ ไม่อนุมานจากเลข client patch |
Go message บางชื่อเป็น alias เก่าของชื่อที่ upstream ตั้งใหม่ เช่น StartQuickCocoonStage/QuickStartCocoonStage และ TakeChallengeReward/TakeChallengeCumulativeReward คงชนิด Go เดิมในจุดที่ wire layout ตรงกัน แล้วผูกหมายเลขคำสั่งกับตารางปัจจุบัน
## ผล source audit
- CmdID 2,690 รายการตรงกับ upstream
- คู่ request/response เดิม 865 คู่ตรงกับ upstream
- ชุดหมายเลข field ของ message ที่มีชื่อร่วมกัน 4,435 ชนิดตรงกันหลังนับ oneof ทั้งสองฝั่ง
- การตรวจนี้ไม่ครอบคลุมความหมายของชื่อ obfuscated, ชนิด/default ของทุก field, resource รุ่นที่ตรง Client หรือผลการเล่นจริง
เพิ่ม `tools/sync_protocol_commands.py` สำหรับ generate ตารางคำสั่ง และ `tools/audit_protocol_handlers.py` สำหรับตรวจซอร์สซ้ำ โดยระบุ checkout ของ upstream เป็น argument ไม่ฝัง path เครื่อง
## ขอบเขตที่ยังเหลือ
การเปลี่ยนนี้อัปเดต dispatch/schema/handlers ข้างต้น ไม่ได้อ้างว่า implementation ของทุกระบบเสร็จแล้ว ข้อมูลปลดล็อก, สูตร fallback ด่าน, บัฟ และสถิติบางส่วนของโปรเจกต์ยังต้องตรวจแยกตาม resource จริง การ build หรือ source audit ผ่านไม่ยืนยันว่า Calyx, teleport หรือ Endgame ทำงานสำเร็จใน Client
+1 -1
View File
@@ -14531,7 +14531,7 @@
{ {
"level": 15, "level": 15,
"relic_id": 63095, "relic_id": 63095,
"main_affix_id": 11, "main_affix_id": 10,
"sub_affixes": [ "sub_affixes": [
{ {
"sub_affix_id": 7, "sub_affix_id": 7,
+1 -1
View File
@@ -111,7 +111,7 @@ func (m *MultiPathAvatar) UnmarshalJSON(data []byte) error {
return nil return nil
} }
// MultiPathAvatarType in the 4.6.51 proto uses the avatar id itself as the // MultiPathAvatarType in the loaded schema uses the avatar id itself as the
// enum value (Mar7thKnightType=1001, BoyWarriorType=8001, ...), so the proto // enum value (Mar7thKnightType=1001, BoyWarriorType=8001, ...), so the proto
// value is simply the numeric id (0 for Unk). // value is simply the numeric id (0 for Unk).
func (m MultiPathAvatar) ProtoType() uint32 { return uint32(m) } func (m MultiPathAvatar) ProtoType() uint32 { return uint32(m) }
+19 -11
View File
@@ -44,11 +44,12 @@ func relicAffixes(subs []SubAffix) []*goproto.RelicAffix {
} }
func (r *Relic) ToBattleRelic() *goproto.BattleRelic { func (r *Relic) ToBattleRelic() *goproto.BattleRelic {
// Match PearlSR's standalone battle relic format. Inventory UniqueID is
// only sent by ToProtoRelic/ToEquipRelic; battle carries the full affixes.
return &goproto.BattleRelic{ return &goproto.BattleRelic{
Id: r.RelicID, Id: r.RelicID,
Level: r.Level, Level: r.Level,
MainAffixId: r.MainAffixID, MainAffixId: r.MainAffixID,
UniqueId: r.UniqueID(),
SubAffixList: relicAffixes(r.SubAffixes), SubAffixList: relicAffixes(r.SubAffixes),
} }
} }
@@ -316,19 +317,26 @@ func (m *Monster) ToSceneMonster() *goproto.SceneMonster {
return &goproto.SceneMonster{ return &goproto.SceneMonster{
MonsterId: m.MonsterID, MonsterId: m.MonsterID,
MaxHp: hp, MaxHp: hp,
CurHp: hp,
} }
} }
func monsterWave(waveID uint32, monsters []Monster) *goproto.SceneMonsterWave { func monsterWave(monsters []Monster) *goproto.SceneMonsterWave {
maxLevel := uint32(0)
for _, monster := range monsters {
if monster.Level > maxLevel {
maxLevel = monster.Level
}
}
if maxLevel == 0 {
maxLevel = 95
}
wave := &goproto.SceneMonsterWave{ wave := &goproto.SceneMonsterWave{
WaveId: waveID,
MonsterList: make([]*goproto.SceneMonster, 0, len(monsters)), MonsterList: make([]*goproto.SceneMonster, 0, len(monsters)),
// NOTE: WaveParam MUST be nil! In client 4.6.53 GameAssembly.dll // Include a level so the client can validate this wave directly instead
// AdventureModule.CreateBattleServerCheckResult (RVA 0x166b2ef6), // of looking up monster data from the battle stage's static table. This
// if WaveParam is non-nil it tries to dereference [wave_param + 0x10] // matters when a Calyx request reuses a custom battle stage ID.
// and throws NullReferenceException. When nil, it safely jumps over. WaveParam: &goproto.SceneMonsterWaveParam{Level: maxLevel},
WaveParam: nil,
} }
for i := range monsters { for i := range monsters {
wave.MonsterList = append(wave.MonsterList, monsters[i].ToSceneMonster()) wave.MonsterList = append(wave.MonsterList, monsters[i].ToSceneMonster())
@@ -338,8 +346,8 @@ func monsterWave(waveID uint32, monsters []Monster) *goproto.SceneMonsterWave {
func ToSceneMonsterWaves(waves [][]Monster) []*goproto.SceneMonsterWave { func ToSceneMonsterWaves(waves [][]Monster) []*goproto.SceneMonsterWave {
out := make([]*goproto.SceneMonsterWave, 0, len(waves)) out := make([]*goproto.SceneMonsterWave, 0, len(waves))
for i, wave := range waves { for _, wave := range waves {
out = append(out, monsterWave(uint32(i+1), wave)) out = append(out, monsterWave(wave))
} }
return out return out
} }
+35
View File
@@ -0,0 +1,35 @@
package common
import (
"testing"
goproto "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
func TestStandaloneBattleRelicWire(t *testing.T) {
relic := Relic{
RelicID: 61151, Level: 15, MainAffixID: 1, InternalUID: 42,
SubAffixes: []SubAffix{{SubAffixID: 7, Count: 3, Step: 3}, {SubAffixID: 8, Count: 3, Step: 0}},
}
encoded, err := proto.Marshal(relic.ToBattleRelic())
if err != nil {
t.Fatal(err)
}
var decoded goproto.BattleRelic
if err := proto.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if decoded.UniqueId != 0 {
t.Fatal("standalone battle relic must not use inventory ID")
}
if decoded.Id != 61151 || decoded.MainAffixId != 1 || len(decoded.SubAffixList) != 2 {
t.Fatalf("relic or affixes lost on wire: %v", &decoded)
}
if decoded.SubAffixList[0].Cnt != 3 || decoded.SubAffixList[0].Step != 3 || decoded.SubAffixList[1].Step != 0 {
t.Fatalf("sub-affix values changed on wire: %v", decoded.SubAffixList)
}
if relic.ToProtoRelic().UniqueId != 42 || relic.ToEquipRelic().RelicUniqueId != 42 {
t.Fatal("inventory and equipped relic references must retain their IDs")
}
}
+88
View File
@@ -0,0 +1,88 @@
package common
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
)
// RuntimeConfig is shared by both server processes. Deployment values belong
// in configuration rather than in handlers or executable entry points.
type RuntimeConfig struct {
BattleDiagnostics BattleDiagnosticsConfig `json:"battle_diagnostics"`
ClientVersion string `json:"client_version"`
GameHost string `json:"game_host"`
GamePort uint16 `json:"game_port"`
SDKHost string `json:"sdk_host"`
SDKPort uint16 `json:"sdk_port"`
PublicGameHost string `json:"public_game_host"`
PublicSDKURL string `json:"public_sdk_url"`
}
// The obfuscated protobuf class name is version-specific and comes from the
// client's stack trace. Empty disables the read-only client wire probe.
type BattleDiagnosticsConfig struct {
ClientBattleType string `json:"client_battle_type"`
}
func LoadRuntimeConfig() (RuntimeConfig, error) {
path := os.Getenv("GAC_CONFIG")
if path == "" {
path = "server.json"
}
var cfg RuntimeConfig
for i, filename := range []string{path, filepath.Join(filepath.Dir(path), strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))+".local.json")} {
raw, err := os.ReadFile(filename)
if i == 1 && os.IsNotExist(err) {
continue
}
if err != nil {
return cfg, fmt.Errorf("read runtime config %s: %w", filename, err)
}
if err := json.Unmarshal(bytes.TrimPrefix(raw, []byte{0xef, 0xbb, 0xbf}), &cfg); err != nil {
return cfg, fmt.Errorf("parse runtime config %s: %w", filename, err)
}
}
for name, dest := range map[string]*string{
"GAC_CLIENT_VERSION": &cfg.ClientVersion, "GAC_GAME_HOST": &cfg.GameHost,
"GAC_SDK_HOST": &cfg.SDKHost, "GAC_PUBLIC_GAME_HOST": &cfg.PublicGameHost,
"GAC_PUBLIC_SDK_URL": &cfg.PublicSDKURL,
} {
if value, ok := os.LookupEnv(name); ok {
*dest = value
}
}
for name, dest := range map[string]*uint16{"GAC_GAME_PORT": &cfg.GamePort, "GAC_SDK_PORT": &cfg.SDKPort} {
if value, ok := os.LookupEnv(name); ok {
n, err := strconv.ParseUint(value, 10, 16)
if err != nil || n == 0 {
return cfg, fmt.Errorf("%s must be a port between 1 and 65535", name)
}
*dest = uint16(n)
}
}
if cfg.GamePort == 0 || cfg.SDKPort == 0 || strings.TrimSpace(cfg.ClientVersion) == "" {
return cfg, fmt.Errorf("runtime config requires client_version, game_port and sdk_port")
}
for name, host := range map[string]string{"game_host": cfg.GameHost, "sdk_host": cfg.SDKHost, "public_game_host": cfg.PublicGameHost} {
if strings.TrimSpace(host) == "" || strings.ContainsAny(host, "/\\ \t\r\n") {
return cfg, fmt.Errorf("invalid %s", name)
}
}
u, err := url.Parse(cfg.PublicSDKURL)
if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
return cfg, fmt.Errorf("public_sdk_url must be an HTTP(S) base URL")
}
cfg.PublicSDKURL = strings.TrimRight(cfg.PublicSDKURL, "/")
return cfg, nil
}
func (c RuntimeConfig) SDKAddress() string {
return net.JoinHostPort(c.SDKHost, strconv.Itoa(int(c.SDKPort)))
}
+58
View File
@@ -0,0 +1,58 @@
package common
import (
"os"
"path/filepath"
"testing"
)
// Addresses and ports below are deliberately different fixture values, not
// deployment defaults. These tests must never bind sockets or start servers.
func TestRuntimeConfigOverrides(t *testing.T) {
t.Chdir(t.TempDir())
for _, name := range []string{"GAC_CONFIG", "GAC_CLIENT_VERSION", "GAC_GAME_HOST", "GAC_GAME_PORT", "GAC_SDK_HOST", "GAC_SDK_PORT", "GAC_PUBLIC_GAME_HOST", "GAC_PUBLIC_SDK_URL"} {
t.Setenv(name, "")
if err := os.Unsetenv(name); err != nil {
t.Fatal(err)
}
}
base := `{"client_version":"fixture","game_host":"localhost","game_port":32101,"sdk_host":"localhost","sdk_port":32102,"public_game_host":"example.invalid","public_sdk_url":"https://example.invalid/base/"}`
if err := os.WriteFile("server.json", []byte(base), 0600); err != nil {
t.Fatal(err)
}
local := append([]byte{0xef, 0xbb, 0xbf}, []byte(`{"game_port":32201,"launcher":{"client_path":"fixture"}}`)...)
if err := os.WriteFile("server.local.json", local, 0600); err != nil {
t.Fatal(err)
}
cfg, err := LoadRuntimeConfig()
if err != nil {
t.Fatal(err)
}
if cfg.GamePort != 32201 || cfg.SDKPort != 32102 || cfg.PublicSDKURL != "https://example.invalid/base" {
t.Fatalf("merge failed: %+v", cfg)
}
t.Setenv("GAC_GAME_PORT", "32301")
t.Setenv("GAC_PUBLIC_GAME_HOST", "gateway.example.invalid")
cfg, err = LoadRuntimeConfig()
if err != nil {
t.Fatal(err)
}
if cfg.GamePort != 32301 || cfg.PublicGameHost != "gateway.example.invalid" {
t.Fatalf("environment override failed: %+v", cfg)
}
t.Setenv("GAC_GAME_PORT", "65536")
if _, err := LoadRuntimeConfig(); err == nil {
t.Fatal("out-of-range port accepted")
}
t.Setenv("GAC_GAME_PORT", "0")
if _, err := LoadRuntimeConfig(); err == nil {
t.Fatal("zero port accepted")
}
}
func TestRuntimeConfigRequiresFile(t *testing.T) {
t.Setenv("GAC_CONFIG", filepath.Join(t.TempDir(), "missing.json"))
if _, err := LoadRuntimeConfig(); err == nil {
t.Fatal("missing config silently accepted")
}
}
+140
View File
@@ -0,0 +1,140 @@
package gameserver
import (
"crypto/sha256"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"gac-hkrpg-go/internal/common"
goproto "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/encoding/protowire"
"google.golang.org/protobuf/proto"
)
//go:embed battle_wire_probe.lua
var battleWireProbe string
type battleCapture struct {
ResponseName string `json:"response_name"`
CmdID uint16 `json:"cmd_id"`
ResponseSHA256 string `json:"response_sha256"`
BattleSHA256 string `json:"battle_sha256"`
CapturedUTC time.Time `json:"captured_utc"`
StageID uint32 `json:"stage_id"`
}
func saveBattleCapture(msg proto.Message, cmdID uint16, response []byte) ([]byte, *battleCapture, error) {
carrier, ok := msg.(interface {
GetBattleInfo() *goproto.SceneBattleInfo
})
if !ok || carrier.GetBattleInfo() == nil {
return nil, nil, nil
}
battle := carrier.GetBattleInfo()
wire, err := extractBattleWire(msg, response)
if err != nil {
return nil, nil, err
}
pretty, err := (protojson.MarshalOptions{Indent: " "}).Marshal(battle)
if err != nil {
return nil, nil, err
}
meta := &battleCapture{
ResponseName: string(msg.ProtoReflect().Descriptor().Name()), CmdID: cmdID,
ResponseSHA256: fmt.Sprintf("%x", sha256.Sum256(response)),
BattleSHA256: fmt.Sprintf("%x", sha256.Sum256(wire)),
CapturedUTC: time.Now().UTC(), StageID: battle.StageId,
}
info, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return nil, nil, err
}
files := map[string][]byte{"battle_last_response.bin": response, "battle_last.bin": wire, "battle_last.json": pretty, "battle_last_capture.json": info}
// Keep earlier failures intact so consecutive attempts can be compared.
archive := filepath.Join("battle_diagnostics", meta.CapturedUTC.Format("20060102T150405.000000000Z")+"_"+meta.ResponseSHA256[:12])
if err := os.MkdirAll(archive, 0700); err != nil {
return nil, nil, err
}
for name, data := range files {
if err := os.WriteFile(filepath.Join(archive, name), data, 0600); err != nil {
return nil, nil, err
}
if err := os.WriteFile(name, data, 0600); err != nil {
return nil, nil, err
}
}
return wire, meta, nil
}
func extractBattleWire(msg proto.Message, response []byte) ([]byte, error) {
field := msg.ProtoReflect().Descriptor().Fields().ByName("battle_info")
if field == nil {
return nil, fmt.Errorf("response has no battle_info field")
}
for len(response) > 0 {
number, kind, n := protowire.ConsumeTag(response)
if n < 0 {
return nil, protowire.ParseError(n)
}
response = response[n:]
if number == protowire.Number(field.Number()) && kind == protowire.BytesType {
wire, n := protowire.ConsumeBytes(response)
if n < 0 {
return nil, protowire.ParseError(n)
}
return append([]byte(nil), wire...), nil
}
n = protowire.ConsumeFieldValue(number, kind, response)
if n < 0 {
return nil, protowire.ParseError(n)
}
response = response[n:]
}
return nil, fmt.Errorf("battle_info missing from serialized response")
}
func makeBattleProbe(wire []byte, typeName, hash string) []byte {
// Values are quoted as Lua strings; base64 contains no executable syntax.
replacer := strings.NewReplacer("__TYPE_NAME__", strconv.Quote(typeName), "__PAYLOAD__", strconv.Quote(base64.StdEncoding.EncodeToString(wire)), "__CAPTURE_HASH__", strconv.Quote(hash))
return []byte(replacer.Replace(battleWireProbe))
}
func (s *Session) captureBattleResponse(msg proto.Message, cmdID uint16, body []byte) {
wire, meta, err := saveBattleCapture(msg, cmdID, body)
if err != nil {
Log(fmt.Sprintf("[BATTLE CAPTURE] %v", err))
return
}
if meta == nil {
return
}
Log(fmt.Sprintf("[BATTLE CAPTURE] cmd=%d stage=%d response_sha256=%s battle_sha256=%s", cmdID, meta.StageID, meta.ResponseSHA256, meta.BattleSHA256))
cfg, err := common.LoadRuntimeConfig()
if err != nil {
Log(fmt.Sprintf("[BATTLE PROBE] config error: %v", err))
return
}
if cfg.BattleDiagnostics.ClientBattleType == "" {
return
}
s.battleProbeMu.Lock()
s.battleProbe = makeBattleProbe(wire, cfg.BattleDiagnostics.ClientBattleType, meta.BattleSHA256)
s.battleProbeMu.Unlock()
Log("[BATTLE PROBE] queued read-only client parser check for next heartbeat")
}
func (s *Session) takeBattleProbe() []byte {
s.battleProbeMu.Lock()
defer s.battleProbeMu.Unlock()
probe := s.battleProbe
s.battleProbe = nil
return probe
}
@@ -0,0 +1,72 @@
package gameserver
import (
"bytes"
"crypto/sha256"
"fmt"
"os"
"strings"
"testing"
goproto "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
func TestCaptureUsesExactResponseBattleBytes(t *testing.T) {
t.Chdir(t.TempDir())
// Fixture values exercise zero-step affixes and map ordering on the wire.
battle := &goproto.SceneBattleInfo{BattleAvatarList: []*goproto.BattleAvatar{{Id: 1204, RelicList: []*goproto.BattleRelic{{Id: 61151, SubAffixList: []*goproto.RelicAffix{{AffixId: 7, Cnt: 3, Step: 0}}}}}}, BuffList: []*goproto.BattleBuff{{DynamicValues: map[string]float32{"first": 1, "second": 2}}}}
rsp := &goproto.StartQuickCocoonStageRsp{BattleInfo: battle}
body, err := proto.Marshal(rsp)
if err != nil {
t.Fatal(err)
}
wire, meta, err := saveBattleCapture(rsp, 1371, body)
if err != nil {
t.Fatal(err)
}
if meta.ResponseSHA256 != fmt.Sprintf("%x", sha256.Sum256(body)) {
t.Fatal("response checksum mismatch")
}
var decoded goproto.SceneBattleInfo
if err := proto.Unmarshal(wire, &decoded); err != nil {
t.Fatal(err)
}
if !proto.Equal(battle, &decoded) {
t.Fatal("captured battle differs from outgoing battle")
}
written, err := os.ReadFile("battle_last_response.bin")
if err != nil || !bytes.Equal(written, body) {
t.Fatalf("response not preserved: %v", err)
}
written, err = os.ReadFile("battle_last.bin")
if err != nil || !bytes.Equal(written, wire) {
t.Fatalf("battle wire not preserved: %v", err)
}
probe := string(makeBattleProbe(wire, "FixtureBattleType", meta.BattleSHA256))
if strings.Contains(probe, "__TYPE_NAME__") || !strings.Contains(probe, meta.BattleSHA256) {
t.Fatal("probe placeholders not populated")
}
}
func TestProbeDoesNotRunWithoutBattle(t *testing.T) {
t.Chdir(t.TempDir())
_, meta, err := saveBattleCapture(&goproto.GetCurBattleInfoScRsp{}, 171, nil)
if err != nil || meta != nil {
t.Fatalf("empty login response captured: %v", err)
}
if _, err := os.Stat("battle_last.bin"); !os.IsNotExist(err) {
t.Fatal("empty response wrote a capture")
}
s := &Session{}
s.battleProbe = []byte("fixture")
if string(s.takeBattleProbe()) != "fixture" || len(s.takeBattleProbe()) != 0 {
t.Fatal("probe was not consumed exactly once")
}
}
func TestBattleWireRejectsTruncatedResponse(t *testing.T) {
if _, err := extractBattleWire(&goproto.StartQuickCocoonStageRsp{}, []byte{0x2a, 0x7f}); err == nil {
t.Fatal("truncated payload accepted")
}
}
+95
View File
@@ -0,0 +1,95 @@
-- Read-only: decode the captured battle with the client's own protobuf parser.
-- This does not start battles, modify actors, replace handlers, or change saves.
do
local captureHash = __CAPTURE_HASH__
if _G.__gacBattleWireProbeV2Hash ~= captureHash then
_G.__gacBattleWireProbeV2Hash = captureHash
local function log(text)
CS.UnityEngine.Debug.Log('[GAC_BATTLE_WIRE] ' .. captureHash .. ' ' .. tostring(text))
end
local ok, err = pcall(function()
local typeName = __TYPE_NAME__
local assemblies = CS.System.AppDomain.CurrentDomain:GetAssemblies()
local messageType = nil
for i = 0, assemblies.Length - 1 do
messageType = assemblies[i]:GetType(typeName)
if messageType ~= nil then break end
end
if messageType == nil then error('message type not found: ' .. typeName) end
-- IL2CPP builds can expose the parser as a non-public member.
-- Include both visibility levels instead of assuming a public getter.
local flags = CS.System.Reflection.BindingFlags.Public
+ CS.System.Reflection.BindingFlags.NonPublic
+ CS.System.Reflection.BindingFlags.Static
+ CS.System.Reflection.BindingFlags.Instance
local properties = messageType:GetProperties(flags)
local parser, descriptor = nil, nil
log('message_type=' .. tostring(messageType.FullName) .. ' properties=' .. tostring(properties.Length))
for i = 0, properties.Length - 1 do
local property = properties[i]
local getter = property:GetGetMethod(true)
if getter ~= nil and getter.IsStatic then
local propertyType = tostring(property.PropertyType.FullName)
log('static_property=' .. tostring(property.Name) .. ' type=' .. propertyType)
if string.find(propertyType, 'Google.Protobuf.MessageParser', 1, true) then
parser = property:GetValue(nil, nil)
elseif propertyType == 'Google.Protobuf.Reflection.MessageDescriptor' then
descriptor = property:GetValue(nil, nil)
end
end
end
local fields = messageType:GetFields(flags)
for i = 0, fields.Length - 1 do
local field = fields[i]
local fieldType = tostring(field.FieldType.FullName)
log('member=' .. tostring(field.Name) .. ' type=' .. fieldType .. ' static=' .. tostring(field.IsStatic))
if field.IsStatic then
if parser == nil and string.find(fieldType, 'Google.Protobuf.MessageParser', 1, true) then
parser = field:GetValue(nil)
elseif descriptor == nil and fieldType == 'Google.Protobuf.Reflection.MessageDescriptor' then
descriptor = field:GetValue(nil)
end
end
end
local bytes = CS.System.Convert.FromBase64String(__PAYLOAD__)
local message = nil
if parser ~= nil then
message = parser:ParseFrom(bytes)
else
-- A generated message can decode through MergeFrom even when
-- its static parser metadata was stripped from reflection.
message = CS.System.Activator.CreateInstance(messageType)
local methods = messageType:GetMethods(flags)
local merge = nil
for i = 0, methods.Length - 1 do
local method = methods[i]
local parameters = method:GetParameters()
if not method.IsStatic and parameters.Length == 1
and tostring(parameters[0].ParameterType.FullName) == 'Google.Protobuf.CodedInputStream' then
merge = method
break
end
end
if merge == nil then error('no parser or CodedInputStream decoder on ' .. typeName) end
local input = CS.Google.Protobuf.CodedInputStream(bytes)
local arguments = CS.System.Array.CreateInstance(typeof(CS.System.Object), 1)
arguments:SetValue(input, 0)
merge:Invoke(message, arguments)
end
log('type=' .. typeName .. ' bytes=' .. tostring(bytes.Length))
log('decoded=' .. message:ToString())
if descriptor ~= nil then
local fields = descriptor.Fields:InFieldNumberOrder()
local iterator = fields:GetEnumerator()
while iterator:MoveNext() do
local field = iterator.Current
local nested = field.MessageType
log('field=' .. tostring(field.FieldNumber) .. ' name=' .. tostring(field.Name)
.. ' type=' .. tostring(field.FieldType)
.. ' nested=' .. (nested ~= nil and tostring(nested.FullName) or ''))
end
end
end)
if not ok then log('probe_error=' .. tostring(err)) end
end
end
+26 -13
View File
@@ -1,6 +1,7 @@
package gameserver package gameserver
import ( import (
"errors"
"strings" "strings"
"fmt" "fmt"
@@ -42,6 +43,10 @@ var BaseAvatarIDs = common.BaseAvatarIDs
type handlerFunc func(s *Session, payload []byte) error type handlerFunc func(s *Session, payload []byte) error
// errResponseAlreadySent tells the typed dispatcher that the handler has
// explicitly sent its response early to preserve packet ordering.
var errResponseAlreadySent = errors.New("response already sent")
var ( var (
typedHandlers = map[uint16]handlerFunc{} typedHandlers = map[uint16]handlerFunc{}
dummyResponses = map[uint16]uint16{} // req cmd -> rsp cmd (empty body reply) dummyResponses = map[uint16]uint16{} // req cmd -> rsp cmd (empty body reply)
@@ -59,12 +64,12 @@ func handle[ReqT any, RspT any](name string, fn func(s *Session, req *ReqT, rsp
func handleNamed[ReqT any, RspT any](name, rspName string, fn func(s *Session, req *ReqT, rsp *RspT) error) { func handleNamed[ReqT any, RspT any](name, rspName string, fn func(s *Session, req *ReqT, rsp *RspT) error) {
reqID, ok := goproto.CmdIDByName[name+"CsReq"] reqID, ok := goproto.CmdIDByName[name+"CsReq"]
if !ok { if !ok {
Log("[DISPATCH] proto 4.6.51 has no " + name + "CsReq; handler skipped") Log("[DISPATCH] loaded protocol has no " + name + "CsReq; handler skipped")
return return
} }
rspID, ok := goproto.CmdIDByName[rspName] rspID, ok := goproto.CmdIDByName[rspName]
if !ok { if !ok {
Log("[DISPATCH] proto 4.6.51 has no " + rspName + "; handler skipped") Log("[DISPATCH] loaded protocol has no " + rspName + "; handler skipped")
return return
} }
typedHandlers[uint16(reqID)] = func(s *Session, payload []byte) error { typedHandlers[uint16(reqID)] = func(s *Session, payload []byte) error {
@@ -78,7 +83,9 @@ func handleNamed[ReqT any, RspT any](name, rspName string, fn func(s *Session, r
} }
} }
rsp := new(RspT) rsp := new(RspT)
if err := fn(s, req, rsp); err != nil { if err := fn(s, req, rsp); errors.Is(err, errResponseAlreadySent) {
return nil
} else if err != nil {
return err return err
} }
return s.Send(any(rsp).(proto.Message)) return s.Send(any(rsp).(proto.Message))
@@ -91,12 +98,25 @@ func dummy(name string) {
reqID, ok1 := goproto.CmdIDByName[name+"CsReq"] reqID, ok1 := goproto.CmdIDByName[name+"CsReq"]
rspID, ok2 := goproto.CmdIDByName[name+"ScRsp"] rspID, ok2 := goproto.CmdIDByName[name+"ScRsp"]
if !ok1 || !ok2 { if !ok1 || !ok2 {
Log("[DISPATCH] proto 4.6.51 has no dummy pair " + name + "; skipped") Log("[DISPATCH] loaded protocol has no dummy pair " + name + "; skipped")
return return
} }
dummyResponses[uint16(reqID)] = uint16(rspID) dummyResponses[uint16(reqID)] = uint16(rspID)
} }
// rawNamed binds only the request declared by the current command table.
// Old numeric aliases can collide with responses after a protocol update.
func rawNamed(request string, fn func(*Session, []byte) error) {
id, ok := goproto.CmdIDByName[request]
if !ok {
panic("raw handler request missing from protocol: " + request)
}
if _, exists := rawHandlers[id]; exists {
panic("duplicate raw handler: " + request)
}
rawHandlers[id] = fn
}
// dangerFamilies are client modules that Sync(nil) -> NRE -> black screen when // dangerFamilies are client modules that Sync(nil) -> NRE -> black screen when
// they receive an empty ScRsp (lesson recorded in the Rust packet.rs / HANDOFF): // they receive an empty ScRsp (lesson recorded in the Rust packet.rs / HANDOFF):
// never auto-answer these, drop them silently instead. // never auto-answer these, drop them silently instead.
@@ -112,15 +132,8 @@ func isDangerCmd(cmdID uint16) bool {
return true return true
} }
} }
// CycleScore cmd ids (4143/4146) and GridFight range carry generic names // This module requires populated rotation state before it can be answered.
switch cmdID { if name == "GetMapRotationDataCsReq" || strings.HasPrefix(name, "ChimeraGet") {
case 4143, 4146, 8541, 5410, 5449, 7142, 3174, 9252, 8175, 6995, 6988:
return true
}
// GetMapRotationData: an empty reply NREs the client's map init
// (AdventurePhase._EnterStep1_InitializeMapInfo) - drop until real
// RotaterData is implemented
if cmdID == 6827 {
return true return true
} }
return false return false
@@ -1,156 +0,0 @@
package gameserver
// Code generated from the March7th PS ActivityConfig (real 4.6.51 client
// activity ids). DO NOT EDIT manually.
type activityScheduleEntry struct{ activityID, panelID uint32 }
var realActivitySchedule = []activityScheduleEntry{
{1001501, 10015},
{1001701, 10017},
{1002101, 10191},
{1002501, 10025},
{1002601, 10192},
{1004101, 10041},
{1006201, 10066},
{1010801, 10187},
{2000603, 20016},
{2001101, 20011},
{2001102, 20011},
{2001103, 20011},
{2001104, 20011},
{2001105, 20011},
{2001106, 20011},
{2001107, 20011},
{2001108, 20011},
{2001109, 20011},
{2002303, 20024},
{2002502, 20027},
{2006301, 20064},
{2006302, 20064},
{2006400, 20064},
{2008201, 20091},
{2008401, 20092},
{2008601, 20087},
{2008701, 20087},
{2009100, 20091},
{2009200, 20092},
{2009301, 20093},
{2101001, 21010},
{2101101, 21011},
{3000801, 30008},
{4000208, 40002},
{4000301, 40003},
{4000302, 40003},
{4000303, 40003},
{4000304, 40003},
{4000305, 40003},
{4000306, 40003},
{4000307, 40003},
{5000002, 50000},
{5000003, 50000},
{5000004, 50000},
{5000005, 50000},
{5000006, 50000},
{5000007, 50000},
{5000008, 50000},
{5000009, 50000},
{5000010, 50000},
{5000011, 50000},
{5000013, 50000},
{5000105, 50001},
{5000106, 50001},
{5000107, 50001},
{5000201, 50002},
{5000202, 50002},
{5000203, 50002},
{5000204, 50002},
{5000301, 50003},
{5000302, 50003},
{5000303, 50003},
{5000304, 50003},
{5000408, 50004},
{5000501, 50005},
{5000502, 50005},
{5000503, 50005},
{5000504, 50005},
{5000505, 50005},
{5000601, 50006},
{5000602, 50006},
{5000603, 50006},
{5000604, 50006},
{5000605, 50006},
{5000702, 50007},
{5000902, 50009},
{5001202, 50012},
{5001401, 50014},
{5001501, 50015},
{5001802, 50018},
{5002102, 50021},
{5002202, 50022},
{5002402, 50024},
{5002602, 50026},
{5002902, 50029},
{5003402, 50034},
{5003502, 50035},
{5004102, 50041},
{5004202, 50042},
{5004502, 50045},
{5006302, 50063},
{5006802, 50068},
{5007001, 50070},
{5007002, 50070},
{5007003, 50070},
{5007004, 50070},
{5007005, 50070},
{5007007, 50070},
{5007302, 50073},
{5007402, 50074},
{5009202, 50092},
{5009402, 50094},
{5010102, 50101},
{5011102, 50111},
{5011202, 50112},
{5011402, 50114},
{5011602, 50116},
{5012501, 50125},
{5012502, 50125},
{5012503, 50125},
{5012504, 50125},
{5012505, 50125},
{5012601, 50126},
{5012701, 50127},
{5012901, 50129},
{5013201, 50132},
{5013202, 50132},
{5013301, 50133},
{5013401, 50134},
{5013402, 50134},
{5013501, 50135},
{5013502, 50135},
{5013701, 50137},
{5014201, 50142},
{5014202, 50142},
{5014301, 50143},
{5014302, 50143},
{5014303, 50143},
{5014304, 50143},
{5014305, 50143},
{5014311, 50143},
{5014312, 50143},
{5014313, 50143},
{5014314, 50143},
{5014315, 50143},
{5015401, 50154},
{6000101, 60001},
{6000302, 60003},
{6000901, 60009},
{6001402, 60014},
{6001601, 60016},
{6002301, 60023},
{6002401, 60024},
{7100001, 71000},
{7100101, 71001},
{7110001, 71100},
{7110101, 71101},
}
+18 -50
View File
@@ -13,33 +13,25 @@ import (
func init() { func init() {
handle("StartCocoonStage", func(s *Session, req *goproto.StartCocoonStageCsReq, rsp *goproto.StartCocoonStageScRsp) error { handle("StartCocoonStage", func(s *Session, req *goproto.StartCocoonStageCsReq, rsp *goproto.StartCocoonStageScRsp) error {
Log(fmt.Sprintf("[BATTLE] StartCocoonStage: CocoonId=%d Wave=%d WorldLevel=%d PropEntityId=%d", req.CocoonId, req.Wave, req.WorldLevel, req.PropEntityId)) Log(fmt.Sprintf("[BATTLE] StartCocoonStage: CocoonId=%d Wave=%d WorldLevel=%d PropEntityId=%d", req.CocoonId, req.Wave, req.WorldLevel, req.PropEntityId))
battleInfo := createBattleInfo(s, 0, 0)
normalizeCalyxBattleInfo(battleInfo, req.Wave, req.WorldLevel)
CURRENT_BATTLE.Store(s.Token, battleInfo)
challengeCnt := req.Wave
if challengeCnt == 0 {
challengeCnt = 1
}
rsp.Retcode = 0 rsp.Retcode = 0
rsp.PropEntityId = req.PropEntityId rsp.PropEntityId = req.PropEntityId
rsp.CocoonId = req.CocoonId rsp.CocoonId = req.CocoonId
rsp.Wave = req.Wave rsp.Wave = req.Wave
rsp.ChallengeCnt = challengeCnt battleInfo := createBattleInfo(s, 0, 0)
CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.BattleInfo = battleInfo rsp.BattleInfo = battleInfo
return nil return nil
}) })
// NOTE: 4.6.51 proto names this message StartQuickCocoonStageCsReq/Rsp // The Go schema names this message StartQuickCocoonStageCsReq/Rsp
handleNamed("StartQuickCocoonStage", "StartQuickCocoonStageRsp", func(s *Session, req *goproto.StartQuickCocoonStageCsReq, rsp *goproto.StartQuickCocoonStageRsp) error { handleNamed("StartQuickCocoonStage", "StartQuickCocoonStageRsp", func(s *Session, req *goproto.StartQuickCocoonStageCsReq, rsp *goproto.StartQuickCocoonStageRsp) error {
Log(fmt.Sprintf("[BATTLE] StartQuickCocoonStage: CocoonId=%d Wave=%d WorldLevel=%d", req.CocoonId, req.Wave, req.WorldLevel)) Log(fmt.Sprintf("[BATTLE] StartQuickCocoonStage: CocoonId=%d Wave=%d WorldLevel=%d", req.CocoonId, req.Wave, req.WorldLevel))
battleInfo := createBattleInfo(s, 0, 0)
normalizeCalyxBattleInfo(battleInfo, req.Wave, req.WorldLevel)
CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.Retcode = 0 rsp.Retcode = 0
rsp.CocoonId = req.CocoonId rsp.CocoonId = req.CocoonId
rsp.Wave = req.Wave rsp.Wave = req.Wave
battleInfo := createBattleInfo(s, 0, 0)
CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.BattleInfo = battleInfo rsp.BattleInfo = battleInfo
return nil return nil
}) })
@@ -47,7 +39,6 @@ func init() {
handleNamed("StartQuickFarmElement", "StartQuickFarmElementRsp", func(s *Session, req *goproto.StartQuickFarmElementCsReq, rsp *goproto.StartQuickFarmElementRsp) error { handleNamed("StartQuickFarmElement", "StartQuickFarmElementRsp", func(s *Session, req *goproto.StartQuickFarmElementCsReq, rsp *goproto.StartQuickFarmElementRsp) error {
Log(fmt.Sprintf("[BATTLE] StartQuickFarmElement: FarmID=%d WorldLevel=%d", req.PAOFHFLFFHD, req.WorldLevel)) Log(fmt.Sprintf("[BATTLE] StartQuickFarmElement: FarmID=%d WorldLevel=%d", req.PAOFHFLFFHD, req.WorldLevel))
battleInfo := createBattleInfo(s, 0, 0) battleInfo := createBattleInfo(s, 0, 0)
normalizeCalyxBattleInfo(battleInfo, 1, req.WorldLevel)
CURRENT_BATTLE.Store(s.Token, battleInfo) CURRENT_BATTLE.Store(s.Token, battleInfo)
rsp.Retcode = 0 rsp.Retcode = 0
@@ -115,24 +106,24 @@ func init() {
// //
// อาการ: กดโจมตีใน overworld แล้วกล้องหลุดออกจากตัวละคร // อาการ: กดโจมตีใน overworld แล้วกล้องหลุดออกจากตัวละคร
// //
// สาเหตุ (จากโค้ด Rust เดิม): handler ตอบ battle_info + monster_battle_info // สาเหตุ (จากโค้ด Rust เดิม): handler ตอบ_info + monster_battle_info
// ให้ client เข้าสู่การต่อสู้กับ target ที่ "ผ่าน filter แบบหลวมๆ" // ให้ client เข้าสู่การต่อสู้กับ target ที่ "ผ่าน filter แบบหลวมๆ"
// filter เดิม: id > 30000 (monster ในฉาก) || id < 1000 (avatar entity ของผู้เล่น!) // filter เดิม: id > 30000 (monster ในฉาก) || id < 1000 (avatar entity ของผู้เล่น!)
// ถ้า targets ว่างและอยู่ใน arena (entry >= 3000000) ยัง "หลอก" target // ถ้า targets ว่างและอยู่ใน arena (entry >= 3000000) ยัง "หลอก" target
// เป็น 30001 อีกชั้น // เป็น 30001 อีกชั้น
// พอ client ได้ battle_info กับ "monster" ที่จริงคือ prop/NPC/ตัวผู้เล่นเอง // พอ client ได้_info กับ "monster" ที่จริงคือ prop/NPC/ตัวผู้เล่นเอง
// หรือ monster ที่ไม่มี event_id มันจะเริ่ม battle transition ที่โหลดไม่สำเร็จ // หรือ monster ที่ไม่มี event_id มันจะเริ่ม transition ที่โหลดไม่สำเร็จ
// → battle camera ไม่มี target ให้ตาม → กล้องหลุดจากตัวละคร // → camera ไม่มี target ให้ตาม → กล้องหลุดจากตัวละคร
// //
// FIX: ยอมรับเฉพาะ entity ที่ server spawn เป็น "monster จริง" ในฉากปัจจุบัน // FIX: ยอมรับเฉพาะ entity ที่ server spawn เป็น "monster จริง" ในฉากปัจจุบัน
// (เก็บไว้ใน Session.MonsterEntities ตอน load_scene) ถ้าไม่มี monster จริง // (เก็บไว้ใน Session.MonsterEntities ตอน load_scene) ถ้าไม่มี monster จริง
// ถูกโจมตี → ตอบ retcode 0 + cast_entity_id เฉยๆ ไม่เข้า battle // ถูกโจมตี → ตอบ retcode 0 + cast_entity_id เฉยๆ ไม่เข้า
// client จะเล่นแอนิเมชันตีปกติ กล้องค้างกับตัวละคร // client จะเล่นแอนิเมชันตีปกติ กล้องค้างกับตัวละคร
// ================================================================ // ================================================================
handle("SceneCastSkill", func(s *Session, req *goproto.SceneCastSkillCsReq, rsp *goproto.SceneCastSkillScRsp) error { handle("SceneCastSkill", func(s *Session, req *goproto.SceneCastSkillCsReq, rsp *goproto.SceneCastSkillScRsp) error {
rsp.CastEntityId = req.CastEntityId rsp.CastEntityId = req.CastEntityId
// เฉพาะ monster entity จริงเท่านั้นที่ trigger battle // เฉพาะ monster entity จริงเท่านั้นที่ trigger
var targets []uint32 var targets []uint32
seen := map[uint32]bool{} seen := map[uint32]bool{}
addTarget := func(id uint32) { addTarget := func(id uint32) {
@@ -149,14 +140,14 @@ func init() {
} }
if len(targets) == 0 { if len(targets) == 0 {
// ไม่มี monster จริงถูกตี — ไม่เข้า battle (นี่คือ fix หลัก) // ไม่มี monster จริงถูกตี — ไม่เข้า (นี่คือ fix หลัก)
SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d skill_index=%d -> no real monster target (battle skipped)", SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d skill_index=%d -> no real monster target (battle skipped)",
req.CastEntityId, req.CasterId, req.SkillIndex) req.CastEntityId, req.CasterId, req.SkillIndex)
rsp.Retcode = 0 rsp.Retcode = 0
return nil return nil
} }
SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d -> battle with %d monster(s)", SceneDebugLog("SCENE_CAST_SKILL cast_entity=%d caster=%d -> with %d monster(s)",
req.CastEntityId, req.CasterId, len(targets)) req.CastEntityId, req.CasterId, len(targets))
battleInfo := createBattleInfo(s, req.CasterId, req.SkillIndex) battleInfo := createBattleInfo(s, req.CasterId, req.SkillIndex)
@@ -179,39 +170,19 @@ func init() {
}) })
handle("GetCurBattleInfo", func(s *Session, req *goproto.GetCurBattleInfoCsReq, rsp *goproto.GetCurBattleInfoScRsp) error { handle("GetCurBattleInfo", func(s *Session, req *goproto.GetCurBattleInfoCsReq, rsp *goproto.GetCurBattleInfoScRsp) error {
if info, ok := CURRENT_BATTLE.Load(s.Token); ok { // Login queries this even when no battle is active. An absent battle
// is a successful empty result, not RET_BATTLE_STAGE_NOT_MATCH.
rsp.Retcode = 0 rsp.Retcode = 0
if info, ok := CURRENT_BATTLE.Load(s.Token); ok {
rsp.BattleInfo = info rsp.BattleInfo = info
return nil return nil
} }
rsp.Retcode = 2000 // RET_BATTLE_STAGE_NOT_MATCH
return nil return nil
}) })
} }
func normalizeCalyxBattleInfo(battleInfo *goproto.SceneBattleInfo, reqWave uint32, worldLevel uint32) { // createBattleInfo is the Go port of.rs create_battle_info.
if worldLevel > 0 {
battleInfo.WorldLevel = worldLevel
}
if reqWave > 0 && int(reqWave) <= len(battleInfo.MonsterWaveList) {
battleInfo.MonsterWaveList = battleInfo.MonsterWaveList[:reqWave]
} else if len(battleInfo.MonsterWaveList) > 1 {
battleInfo.MonsterWaveList = battleInfo.MonsterWaveList[:1]
}
battleInfo.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList))
for _, w := range battleInfo.MonsterWaveList {
// NOTE: In client 4.6.53 GameAssembly.dll 0x166b2fdd, client verifies:
// (wave.WaveId & ~8) == 1. If wave_id != 1, it jumps to 0x166b3161
// and throws NullReferenceException in AdventureModule.CreateBattleServerCheckResult.
// Therefore every wave in Calyx / FarmElement MUST have WaveId = 1 and WaveParam = nil.
w.WaveId = 1
w.WaveParam = nil
w.BattleStageId = battleInfo.StageId
}
}
// createBattleInfo is the Go port of battle.rs create_battle_info.
func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBattleInfo { func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBattleInfo {
player := s.JSON() player := s.JSON()
if player == nil { if player == nil {
@@ -431,9 +402,6 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32) *goproto.SceneBat
// monsters // monsters
battleInfo.MonsterWaveList = common.ToSceneMonsterWaves(player.BattleConfig.Monsters) battleInfo.MonsterWaveList = common.ToSceneMonsterWaves(player.BattleConfig.Monsters)
battleInfo.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList)) battleInfo.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList))
for _, wave := range battleInfo.MonsterWaveList {
wave.BattleStageId = battleInfo.StageId
}
// global buffs // global buffs
hasCastorice, hasSilverWolf := false, false hasCastorice, hasSilverWolf := false, false
+4 -12
View File
@@ -274,12 +274,8 @@ func init() {
}) })
handle("GetCurChallengePeak", func(s *Session, req *goproto.GetCurChallengePeakCsReq, rsp *goproto.GetCurChallengePeakScRsp) error { handle("GetCurChallengePeak", func(s *Session, req *goproto.GetCurChallengePeakCsReq, rsp *goproto.GetCurChallengePeakScRsp) error {
// reverted to the state of the build that reached Pom-Pom: // Match upstream's empty current-state response until a Peak is active.
// empty peak_id (a fabricated peak_id:1 crashed CreateMemberEntityLeader)
rsp.Retcode = 0 rsp.Retcode = 0
rsp.HasPassed = true
rsp.PeakId = 1
rsp.BossBuffId = 0
return nil return nil
}) })
@@ -293,13 +289,9 @@ func init() {
EndTime: 1924992000, EndTime: 1924992000,
}) })
} }
// NOTE: the real 4.6.51 activity schedule (realActivitySchedule, from // Advertise only the supported Endgame schedules. Exposing the full
// the March7th dump) BLACK-SCREENS the events page: the client renders // ActivityConfig before implementing its activity-data handlers makes
// real event panels, then requests each activity's data (GetActivity- // the Client request unavailable state and can stall the events page.
// HotData/GetActiveActivityData/...) which we cannot fill yet - NRE.
// The synthetic list below resolves to nothing and keeps the page open
// (empty but usable). Re-enable the real list only after implementing
// per-activity data handlers.
// Apocalyptic Shadow tab requires an active schedule with panel 21001 // Apocalyptic Shadow tab requires an active schedule with panel 21001
for actID := uint32(21001); actID <= 21030; actID++ { for actID := uint32(21001); actID <= 21030; actID++ {
+92 -546
View File
@@ -1,6 +1,7 @@
package gameserver package gameserver
import ( import (
"fmt"
"sort" "sort"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@@ -9,8 +10,8 @@ import (
goproto "gac-hkrpg-go/protocol/proto" goproto "gac-hkrpg-go/protocol/proto"
) )
// Full Go port of challenge.rs: StartChallenge / Tierce / Peak flows with the // Challenge flows use the generated request and response layouts. Command
// hand-built 4.6.51 response layouts (cmd 1787 / 8973). // numbers are resolved through the current protocol table at registration/send.
type decodedChallengeStart struct { type decodedChallengeStart struct {
challengeID uint32 challengeID uint32
@@ -106,433 +107,89 @@ func mapChallengeStageID(id uint32) uint32 {
return id return id
} }
func readLenDelimAndAdvance(buf *[]byte) ([]byte, bool) {
b := *buf
l, n := readUvarint(b)
if n == 0 || int(l) > len(b[n:]) {
*buf = nil
return nil, false
}
sub := b[n : n+int(l)]
*buf = b[n+int(l):]
return sub, true
}
func readLenDelim(buf []byte) ([]byte, bool) {
l, n := readUvarint(buf)
if n == 0 || int(l) > len(buf[n:]) {
return nil, false
}
return buf[n : n+int(l)], true
}
func decodeStartChallengeReq(payload []byte) decodedChallengeStart { func decodeStartChallengeReq(payload []byte) decodedChallengeStart {
var req decodedChallengeStart
var pbReq goproto.StartChallengeCsReq var pbReq goproto.StartChallengeCsReq
if err := proto.Unmarshal(payload, &pbReq); err == nil && pbReq.ChallengeId != 0 { if err := proto.Unmarshal(payload, &pbReq); err != nil {
req.challengeID = pbReq.ChallengeId return decodedChallengeStart{}
req.firstAvatars = pbReq.FirstLineup }
if len(req.firstAvatars) == 0 && len(pbReq.AvatarLineupFirst) > 0 { req := decodedChallengeStart{challengeID: pbReq.ChallengeId,
for _, av := range pbReq.AvatarLineupFirst { firstAvatars: pbReq.FirstLineup, secondAvatars: pbReq.SecondLineup}
if av.Id != 0 { if len(req.firstAvatars) == 0 {
req.firstAvatars = append(req.firstAvatars, av.Id) for _, avatar := range pbReq.AvatarLineupFirst {
if avatar.Id != 0 {
req.firstAvatars = append(req.firstAvatars, avatar.Id)
} }
} }
} }
req.secondAvatars = pbReq.SecondLineup if len(req.secondAvatars) == 0 {
if len(req.secondAvatars) == 0 && len(pbReq.AvatarLineupSecond) > 0 { for _, avatar := range pbReq.AvatarLineupSecond {
for _, av := range pbReq.AvatarLineupSecond { if avatar.Id != 0 {
if av.Id != 0 { req.secondAvatars = append(req.secondAvatars, avatar.Id)
req.secondAvatars = append(req.secondAvatars, av.Id)
} }
} }
} }
if pbReq.StageInfo != nil { if pbReq.StageInfo != nil {
if pbReq.StageInfo.BossInfo != nil && pbReq.StageInfo.BossInfo.BuffOne != 0 { if info := pbReq.StageInfo.BossInfo; info != nil {
req.buffID = pbReq.StageInfo.BossInfo.BuffOne req.buffID = info.BuffOne
} else if pbReq.StageInfo.StoryInfo != nil && pbReq.StageInfo.StoryInfo.BuffOne != 0 { }
req.buffID = pbReq.StageInfo.StoryInfo.BuffOne if info := pbReq.StageInfo.StoryInfo; info != nil && req.buffID == 0 {
req.buffID = info.BuffOne
} }
} }
return req return req
}
buf := payload
for len(buf) > 0 {
tag, n := readUvarint(buf)
if n == 0 {
break
}
buf = buf[n:]
fieldNumber := uint32(tag >> 3)
wireType := uint32(tag & 0x7)
switch {
case (fieldNumber == 14 || fieldNumber == 12) && wireType == 0:
v, n2 := readUvarint(buf)
if n2 == 0 {
buf = nil
break
}
buf = buf[n2:]
if req.challengeID == 0 {
req.challengeID = uint32(v)
}
case fieldNumber == 6 && wireType == 0:
v, n2 := readUvarint(buf)
if n2 == 0 {
buf = nil
break
}
buf = buf[n2:]
req.firstAvatars = append(req.firstAvatars, uint32(v))
case fieldNumber == 6 && wireType == 2:
sub, ok := readLenDelimAndAdvance(&buf)
if !ok {
break
}
for len(sub) > 0 {
v, n3 := readUvarint(sub)
if n3 == 0 {
break
}
sub = sub[n3:]
req.firstAvatars = append(req.firstAvatars, uint32(v))
}
case fieldNumber == 8 && wireType == 0:
v, n2 := readUvarint(buf)
if n2 == 0 {
buf = nil
break
}
buf = buf[n2:]
req.secondAvatars = append(req.secondAvatars, uint32(v))
case fieldNumber == 8 && wireType == 2:
sub, ok := readLenDelimAndAdvance(&buf)
if !ok {
break
}
for len(sub) > 0 {
v, n3 := readUvarint(sub)
if n3 == 0 {
break
}
sub = sub[n3:]
req.secondAvatars = append(req.secondAvatars, uint32(v))
}
case (fieldNumber == 4 || fieldNumber == 15) && wireType == 2:
sub, ok := readLenDelimAndAdvance(&buf)
if !ok {
break
}
for len(sub) > 0 {
stag, n3 := readUvarint(sub)
if n3 == 0 {
break
}
sfn := uint32(stag >> 3)
swt := uint32(stag & 0x7)
if sfn == 1 && swt == 0 {
v, n4 := readUvarint(sub[n3:])
if n4 == 0 {
break
}
sub = sub[n3+n4:]
if fieldNumber == 4 {
req.firstAvatars = append(req.firstAvatars, uint32(v))
} else {
req.secondAvatars = append(req.secondAvatars, uint32(v))
}
} else {
skipWireField(swt, &sub)
}
}
case fieldNumber == 10 && wireType == 2:
sub, ok := readLenDelimAndAdvance(&buf)
if !ok {
break
}
for len(sub) > 0 {
stag, n3 := readUvarint(sub)
if n3 == 0 {
break
}
sfn := uint32(stag >> 3)
swt := uint32(stag & 0x7)
sub = sub[n3:]
if swt == 2 && (sfn == 9 || sfn == 10) {
inner, ok2 := readLenDelimAndAdvance(&sub)
if !ok2 {
break
}
for len(inner) > 0 {
t2, n4 := readUvarint(inner)
if n4 == 0 {
break
}
f2 := uint32(t2 >> 3)
w2 := uint32(t2 & 0x7)
inner = inner[n4:]
if w2 == 0 && (f2 == 10 || f2 == 15) {
v, n5 := readUvarint(inner)
if n5 == 0 {
break
}
inner = inner[n5:]
if req.buffID == 0 {
req.buffID = uint32(v)
}
} else {
skipWireField(w2, &inner)
}
}
} else {
skipWireField(swt, &sub)
}
}
default:
skipWireField(wireType, &buf)
}
}
return req
}
type tierceStageEntry struct {
buff uint32
lineup []uint32
} }
func decodeStartChallengeTierceReq(payload []byte) decodedChallengeStart { func decodeStartChallengeTierceReq(payload []byte) decodedChallengeStart {
var req decodedChallengeStart
var stagesAvatars []tierceStageEntry
var pbReq goproto.StartChallengeTierceCsReq var pbReq goproto.StartChallengeTierceCsReq
if err := proto.Unmarshal(payload, &pbReq); err == nil && pbReq.ChallengeId != 0 { if err := proto.Unmarshal(payload, &pbReq); err != nil {
req.challengeID = pbReq.ChallengeId return decodedChallengeStart{}
req.stageIndex = pbReq.FINOCEGDBDI }
req.isSingleStage = pbReq.MPKMIKBFEHG req := decodedChallengeStart{challengeID: pbReq.ChallengeId,
for _, lt := range pbReq.LineupTierceList { stageIndex: pbReq.FINOCEGDBDI, isSingleStage: pbReq.MPKMIKBFEHG}
var l []uint32 for index, stage := range pbReq.LineupTierceList {
for _, av := range lt.TierceAvatarIdList { var avatars []uint32
if av.Id != 0 { for _, avatar := range stage.TierceAvatarIdList {
l = append(l, av.Id) if avatar.Id != 0 {
avatars = append(avatars, avatar.Id)
} }
} }
stagesAvatars = append(stagesAvatars, tierceStageEntry{ req.stageInfoList = append(req.stageInfoList, avatars)
buff: lt.BuffId, if uint32(index) == req.stageIndex {
lineup: l, req.buffID = stage.BuffId
})
}
} else {
buf := payload
for len(buf) > 0 {
tag, n := readUvarint(buf)
if n == 0 {
break
}
buf = buf[n:]
fieldNumber := uint32(tag >> 3)
wireType := uint32(tag & 0x7)
switch {
case (fieldNumber == 10 || fieldNumber == 1 || fieldNumber == 5) && wireType == 0:
v, n2 := readUvarint(buf)
if n2 == 0 {
buf = nil
break
}
buf = buf[n2:]
req.stageIndex = uint32(v)
case (fieldNumber == 15 || fieldNumber == 3) && wireType == 0:
v, n2 := readUvarint(buf)
if n2 == 0 {
buf = nil
break
}
buf = buf[n2:]
req.challengeID = uint32(v)
case (fieldNumber == 2 || fieldNumber == 13) && wireType == 0:
v, n2 := readUvarint(buf)
if n2 == 0 {
buf = nil
break
}
buf = buf[n2:]
req.isSingleStage = v != 0
case (fieldNumber == 9 || fieldNumber == 12 || fieldNumber == 14) && wireType == 2:
sub, ok := readLenDelimAndAdvance(&buf)
if !ok {
break
}
var stageBuff uint32
var stageLineup []uint32
for len(sub) > 0 {
subTag, n2 := readUvarint(sub)
if n2 == 0 {
break
}
subFn := uint32(subTag >> 3)
subWt := uint32(subTag & 0x7)
sub = sub[n2:]
switch {
case (subFn == 10 || subFn == 6 || subFn == 5) && subWt == 0:
v, n3 := readUvarint(sub)
if n3 == 0 {
sub = nil
break
}
sub = sub[n3:]
stageBuff = uint32(v)
case (subFn == 9 || subFn == 4) && subWt == 2:
abuf, ok2 := readLenDelimAndAdvance(&sub)
if !ok2 {
break
}
var aid uint32
for len(abuf) > 0 {
atag, n4 := readUvarint(abuf)
if n4 == 0 {
break
}
afn := uint32(atag >> 3)
awt := uint32(atag & 0x7)
abuf = abuf[n4:]
if (afn == 1 || afn == 7 || afn == 9) && awt == 0 {
v, n5 := readUvarint(abuf)
if n5 == 0 {
break
}
abuf = abuf[n5:]
aid = uint32(v)
} else {
skipWireField(awt, &abuf)
} }
} }
if aid != 0 { if len(req.stageInfoList) > 0 {
stageLineup = append(stageLineup, aid) req.firstAvatars = req.stageInfoList[0]
} }
default: if len(req.stageInfoList) > 1 {
skipWireField(subWt, &sub) req.secondAvatars = req.stageInfoList[1]
} }
if len(req.stageInfoList) > 2 {
req.thirdAvatars = req.stageInfoList[2]
} }
stagesAvatars = append(stagesAvatars, tierceStageEntry{buff: stageBuff, lineup: stageLineup})
default:
skipWireField(wireType, &buf)
}
}
}
for _, st := range stagesAvatars {
req.stageInfoList = append(req.stageInfoList, st.lineup)
}
if len(stagesAvatars) > 0 {
req.firstAvatars = stagesAvatars[0].lineup
if req.stageIndex == 0 && stagesAvatars[0].buff != 0 {
req.buffID = stagesAvatars[0].buff
}
}
if len(stagesAvatars) > 1 {
req.secondAvatars = stagesAvatars[1].lineup
if req.stageIndex == 1 && stagesAvatars[1].buff != 0 {
req.buffID = stagesAvatars[1].buff
}
}
if len(stagesAvatars) > 2 {
req.thirdAvatars = stagesAvatars[2].lineup
if req.stageIndex == 2 && stagesAvatars[2].buff != 0 {
req.buffID = stagesAvatars[2].buff
}
}
return req return req
} }
func decodeSetChallengeTierceLineupReq(payload []byte) (uint32, [][]uint32) { func decodeSetChallengeTierceLineupReq(payload []byte) (uint32, [][]uint32) {
var challengeID uint32 var req goproto.SetChallengeTierceLineupCsReq
if err := proto.Unmarshal(payload, &req); err != nil {
return 0, nil
}
var stages [][]uint32 var stages [][]uint32
buf := payload for _, stage := range req.StageInfoList {
var avatars []uint32
for len(buf) > 0 { for _, avatar := range stage.TierceAvatarIdList {
tag, n := readUvarint(buf) if avatar.Id != 0 {
if n == 0 { avatars = append(avatars, avatar.Id)
break
}
buf = buf[n:]
fieldNumber := uint32(tag >> 3)
wireType := uint32(tag & 0x7)
switch {
case fieldNumber == 7 && wireType == 0:
v, n2 := readUvarint(buf)
if n2 == 0 {
buf = nil
break
}
buf = buf[n2:]
challengeID = uint32(v)
case fieldNumber == 12 && wireType == 2:
sub, ok := readLenDelim(buf)
if !ok {
buf = nil
break
}
buf = buf[len(sub):]
var stageLineup []uint32
for len(sub) > 0 {
subTag, n2 := readUvarint(sub)
if n2 == 0 {
break
}
subFn := uint32(subTag >> 3)
subWt := uint32(subTag & 0x7)
if subFn == 9 && subWt == 2 {
abuf, ok2 := readLenDelim(sub[n2:])
if !ok2 {
sub = nil
break
}
sub = sub[n2+len(abuf):]
var aid uint32
for len(abuf) > 0 {
atag, n4 := readUvarint(abuf)
if n4 == 0 {
break
}
afn := uint32(atag >> 3)
awt := uint32(atag & 0x7)
if afn == 9 && awt == 0 {
v, n5 := readUvarint(abuf[n4:])
if n5 == 0 {
break
}
abuf = abuf[n4+n5:]
aid = uint32(v)
} else {
skipWireField(awt, &abuf)
} }
} }
if aid != 0 { stages = append(stages, avatars)
stageLineup = append(stageLineup, aid)
} }
} else { return req.ChallengeId, stages
skipWireField(subWt, &sub)
}
}
stages = append(stages, stageLineup)
default:
skipWireField(wireType, &buf)
}
}
return challengeID, stages
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// battle config setup shared by start handlers // config setup shared by start handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func setChallengeBattleConfig(player *common.FreesrData, avatars []uint32, challengeID uint32, eventID uint32, buff uint32) { func setChallengeBattleConfig(player *common.FreesrData, avatars []uint32, challengeID uint32, eventID uint32, buff uint32) {
@@ -618,63 +275,12 @@ func dedupeTruncate4(in []uint32) []uint32 {
return out return out
} }
// looksLikeChallengeID: known challenge-id ranges across endgame modes.
func looksLikeChallengeID(v uint64) bool {
switch {
case v >= 101 && v <= 119, v == 900,
v >= 1001 && v <= 1035,
v >= 2001 && v <= 2025,
v >= 3001 && v <= 3020,
v >= 5000 && v <= 5700, // MoC floors incl. 4.6 phases
v >= 20000 && v <= 20300, // Pure Fiction
v >= 30000 && v <= 30300: // Apocalyptic Shadow
return true
}
cd := getChallengeData()
_, okC := cd.Challenges[uint32(v)]
_, okT := cd.Tierce[uint32(v)]
return okC || okT
}
// detectChallengeID: when the strict decoder misses the challenge id (field
// layout drift between client builds), scan top-level varint fields for the
// first value that looks like a challenge id.
func detectChallengeID(payload []byte) (uint32, int) {
buf := payload
for len(buf) > 0 {
tag, n := readUvarint(buf)
if n == 0 {
break
}
buf = buf[n:]
wireType := uint32(tag & 0x7)
if wireType == 0 {
v, n2 := readUvarint(buf)
if n2 == 0 {
break
}
buf = buf[n2:]
if looksLikeChallengeID(v) {
return uint32(v), int(tag >> 3)
}
} else {
skipWireField(wireType, &buf)
}
}
return 0, 0
}
// handleStartChallenge ports handle_start_challenge: respond cmd 1787 with // handleStartChallenge ports handle_start_challenge: respond cmd 1787 with
// retcode(3) + cur_challenge(5) + lineup_list(6) + scene(10) + stage_info(13). // retcode(3) + cur_challenge(5) + lineup_list(6) + scene(10) + stage_info(13).
func handleStartChallenge(s *Session, payload []byte) error { func handleStartChallenge(s *Session, payload []byte) error {
req := decodeStartChallengeReq(payload) req := decodeStartChallengeReq(payload)
if req.challengeID == 0 { if req.challengeID == 0 {
if id, field := detectChallengeID(payload); id != 0 { return s.Send(&goproto.StartChallengeScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
Log("handle_start_challenge: strict decode missed, detected challenge_id=" + itoa(int(id)) + " at field " + itoa(field))
req.challengeID = id
} else {
PacketLog("[CHALLENGE] challenge_id=0 payload=" + hexDump(payload, 128))
}
} }
Log("handle_start_challenge: challenge_id=" + itoa(int(req.challengeID))) Log("handle_start_challenge: challenge_id=" + itoa(int(req.challengeID)))
@@ -742,12 +348,7 @@ func handleStartChallenge(s *Session, payload []byte) error {
LineupList: lineups, LineupList: lineups,
Scene: sceneInfo, Scene: sceneInfo,
} }
rspBuf, err := proto.Marshal(rsp) return s.Send(rsp)
if err != nil {
return err
}
return s.SendRaw(&NetPacket{CmdType: 1787, Body: rspBuf})
} }
func scoreIDFor(challengeID uint32) uint32 { func scoreIDFor(challengeID uint32) uint32 {
@@ -757,9 +358,12 @@ func scoreIDFor(challengeID uint32) uint32 {
return 0 return 0
} }
// handleStartChallengeTierce ports handle_start_challenge_tierce -> cmd 8998 (4.6.51). // handleStartChallengeTierce uses the current Tierce request schema.
func handleStartChallengeTierce(s *Session, payload []byte) error { func handleStartChallengeTierce(s *Session, payload []byte) error {
req := decodeStartChallengeTierceReq(payload) req := decodeStartChallengeTierceReq(payload)
if req.challengeID == 0 {
return s.Send(&goproto.StartChallengeTierceScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
baseID := mapChallengeStageID(req.challengeID) baseID := mapChallengeStageID(req.challengeID)
Log("handle_start_challenge_tierce: challenge_id=" + itoa(int(req.challengeID)) + " (mapped " + itoa(int(baseID)) + ")") Log("handle_start_challenge_tierce: challenge_id=" + itoa(int(req.challengeID)) + " (mapped " + itoa(int(baseID)) + ")")
@@ -910,8 +514,8 @@ func handleStartChallengeTierce(s *Session, payload []byte) error {
tierceLineupList = append(tierceLineupList, lineupInfoFromAvatars(stageAvatars, sceneInfo.PlaneId, goproto.ExtraLineupType(1), uint32(stIdx))) tierceLineupList = append(tierceLineupList, lineupInfoFromAvatars(stageAvatars, sceneInfo.PlaneId, goproto.ExtraLineupType(1), uint32(stIdx)))
} }
// 4.6.51 StartChallengeTierceScRsp (CmdID 8998) // Generated response resolves its command ID through Session.Send.
rsp46 := &goproto.StartChallengeTierceScRsp{ rsp := &goproto.StartChallengeTierceScRsp{
Retcode: 0, Retcode: 0,
Scene: sceneInfo, Scene: sceneInfo,
CurTierceChallenge: &goproto.CurTierceChallenge{ CurTierceChallenge: &goproto.CurTierceChallenge{
@@ -921,110 +525,61 @@ func handleStartChallengeTierce(s *Session, payload []byte) error {
MPKMIKBFEHG: req.isSingleStage, MPKMIKBFEHG: req.isSingleStage,
}, },
} }
rspBuf, err := proto.Marshal(rsp46) return s.Send(rsp)
if err != nil {
return err
}
return s.SendRaw(&NetPacket{CmdType: 8998, Body: rspBuf})
} }
func handleSetChallengeTierceLineup(s *Session, payload []byte) error { func handleSetChallengeTierceLineup(s *Session, payload []byte) error {
challengeID, stages := decodeSetChallengeTierceLineupReq(payload) challengeID, stages := decodeSetChallengeTierceLineupReq(payload)
if challengeID == 0 {
return s.Send(&goproto.SetChallengeTierceLineupScRsp{Retcode: uint32(goproto.Retcode_RET_CHALLENGE_NOT_EXIST)})
}
if len(stages) > 0 { if len(stages) > 0 {
tierceLineupsMu.Lock() tierceLineupsMu.Lock()
tierceLineups[challengeID] = stages tierceLineups[challengeID] = stages
tierceLineupsMu.Unlock() tierceLineupsMu.Unlock()
} }
// 4.6.51 SetChallengeTierceLineupScRsp (cmd 8985, retcode tag 1 = 0) return s.Send(&goproto.SetChallengeTierceLineupScRsp{})
if err := s.SendRaw(&NetPacket{CmdType: 8985, Body: []byte{0x08, 0x00}}); err != nil {
return err
}
_ = s.SendRaw(&NetPacket{CmdType: 8999, Body: []byte{0x08, 0x00}})
_ = s.SendRaw(&NetPacket{CmdType: 8995, Body: []byte{0x60, 0x00}})
return nil
} }
func handleLeaveChallenge(s *Session, _ []byte) error { func handleLeaveChallenge(s *Session, _ []byte) error {
var returnEntry uint32 = 102020107 return leaveChallengeScene(s, &goproto.LeaveChallengeScRsp{})
if player := s.JSON(); player != nil {
player.BattleConfig.CustomBattleLineup = nil
// Always return safely to Belobog Admin District 102020107
player.Scene.EntryID = 102020107
player.Scene.PlaneID = 10202
player.Scene.FloorID = 10202001
player.Position = common.Position{X: -92, Y: 4278, Z: 68083, RotY: 155566}
player.SavePersistent()
}
// 4.6.51 LeaveChallengeScRsp (cmd 1771, tag 10 retcode 0)
if err := s.SendRaw(&NetPacket{CmdType: 1771, Body: []byte{0x50, 0x00}}); err != nil {
return err
}
_ = s.SendRaw(&NetPacket{CmdType: 1716, Body: []byte{0x20, 0x00}})
_ = s.SendRaw(&NetPacket{CmdType: 1781, Body: []byte{0x40, 0x00}})
// Restore the player back to the open world
_, _ = loadScene(s, returnEntry, true, nil, nil)
_ = refreshLineup(s)
return nil
} }
func handleLeaveChallengeTierce(s *Session, _ []byte) error { func handleLeaveChallengeTierce(s *Session, _ []byte) error {
var returnEntry uint32 = 102020107 return leaveChallengeScene(s, &goproto.LeaveChallengeTierceScRsp{})
if player := s.JSON(); player != nil { }
player.BattleConfig.CustomBattleLineup = nil
// Always return safely to Belobog Admin District 102020107 func leaveChallengeScene(s *Session, response proto.Message) error {
player.Scene.EntryID = 102020107 player := s.JSON()
player.Scene.PlaneID = 10202 if player == nil || player.Scene.EntryID == 0 {
player.Scene.FloorID = 10202001 return fmt.Errorf("challenge return scene is unavailable")
player.Position = common.Position{X: -92, Y: 4278, Z: 68083, RotY: 155566}
player.SavePersistent()
} }
// 4.6.51 LeaveChallengeTierceScRsp (cmd 8983, tag 13 retcode 0) // loadChallengeScene preserves the player's saved overworld scene.
if err := s.SendRaw(&NetPacket{CmdType: 8983, Body: []byte{0x68, 0x00}}); err != nil { entryID := player.Scene.EntryID
player.BattleConfig.CustomBattleLineup = nil
CURRENT_BATTLE.Delete(s.Token)
if err := s.Send(&goproto.QuitBattleScNotify{}); err != nil {
return err return err
} }
_ = s.SendRaw(&NetPacket{CmdType: 8997, Body: []byte{0x38, 0x00}}) if _, err := loadScene(s, entryID, true, true, nil, nil); err != nil {
_ = s.SendRaw(&NetPacket{CmdType: 8982, Body: []byte{0x68, 0x00}}) return err
}
// Restore the player back to the open world if err := refreshLineup(s); err != nil {
_, _ = loadScene(s, returnEntry, true, nil, nil) return err
_ = refreshLineup(s) }
return nil return s.Send(response)
} }
func handleTakeChallengeReward(s *Session, payload []byte) error { func handleTakeChallengeReward(s *Session, payload []byte) error {
var groupID uint32 var req goproto.TakeChallengeRewardCsReq
buf := payload if err := proto.Unmarshal(payload, &req); err != nil {
for len(buf) > 0 { return err
tag, n := readUvarint(buf)
if n == 0 {
break
} }
buf = buf[n:] return s.Send(&goproto.TakeChallengeRewardScRsp{GroupId: req.GroupId})
fnNum := uint32(tag >> 3)
wt := uint32(tag & 0x7)
if fnNum == 4 && wt == 0 {
v, n2 := readUvarint(buf)
if n2 == 0 {
break
}
buf = buf[n2:]
groupID = uint32(v)
break
}
skipWireField(wt, &buf)
}
var body []byte
body = append(body, 0x10) // tag 2: group_id
body = appendUvarint(body, uint64(groupID))
body = append(body, 0x30, 0x00) // tag 6: retcode = 0
return s.SendRaw(&NetPacket{CmdType: 1704, Body: body})
} }
// buildGetChallengeTierceDataScRsp — verbatim port of the Rust builder. // buildGetChallengeTierceDataScRspReal builds the generated current response.
func buildGetChallengeTierceDataScRspReal() []byte { func buildGetChallengeTierceDataScRspReal() *goproto.GetChallengeTierceDataScRsp {
cd := getChallengeData() cd := getChallengeData()
type tierceEntry struct { type tierceEntry struct {
@@ -1066,17 +621,8 @@ func buildGetChallengeTierceDataScRspReal() []byte {
}) })
} }
rspMsg := &goproto.GetChallengeTierceDataScRsp{ return &goproto.GetChallengeTierceDataScRsp{
ChallengeTierceGroups: groups, ChallengeTierceGroups: groups,
Retcode: 0, Retcode: 0,
} }
body, err := proto.Marshal(rspMsg)
if err == nil {
return body
}
var rsp []byte
rsp = append(rsp, 0x40, 0x00) // tag 8: retcode = 0
rsp = append(rsp, 0x60, 0x00) // legacy tag 12: retcode = 0
return rsp
} }
+1 -1
View File
@@ -161,7 +161,7 @@ func init() {
} }
rsp.RecommendType = &goproto.GetBigDataAllRecommendScRsp_RelicAvatar{RelicAvatar: inner} rsp.RecommendType = &goproto.GetBigDataAllRecommendScRsp_RelicAvatar{RelicAvatar: inner}
case goproto.BigDataRecommendType_BIG_DATA_RECOMMEND_TYPE_AVATAR_RELIC: case goproto.BigDataRecommendType_BIG_DATA_RECOMMEND_TYPE_AVATAR_RELIC:
// 4.6.51 proto: recomended_relic_info_list is repeated GDFKJKDLEPM // Loaded schema: recomended_relic_info_list is repeated GDFKJKDLEPM
// (obfuscated, contents unused by this PS) — reply with empty entries // (obfuscated, contents unused by this PS) — reply with empty entries
inner := &goproto.BigDataRecommendAvatarRelic{} inner := &goproto.BigDataRecommendAvatarRelic{}
for range BaseAvatarIDs { for range BaseAvatarIDs {
+6 -2
View File
@@ -11,7 +11,7 @@ import (
func init() { func init() {
// The three optional PlayerSettingInfo sub-messages are required by the // The three optional PlayerSettingInfo sub-messages are required by the
// 4.6.51 client: leaving them unset NREs inside _CmdGetBasicInfoScRsp. // Client requires these when processing _CmdGetBasicInfoScRsp.
handle("GetBasicInfo", func(s *Session, req *goproto.GetBasicInfoCsReq, rsp *goproto.GetBasicInfoScRsp) error { handle("GetBasicInfo", func(s *Session, req *goproto.GetBasicInfoCsReq, rsp *goproto.GetBasicInfoScRsp) error {
rsp.Retcode = 0 rsp.Retcode = 0
rsp.Gender = 2 rsp.Gender = 2
@@ -40,6 +40,10 @@ func init() {
var finalData []byte var finalData []byte
finalData = append(finalData, heartbeatLua...) finalData = append(finalData, heartbeatLua...)
if probe := s.takeBattleProbe(); len(probe) > 0 {
finalData = append(finalData, '\n')
finalData = append(finalData, probe...)
}
// Load all .lua files from scripts/ directory in deterministic order // Load all .lua files from scripts/ directory in deterministic order
if entries, err := os.ReadDir("scripts"); err == nil { if entries, err := os.ReadDir("scripts"); err == nil {
@@ -62,7 +66,7 @@ func init() {
} }
rsp.DownloadData = &goproto.ClientDownloadData{ rsp.DownloadData = &goproto.ClientDownloadData{
Version: 51, Version: goproto.ClientDownloadDataLuaVersion,
Time: int64(rsp.ServerTimeMs), Time: int64(rsp.ServerTimeMs),
Data: finalData, Data: finalData,
} }
+35 -162
View File
@@ -39,94 +39,43 @@ func init() {
dummy(name) dummy(name)
} }
// ---- fixed-body raw replies (cmd -> rsp cmd + body bytes) ---- // Bind requests by the current command names; do not retain numeric aliases
simpleReply := func(reqIDs []uint16, rspID uint16, body []byte) { // from earlier builds, which may now identify responses or notifications.
for _, id := range reqIDs { rawNamed("GetChallengeGroupStatisticsCsReq", challengeGroupStatsReply)
id := id rawNamed("GetChallengeTierceDataCsReq", challengeTierceReply)
rawHandlers[id] = func(s *Session, payload []byte) error { rawNamed("StartChallengeCsReq", handleStartChallenge)
return s.SendRaw(&NetPacket{CmdType: rspID, Body: body}) rawNamed("StartChallengeTierceCsReq", handleStartChallengeTierce)
} rawNamed("SetChallengeTierceLineupCsReq", handleSetChallengeTierceLineup)
} rawNamed("LeaveChallengeCsReq", handleLeaveChallenge)
} rawNamed("LeaveChallengeTierceCsReq", handleLeaveChallengeTierce)
rawNamed("TakeChallengeCumulativeRewardCsReq", handleTakeChallengeReward)
// The request/response names remain obfuscated in the upstream schema.
rawNamed("HIGLJHLNODG", handleExRankUnlock)
simpleReply([]uint16{8909}, 8923, []byte{0x60, 0x00}) // tag 12 retcode 0 rawNamed("GetSwitchHandDataCsReq", func(s *Session, payload []byte) error {
simpleReply([]uint16{8924, 8968}, 8920, []byte{0x10, 0x00}) // tag 2 retcode 0 return s.Send(&goproto.GetSwitchHandDataScRsp{})
simpleReply([]uint16{8935}, 8915, []byte{0x08, 0x00}) // tag 1 retcode 0 })
simpleReply([]uint16{8917}, 8929, []byte{0x08, 0x00}) // tag 1 retcode 0
simpleReply([]uint16{8933}, 8906, []byte{0x08, 0x00}) // tag 1 retcode 0
simpleReply([]uint16{8919}, 8948, []byte{0x08, 0x00}) // tag 1 retcode 0
simpleReply([]uint16{8910}, 8921, []byte{0x08, 0x00}) // tag 1 retcode 0
simpleReply([]uint16{8975}, 8992, []byte{0x40, 0x00}) // tag 8 retcode 0
simpleReply([]uint16{8990}, 8977, []byte{0x40, 0x00}) // tag 8 retcode 0
simpleReply([]uint16{8977}, 8999, []byte{0x50, 0x00}) // tag 10 retcode 0
simpleReply([]uint16{2966}, 2955, []byte{0x30, 0x00}) // tag 6 retcode 0
simpleReply([]uint16{2916}, 2906, []byte{0x18, 0x00}) // tag 3 retcode 0
simpleReply([]uint16{2919}, 2928, []byte{0x48, 0x00}) // tag 9 retcode 0
simpleReply([]uint16{2974}, 2919, []byte{0x18, 0x00}) // GetFriendListInfo tag 3 retcode 0
simpleReply([]uint16{740}, 733, []byte{0x58, 0x00}) // tag 11 retcode 0
simpleReply([]uint16{1748}, 1745, []byte{0x08, 0x00}) // tag 1 retcode 0
simpleReply([]uint16{1765}, 1730, []byte{0x08, 0x00}) // tag 1 retcode 0
simpleReply([]uint16{1742}, 1772, []byte{0x08, 0x00}) // tag 1 retcode 0
simpleReply([]uint16{1738}, 1748, nil) // empty body
simpleReply([]uint16{188}, 181, []byte{0x50, 0x00}) // tag 10 retcode 0
simpleReply([]uint16{4130}, 4124, []byte{0x70, 0x00}) // tag 14 retcode 0
simpleReply([]uint16{8110, 8115}, 8108, []byte{0x18, 0x00}) // GetSwitchHandData tag 3 retcode 0 (4.6.51 CmdID: 8108)
simpleReply([]uint16{8993, 8991}, 8981, []byte{0x70, 0x00}) // 4.6.51: tag 14 retcode 0
simpleReply([]uint16{8987}, 8986, []byte{0x38, 0x00}) // ConfirmChallengeTierceStageSettle tag 7 retcode 0
// ---- 1775 | 1711 -> GetChallengeGroupStatisticsScRsp (cmd 1708) ---- handle("GetChallengeTierceController", func(s *Session, req *goproto.GetChallengeTierceControllerCsReq, rsp *goproto.GetChallengeTierceControllerScRsp) error {
rawHandlers[1775] = challengeGroupStatsReply rsp.Retcode = 0
rawHandlers[1711] = challengeGroupStatsReply return nil
})
handle("StartChallengePeak", handleStartChallengePeak)
rawNamed("LeaveChallengePeakCsReq", func(s *Session, payload []byte) error {
return leaveChallengeScene(s, &goproto.LeaveChallengePeakScRsp{})
})
// ---- 8995 | 8979 | 8981 -> tierce data (cmd 8996 + legacy 8994) ----
rawHandlers[8995] = challengeTierceReply
rawHandlers[8979] = challengeTierceReply
rawHandlers[8981] = challengeTierceReply
// ---- challenge flow (StartChallenge family) ----
rawHandlers[1731] = handleStartChallenge
rawHandlers[1793] = handleStartChallenge
rawHandlers[8986] = handleStartChallengeTierce
rawHandlers[8988] = handleStartChallengeTierce
rawHandlers[8978] = handleSetChallengeTierceLineup
rawHandlers[9000] = handleSetChallengeTierceLineup
rawHandlers[1705] = handleLeaveChallenge
rawHandlers[1760] = handleLeaveChallenge
rawHandlers[1788] = handleLeaveChallenge
rawHandlers[8989] = handleLeaveChallengeTierce
rawHandlers[1739] = handleTakeChallengeReward
// ---- 400 -> Ex-Rank / Affinitive Eidolon Unlock ----
rawHandlers[400] = handleExRankUnlock
} }
// handleExRankUnlock handles CmdID 400 (Affinitive Eidolon / Ex-Rank unlock request from client). // handleExRankUnlock handles CmdID 400 (Affinitive Eidolon / Ex-Rank unlock request from client).
func handleExRankUnlock(s *Session, payload []byte) error { func handleExRankUnlock(s *Session, payload []byte) error {
Log(fmt.Sprintf("[EX-RANK] Received cmd 400 (len: %d) payload: %x", len(payload), payload)) Log(fmt.Sprintf("[EX-RANK] Received cmd 400 (len: %d) payload: %x", len(payload), payload))
var parsedVal uint32 var req goproto.HIGLJHLNODG
buf := payload if err := proto.Unmarshal(payload, &req); err != nil {
for len(buf) > 0 { return err
tag, n := readUvarint(buf)
if n == 0 {
break
}
buf = buf[n:]
wireType := tag & 0x7
fieldNum := tag >> 3
if wireType == 0 {
v, n2 := readUvarint(buf)
if n2 > 0 {
buf = buf[n2:]
Log(fmt.Sprintf("[EX-RANK] field %d = %d", fieldNum, v))
if v >= 100000 || (v >= 1000 && v < 10000 && parsedVal == 0) {
parsedVal = uint32(v)
}
continue
}
}
skipWireField(uint32(wireType), &buf)
} }
parsedVal := req.OJFIGABCKLG
var avatarID uint32 var avatarID uint32
if parsedVal >= 100000 { if parsedVal >= 100000 {
@@ -162,99 +111,23 @@ func handleExRankUnlock(s *Session, payload []byte) error {
player.SavePersistent() player.SavePersistent()
} }
// Send candidate response packets so client RPC callback resolves without spinning // The matching response is declared by the current upstream schema.
// Candidates in CmdAvatarType: 395 (CmdAvatarType_ENKEIJOLCNO), 354, 355, 364, 341 return s.Send(&goproto.LPOLGDLAADM{})
for _, rspID := range []uint16{395, 354, 355, 364, 341} {
_ = s.SendRaw(&NetPacket{CmdType: rspID, Body: []byte{}})
}
return nil
} }
// challengeGroupStatsReply handles CmdGetChallengeGroupStatisticsCsReq (1775/1711) // challengeGroupStatsReply uses the current typed statistics response.
// and returns GetChallengeGroupStatisticsScRsp (1708) using standard 4.6.51 proto.
func challengeGroupStatsReply(s *Session, payload []byte) error { func challengeGroupStatsReply(s *Session, payload []byte) error {
groupID := uint32(100)
var req goproto.GetChallengeGroupStatisticsCsReq var req goproto.GetChallengeGroupStatisticsCsReq
if err := proto.Unmarshal(payload, &req); err == nil && req.GroupId != 0 { if err := proto.Unmarshal(payload, &req); err != nil {
groupID = req.GroupId
} else {
buf := payload
for len(buf) > 0 {
tag, n := readUvarint(buf)
if n == 0 {
break
}
buf = buf[n:]
fieldNumber := tag >> 3
wireType := tag & 0x7
if (fieldNumber == 5 || fieldNumber == 12) && wireType == 0 {
v, n2 := readUvarint(buf)
if n2 > 0 {
groupID = uint32(v)
break
}
}
skipWireField(uint32(wireType), &buf)
}
}
rsp := &goproto.GetChallengeGroupStatisticsScRsp{
GroupId: groupID,
Retcode: 0,
}
switch {
case groupID >= 3000:
// Apocalyptic Shadow: challenge_boss (field 1)
rsp.ChallengeBoss = &goproto.ChallengeBossStatistics{
RecordId: 1,
PPBHLLOJNEK: &goproto.AANLJBLOOFO{
Level: 4,
ScoreId: 8000,
},
}
case groupID >= 2000 && groupID < 3000:
// Pure Fiction: challenge_story (field 3)
rsp.ChallengeStory = &goproto.ChallengeStoryStatistics{
RecordId: 1,
PPBHLLOJNEK: &goproto.EIKPHEMHIOH{
ScoreId: 80000,
},
}
case groupID >= 1000 && groupID < 2000:
// Memory of Chaos: challenge_default (field 11)
rsp.ChallengeDefault = &goproto.ChallengeStatistics{
RecordId: 1,
PPBHLLOJNEK: &goproto.ADKJKMKBFDC{
RoundCount: 20,
Level: 12,
},
}
}
body, err := proto.Marshal(rsp)
if err != nil {
return err return err
} }
return s.SendRaw(&NetPacket{CmdType: 1708, Body: body}) // Match upstream: echo the group without fabricating historical scores.
return s.Send(&goproto.GetChallengeGroupStatisticsScRsp{GroupId: req.GroupId})
} }
// challengeTierceReply ports the 8995/8979/8981 arm: cmd 8996 (4.6.51) plus // challengeTierceReply sends only the response paired with the current request.
// legacy cmd 8994 with the same tierce body.
func challengeTierceReply(s *Session, payload []byte) error { func challengeTierceReply(s *Session, payload []byte) error {
body := buildGetChallengeTierceDataScRsp() return s.Send(buildGetChallengeTierceDataScRspReal())
if err := s.SendRaw(&NetPacket{CmdType: 8996, Body: body}); err != nil {
return err
}
_ = s.SendRaw(&NetPacket{CmdType: 8994, Body: body})
return nil
}
// buildGetChallengeTierceDataScRsp mirrors challenge::build_get_challenge_tierce_data_sc_rsp
// (hand-built protobuf, verified against the 4.6.51 client).
func buildGetChallengeTierceDataScRsp() []byte {
return buildGetChallengeTierceDataScRspReal()
} }
func readUvarint(b []byte) (uint64, int) { func readUvarint(b []byte) (uint64, int) {
+94 -14
View File
@@ -52,6 +52,57 @@ func firstSceneOfEntry(res *common.JsonConfig, entryID uint32) (string, *common.
return "", nil, false return "", nil, false
} }
// sceneOfEntryForTeleport selects the floor config that actually owns the
// requested teleport. Some resource sets put several floors under one entry;
// choosing the lexicographically first config can then send a valid scene
// transition with the wrong floor and spawn position.
func sceneOfEntryForTeleport(res *common.JsonConfig, entryID, floorID, teleportID uint32) (string, *common.LevelOutputConfig, bool) {
configs := res.LevelOutputConfigs[entryID]
names := make([]string, 0, len(configs))
for name := range configs {
names = append(names, name)
}
sort.Strings(names)
if floorID != 0 {
want := fmt.Sprintf("_F%d", floorID)
for _, name := range names {
cfg := configs[name]
if len(name) > len(want) && name[len(name)-len(want):] == want && sceneHasTeleport(cfg, teleportID) {
return name, cfg, true
}
}
}
if teleportID != 0 {
for _, name := range names {
cfg := configs[name]
if sceneHasTeleport(cfg, teleportID) {
return name, cfg, true
}
}
}
if floorID != 0 {
want := fmt.Sprintf("_F%d", floorID)
for _, name := range names {
if len(name) > len(want) && name[len(name)-len(want):] == want {
return name, configs[name], true
}
}
}
return firstSceneOfEntry(res, entryID)
}
func sceneHasTeleport(cfg *common.LevelOutputConfig, teleportID uint32) bool {
if cfg == nil || teleportID == 0 {
return false
}
for _, group := range cfg.Scenes {
if _, ok := group.Teleports[teleportID]; ok {
return true
}
}
return false
}
// parsePlaneFloor splits "P<plane>_F<floor>". // parsePlaneFloor splits "P<plane>_F<floor>".
func parsePlaneFloor(name string) (uint32, uint32, error) { func parsePlaneFloor(name string) (uint32, uint32, error) {
if len(name) < 2 || name[0] != 'P' { if len(name) < 2 || name[0] != 'P' {
@@ -108,7 +159,7 @@ func init() {
defaultScene.FloorId = player.Scene.FloorID defaultScene.FloorId = player.Scene.FloorID
} }
scene, err := loadScene(s, entryID, false, nil, nil) scene, err := loadScene(s, entryID, false, false, nil, nil)
rsp.Retcode = 0 rsp.Retcode = 0
if err == nil { if err == nil {
rsp.Scene = scene rsp.Scene = scene
@@ -120,7 +171,7 @@ func init() {
handle("EnterScene", func(s *Session, req *goproto.EnterSceneCsReq, rsp *goproto.EnterSceneScRsp) error { handle("EnterScene", func(s *Session, req *goproto.EnterSceneCsReq, rsp *goproto.EnterSceneScRsp) error {
Log(fmt.Sprintf("on_enter_scene_cs_req: entry_id=%d, teleport_id=%d, interact_id=%d", req.EntryId, req.TeleportId, req.InteractId)) Log(fmt.Sprintf("on_enter_scene_cs_req: entry_id=%d, teleport_id=%d, interact_id=%d", req.EntryId, req.TeleportId, req.InteractId))
SceneDebugLog("ENTER_SCENE entry_id=%d teleport_id=%d interact_id=%d", req.EntryId, req.TeleportId, req.InteractId) SceneDebugLog("ENTER_SCENE entry_id=%d teleport_id=%d interact_id=%d scene_identifier=%v", req.EntryId, req.TeleportId, req.InteractId, req.SceneIdentifier)
res, err := common.GameRes() res, err := common.GameRes()
if err != nil { if err != nil {
@@ -222,7 +273,7 @@ func init() {
teleportID = &v teleportID = &v
} }
sceneInfo, err := loadScene(s, resolvedEntry, true, teleportID, sceneIdent) sceneInfo, err := loadScene(s, resolvedEntry, true, false, teleportID, sceneIdent)
if err != nil { if err != nil {
SceneDebugLog("ENTER_SCENE_FAILED requested_entry=%d resolved_entry=%d err=%v", req.EntryId, resolvedEntry, err) SceneDebugLog("ENTER_SCENE_FAILED requested_entry=%d resolved_entry=%d err=%v", req.EntryId, resolvedEntry, err)
Log(fmt.Sprintf("Failed to enter scene %d: %v", resolvedEntry, err)) Log(fmt.Sprintf("Failed to enter scene %d: %v", resolvedEntry, err))
@@ -230,11 +281,21 @@ func init() {
return nil return nil
} }
SceneDebugLog("ENTER_SCENE_RESOLVED requested_entry=%d resolved_entry=%d", req.EntryId, resolvedEntry) SceneDebugLog("ENTER_SCENE_RESOLVED requested_entry=%d resolved_entry=%d floor_id=%d plane_id=%d teleport_id=%d", req.EntryId, resolvedEntry, sceneInfo.FloorId, sceneInfo.PlaneId, teleportIDValue(teleportID))
rsp.Retcode = 0 rsp.Retcode = 0
rsp.SceneIdentifier = sceneInfo.SceneIdentifier rsp.SceneIdentifier = sceneInfo.SceneIdentifier
rsp.IsOverMap = true
rsp.IsCloseMap = req.IsCloseMap rsp.IsCloseMap = req.IsCloseMap
return nil if err := s.Send(rsp); err != nil {
return err
}
if err := s.Send(&goproto.EnterSceneByServerScNotify{
Scene: sceneInfo,
Lineup: common.ToLineupInfo(player.Lineups),
}); err != nil {
return err
}
return errResponseAlreadySent
}) })
handle("InteractProp", func(s *Session, req *goproto.InteractPropCsReq, rsp *goproto.InteractPropScRsp) error { handle("InteractProp", func(s *Session, req *goproto.InteractPropCsReq, rsp *goproto.InteractPropScRsp) error {
@@ -268,14 +329,14 @@ func init() {
return nil return nil
}) })
// decodeSceneIdentifiersLenient: the 4.6.51 client's cmd-1461 payload can // decodeSceneIdentifiersLenient: some Client scene-map payloads can
// fail strict proto parsing (field layout drift between builds) — walk the // fail strict proto parsing (field layout drift between builds) — walk the
// wire format manually and pull floor_id/content_id out of field 2. // wire format manually and pull floor_id/content_id out of field 2.
// GetSceneMapInfo as a raw handler: the 4.6.51 client's cmd-1461 payload // GetSceneMapInfo as a raw handler: some Client scene-map payloads
// can fail strict proto parsing (layout drift), so fall back to a manual // can fail strict proto parsing (layout drift), so fall back to a manual
// wire walk instead of dropping the request (the map UI stalls without // wire walk instead of dropping the request (the map UI stalls without
// this reply - no pillars, "navigation loading"). // this reply - no pillars, "navigation loading").
rawHandlers[1461] = func(s *Session, payload []byte) error { rawNamed("GetSceneMapInfoCsReq", func(s *Session, payload []byte) error {
res, err := common.GameRes() res, err := common.GameRes()
if err != nil { if err != nil {
return err return err
@@ -352,7 +413,7 @@ func init() {
} }
return s.Send(rsp) return s.Send(rsp)
} })
handle("SceneEntityMove", func(s *Session, req *goproto.SceneEntityMoveCsReq, rsp *goproto.SceneEntityMoveScRsp) error { handle("SceneEntityMove", func(s *Session, req *goproto.SceneEntityMoveCsReq, rsp *goproto.SceneEntityMoveScRsp) error {
player := s.JSON() player := s.JSON()
@@ -696,9 +757,9 @@ func sortedU32Keys2[V any](m map[uint32]V) []uint32 {
func sortedU32Keys[V any](m map[uint32]V) []uint32 { return sortedU32Keys2(m) } func sortedU32Keys[V any](m map[uint32]V) []uint32 { return sortedU32Keys2(m) }
// loadScene is the Go port of load_scene. When isEnterScene is true it also // loadScene is the Go port of load_scene. When isEnterScene is true it updates
// sends EnterSceneByServerScNotify and persists the new position. // the player's scene; sendNotify controls whether it also emits the notify.
func loadScene(s *Session, entryID uint32, isEnterScene bool, teleportID *uint32, reqSceneIdent *goproto.SceneIdentifier) (*goproto.SceneInfo, error) { func loadScene(s *Session, entryID uint32, isEnterScene, sendNotify bool, teleportID *uint32, reqSceneIdent *goproto.SceneIdentifier) (*goproto.SceneInfo, error) {
player := s.JSON() player := s.JSON()
if player == nil { if player == nil {
return nil, fmt.Errorf("data is not set!") return nil, fmt.Errorf("data is not set!")
@@ -708,7 +769,15 @@ func loadScene(s *Session, entryID uint32, isEnterScene bool, teleportID *uint32
return nil, err return nil, err
} }
name, scene, ok := firstSceneOfEntry(res, entryID) floorHint := uint32(0)
if reqSceneIdent != nil {
floorHint = reqSceneIdent.FloorId
}
requestedTeleport := uint32(0)
if teleportID != nil {
requestedTeleport = *teleportID
}
name, scene, ok := sceneOfEntryForTeleport(res, entryID, floorHint, requestedTeleport)
if !ok { if !ok {
Log(fmt.Sprintf("Map Entrance Not Found %d", entryID)) Log(fmt.Sprintf("Map Entrance Not Found %d", entryID))
return nil, fmt.Errorf("Map Entrance Not Found %d", entryID) return nil, fmt.Errorf("Map Entrance Not Found %d", entryID)
@@ -750,6 +819,7 @@ func loadScene(s *Session, entryID uint32, isEnterScene bool, teleportID *uint32
} }
} }
} }
SceneDebugLog("LOAD_SCENE_TELEPORT entry_id=%d config=%s floor_id=%d teleport_id=%d matched=%t pos=(%d,%d,%d)", entryID, name, floorID, *teleportID, found, jsonPos.X, jsonPos.Y, jsonPos.Z)
} else if isEnterScene || jsonPos.Y < -5000 { } else if isEnterScene || jsonPos.Y < -5000 {
// first teleport in the scene, else the teleport DB, else spawn near the first prop // first teleport in the scene, else the teleport DB, else spawn near the first prop
var firstTP *common.TeleportInfo var firstTP *common.TeleportInfo
@@ -861,7 +931,7 @@ func loadScene(s *Session, entryID uint32, isEnterScene bool, teleportID *uint32
for _, groupID := range sortedU32Keys2(scene.Scenes) { for _, groupID := range sortedU32Keys2(scene.Scenes) {
group := scene.Scenes[groupID] group := scene.Scenes[groupID]
groupInfo := &goproto.SceneEntityGroupInfo{GroupId: groupID} groupInfo := &goproto.SceneEntityGroupInfo{State: 1, GroupId: groupID}
// props // props
for i := range group.Props { for i := range group.Props {
@@ -924,6 +994,7 @@ func loadScene(s *Session, entryID uint32, isEnterScene bool, teleportID *uint32
}) })
} }
sceneInfo.EntityList = append(sceneInfo.EntityList, groupInfo.EntityList...)
if len(group.Props) == 0 && len(group.Npcs) == 0 && len(group.Monsters) == 0 { if len(group.Props) == 0 && len(group.Npcs) == 0 && len(group.Monsters) == 0 {
continue continue
} }
@@ -959,6 +1030,7 @@ func loadScene(s *Session, entryID uint32, isEnterScene bool, teleportID *uint32
player.Scene.PlaneID = planeID player.Scene.PlaneID = planeID
player.Position = jsonPos player.Position = jsonPos
if sendNotify {
if err := s.Send(&goproto.EnterSceneByServerScNotify{ if err := s.Send(&goproto.EnterSceneByServerScNotify{
Scene: sceneInfo, Scene: sceneInfo,
Lineup: common.ToLineupInfo(player.Lineups), Lineup: common.ToLineupInfo(player.Lineups),
@@ -966,10 +1038,18 @@ func loadScene(s *Session, entryID uint32, isEnterScene bool, teleportID *uint32
return nil, err return nil, err
} }
} }
}
return sceneInfo, nil return sceneInfo, nil
} }
func teleportIDValue(id *uint32) uint32 {
if id == nil {
return 0
}
return *id
}
func hasAvatar(player *common.FreesrData, id uint32) bool { func hasAvatar(player *common.FreesrData, id uint32) bool {
_, ok := player.Avatars[id] _, ok := player.Avatars[id]
return ok return ok
+8
View File
@@ -8,6 +8,10 @@ import (
// tutorial modal (which locks camera + input). // tutorial modal (which locks camera + input).
func init() { func init() {
handle("GetTutorial", func(s *Session, req *goproto.GetTutorialCsReq, rsp *goproto.GetTutorialScRsp) error { handle("GetTutorial", func(s *Session, req *goproto.GetTutorialCsReq, rsp *goproto.GetTutorialScRsp) error {
tutorialIDs, _, err := getTutorialResourceIDs()
if err != nil {
return err
}
rsp.Retcode = 0 rsp.Retcode = 0
for _, id := range tutorialIDs { for _, id := range tutorialIDs {
rsp.TutorialList = append(rsp.TutorialList, &goproto.Tutorial{ rsp.TutorialList = append(rsp.TutorialList, &goproto.Tutorial{
@@ -19,6 +23,10 @@ func init() {
}) })
handle("GetTutorialGuide", func(s *Session, req *goproto.GetTutorialGuideCsReq, rsp *goproto.GetTutorialGuideScRsp) error { handle("GetTutorialGuide", func(s *Session, req *goproto.GetTutorialGuideCsReq, rsp *goproto.GetTutorialGuideScRsp) error {
_, tutorialGuideIDs, err := getTutorialResourceIDs()
if err != nil {
return err
}
rsp.Retcode = 0 rsp.Retcode = 0
for _, id := range tutorialGuideIDs { for _, id := range tutorialGuideIDs {
rsp.TutorialGuideList = append(rsp.TutorialGuideList, &goproto.TutorialGuide{ rsp.TutorialGuideList = append(rsp.TutorialGuideList, &goproto.TutorialGuide{
+69 -109
View File
@@ -1,115 +1,75 @@
package gameserver package gameserver
// Generated from the Rust tutorial.rs lists (client 4.6.51). import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
)
var tutorialIDs = []uint32{ // Tutorial IDs come from the installed Client resource tables, not a list
1001, 1002, 1003, 1004, 1005, 1007, 1008, 1010, 2000, 2001, 2002, 2003, 2004, 2008, 2010, 2011, 2012, 2013, 3001, 3002, // copied from an older server build. Restart after replacing these resources.
3003, 3004, 3005, 3006, 3008, 3009, 3010, 3011, 3012, 3013, 3014, 3202, 4002, 4003, 4004, 4005, 4006, 4007, 4008, 4009, var tutorialResourceCache struct {
4010, 4011, 4013, 4014, 4015, 4017, 4018, 4019, 4020, 4021, 4022, 4023, 4024, 4025, 4026, 4027, 4028, 4029, 4030, 4031, sync.Once
4032, 4033, 4034, 4035, 4036, 4037, 4038, 4039, 4040, 4041, 4042, 4043, 4044, 4045, 4046, 4047, 4048, 4049, 4050, 4051, tutorials []uint32
4052, 4053, 4054, 4055, 4056, 4057, 4058, 4059, 4060, 4061, 4063, 4066, 4067, 4068, 4070, 4071, 4072, 4073, 4074, 4075, guides []uint32
4076, 4077, 4078, 4079, 4081, 4083, 4084, 4085, 4097, 4098, 4112, 5001, 5002, 5003, 5004, 5005, 5006, 5007, 5008, 5009, err error
5010, 5011, 5012, 5013, 5014, 5015, 5016, 5017, 5018, 5019, 5021, 5022, 5023, 5024, 5025, 5026, 5027, 5028, 5029, 5030,
5031, 5032, 5033, 5034, 5035, 5036, 5038, 5039, 5041, 5044, 5045, 5046, 5047, 5048, 5049, 5050, 5051, 5052, 5053, 5054,
5055, 5056, 5057, 5058, 5059, 5060, 5061, 5062, 5063, 5064, 5065, 5070, 5071, 5072, 5073, 5074, 5075, 5077, 5078, 5079,
5081, 5082, 5083, 5084, 5085, 5086, 5087, 5088, 5089, 5090, 5091, 5092, 5093, 5094, 5095, 5096, 5097, 5098, 5099, 5100,
5101, 5102, 5103, 5104, 5105, 5106, 5107, 5108, 5109, 5110, 5111, 5112, 5113, 5114, 5115, 5116, 5130, 5131, 5132, 5133,
5134, 5135, 5140, 5141, 5142, 5143, 5144, 5145, 5146, 5150, 5151, 5152, 5153, 5154, 5155, 5156, 5157, 5158, 5159, 5160,
5161, 5162, 5163, 5164, 5172, 5173, 5174, 5175, 5176, 5177, 5178, 5179, 5180, 5181, 5182, 5183, 5184, 5185, 5186, 5187,
5188, 5189, 5190, 5191, 5192, 5193, 5194, 5195, 5196, 5197, 5198, 5199, 5200, 5201, 5202, 5210, 5211, 5212, 5213, 5214,
5215, 5216, 5217, 5218, 5219, 5220, 5221, 5222, 5301, 5302, 5303, 5304, 5305, 5306, 5307, 5308, 5309, 5310, 5311, 5312,
5313, 5314, 5316, 5317, 5318, 5319, 5320, 5321, 5322, 5323, 5324, 5325, 5326, 5350, 5351, 5352, 5355, 5356, 5357, 5358,
5359, 5360, 5361, 5365, 5366, 5367, 5368, 5369, 5370, 5371, 5372, 5373, 5374, 5375, 5376, 5377, 5378, 5380, 5381, 5382,
5383, 5384, 5385, 5386, 5387, 5388, 5389, 5390, 5391, 5392, 5393, 5394, 5395, 5397, 5398, 5399, 5400, 5401, 5402, 5403,
5404, 5405, 5406, 5407, 5408, 5409, 5410, 5450, 5451, 5452, 5453, 5454, 5455, 5456, 5457, 5458, 5459, 5460, 5461, 5462,
5463, 5464, 5465, 5510, 5511, 5512, 5513, 5514, 5515, 5517, 5518, 5520, 5521, 5522, 5523, 5524, 5525, 5530, 5531, 5532,
5533, 5534, 5535, 5536, 5537, 5540, 5550, 5551, 5552, 5553, 5554, 5555, 5556, 5560, 5561, 5562, 5563, 5564, 5565, 5566,
5567, 5568, 5569, 5600, 5601, 5602, 5603, 5604, 5605, 5606, 5607, 5608, 5609, 5610, 5611, 5612, 5613, 5614, 5615, 5700,
5701, 5702, 5703, 5704, 5705, 5706, 5707, 5708, 5709, 5710, 5711, 5712, 5713, 5714, 5715, 5716, 5717, 5718, 5750, 5751,
5752, 5753, 5754, 5755, 5756, 5757, 5758, 5759, 5760, 5761, 5762, 5763, 5764, 5765, 5766, 5767, 5768, 5769, 5770, 5771,
5772, 5773, 5774, 5775, 5801, 5802, 5803, 5804, 5805, 5806, 5810, 5811, 5812, 5813, 5814, 5815, 5816, 5817, 5901, 5902,
5903, 5904, 5905, 5906, 5907, 5908, 5909, 5910, 5911, 5912, 5913, 5914, 5915, 5916, 5917, 5918, 5921, 5922, 5923, 5924,
5925, 5926, 5927, 5928, 5929, 5930, 5931, 5933, 5934, 5936, 5937, 5938, 5939, 5940, 5950, 5961, 5962, 5963, 5964, 5965,
5966, 5967, 5968, 5969, 5970, 5971, 5972, 5973, 5974, 5975, 5976, 5977, 5978, 5979, 5980, 6000, 6001, 6002, 6003, 6004,
6005, 6006, 6007, 6008, 6009, 6010, 6011, 6012, 6013, 6014, 6015, 6016, 6017, 6018, 6019, 6020, 6021, 6022, 6023, 6024,
6025, 6026, 6027, 6028, 6029, 6030, 6031, 6050, 6051, 6052, 6053, 6054, 6055, 6100, 6102, 6103, 6104, 6105, 6106, 6107,
6108, 6109, 6110, 6111, 6112, 6113, 6114, 6115, 6116, 6117, 6118, 6119, 6120, 6121, 6122, 6123, 6124, 6125, 6126, 6127,
6128, 6129, 6130, 6131, 6132, 6133, 6134, 6135, 6136, 6137, 6138, 6139, 6141, 6142, 6193, 6194, 6195, 6196, 6197, 6198,
6199, 6200, 6300, 6301, 6302, 6303, 6304, 6305, 6306, 6307, 6308, 6309, 6310, 6311, 6312, 6313, 6314, 6315, 6316, 6317,
6318, 6319, 6320, 6321, 6322, 6323, 6324, 6325, 6326, 6327, 6328, 6329, 6330, 6331, 6332, 6333, 6334, 6335, 6336, 6337,
6338, 6339, 6340, 6341, 6342, 6343, 6400, 6401, 6402, 6403, 6404, 6405, 6406, 6408, 6409, 6410, 6411, 6412, 6450, 6451,
6452, 6453, 6454, 6455, 6456, 6500, 6501, 6503, 6505, 6506, 6507, 6508, 6509, 6510, 6511, 6512, 6513, 6514, 6515, 6516,
6517, 6518, 6519, 6601, 6602, 6603, 6604, 6605, 6606, 6607, 6608, 6609, 6610, 6611, 6612, 6613, 6614, 6700, 6701, 6702,
6703, 6704, 6705, 6706, 6707, 6708, 6709, 6710, 6711, 6712, 6713, 6714, 6715, 6716, 6717, 6718, 6719, 6750, 6751, 6752,
6753, 6754, 6755, 6756, 6757, 6758, 6759, 7001, 7002, 7003, 7004, 7005, 7006, 7007, 7008, 7009, 7010, 7011, 7012, 7013,
7014, 7015, 7016, 7017, 7023, 7024, 7025, 7026, 7027, 7028, 7029, 7030, 7031, 7032, 7033, 7034, 7035, 8001, 8002, 8003,
8005, 8006, 8007, 8009, 8012, 8013, 8014, 8015, 8016, 8017, 8018, 8019, 8020, 8021, 8101, 8102, 8103, 8104, 8105, 8106,
8107, 8108, 8109, 8110, 8111, 8112, 8113, 8114, 8115, 8116, 8117, 8118, 8119, 8120, 8121, 8122, 8123, 8124, 8125, 8126,
8127, 8128, 8129, 8130, 8201, 8202, 8203, 8204, 8205, 8206, 8301, 8302, 8303, 8304, 8305, 8306, 8307, 8308, 8309, 8310,
8311, 8312, 8313, 8314, 8315, 8316, 8317, 8318, 8319, 8320, 8321, 8322, 8323, 8324, 8325, 8326, 8327, 8328, 8401, 8404,
8405, 8406, 8501, 8502, 8503, 8504, 8505, 8506, 8508, 8509, 8601, 8602, 8603, 8604, 8605, 8606, 8607, 8608, 8609, 8611,
8612, 8613, 8614, 8621, 8625, 8626, 8630, 8631, 8632, 8633, 8634, 8635, 8636, 8637, 8638, 8639, 8640, 8641, 8700, 8701,
8702, 8703, 8704, 8705, 8706, 8707, 8708, 8709, 8710, 8711, 8712, 8713, 8714, 8715, 8716, 8717, 8718, 8719, 8720, 8721,
8722, 8723, 8724, 8725, 8802, 8803, 8806, 8807, 8808, 8809, 8811, 8812, 8813, 8905, 8906, 8907, 8908, 8909, 8910, 8911,
8931, 8932, 9001, 9002, 9003, 9004, 9005, 9006, 9007, 9008, 9009, 9010, 9011, 9012, 9013, 9014, 9015, 9016, 9017, 9018,
9019, 9020, 9021, 9022, 9023, 9024, 9025, 9026, 9027, 9028, 9029, 9030, 9031, 9032, 9033, 9034, 9035, 9036, 9037, 9038,
9039, 9040, 9041, 9042, 9043, 9044, 9045, 9046, 9047, 9048, 9049, 9050, 9051, 9052, 9053, 9054, 9055, 9056, 9057, 9058,
9059, 9060, 9061, 9062, 9063, 9065, 9066, 9067, 9068, 9070, 9071, 9072, 9073, 9074, 9075, 9076, 9077, 9078, 9079, 9080,
9081, 9082, 9083, 9084, 9085, 9086, 9087, 9088, 9090, 9091, 9092, 9093, 9094, 9095, 9096, 9097, 9098, 9099, 9100, 9110,
9111, 9112, 9113, 9114, 9115, 9116, 9117, 9118, 9119, 9120, 9121, 9122, 9123, 9124, 9125, 9126, 9127, 9128, 9129, 9130,
9131, 9132, 9133, 9134, 9135, 9136, 9137, 9201, 9202, 9203, 9204, 9205, 9206, 9207, 9208, 9209, 9210, 9211, 9212, 9213,
9214, 9301, 9302, 9303, 9304, 9305, 9306, 9307, 9308, 9309, 9310, 9311, 9313, 9314, 9315, 9316, 9317, 9318, 9319, 9321,
9322, 9323, 9324, 9325, 9401, 9402, 9403, 9404, 9405, 9501, 9502, 9503, 9504, 9505, 9506, 9507, 9508, 9509, 9510, 9511,
9512, 9513, 9514, 9515, 9598, 9599, 9610, 9611, 9612, 9613, 9614, 9615, 9616, 9617, 9618, 9619, 9620, 9621, 9622, 9623,
9624, 9625, 9626, 9627, 9628, 9629, 9630, 9631, 9650, 9651, 9701, 9702, 9703, 9704, 9801, 9802, 9803, 9804, 9805, 9806,
9807, 9808, 9809, 9810, 9811, 9812, 9813, 9814, 9815, 9816, 9817, 9818, 9819, 9820, 9901, 9903, 9904, 9905, 9906, 9908,
10001, 10002, 10003, 10004, 10005, 10006, 10007, 10008, 10009, 10031, 10032, 10033, 10034, 10036, 10037, 10038, 10039, 10040, 10041, 10042,
10043, 10045, 43000, 43001, 43002, 43003, 43004, 43005, 43006, 43007, 43008, 43009, 43010, 43011, 44001, 44002, 44003, 44004, 44005, 44006,
44007, 44008, 44009, 44010, 44011, 44012, 44013, 44014, 45001,
} }
var tutorialGuideIDs = []uint32{ func getTutorialResourceIDs() ([]uint32, []uint32, error) {
1101, 1102, 1103, 1104, 1105, 1108, 1109, 1110, 1116, 1117, 1118, 1119, 2006, 2007, 2008, 2105, 2106, 2107, 2200, 2201, tutorialResourceCache.Do(func() {
2202, 2203, 2204, 2205, 2206, 2207, 2208, 2209, 2210, 2211, 2212, 2213, 2214, 2215, 2216, 2217, 2218, 2219, 2220, 2221, var tutorialFile struct {
2222, 2223, 2224, 2225, 2226, 2227, 2228, 2229, 2230, 2231, 2232, 2233, 2234, 2235, 2236, 2237, 2238, 2242, 2243, 2244, Entries []struct {
2245, 2246, 2247, 2249, 2251, 2252, 2253, 2254, 2260, 3007, 3105, 3106, 3107, 3108, 3109, 3201, 3202, 3203, 3204, 3205, ID uint32 `json:"TutorialID"`
3206, 3207, 3208, 3209, 3210, 3211, 3212, 3213, 3214, 3215, 3216, 3217, 3218, 3219, 3221, 3222, 3223, 3225, 3227, 3228, } `json:"tutorial_config"`
3229, 3230, 3231, 3232, 4001, 4101, 4102, 4103, 4104, 4105, 4106, 4107, 4109, 4110, 5101, 5102, 5103, 5104, 5105, 5106, }
6001, 6002, 6003, 6004, 6008, 6009, 6010, 6011, 6012, 6014, 6015, 6018, 6020, 6021, 6023, 6024, 6025, 6026, 6027, 6028, var guideFile struct {
6029, 6030, 6031, 6032, 6033, 6034, 6035, 6036, 6037, 6038, 6039, 6040, 6041, 6042, 6043, 6044, 6045, 6046, 6047, 6048, Entries []struct {
6049, 6050, 6051, 6052, 6053, 6054, 6055, 6056, 6057, 6058, 6059, 6060, 6061, 6062, 6063, 6064, 6065, 6066, 6067, 6068, ID uint32 `json:"GroupID"`
6069, 6070, 6071, 6072, 6073, 6074, 6075, 6076, 6077, 6078, 6079, 6080, 6081, 6082, 6083, 6085, 6086, 6087, 6088, 6089, } `json:"tutorial_guide_config"`
6090, 6091, 6092, 6093, 6094, 6095, 6096, 6097, 6098, 6099, 6100, 6101, 6102, 6103, 6104, 6105, 6106, 6107, 6108, 6109, }
6110, 6111, 6112, 6113, 6114, 6116, 6117, 6118, 6119, 6120, 6122, 6124, 6125, 6126, 6127, 6128, 6129, 6130, 6301, 6302, load := func(name string, dst any) error {
6303, 6304, 6305, 6306, 6307, 6308, 6309, 6310, 6311, 6312, 6314, 6315, 6316, 6317, 6318, 6319, 6320, 6321, 6322, 6323, path := filepath.Join("resources", name)
6324, 6325, 6326, 6327, 6328, 6329, 6330, 6331, 6332, 6333, 6334, 6335, 6336, 6337, 6338, 6339, 6340, 6341, 6342, 6343, data, err := os.ReadFile(path)
6345, 6346, 6347, 6348, 6349, 6350, 6351, 6352, 6354, 6355, 6356, 6357, 6358, 6359, 6360, 6361, 6362, 6363, 6364, 6365, if err != nil {
6366, 6367, 6368, 6369, 6371, 6372, 6374, 6375, 6376, 6377, 6378, 6379, 6380, 7090, 7091, 7092, 7100, 7101, 7501, 7502, return fmt.Errorf("tutorial resource %s: %w", path, err)
7503, 7504, 7506, 7507, 7508, 7509, 7511, 7514, 7515, 8001, 8002, 8003, 8004, 8006, 8007, 8008, 8010, 8011, 8012, 8013, }
8014, 8015, 8016, 8017, 8018, 8019, 8020, 8021, 8022, 8023, 8024, 8025, 8026, 8027, 8028, 8038, 8039, 8047, 8050, 8051, if err := json.Unmarshal(data, dst); err != nil {
8052, 8055, 8056, 8057, 8058, 8059, 8061, 8062, 8063, 8064, 8065, 8066, 8067, 8069, 8070, 8072, 8073, 8074, 8075, 8076, return fmt.Errorf("tutorial resource %s: %w", path, err)
8078, 8079, 8080, 8090, 8091, 8092, 8093, 8094, 8095, 8096, 8100, 8101, 8102, 8103, 8104, 8105, 8106, 8107, 8108, 8109, }
8110, 8111, 8112, 8113, 8122, 8123, 8124, 8130, 8131, 8132, 8133, 8134, 8135, 8136, 8137, 8138, 8139, 8140, 8141, 8142, return nil
8143, 8144, 8145, 8146, 8147, 8148, 8149, 8150, 8151, 8152, 8153, 8154, 8155, 8156, 8157, 8158, 8160, 8161, 8162, 8163, }
8164, 8165, 8166, 8167, 8168, 8170, 8171, 8172, 8173, 8174, 8175, 8176, 8180, 8181, 8182, 8183, 8184, 8185, 8186, 8187, if err := load("TutorialData.json", &tutorialFile); err != nil {
8188, 8189, 8190, 8191, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8203, 8204, 8205, 8206, 8207, tutorialResourceCache.err = err
8208, 8209, 8211, 8212, 8213, 8301, 8302, 8303, 8304, 8305, 8306, 8310, 8340, 8341, 8342, 8343, 8344, 8345, 8346, 8701, return
8702, 8703, 8704, 8802, 8803, 8804, 8805, 9101, 9102, 9103, 9104, 9105, 9107, 9108, 9109, 9110, 9111, 9112, 9113, 9114, }
9115, 9116, 9117, 9118, 9119, 9120, 9201, 9202, 9203, 9204, 9205, 9206, 9207, 9208, 9209, 9210, 9211, 9212, 9301, 9303, if err := load("TutorialGuideGroup.json", &guideFile); err != nil {
9304, 9305, 9306, 9308, 9309, 9310, 9312, 9313, 9314, 9315, 9316, 9317, 9318, 9319, 9320, 9321, 9323, 9324, 9325, 9326, tutorialResourceCache.err = err
9327, 9330, 9331, 9332, 9335, 9336, 9337, 9338, 9339, 9340, 9341, 9342, 9343, 9344, 9345, 9601, 9602, 9603, 9604, 9605, return
9701, 9702, 9801, 9802, 9803, 9804, 9811, 9812, 9813, 9814, 9815, 9816, 9817, 9818, 9819, 9820, 9821, 9822, 9823, 9824, }
9825, 9826, 9827, 9829, 9830, 9831, 9832, 9833, 9834, 9835, 9836, 9837, 9838, 9839, 9840, 9841, 9842, 9843, 9844, 9845, collect := func(ids []uint32) []uint32 {
9846, 9847, 9848, 9849, 9850, 9851, 9861, 9862, 9863, 9864, 9871, 9872, 9901, 9902, 9903, 9904, 9905, 9911, 9913, 9915, sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
9920, 9921, 9922, 9923, 9925, 9926, 9931, 9932, 9933, 9935, 9936, 9937, 9938, 9941, 9942, 9943, 9944, 9945, 9946, 9947, out := ids[:0]
9951, 9952, 9953, 9954, 9955, 9956, 9957, 9958, 9959, 9960, 9961, 9962, 9963, 9964, 9965, 9967, 9968, 9969, 9970, 9971, for _, id := range ids {
9973, 9974, 9975, 9976, 9978, 9979, 9980, 9981, 9982, 9983, 9984, 9985, 9986, 9987, 9988, 9989, 9990, 9991, 9992, 9993, if id != 0 && (len(out) == 0 || id != out[len(out)-1]) {
9994, 9995, 10000, 10001, 10002, 10003, 10005, 10010, 10011, 10012, 10013, 10014, 10015, 10016, 10017, 10020, 10021, 10022, 10030, 10031, out = append(out, id)
10032, 10033, 10034, 10040, 10041, 10042, 10043, 10044, 10045, 10050, 10051, 10052, 10053, 10054, 10055, 10060, 10061, 10062, 10070, 10071, }
10072, 10073, 10080, 10081, 10082, 10083, 10084, 10085, 10086, 10087, 10088, 10090, 10091, 10092, 10093, 10096, 10100, 10101, 10102, 10103, }
10111, 10112, 10113, 10201, 10202, 10203, 10204, 10310, 10311, 10312, 10313, 10314, 10315, 10401, 10502, 16505, 16506, 16507, 16508, 16509, return out
31001, 31102, 31103, 31105, 31106, 31109, 31204, 31206, 45001, 100001, 100002, 100003, 100004, 100005, 100006, 100007, 100008, 100009, 100010, 100011, }
100012, 100013, 100014, 100015, 100016, 100017, 100021, 100022, 100023, 100024, 100025, 100026, 100027, 100028, 100029, 100030, 100031, 100032, 100033, 100034, for _, entry := range tutorialFile.Entries {
100035, 100036, 100037, 100038, 100039, 100040, 100041, 100042, 100043, 100044, 100045, 100046, 100047, 100048, 100049, 100050, 100051, 100052, 100053, 100054, tutorialResourceCache.tutorials = append(tutorialResourceCache.tutorials, entry.ID)
100055, 100056, 100057, 100058, 100059, 100060, 100061, 100062, 100063, 100064, 100065, 100066, 100067, 100068, 100069, }
for _, entry := range guideFile.Entries {
tutorialResourceCache.guides = append(tutorialResourceCache.guides, entry.ID)
}
tutorialResourceCache.tutorials = collect(tutorialResourceCache.tutorials)
tutorialResourceCache.guides = collect(tutorialResourceCache.guides)
if len(tutorialResourceCache.tutorials) == 0 || len(tutorialResourceCache.guides) == 0 {
tutorialResourceCache.err = fmt.Errorf("tutorial resource tables contain no IDs")
}
})
return tutorialResourceCache.tutorials, tutorialResourceCache.guides, tutorialResourceCache.err
} }
+6 -9
View File
@@ -4,9 +4,7 @@ import (
goproto "gac-hkrpg-go/protocol/proto" goproto "gac-hkrpg-go/protocol/proto"
) )
// Handlers for cmds the real 4.6.51 client sends during normal play that were // UI requests bound to the command names in the loaded protocol.
// dropped in the first test session (2026-10-07 01:00) — several of them gate
// UI state (pause menu, Endgame entry, map rotation) on the server's reply.
func init() { func init() {
// client pauses itself when opening menus/map — must be acked // client pauses itself when opening menus/map — must be acked
@@ -16,12 +14,11 @@ func init() {
return nil return nil
}) })
// the Endgame entrance waits for this before opening its UI // The Endgame entrance waits for this response before opening its UI.
// (4.6.51: CsReq 4190 -> ScRsp 4112, retcode = tag 14 — the Rust build handle("GetUnreleasedBlockInfo", func(s *Session, req *goproto.GetUnreleasedBlockInfoCsReq, rsp *goproto.GetUnreleasedBlockInfoScRsp) error {
// answered the wrong id 4130 and the client stalled) rsp.Retcode = 0
rawHandlers[4190] = func(s *Session, payload []byte) error { return nil
return s.SendRaw(&NetPacket{CmdType: 4112, Body: []byte{0x70, 0x00}}) })
}
handle("TakeQuestReward", func(s *Session, req *goproto.TakeQuestRewardCsReq, rsp *goproto.TakeQuestRewardScRsp) error { handle("TakeQuestReward", func(s *Session, req *goproto.TakeQuestRewardCsReq, rsp *goproto.TakeQuestRewardScRsp) error {
rsp.Retcode = 0 rsp.Retcode = 0
+8 -2
View File
@@ -37,6 +37,8 @@ type Session struct {
startTime time.Time startTime time.Time
nextSceneSave int64 nextSceneSave int64
battleProbeMu sync.Mutex
battleProbe []byte
PropEntities map[uint32]PropEntry PropEntities map[uint32]PropEntry
// MonsterEntities tracks real overworld monster entities (entity_id -> event_id) // MonsterEntities tracks real overworld monster entities (entity_id -> event_id)
// spawned in the current scene. Used by SceneCastSkill to decide whether an // spawned in the current scene. Used by SceneCastSkill to decide whether an
@@ -127,7 +129,7 @@ func (s *Session) Consume(data []byte) error {
s.kcpMu.Unlock() s.kcpMu.Unlock()
for _, pkt := range packets { for _, pkt := range packets {
if pkt.CmdType == 31 { // CmdPlayerLogoutCsReq if pkt.CmdType == goproto.CmdIDByName["PlayerLogoutCsReq"] {
Log("[PACKET] Player logged out") Log("[PACKET] Player logged out")
s.Close() s.Close()
return nil return nil
@@ -156,7 +158,11 @@ func (s *Session) Send(msg proto.Message) error {
PacketLog(fmt.Sprintf("[PACKET SEND] cmd_id: %d %s (len: %d)", cmdID, name, len(body))) PacketLog(fmt.Sprintf("[PACKET SEND] cmd_id: %d %s (len: %d)", cmdID, name, len(body)))
Log(fmt.Sprintf("sent packet with CmdID: %d (%s)", cmdID, name)) Log(fmt.Sprintf("sent packet with CmdID: %d (%s)", cmdID, name))
return s.SendRaw(&NetPacket{CmdType: cmdID, Body: body}) if err := s.SendRaw(&NetPacket{CmdType: cmdID, Body: body}); err != nil {
return err
}
s.captureBattleResponse(msg, cmdID, body)
return nil
} }
// SendRaw writes an already-framed packet into the KCP stream. // SendRaw writes an already-framed packet into the KCP stream.
+6 -1
View File
@@ -50,9 +50,14 @@ func apiRoot(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
cfg, err := common.LoadRuntimeConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{ writeJSON(w, map[string]any{
"name": "GaC-hkrpg-go API Gateway", "name": "GaC-hkrpg-go API Gateway",
"version": "4.6.53", "version": cfg.ClientVersion,
"status": 200, "status": 200,
"description": "Backend connection endpoints for freesr-data, persistent, and GaC-Srtool", "description": "Backend connection endpoints for freesr-data, persistent, and GaC-Srtool",
"endpoints": map[string]string{ "endpoints": map[string]string{
+43
View File
@@ -0,0 +1,43 @@
package sdkserver
import (
"encoding/base64"
"net/http/httptest"
"testing"
"gac-hkrpg-go/internal/common"
goproto "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
func TestConfiguredDispatchAndGateway(t *testing.T) {
// Fixture endpoints must appear on the wire instead of any local defaults.
cfg := common.RuntimeConfig{PublicSDKURL: "https://sdk.example.invalid/prefix", PublicGameHost: "game.example.invalid", GamePort: 32123}
w := httptest.NewRecorder()
queryDispatch(w, httptest.NewRequest("GET", "/query_dispatch", nil), cfg)
raw, err := base64.StdEncoding.DecodeString(w.Body.String())
if err != nil {
t.Fatal(err)
}
var dispatch goproto.Dispatch
if err := proto.Unmarshal(raw, &dispatch); err != nil {
t.Fatal(err)
}
if len(dispatch.RegionList) != 1 || dispatch.RegionList[0].DispatchUrl != cfg.PublicSDKURL+"/query_gateway" {
t.Fatalf("wrong dispatch: %v", &dispatch)
}
state := &appState{runtime: cfg, hotfixMap: map[string]VersionConfig{"fixture": {}}}
w = httptest.NewRecorder()
queryGateway(state)(w, httptest.NewRequest("GET", "/query_gateway?version=fixture", nil))
raw, err = base64.StdEncoding.DecodeString(w.Body.String())
if err != nil {
t.Fatal(err)
}
var gateway goproto.Gateserver
if err := proto.Unmarshal(raw, &gateway); err != nil {
t.Fatal(err)
}
if gateway.Ip != cfg.PublicGameHost || gateway.Port != uint32(cfg.GamePort) {
t.Fatalf("wrong gateway: %v", &gateway)
}
}
+16 -12
View File
@@ -1,5 +1,5 @@
// Package sdkserver is the Go port of the Rust sdkserver: HTTP dispatch + // Package sdkserver is the Go port of the Rust sdkserver: HTTP dispatch +
// auth + SRTools save upload, listening on :21000. // auth + SRTools save upload, using the shared runtime configuration.
package sdkserver package sdkserver
import ( import (
@@ -11,13 +11,12 @@ import (
"os" "os"
"sync" "sync"
"gac-hkrpg-go/internal/common"
google "google.golang.org/protobuf/proto" google "google.golang.org/protobuf/proto"
goproto "gac-hkrpg-go/protocol/proto" goproto "gac-hkrpg-go/protocol/proto"
) )
const Port = 21000
// VersionConfig is one entry of versions.json. // VersionConfig is one entry of versions.json.
type VersionConfig struct { type VersionConfig struct {
AssetBundleURL string `json:"asset_bundle_url"` AssetBundleURL string `json:"asset_bundle_url"`
@@ -27,6 +26,7 @@ type VersionConfig struct {
} }
type appState struct { type appState struct {
runtime common.RuntimeConfig
mu sync.Mutex mu sync.Mutex
hotfixMap map[string]VersionConfig hotfixMap map[string]VersionConfig
} }
@@ -129,7 +129,7 @@ func fetchHotfix(version, dispatchSeed string) (VersionConfig, error) {
}, nil }, nil
} }
// parseVersionString extracts region/branch, e.g. "OSBETAWin4.6.51" -> OS,BETA. // parseVersionString extracts region/branch, e.g. a version with the OSBETAWin prefix -> OS,BETA.
func parseVersionString(s string) (string, string) { func parseVersionString(s string) (string, string) {
branches := []string{"PREbeta", "BETA", "PROD", "DEV", "PRE", "GM", "CECREATION"} branches := []string{"PREbeta", "BETA", "PROD", "DEV", "PRE", "GM", "CECREATION"}
if len(s) < 2 { if len(s) < 2 {
@@ -154,14 +154,14 @@ func writeJSON(w http.ResponseWriter, v any) {
_ = json.NewEncoder(w).Encode(v) _ = json.NewEncoder(w).Encode(v)
} }
func queryDispatch(w http.ResponseWriter, _ *http.Request) { func queryDispatch(w http.ResponseWriter, _ *http.Request, runtime common.RuntimeConfig) {
rsp := &goproto.Dispatch{ rsp := &goproto.Dispatch{
Retcode: 0, Retcode: 0,
RegionList: []*goproto.RegionInfo{{ RegionList: []*goproto.RegionInfo{{
Name: "GaC hkrpg GO", Name: "GaC hkrpg GO",
Title: "GaC hkrpg GO", Title: "GaC hkrpg GO",
EnvType: "9", EnvType: "9",
DispatchUrl: fmt.Sprintf("http://127.0.0.1:%d/query_gateway", Port), DispatchUrl: runtime.PublicSDKURL + "/query_gateway",
}}, }},
} }
bin, err := google.Marshal(rsp) bin, err := google.Marshal(rsp)
@@ -186,8 +186,8 @@ func queryGateway(state *appState) http.HandlerFunc {
cfg := state.getOrInsertHotfix(version, seed) cfg := state.getOrInsertHotfix(version, seed)
rsp := &goproto.Gateserver{ rsp := &goproto.Gateserver{
Ip: "127.0.0.1", Ip: state.runtime.PublicGameHost,
Port: 23301, Port: uint32(state.runtime.GamePort),
AssetBundleUrl: cfg.AssetBundleURL, AssetBundleUrl: cfg.AssetBundleURL,
AssetBundleUrlB: cfg.AssetBundleURL, AssetBundleUrlB: cfg.AssetBundleURL,
ExResourceUrl: cfg.ExResourceURL, ExResourceUrl: cfg.ExResourceURL,
@@ -344,11 +344,15 @@ func srToolsSave(w http.ResponseWriter, r *http.Request) {
// Listen starts the HTTP sdkserver. // Listen starts the HTTP sdkserver.
func Listen() error { func Listen() error {
state := &appState{hotfixMap: loadHotfix()} runtime, err := common.LoadRuntimeConfig()
if err != nil {
return err
}
state := &appState{hotfixMap: loadHotfix(), runtime: runtime}
Log(fmt.Sprintf("loaded %d hotfix versions", len(state.hotfixMap))) Log(fmt.Sprintf("loaded %d hotfix versions", len(state.hotfixMap)))
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/query_dispatch", queryDispatch) mux.HandleFunc("/query_dispatch", func(w http.ResponseWriter, r *http.Request) { queryDispatch(w, r, runtime) })
mux.HandleFunc("/query_gateway", queryGateway(state)) mux.HandleFunc("/query_gateway", queryGateway(state))
mux.HandleFunc("/{product_name}/mdk/shield/api/login", func(w http.ResponseWriter, r *http.Request) { loginReply(w) }) mux.HandleFunc("/{product_name}/mdk/shield/api/login", func(w http.ResponseWriter, r *http.Request) { loginReply(w) })
mux.HandleFunc("/{product_name}/mdk/shield/api/verify", func(w http.ResponseWriter, r *http.Request) { loginReply(w) }) mux.HandleFunc("/{product_name}/mdk/shield/api/verify", func(w http.ResponseWriter, r *http.Request) { loginReply(w) })
@@ -366,9 +370,9 @@ func Listen() error {
notFound(w, r) notFound(w, r)
}) })
addr := fmt.Sprintf("0.0.0.0:%d", Port) addr := runtime.SDKAddress()
Log("sdkserver is listening at " + addr) Log("sdkserver is listening at " + addr)
Log(fmt.Sprintf("REST API Gateway is live at http://localhost:%d/ (for GaC-Srtool)", Port)) Log(fmt.Sprintf("REST API Gateway is live at %s/ (for GaC-Srtool)", runtime.PublicSDKURL))
return http.ListenAndServe(addr, mux) return http.ListenAndServe(addr, mux)
} }
+66 -10
View File
@@ -93775,9 +93775,12 @@ type GetChallengeGroupStatisticsScRsp struct {
GroupId uint32 `protobuf:"varint,5,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` GroupId uint32 `protobuf:"varint,5,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"`
Retcode uint32 `protobuf:"varint,9,opt,name=retcode,proto3" json:"retcode,omitempty"` Retcode uint32 `protobuf:"varint,9,opt,name=retcode,proto3" json:"retcode,omitempty"`
MEGBIPBAFBP *BBBIAPEHABA `protobuf:"bytes,1461,opt,name=MEGBIPBAFBP,proto3" json:"MEGBIPBAFBP,omitempty"` MEGBIPBAFBP *BBBIAPEHABA `protobuf:"bytes,1461,opt,name=MEGBIPBAFBP,proto3" json:"MEGBIPBAFBP,omitempty"`
ChallengeBoss *ChallengeBossStatistics `protobuf:"bytes,1,opt,name=challenge_boss,json=challengeBoss,proto3" json:"challenge_boss,omitempty"` // Types that are valid to be assigned to EDKOHAAMONH:
ChallengeStory *ChallengeStoryStatistics `protobuf:"bytes,3,opt,name=challenge_story,json=challengeStory,proto3" json:"challenge_story,omitempty"` //
ChallengeDefault *ChallengeStatistics `protobuf:"bytes,11,opt,name=challenge_default,json=challengeDefault,proto3" json:"challenge_default,omitempty"` // *GetChallengeGroupStatisticsScRsp_ChallengeBoss
// *GetChallengeGroupStatisticsScRsp_ChallengeStory
// *GetChallengeGroupStatisticsScRsp_ChallengeDefault
EDKOHAAMONH isGetChallengeGroupStatisticsScRsp_EDKOHAAMONH `protobuf_oneof:"EDKOHAAMONH"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -93833,27 +93836,65 @@ func (x *GetChallengeGroupStatisticsScRsp) GetMEGBIPBAFBP() *BBBIAPEHABA {
return nil return nil
} }
func (x *GetChallengeGroupStatisticsScRsp) GetEDKOHAAMONH() isGetChallengeGroupStatisticsScRsp_EDKOHAAMONH {
if x != nil {
return x.EDKOHAAMONH
}
return nil
}
func (x *GetChallengeGroupStatisticsScRsp) GetChallengeBoss() *ChallengeBossStatistics { func (x *GetChallengeGroupStatisticsScRsp) GetChallengeBoss() *ChallengeBossStatistics {
if x != nil { if x != nil {
if x, ok := x.EDKOHAAMONH.(*GetChallengeGroupStatisticsScRsp_ChallengeBoss); ok {
return x.ChallengeBoss return x.ChallengeBoss
} }
}
return nil return nil
} }
func (x *GetChallengeGroupStatisticsScRsp) GetChallengeStory() *ChallengeStoryStatistics { func (x *GetChallengeGroupStatisticsScRsp) GetChallengeStory() *ChallengeStoryStatistics {
if x != nil { if x != nil {
if x, ok := x.EDKOHAAMONH.(*GetChallengeGroupStatisticsScRsp_ChallengeStory); ok {
return x.ChallengeStory return x.ChallengeStory
} }
}
return nil return nil
} }
func (x *GetChallengeGroupStatisticsScRsp) GetChallengeDefault() *ChallengeStatistics { func (x *GetChallengeGroupStatisticsScRsp) GetChallengeDefault() *ChallengeStatistics {
if x != nil { if x != nil {
if x, ok := x.EDKOHAAMONH.(*GetChallengeGroupStatisticsScRsp_ChallengeDefault); ok {
return x.ChallengeDefault return x.ChallengeDefault
} }
}
return nil return nil
} }
type isGetChallengeGroupStatisticsScRsp_EDKOHAAMONH interface {
isGetChallengeGroupStatisticsScRsp_EDKOHAAMONH()
}
type GetChallengeGroupStatisticsScRsp_ChallengeBoss struct {
ChallengeBoss *ChallengeBossStatistics `protobuf:"bytes,1,opt,name=challenge_boss,json=challengeBoss,proto3,oneof"`
}
type GetChallengeGroupStatisticsScRsp_ChallengeStory struct {
ChallengeStory *ChallengeStoryStatistics `protobuf:"bytes,3,opt,name=challenge_story,json=challengeStory,proto3,oneof"`
}
type GetChallengeGroupStatisticsScRsp_ChallengeDefault struct {
ChallengeDefault *ChallengeStatistics `protobuf:"bytes,11,opt,name=challenge_default,json=challengeDefault,proto3,oneof"`
}
func (*GetChallengeGroupStatisticsScRsp_ChallengeBoss) isGetChallengeGroupStatisticsScRsp_EDKOHAAMONH() {
}
func (*GetChallengeGroupStatisticsScRsp_ChallengeStory) isGetChallengeGroupStatisticsScRsp_EDKOHAAMONH() {
}
func (*GetChallengeGroupStatisticsScRsp_ChallengeDefault) isGetChallengeGroupStatisticsScRsp_EDKOHAAMONH() {
}
type ChallengeBossSingleNodeInfo struct { type ChallengeBossSingleNodeInfo struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
MaxScore uint32 `protobuf:"varint,4,opt,name=max_score,json=maxScore,proto3" json:"max_score,omitempty"` MaxScore uint32 `protobuf:"varint,4,opt,name=max_score,json=maxScore,proto3" json:"max_score,omitempty"`
@@ -208294,6 +208335,7 @@ func (x *ReplaceLineupCsReq) GetLineupSlotList() []*LineupSlotData {
type ReplaceLineupScRsp struct { type ReplaceLineupScRsp struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Retcode uint32 `protobuf:"varint,8,opt,name=retcode,proto3" json:"retcode,omitempty"` Retcode uint32 `protobuf:"varint,8,opt,name=retcode,proto3" json:"retcode,omitempty"`
OMDLCGEMBLA uint32 `protobuf:"varint,14,opt,name=OMDLCGEMBLA,proto3" json:"OMDLCGEMBLA,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -208335,6 +208377,13 @@ func (x *ReplaceLineupScRsp) GetRetcode() uint32 {
return 0 return 0
} }
func (x *ReplaceLineupScRsp) GetOMDLCGEMBLA() uint32 {
if x != nil {
return x.OMDLCGEMBLA
}
return 0
}
// CmdID: 723 // CmdID: 723
type ExtraLineupDestroyNotify struct { type ExtraLineupDestroyNotify struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@@ -345159,14 +345208,15 @@ const file_protocol_proto_StarRail_proto_rawDesc = "" +
"\vEEJCPNAEKLJ\x18\f \x01(\rR\vEEJCPNAEKLJ\x12\x19\n" + "\vEEJCPNAEKLJ\x18\f \x01(\rR\vEEJCPNAEKLJ\x12\x19\n" +
"\bbuff_two\x18\x0f \x01(\rR\abuffTwo\"=\n" + "\bbuff_two\x18\x0f \x01(\rR\abuffTwo\"=\n" +
" GetChallengeGroupStatisticsCsReq\x12\x19\n" + " GetChallengeGroupStatisticsCsReq\x12\x19\n" +
"\bgroup_id\x18\x05 \x01(\rR\agroupId\"\xd0\x02\n" + "\bgroup_id\x18\x05 \x01(\rR\agroupId\"\xe5\x02\n" +
" GetChallengeGroupStatisticsScRsp\x12\x19\n" + " GetChallengeGroupStatisticsScRsp\x12\x19\n" +
"\bgroup_id\x18\x05 \x01(\rR\agroupId\x12\x18\n" + "\bgroup_id\x18\x05 \x01(\rR\agroupId\x12\x18\n" +
"\aretcode\x18\t \x01(\rR\aretcode\x12/\n" + "\aretcode\x18\t \x01(\rR\aretcode\x12/\n" +
"\vMEGBIPBAFBP\x18\xb5\v \x01(\v2\f.BBBIAPEHABAR\vMEGBIPBAFBP\x12?\n" + "\vMEGBIPBAFBP\x18\xb5\v \x01(\v2\f.BBBIAPEHABAR\vMEGBIPBAFBP\x12A\n" +
"\x0echallenge_boss\x18\x01 \x01(\v2\x18.ChallengeBossStatisticsR\rchallengeBoss\x12B\n" + "\x0echallenge_boss\x18\x01 \x01(\v2\x18.ChallengeBossStatisticsH\x00R\rchallengeBoss\x12D\n" +
"\x0fchallenge_story\x18\x03 \x01(\v2\x19.ChallengeStoryStatisticsR\x0echallengeStory\x12A\n" + "\x0fchallenge_story\x18\x03 \x01(\v2\x19.ChallengeStoryStatisticsH\x00R\x0echallengeStory\x12C\n" +
"\x11challenge_default\x18\v \x01(\v2\x14.ChallengeStatisticsR\x10challengeDefault\"\x8c\x01\n" + "\x11challenge_default\x18\v \x01(\v2\x14.ChallengeStatisticsH\x00R\x10challengeDefaultB\r\n" +
"\vEDKOHAAMONH\"\x8c\x01\n" +
"\x1bChallengeBossSingleNodeInfo\x12\x1b\n" + "\x1bChallengeBossSingleNodeInfo\x12\x1b\n" +
"\tmax_score\x18\x04 \x01(\rR\bmaxScore\x12 \n" + "\tmax_score\x18\x04 \x01(\rR\bmaxScore\x12 \n" +
"\vMBHCJPONJOM\x18\x05 \x01(\bR\vMBHCJPONJOM\x12\x15\n" + "\vMBHCJPONJOM\x18\x05 \x01(\bR\vMBHCJPONJOM\x12\x15\n" +
@@ -353098,9 +353148,10 @@ const file_protocol_proto_StarRail_proto_rawDesc = "" +
"\vleader_slot\x18\r \x01(\rR\n" + "\vleader_slot\x18\r \x01(\rR\n" +
"leaderSlot\x12 \n" + "leaderSlot\x12 \n" +
"\vPLIEODDEEHG\x18\x0e \x01(\bR\vPLIEODDEEHG\x129\n" + "\vPLIEODDEEHG\x18\x0e \x01(\bR\vPLIEODDEEHG\x129\n" +
"\x10lineup_slot_list\x18\x0f \x03(\v2\x0f.LineupSlotDataR\x0elineupSlotList\".\n" + "\x10lineup_slot_list\x18\x0f \x03(\v2\x0f.LineupSlotDataR\x0elineupSlotList\"P\n" +
"\x12ReplaceLineupScRsp\x12\x18\n" + "\x12ReplaceLineupScRsp\x12\x18\n" +
"\aretcode\x18\b \x01(\rR\aretcode\"X\n" + "\aretcode\x18\b \x01(\rR\aretcode\x12 \n" +
"\vOMDLCGEMBLA\x18\x0e \x01(\rR\vOMDLCGEMBLA\"X\n" +
"\x18ExtraLineupDestroyNotify\x12<\n" + "\x18ExtraLineupDestroyNotify\x12<\n" +
"\x11extra_lineup_type\x18\a \x01(\x0e2\x10.ExtraLineupTypeR\x0fextraLineupType\"\xa9\x01\n" + "\x11extra_lineup_type\x18\a \x01(\x0e2\x10.ExtraLineupTypeR\x0fextraLineupType\"\xa9\x01\n" +
"&VirtualLineupTrialAvatarChangeScNotify\x12 \n" + "&VirtualLineupTrialAvatarChangeScNotify\x12 \n" +
@@ -379094,6 +379145,11 @@ func file_protocol_proto_StarRail_proto_init() {
(*CakeRaceBetCoinPVECsReq_BOEJNBKDJLM)(nil), (*CakeRaceBetCoinPVECsReq_BOEJNBKDJLM)(nil),
(*CakeRaceBetCoinPVECsReq_BCFPLAPCFPL)(nil), (*CakeRaceBetCoinPVECsReq_BCFPLAPCFPL)(nil),
} }
file_protocol_proto_StarRail_proto_msgTypes[849].OneofWrappers = []any{
(*GetChallengeGroupStatisticsScRsp_ChallengeBoss)(nil),
(*GetChallengeGroupStatisticsScRsp_ChallengeStory)(nil),
(*GetChallengeGroupStatisticsScRsp_ChallengeDefault)(nil),
}
file_protocol_proto_StarRail_proto_msgTypes[908].OneofWrappers = []any{ file_protocol_proto_StarRail_proto_msgTypes[908].OneofWrappers = []any{
(*DOPHNDHILPA_IIHFEDLHOPI)(nil), (*DOPHNDHILPA_IIHFEDLHOPI)(nil),
(*DOPHNDHILPA_DFNNKENBOEN)(nil), (*DOPHNDHILPA_DFNNKENBOEN)(nil),
+3
View File
@@ -13437,9 +13437,11 @@ message GetChallengeGroupStatisticsScRsp {
uint32 group_id = 5; uint32 group_id = 5;
uint32 retcode = 9; uint32 retcode = 9;
BBBIAPEHABA MEGBIPBAFBP = 1461; BBBIAPEHABA MEGBIPBAFBP = 1461;
oneof EDKOHAAMONH {
ChallengeBossStatistics challenge_boss = 1; ChallengeBossStatistics challenge_boss = 1;
ChallengeStoryStatistics challenge_story = 3; ChallengeStoryStatistics challenge_story = 3;
ChallengeStatistics challenge_default = 11; ChallengeStatistics challenge_default = 11;
}
} }
message ChallengeBossSingleNodeInfo { message ChallengeBossSingleNodeInfo {
@@ -25147,6 +25149,7 @@ message ReplaceLineupCsReq {
// CmdID: 715 // CmdID: 715
message ReplaceLineupScRsp { message ReplaceLineupScRsp {
uint32 retcode = 8; uint32 retcode = 8;
uint32 OMDLCGEMBLA = 14;
} }
// CmdID: 723 // CmdID: 723
+184
View File
@@ -0,0 +1,184 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.12
// protoc v7.36.0
// source: protocol/proto/current_handlers.proto
package proto
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// PearlSR 005d2c12db4781071f4b5b68e8f364587886a710:
// protocol/src/protocol.pb.zig, SetChallengeTierceLineup messages.
// These named messages are missing from the original Go schema.
type SetChallengeTierceLineupCsReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
ChallengeId uint32 `protobuf:"varint,4,opt,name=challenge_id,json=challengeId,proto3" json:"challenge_id,omitempty"`
StageInfoList []*LineupTierce `protobuf:"bytes,5,rep,name=stage_info_list,json=stageInfoList,proto3" json:"stage_info_list,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SetChallengeTierceLineupCsReq) Reset() {
*x = SetChallengeTierceLineupCsReq{}
mi := &file_protocol_proto_current_handlers_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetChallengeTierceLineupCsReq) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SetChallengeTierceLineupCsReq) ProtoMessage() {}
func (x *SetChallengeTierceLineupCsReq) ProtoReflect() protoreflect.Message {
mi := &file_protocol_proto_current_handlers_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SetChallengeTierceLineupCsReq.ProtoReflect.Descriptor instead.
func (*SetChallengeTierceLineupCsReq) Descriptor() ([]byte, []int) {
return file_protocol_proto_current_handlers_proto_rawDescGZIP(), []int{0}
}
func (x *SetChallengeTierceLineupCsReq) GetChallengeId() uint32 {
if x != nil {
return x.ChallengeId
}
return 0
}
func (x *SetChallengeTierceLineupCsReq) GetStageInfoList() []*LineupTierce {
if x != nil {
return x.StageInfoList
}
return nil
}
type SetChallengeTierceLineupScRsp struct {
state protoimpl.MessageState `protogen:"open.v1"`
Retcode uint32 `protobuf:"varint,1,opt,name=retcode,proto3" json:"retcode,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SetChallengeTierceLineupScRsp) Reset() {
*x = SetChallengeTierceLineupScRsp{}
mi := &file_protocol_proto_current_handlers_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetChallengeTierceLineupScRsp) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SetChallengeTierceLineupScRsp) ProtoMessage() {}
func (x *SetChallengeTierceLineupScRsp) ProtoReflect() protoreflect.Message {
mi := &file_protocol_proto_current_handlers_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SetChallengeTierceLineupScRsp.ProtoReflect.Descriptor instead.
func (*SetChallengeTierceLineupScRsp) Descriptor() ([]byte, []int) {
return file_protocol_proto_current_handlers_proto_rawDescGZIP(), []int{1}
}
func (x *SetChallengeTierceLineupScRsp) GetRetcode() uint32 {
if x != nil {
return x.Retcode
}
return 0
}
var File_protocol_proto_current_handlers_proto protoreflect.FileDescriptor
const file_protocol_proto_current_handlers_proto_rawDesc = "" +
"\n" +
"%protocol/proto/current_handlers.proto\x1a\x1dprotocol/proto/StarRail.proto\"y\n" +
"\x1dSetChallengeTierceLineupCsReq\x12!\n" +
"\fchallenge_id\x18\x04 \x01(\rR\vchallengeId\x125\n" +
"\x0fstage_info_list\x18\x05 \x03(\v2\r.LineupTierceR\rstageInfoList\"9\n" +
"\x1dSetChallengeTierceLineupScRsp\x12\x18\n" +
"\aretcode\x18\x01 \x01(\rR\aretcodeB\x1dZ\x1bgac-hkrpg-go/protocol/protob\x06proto3"
var (
file_protocol_proto_current_handlers_proto_rawDescOnce sync.Once
file_protocol_proto_current_handlers_proto_rawDescData []byte
)
func file_protocol_proto_current_handlers_proto_rawDescGZIP() []byte {
file_protocol_proto_current_handlers_proto_rawDescOnce.Do(func() {
file_protocol_proto_current_handlers_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_protocol_proto_current_handlers_proto_rawDesc), len(file_protocol_proto_current_handlers_proto_rawDesc)))
})
return file_protocol_proto_current_handlers_proto_rawDescData
}
var file_protocol_proto_current_handlers_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_protocol_proto_current_handlers_proto_goTypes = []any{
(*SetChallengeTierceLineupCsReq)(nil), // 0: SetChallengeTierceLineupCsReq
(*SetChallengeTierceLineupScRsp)(nil), // 1: SetChallengeTierceLineupScRsp
(*LineupTierce)(nil), // 2: LineupTierce
}
var file_protocol_proto_current_handlers_proto_depIdxs = []int32{
2, // 0: SetChallengeTierceLineupCsReq.stage_info_list:type_name -> LineupTierce
1, // [1:1] is the sub-list for method output_type
1, // [1:1] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_protocol_proto_current_handlers_proto_init() }
func file_protocol_proto_current_handlers_proto_init() {
if File_protocol_proto_current_handlers_proto != nil {
return
}
file_protocol_proto_StarRail_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_protocol_proto_current_handlers_proto_rawDesc), len(file_protocol_proto_current_handlers_proto_rawDesc)),
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_protocol_proto_current_handlers_proto_goTypes,
DependencyIndexes: file_protocol_proto_current_handlers_proto_depIdxs,
MessageInfos: file_protocol_proto_current_handlers_proto_msgTypes,
}.Build()
File_protocol_proto_current_handlers_proto = out.File
file_protocol_proto_current_handlers_proto_goTypes = nil
file_protocol_proto_current_handlers_proto_depIdxs = nil
}
+15
View File
@@ -0,0 +1,15 @@
syntax = "proto3";
option go_package = "gac-hkrpg-go/protocol/proto";
import "protocol/proto/StarRail.proto";
// PearlSR 005d2c12db4781071f4b5b68e8f364587886a710:
// protocol/src/protocol.pb.zig, SetChallengeTierceLineup messages.
// These named messages are missing from the original Go schema.
message SetChallengeTierceLineupCsReq {
uint32 challenge_id = 4;
repeated LineupTierce stage_info_list = 5;
}
message SetChallengeTierceLineupScRsp {
uint32 retcode = 1;
}
+7
View File
@@ -0,0 +1,7 @@
package proto
// ClientDownloadDataLuaVersion is the version value used for Lua downloads by
// PearlSR services/misc.zig at source revision
// 005d2c12db4781071f4b5b68e8f364587886a710 (client 4.6.53).
// Do not derive it from the configured client patch number.
const ClientDownloadDataLuaVersion uint32 = 51
File diff suppressed because it is too large. Load diff
+3 -163
View File
@@ -1,164 +1,4 @@
@echo off @echo off
title GaC hkrpg GO Server (4.6.53) setlocal
cd /d "%~dp0" powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0run.ps1" %*
exit /b %errorlevel%
echo ==============================================================================
echo [ GaC hkrpg GO ] StarRail 4.6.53 Server and Launcher
echo gameserver : UDP 23301 (KCP Gateway)
echo sdkserver : HTTP 21000 (Dispatch / Login / SRTools)
echo ==============================================================================
echo.
echo [1] Start Server and Star Rail Game / Proxy (Default)
echo [2] Start Both Servers (gameserver + sdkserver)
echo [3] Start SDK Server ONLY (HTTP 21000)
echo [4] Start Game Server ONLY (UDP 23301)
echo [5] Start Server and Gay Proxy
echo [6] Build / Rebuild binaries (Go)
echo [7] Exit
echo.
echo ==============================================================================
set "choice=%~1"
if not defined choice set /p choice="Select [1-7] (Press Enter for 1): "
if not defined choice set "choice=1"
set "choice=%choice: =%"
if not defined choice set "choice=1"
if "%choice%"=="1" goto opt_game
if "%choice%"=="2" goto opt_servers_only
if "%choice%"=="3" goto opt_sdk_only
if "%choice%"=="4" goto opt_gs_only
if "%choice%"=="5" goto opt_gay_proxy
if "%choice%"=="6" goto opt_build
if "%choice%"=="7" exit /b 0
if /i "%choice%"=="sdk" goto opt_sdk_only
if /i "%choice%"=="gs" goto opt_gs_only
if /i "%choice%"=="servers" goto opt_servers_only
if /i "%choice%"=="game" goto opt_game
if /i "%choice%"=="proxy" goto opt_gay_proxy
if /i "%choice%"=="build" goto opt_build
echo Unknown option "%choice%", starting servers only...
goto opt_servers_only
:start_core_servers
if not exist "gameserver-go.exe" (
echo [ERROR] gameserver-go.exe not found! Please build first - Option 6
pause
exit /b 1
)
if not exist "sdkserver-go.exe" (
echo [ERROR] sdkserver-go.exe not found! Please build first - Option 6
pause
exit /b 1
)
echo [*] Cleaning old server processes...
taskkill /f /im sdkserver-go.exe >nul 2>&1
taskkill /f /im gameserver-go.exe >nul 2>&1
echo [*] Starting SDK Server [Port 21000]...
start "GaC GO - SDK Server [Port 21000]" /d "%~dp0" cmd /k sdkserver-go.exe
echo [*] Starting Game Server [Port 23301]...
start "GaC GO - Game Server [Port 23301]" /d "%~dp0" cmd /k gameserver-go.exe
exit /b 0
:opt_sdk_only
if not exist "sdkserver-go.exe" (
echo [ERROR] sdkserver-go.exe not found! Please build first - Option 6
pause
exit /b 1
)
echo [*] Cleaning old sdkserver process...
taskkill /f /im sdkserver-go.exe >nul 2>&1
echo [*] Starting SDK Server [Port 21000]...
start "GaC GO - SDK Server [Port 21000]" /d "%~dp0" cmd /k sdkserver-go.exe
goto finish
:opt_gs_only
if not exist "gameserver-go.exe" (
echo [ERROR] gameserver-go.exe not found! Please build first - Option 6
pause
exit /b 1
)
echo [*] Cleaning old gameserver process...
taskkill /f /im gameserver-go.exe >nul 2>&1
echo [*] Starting Game Server [Port 23301]...
start "GaC GO - Game Server [Port 23301]" /d "%~dp0" cmd /k gameserver-go.exe
goto finish
:opt_game
call :start_core_servers
if errorlevel 1 goto :eof
echo.
echo [*] Launching Star Rail Client / Proxy...
set "GAME_DIR="
if exist "%USERPROFILE%\Desktop\StarRail_4.6.53_OS\StarRail.exe" set "GAME_DIR=%USERPROFILE%\Desktop\StarRail_4.6.53_OS"
if not defined GAME_DIR if exist "%~dp0..\StarRail_4.6.53_OS\StarRail.exe" set "GAME_DIR=%~dp0..\StarRail_4.6.53_OS"
if not defined GAME_DIR if exist "%USERPROFILE%\Desktop\StarRail_4.6.51_OS\StarRail.exe" set "GAME_DIR=%USERPROFILE%\Desktop\StarRail_4.6.51_OS"
if not defined GAME_DIR if exist "D:\HoYoPlay\games\Star Rail Games\StarRail.exe" set "GAME_DIR=D:\HoYoPlay\games\Star Rail Games"
if not defined GAME_DIR if exist "%~dp0..\StarRail_4.6.51_OS\StarRail.exe" set "GAME_DIR=%~dp0..\StarRail_4.6.51_OS"
if defined GAME_DIR (
echo [*] Found game client at: %GAME_DIR%
if exist "%GAME_DIR%\GaCy Launcher.exe" (
echo [*] Starting GaCy Launcher...
start "GaCy Launcher" /d "%GAME_DIR%" "%GAME_DIR%\GaCy Launcher.exe"
) else if exist "%GAME_DIR%\launcher.exe" (
echo [*] Starting Launcher...
start "Proxy Launcher" /d "%GAME_DIR%" "%GAME_DIR%\launcher.exe"
) else if exist "%GAME_DIR%\StarRail.exe" (
echo [*] Starting StarRail.exe...
start "Star Rail Client" /d "%GAME_DIR%" "%GAME_DIR%\StarRail.exe"
)
) else (
echo [!] Game client not found automatically. Please open StarRail manually.
)
goto finish
:opt_gay_proxy
call :start_core_servers
if errorlevel 1 goto :eof
echo.
echo [*] Launching Gay Proxy...
set "PROXY_BAT="
if exist "%USERPROFILE%\Desktop\Gay Proxy\run_proxy.bat" set "PROXY_BAT=%USERPROFILE%\Desktop\Gay Proxy\run_proxy.bat"
if not defined PROXY_BAT if exist "%~dp0..\Gay Proxy\run_proxy.bat" set "PROXY_BAT=%~dp0..\Gay Proxy\run_proxy.bat"
if defined PROXY_BAT (
start "Gay Proxy" cmd /k "%PROXY_BAT%"
) else (
echo [!] Gay Proxy not found.
)
goto finish
:opt_servers_only
call :start_core_servers
if errorlevel 1 goto :eof
goto finish
:opt_build
where go >nul 2>nul || set "PATH=%PATH%;C:\Program Files\Go\bin"
echo [*] Building gameserver-go.exe...
go build -o gameserver-go.exe ./cmd/gameserver || (pause & goto :eof)
echo [*] Building sdkserver-go.exe...
go build -o sdkserver-go.exe ./cmd/sdkserver || (pause & goto :eof)
echo [OK] Build successful!
pause
goto :eof
:finish
echo.
echo ==============================================================================
echo [OK] Started successfully!
echo Close server windows to stop them.
echo Closing this launcher window in 5 seconds...
echo ==============================================================================
timeout /t 5 >nul 2>&1 || ping 127.0.0.1 -n 6 >nul
exit
+138
View File
@@ -0,0 +1,138 @@
param([string]$Action)
$ErrorActionPreference = 'Stop'
$interactiveMenu = [string]::IsNullOrWhiteSpace($Action)
# User-requested delay after an interactive menu action finishes.
$menuCloseDelaySeconds = 5
$launcherExitCode = 0
Set-Location -LiteralPath $PSScriptRoot
function Merge-Settings($Base, $Override) {
foreach ($property in $Override.PSObject.Properties) {
$existing = $Base.PSObject.Properties[$property.Name]
if ($existing -and $property.Value -is [pscustomobject] -and $existing.Value -is [pscustomobject]) {
Merge-Settings $existing.Value $property.Value
} else {
$Base | Add-Member -MemberType NoteProperty -Name $property.Name -Value $property.Value -Force
}
}
}
function Resolve-ProjectPath([string]$Path) {
if ([IO.Path]::IsPathRooted($Path)) { return [IO.Path]::GetFullPath($Path) }
return [IO.Path]::GetFullPath((Join-Path $PSScriptRoot $Path))
}
function Stop-ProjectServer([string]$Name) {
$target = Resolve-ProjectPath ($Name + '.exe')
Get-Process -Name $Name -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.Path -and [string]::Equals($_.Path, $target, [StringComparison]::OrdinalIgnoreCase)) {
Stop-Process -Id $_.Id -ErrorAction Stop
if (-not $_.WaitForExit(5000)) { throw "Server did not stop: $target" }
}
}
}
function Start-ProjectServer([string]$Name) {
$path = Resolve-ProjectPath ($Name + '.exe')
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "$Name is not built. Run build first." }
Stop-ProjectServer $Name
$process = Start-Process -FilePath $path -WorkingDirectory $PSScriptRoot -WindowStyle Normal -PassThru
if ($process.WaitForExit(250)) { throw "$Name exited with code $($process.ExitCode). Run .\$Name.exe in a terminal to inspect the startup error." }
Write-Host "Started $Name (PID $($process.Id)) in its own visible console."
}
function Build-Servers {
$go = $env:GAC_GO_COMMAND
if ([string]::IsNullOrWhiteSpace($go)) { $go = $settings.launcher.go_command }
if ([string]::IsNullOrWhiteSpace($go)) { throw 'Set launcher.go_command or GAC_GO_COMMAND.' }
$command = Get-Command -Name $go -CommandType Application -ErrorAction Stop
foreach ($name in @('gameserver', 'sdkserver')) {
$next = Resolve-ProjectPath ($name + '-go.next.exe')
& $command.Source build -o $next ('./cmd/' + $name)
if ($LASTEXITCODE -ne 0) { throw "Build failed: $name. Existing binaries were not replaced." }
}
foreach ($name in @('gameserver-go', 'sdkserver-go')) {
$next = Resolve-ProjectPath ($name + '.next.exe')
$target = Resolve-ProjectPath ($name + '.exe')
Stop-ProjectServer $name
Move-Item -LiteralPath $next -Destination $target -Force
}
Write-Host 'Build successful. Binaries are in the project root.'
}
function Start-External([string]$Setting, [string]$EnvironmentName) {
$path = [Environment]::GetEnvironmentVariable($EnvironmentName)
if ([string]::IsNullOrWhiteSpace($path)) { $path = $settings.launcher.$Setting }
if ([string]::IsNullOrWhiteSpace($path)) {
Write-Host "Servers started. Set launcher.$Setting or $EnvironmentName to launch the external program."
return
}
$path = Resolve-ProjectPath $path
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Configured program does not exist: $path" }
$directory = Split-Path -Parent $path
if ([IO.Path]::GetExtension($path) -in @('.bat', '.cmd')) {
Start-Process -FilePath 'cmd.exe' -ArgumentList ('/d /c ""' + $path + '""') -WorkingDirectory $directory -WindowStyle Hidden
} else {
Start-Process -FilePath $path -WorkingDirectory $directory
}
}
try {
$configPath = $env:GAC_CONFIG
if ([string]::IsNullOrWhiteSpace($configPath)) { $configPath = 'server.json' }
$configPath = Resolve-ProjectPath $configPath
$env:GAC_CONFIG = $configPath
$settings = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
$localPath = [IO.Path]::ChangeExtension($configPath, 'local.json')
if (Test-Path -LiteralPath $localPath) {
Merge-Settings $settings (Get-Content -LiteralPath $localPath -Raw | ConvertFrom-Json)
}
$variables = @{
client_version='GAC_CLIENT_VERSION'; game_host='GAC_GAME_HOST'; game_port='GAC_GAME_PORT';
sdk_host='GAC_SDK_HOST'; sdk_port='GAC_SDK_PORT'; public_game_host='GAC_PUBLIC_GAME_HOST';
public_sdk_url='GAC_PUBLIC_SDK_URL'
}
foreach ($key in $variables.Keys) {
$value = [Environment]::GetEnvironmentVariable($variables[$key])
if ($null -ne $value) { $settings | Add-Member -MemberType NoteProperty -Name $key -Value $value -Force }
}
foreach ($key in @('game_port','sdk_port')) {
$port = 0
if (-not [int]::TryParse([string]$settings.$key, [ref]$port) -or $port -lt 1 -or $port -gt [uint16]::MaxValue) {
throw "$key must be a port between 1 and $([uint16]::MaxValue)."
}
}
foreach ($key in @('client_version','game_host','sdk_host','public_game_host','public_sdk_url')) {
if ([string]::IsNullOrWhiteSpace($settings.$key)) { throw "Missing setting: $key" }
}
foreach ($key in @('game_host','sdk_host','public_game_host')) {
if ([string]$settings.$key -match '[/\\\s]') { throw "Invalid host: $key" }
}
$uri = $null
if (-not [Uri]::TryCreate($settings.public_sdk_url, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -notin @('http','https') -or $uri.Query -or $uri.Fragment -or $uri.UserInfo) {
throw 'public_sdk_url must be an HTTP(S) base URL.'
}
if ($Action -eq 'check') { Write-Host 'Configuration valid.'; exit 0 }
if ([string]::IsNullOrWhiteSpace($Action)) {
Write-Host "GaC hkrpg GO | Client $($settings.client_version)"
Write-Host "Game UDP $($settings.game_host):$($settings.game_port) | SDK HTTP $($settings.sdk_host):$($settings.sdk_port)"
Write-Host '[1] Build, start servers and game (default)'
Write-Host '[2] Start servers | [3] SDK only | [4] Game server only'
Write-Host '[5] Start servers and proxy | [6] Build only | [7] Exit | [8] Stop servers'
$Action = Read-Host 'Select'
if ([string]::IsNullOrWhiteSpace($Action)) { $Action = '1' }
}
switch ($Action.Trim().ToLowerInvariant()) {
{ $_ -in @('1','game') } { Build-Servers; Start-ProjectServer 'sdkserver-go'; Start-ProjectServer 'gameserver-go'; Start-External 'client_path' 'GAC_CLIENT_PATH'; break }
{ $_ -in @('2','servers') } { Start-ProjectServer 'sdkserver-go'; Start-ProjectServer 'gameserver-go'; break }
{ $_ -in @('3','sdk') } { Start-ProjectServer 'sdkserver-go'; break }
{ $_ -in @('4','gs') } { Start-ProjectServer 'gameserver-go'; break }
{ $_ -in @('5','proxy') } { Start-ProjectServer 'sdkserver-go'; Start-ProjectServer 'gameserver-go'; Start-External 'proxy_path' 'GAC_PROXY_PATH'; break }
{ $_ -in @('6','build') } { Build-Servers; break }
'7' { break }
{ $_ -in @('8','stop') } { Stop-ProjectServer 'sdkserver-go'; Stop-ProjectServer 'gameserver-go'; break }
default { throw "Unknown action: $Action" }
}
} catch {
Write-Host "[ERROR] $($_.Exception.Message)" -ForegroundColor Red
$launcherExitCode = 1
}
if ($interactiveMenu) {
Write-Host "Launcher closes in $menuCloseDelaySeconds seconds. Server windows stay open."
Start-Sleep -Seconds $menuCloseDelaySeconds
}
exit $launcherExitCode
-3
View File
@@ -1,3 +0,0 @@
@echo off
cd /d "%~dp0"
call run.bat %*
+17
View File
@@ -0,0 +1,17 @@
{
"client_version": "4.6.53",
"game_host": "0.0.0.0",
"game_port": 23301,
"sdk_host": "0.0.0.0",
"sdk_port": 21000,
"public_game_host": "127.0.0.1",
"public_sdk_url": "http://127.0.0.1:21000",
"battle_diagnostics": {
"client_battle_type": ""
},
"launcher": {
"client_path": "",
"proxy_path": "",
"go_command": "go"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"launcher": {
"client_path": "",
"proxy_path": ""
}
}
+23
View File
@@ -31,3 +31,26 @@
- ใน `GetCurBattleInfo` ตัด fallback ปลอมออก หากไม่มี Token ใน `CURRENT_BATTLE` ให้ตอบ `RET_BATTLE_STAGE_NOT_MATCH` (2000) ป้องกัน NRE - ใน `GetCurBattleInfo` ตัด fallback ปลอมออก หากไม่มี Token ใน `CURRENT_BATTLE` ให้ตอบ `RET_BATTLE_STAGE_NOT_MATCH` (2000) ป้องกัน NRE
- ใน `StartCocoonStage` และ `StartQuickCocoonStage` ส่ง `battleInfo` ที่สมบูรณ์พร้อมเวฟที่ถูกต้อง และเพิ่ม Log แสดงค่า `CocoonId`, `Wave`, `WorldLevel` - ใน `StartCocoonStage` และ `StartQuickCocoonStage` ส่ง `battleInfo` ที่สมบูรณ์พร้อมเวฟที่ถูกต้อง และเพิ่ม Log แสดงค่า `CocoonId`, `Wave`, `WorldLevel`
4. คอมไพล์ `gameserver-go.exe` ใหม่และรีสตาร์ทกระบวนการ 4. คอมไพล์ `gameserver-go.exe` ใหม่และรีสตาร์ทกระบวนการ
## Codex battle investigation correction — main affix (2026-10-16)
- Client stack RVA 0xc4eb77c is the return address after the throw call at 0xc4eb777, reached when main-affix lookup returns null (branch at 0xc4eb2b3). It does NOT prove a null sub-affix array; that throw call would return to 0xc4eb781. Earlier interpretation is withdrawn.
- Captured Calyx/SceneCastSkill payload and inventory UID 483: avatar 1213, relic 63095, main_affix_id 11. Reference relic main group 55 contains IDs 1–10, with 10=ImaginaryAddedRatio. Local AvatarConfig confirms Imaginary for 1213.
- Repaired only this inventory field in freesr-data.json: 11 -> 10. No converter ID override. All 24 relic main affixes in the captured team validate against reference data. Reference lacks new relics 63295/63296, so whole inventory is not fully validated.
- Source: Mar-7th/StarRailRes commit dbe8cdfcfb0bf657b9fe1cf92d5537f118ccb487, index_new/en/relics.json and relic_main_affixes.json.
- Backup: ignored battle_diagnostics/main-affix-repair/. Build completed; root gameserver-go.exe/sdkserver-go.exe updated. In-game battle success still requires a new client run.
- First readback probe reported parser property not found. V2 includes non-public fields and CodedInputStream fallback, but local diagnostic probe is disabled for the repair verification. V2 is not yet verified in client.
## Handler/dispatch update against PearlSR 4.6.53 reference
- Synced 2,690 current command names/IDs from upstream revision 005d2c12db4781071f4b5b68e8f364587886a710; generated table preserves verified old Go type aliases and fails on conflicts.
- Source audit: all 865 existing request/response pairs and 4,435 shared message field-number sets match. This does not validate all field types/defaults or client behavior. Include oneof members (including enum-valued wrappers) when auditing; ignoring oneof produces false missing-field reports.
- Replaced numeric raw registrations/old alias ranges with current named bindings. Removed multi-response guesses. Added missing typed Tierce lineup request8992/response8979, fields4/5; fixed cumulative reward request1747/response1748, group10 -> group12/retcode9.
- Current schema statistics fields1/3/11 are a oneof; do not claim they are absent. Updated Go schema to oneof; statistics handler now echoes group without fabricated past scores, matching upstream.
- Added ReplaceLineupScRsp field14; regenerated protobuf. Peak start/leave bindings restored; empty current Peak response no longer fabricates PeakID1. Challenge leave uses saved overworld scene rather than fixed destination.
- Tutorial handlers load IDs from installed TutorialData/TutorialGuideGroup resources, removing the embedded old list. Removed unused generated activity list.
- ClientDownloadData version51 remains because current upstream still uses51 for Lua payloads; do not infer53 from client patch. Avatar command400 response pairing remains semantically obfuscated; current adjacent schema response is used instead of spraying5 candidates, still requires client verification.
- Added reproducible offline source sync/audit tools and docs/protocol-update.md; README/AGENTS updated. Final run.bat build completed, both root executables updated at22:16 local time. No in-game success claimed; no test suite run in this turn.
## Launcher visibility preference
- User explicitly requested visible SDK/game server consoles and launcher menu closure after5 seconds.
- run.ps1 now launches both root server executables with WindowStyle Normal and no stdout/stderr redirection; each server console remains independent when run.bat exits.
- Interactive menu waits5 seconds after the selected action finishes, preserving exit codes. Argument-based build/check/stop returns immediately. README/AGENTS updated. No binary rebuild needed for this script-only change; did not launch or stop running servers.
+70
View File
@@ -0,0 +1,70 @@
"""Compare command IDs and message field numbers with a local PearlSR checkout.
This is a source audit, not a client compatibility test. It does not verify
field types, default values, assets, or gameplay behavior.
"""
import argparse
import pathlib
import re
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("upstream", type=pathlib.Path)
args = parser.parse_args()
root = pathlib.Path(__file__).resolve().parents[1]
upstream = args.upstream / "protocol/src"
command_source = (upstream / "root.zig").read_text(encoding="utf-8")
commands = {n: int(v) for n, v in re.findall(r"\bCmd(\w+)\s*=\s*(\d+)", command_source)}
go_commands = {}
command_conflicts = []
for filename in ("cmdid.go", "upstream_cmdids.go"):
source = (root / "protocol/proto" / filename).read_text(encoding="utf-8")
for name, value in re.findall(r'^\s*"(\w+)":\s*(\d+),', source, re.M):
number = int(value)
if name in go_commands and go_commands[name] != number:
command_conflicts.append((name, go_commands[name], number))
go_commands[name] = number
command_differences = [(n, go_commands.get(n), v) for n, v in commands.items() if go_commands.get(n) != v]
command_differences.extend(command_conflicts)
zig_messages = {}
source = (upstream / "protocol.pb.zig").read_text(encoding="utf-8")
for name, body in re.findall(r"^pub const (\w+) = struct \{\n(.*?)^};", source, re.M | re.S):
descriptor = re.search(r"^ pub const _desc_table = .\{\n(.*?)^ };", body, re.M | re.S)
if descriptor is None:
continue
numbers = {int(v) for v in re.findall(r"^ \.\w+ = fd\((\d+),", descriptor[1], re.M)}
for union in re.findall(r"pub const _union_desc = .\{(.*?)\n };", body, re.S):
numbers.update(int(v) for v in re.findall(r"\.\w+ = fd\((\d+),", union))
zig_messages[name] = numbers
go_messages, wrappers = {}, {}
for path in (root / "protocol/proto").glob("*.pb.go"):
for name, body in re.findall(r"^type (\w+) struct \{\n(.*?)^}", path.read_text(encoding="utf-8"), re.M | re.S):
numbers = {int(v) for v in re.findall(r'protobuf:"[^,]+,(\d+),', body)}
go_messages[name] = numbers
if ',oneof"' in body and "_" in name:
wrappers.setdefault(name.split("_", 1)[0], set()).update(numbers)
for name, numbers in wrappers.items():
go_messages[name].update(numbers)
shared = go_messages.keys() & zig_messages.keys()
field_differences = [(n, sorted(go_messages[n]), sorted(zig_messages[n])) for n in sorted(shared)
if go_messages[n] != zig_messages[n]]
current_pairs = {}
for name, number in commands.items():
for suffix in ("CsReq", "CSReq", "Req"):
if name.endswith(suffix):
stem = name[:-len(suffix)]
candidates = [commands[n] for n in (stem + "ScRsp", stem + "CSScRsp", stem + "Rsp") if n in commands]
if len(candidates) == 1:
current_pairs[number] = candidates[0]
break
source = (root / "protocol/proto/cmdpairs.go").read_text(encoding="utf-8")
pairs = {int(a): int(b) for a, b in re.findall(r"^\s*(\d+):\s*(\d+),", source, re.M)}
pair_differences = [(a, b, current_pairs.get(a)) for a, b in pairs.items() if current_pairs.get(a) != b]
print(f"Command IDs: {len(commands)} compared, {len(command_differences)} differences")
print(f"Existing request/response pairs: {len(pairs)} compared, {len(pair_differences)} differences")
print(f"Shared message field-number sets: {len(shared)} compared, {len(field_differences)} differences")
for difference in command_differences + pair_differences + field_differences:
print(difference)
raise SystemExit(bool(command_differences or pair_differences or field_differences))
+45
View File
@@ -0,0 +1,45 @@
"""Generate current command names from a local PearlSR checkout; no network I/O."""
import argparse
import pathlib
import re
import subprocess
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("upstream", type=pathlib.Path)
args = parser.parse_args()
checkout = args.upstream.resolve()
revision = subprocess.check_output(
["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True
).strip()
source = (checkout / "protocol/src/root.zig").read_text(encoding="utf-8")
enum = source.split("pub const CmdID = enum(u16) {", 1)[1].split("};", 1)[0]
commands = re.findall(r"\bCmd(\w+)\s*=\s*(\d+)\s*,", enum)
if not commands or len({name for name, _ in commands}) != len(commands):
raise SystemExit("Missing or duplicate upstream command names")
if len({int(value) for _, value in commands}) != len(commands):
raise SystemExit("Duplicate upstream command IDs")
lines = [
"// Code generated by tools/sync_protocol_commands.py. DO NOT EDIT.",
"// Source: https://git.xeondev.com/HonkaiSlopRail/pearl-sr protocol/src/root.zig",
"// Revision: " + revision,
"package proto", "",
'const CommandSourceRevision = "' + revision + '"', "",
"var upstreamCmdIDByName = map[string]uint16{",
]
lines += [f'\t"{name}": {value},' for name, value in commands]
lines += ["}", "", "func init() {",
"\tfor name, id := range upstreamCmdIDByName {",
"\t\tif previous, ok := CmdIDByName[name]; ok && previous != id {",
'\t\t\tpanic("command ID conflict between schema and upstream: " + name)',
"\t\t}", "\t\tCmdIDByName[name] = id", "\t\tNameByCmdID[id] = name",
"\t}", "}", ""]
root = pathlib.Path(__file__).resolve().parents[1]
base_source = (root / "protocol/proto/cmdid.go").read_text(encoding="utf-8")
base_commands = {n: int(v) for n, v in re.findall(r'^\s*"(\w+)":\s*(\d+),', base_source, re.M)}
conflicts = [n for n, v in commands if n in base_commands and base_commands[n] != int(v)]
if conflicts:
raise SystemExit("Update the base schema command IDs first: " + ", ".join(conflicts))
target = root / "protocol/proto/upstream_cmdids.go"
target.write_text("\n".join(lines), encoding="utf-8")
subprocess.run(["gofmt", "-w", str(target)], check=True)
print(f"Generated {len(commands)} commands from {revision}")