43 KiB
คู่มือวิเคราะห์เชิงลึกและแนวทางแก้ไขระบบเซิร์ฟเวอร์ Star Rail 4.6 (GaCy & GaC-hkrpg-go)
สำหรับนำไปใช้เป็นบริบท (Context) และ Prompt ให้ AI (เช่น GPT-4o / Claude 3.5 Sonnet) นำไปเขียนโค้ดและแก้ไขระบบให้ถูกต้องสมบูรณ์ 100%
1. ปัญหาที่เกิดขึ้นจริง (Problem Statement)
1.1 อาการที่เกิดขึ้นกับตัวเกม Client
- หน้าต่างข้อความว่างเปล่าที่ 0%:
เมื่อผู้ใช้ล็อกอินผ่านหน้า UI ของ HoYoverse เข้าไปแล้ว ตัวเกมจะตัดเข้าสู่หน้าจอโหลดฉากอวกาศ (รูปรถไฟ Astral Express บิน) และแถบดาวน์โหลดหยุดนิ่งอยู่ที่
0 % - ไดอะล็อกแจ้งเตือนสีขาว ไม่มี Text:
หลังจากค้างที่
0 %สักครู่ ตัวเกมจะเด้งหน้าต่างโมดอลสีขาวขึ้นมาตรงกลางจอ โดย ไม่มีข้อความใดๆ ปรากฏ (Blank Text) มีเพียงไอคอน/ปุ่มเครื่องหมายถูกสำหรับกดยืนยันด้านล่าง - วนลูปไม่รู้จบ:
เมื่อผู้ใช้กดปุ่มยืนยันบนหน้าต่างนั้น ตัวเกมจะสั่ง
RestartApp()หรือรีโหลดตัวเองกลับไปยังหน้าต่างล็อกอินเริ่มต้นทันที และเมื่อล็อกอินใหม่ก็จะวนกลับมาอาการเดิม
1.2 สภาพแวดล้อมระบบ (Environment)
- เวอร์ชัน Client:
OSBETAAndroid4.6.54_D16927089_A16927089_L16927077(และรองรับOSBETAAndroid4.6.53) - สถาปัตยกรรมเซิร์ฟเวอร์:
- HTTP SDK & Dispatch Gateway: รันที่พอร์ต
21000(Flask บน Android Chaquopy Python หรือ Gointernal/sdkserver) - GameServer (UDP KCP): รันที่พอร์ต
23301(KCP Protocol Handler สำหรับจัดการ Gameplay/Lineup/Scene) - ไฟล์คอนฟิกหลัก:
server.json,persistent.json,hotfix.json
- HTTP SDK & Dispatch Gateway: รันที่พอร์ต
2. การวิเคราะห์สาเหตุเชิงลึก (Deep Technical Root Cause Analysis)
2.1 กลไกการโหลดของตัวเกม Star Rail 4.6 (Unity il2cpp Lifecycle)
ลำดับขั้นตอนที่ตัวเกมทำงานหลังจากกด Log in มีดังนี้:
- Authentication:
- Client ยิง HTTP POST ไปที่
/account/ma-passport/api/appLoginByPasswordหรือ/hkrpg_global/mdk/shield/api/login - Server ตอบกลับ JSON:
{"retcode": 0, "data": {"account_type": 1, "uid": "1", "token": "gacy_token"}}
- Client ยิง HTTP POST ไปที่
- Query Dispatch:
- Client ยิง HTTP GET ไปที่
/query_dispatch?version=... - Server ตอบกลับ Base64 ของ Protobuf Message
Dispatchโดยมีregion_listชี้dispatch_urlไปที่http://<IP>:21000/query_gateway
- Client ยิง HTTP GET ไปที่
- Query Gateway:
- Client ยิง HTTP GET ไปที่
/query_gateway?version=OSBETAAndroid4.6.54_D16927089_A16927089_L16927077&... - Server ต้องตอบกลับ Base64 ของ Protobuf Message
Gateserver
- Client ยิง HTTP GET ไปที่
- Hotfix & Asset Verification (จุดที่เกิดปัญหา):
- ตัวเกม C++ ใน Unity il2cpp จะแกะ Message
Gateserverแล้วนำฟิลด์asset_bundle_url,ex_resource_url,lua_url,lua_versionไปเริ่มต้นโมดูล PatchDownloader - กรณีที่ 1 (URL เป็นค่าว่าง): ถ้าเซิร์ฟเวอร์ส่ง
asset_bundle_url = ""(เช่น การตั้งค่าsend_hotfix = false) ตัว PatchDownloader จะเกิด Fatal Exception - กรณีที่ 2 (Protobuf Tag ผิด): ถ้าโครงสร้าง Tag ของ Protobuf ไม่ตรงกับเวอร์ชัน 4.6 ตัวเกมจะแกะค่า Port เป็น URL และแกะ URL เป็นค่าว่าง
- ผลลัพธ์ของ Exception: ในโค้ด Native C++ ของเกม Error ในจังหวะนี้ไม่มีการผูก Key ข้อความแปลภาษา (Missing Localized String) กล่องโมดอลแจ้งเตือนจึงแสดงเป็น หน้าต่างว่างเปล่าสีขาว และปุ่มยืนยันจะผูกคำสั่ง
RestartGame()ทำให้เกิดอาการค้างลูป
- ตัวเกม C++ ใน Unity il2cpp จะแกะ Message
- UDP KCP Connection (พอร์ต 23301):
- เมื่อผ่านการตรวจ Hotfix ไปได้ ตัวเกมจะสร้าง Socket UDP KCP ไปยัง
ip:portที่ระบุในGateserver - แพ็กเก็ตแรกที่ส่งคือ
PlayerGetTokenCsReq(CmdID = 5 ในเวอร์ชัน 4.6) - หาก Server แมป CmdID ผิดเป็นเวอร์ชันเก่า (เช่น 88) Server จะขึ้น
Unhandled cmd_id 5และตัดการเชื่อมต่อ
- เมื่อผ่านการตรวจ Hotfix ไปได้ ตัวเกมจะสร้าง Socket UDP KCP ไปยัง
2.2 ตารางเปรียบเทียบ Protobuf Wire Format: Gateserver (4.3 vs 4.6)
นี่คือจุดที่ทำให้ระบบพังอย่างเงียบๆ หากใช้ไฟล์ Schema เก่า:
| ฟิลด์ข้อมูล | ชนิดข้อมูล (Type) | 4.3 Tag (Schema เก่าของ GaCy) | 4.6 Tag (Schema แท้ของ Star Rail 4.6) | ผลกระทบเมื่อส่งผิดไปให้ Client 4.6 |
|---|---|---|---|---|
ip |
string (wire 2) | Tag 2 | Tag 3 | Client 4.6 ไม่สนใจ Tag 2 |
ex_resource_url |
string (wire 2) | Tag 5 | Tag 5 | ค่าตรงกัน |
asset_bundle_url |
string (wire 2) | Tag 15 | Tag 6 | CRITICAL: Client 4.6 อ่าน Tag 15 เป็น boolean unk2 ทำให้มองไม่เห็น asset_bundle_url! |
lua_url |
string (wire 2) | Tag 3 | Tag 7 | CRITICAL: Client 4.6 อ่าน Tag 3 เป็น ip! |
port |
uint32 (wire 0) | Tag 6 | Tag 9 | CRITICAL: Client 4.6 อ่าน Tag 6 เป็น asset_bundle_url (เอาเลขพอร์ต 23301 ไปเป็น URL ดาวน์โหลด)! |
unk1 |
bool (wire 0) | - | Tag 10 | ค่าคงที่ true |
unk2 |
bool (wire 0) | - | Tag 15 | ค่าคงที่ true |
unk3 |
bool (wire 0) | - | Tag 77 | ค่าคงที่ true |
unk4 |
bool (wire 0) | - | Tag 454 | ค่าคงที่ true |
ifix_url |
string (wire 2) | - | Tag 694 | URL ifix |
unk5 |
bool (wire 0) | - | Tag 802 | ค่าคงที่ true |
unk6 |
bool (wire 0) | - | Tag 918 | ค่าคงที่ true |
ifix_version |
string (wire 2) | - | Tag 1286 | ค่าคงที่ "0" (ต้องเป็น string ห้ามส่ง int) |
asset_bundle_url_b |
string (wire 2) | - | Tag 1379 | URL สำรอง (ใส่ค่าเดียวกับ asset_bundle_url) |
lua_version |
string (wire 2) | - | Tag 1516 | ค่าเวอร์ชัน Lua (เช่น "16927297", ต้องเป็น string) |
unk7 |
bool (wire 0) | - | Tag 1559 | ค่าคงที่ true |
unk8 |
bool (wire 0) | - | Tag 1975 | ค่าคงที่ true |
2.3 การตัด Hash ท้ายสตริงเวอร์ชัน (Version Query String Mismatch)
- ตัวเกมยิงมาที่ URL:
GET /query_gateway?version=OSBETAAndroid4.6.54_D16927089_A16927089_L16927077 - แต่ในไฟล์
hotfix.jsonบันทึกคีย์ไว้แบบสั้น:"OSBETAAndroid4.6.54" - ข้อผิดพลาดเดิม: โค้ดใช้
hotfix.get(version)ตรงๆ ทำให้ได้ค่าNoneส่งผลให้ URL ในGateserverกลายเป็นค่าว่าง - การแก้ไขที่ถูกต้อง: ต้องตัดเอาเฉพาะส่วนหน้าก่อนเครื่องหมาย
_:clean_version = version.split('_')[0] if version else version hotfix_entry = hotfix_dict.get(version) or hotfix_dict.get(clean_version)
2.4 ตาราง CmdID บน UDP KCP ในเวอร์ชัน 4.6 (เปรียบเทียบกับ 4.3)
เมื่อตัวเกมผ่าน 0% ไปแล้ว จะเริ่มยิง KCP ถ้า CmdID ไม่ตรง Server จะพิมพ์ Unhandled cmd_id และ Client จะหลุด:
| Command Name | CmdID ในเวอร์ชัน 4.3 | CmdID ในเวอร์ชัน 4.6 | ประเภท |
|---|---|---|---|
PlayerGetTokenCsReq |
88 | 5 | Client -> Server (คำสั่งแรกสุดของ KCP) |
PlayerGetTokenScRsp |
44 | 71 | Server -> Client |
PlayerLoginCsReq |
94 | 74 | Client -> Server |
PlayerLoginScRsp |
9 | 19 | Server -> Client |
PlayerLoginFinishCsReq |
84 | 9 | Client -> Server |
PlayerLoginFinishScRsp |
13 | 13 | Server -> Client |
PlayerHeartBeatCsReq |
98 | 45 | Heartbeat |
PlayerHeartBeatScRsp |
99 | 16 | Heartbeat Response |
GetCurSceneInfoCsReq |
1488 | 1459 | โหลดข้อมูลฉาก |
GetCurSceneInfoScRsp |
1444 | 1418 | ตอบข้อมูลฉาก |
SceneCastSkillCsReq |
1424 | 1405 | กดใช้สกิลในฉาก |
SceneCastSkillScRsp |
1403 | 1471 | ตอบรับสกิล |
SceneEntityMoveCsReq |
1494 | 1474 | เดินในฉาก |
SceneEntityMoveScRsp |
1409 | 1419 | ตำแหน่งการเดิน |
GetCurLineupDataCsReq |
788 | 731 | ข้อมูลทีมปัจจุบัน |
GetCurLineupDataScRsp |
744 | 787 | ตอบข้อมูลทีมปัจจุบัน |
GetAllLineupDataCsReq |
794 | 704 | ข้อมูลทีมทั้งหมด |
GetAllLineupDataScRsp |
709 | 794 | ตอบข้อมูลทีมทั้งหมด |
ChangeLineupLeaderCsReq |
784 | 798 | เปลี่ยนตัวละครหลัก |
ChangeLineupLeaderScRsp |
713 | 747 | ตอบรับเปลี่ยนตัวละคร |
GetBagCsReq |
588 | 574 | ข้อมูลกระเป๋า |
GetBagScRsp |
544 | 519 | ตอบข้อมูลกระเป๋า |
GetAvatarDataCsReq |
388 | 374 | ข้อมูลตัวละคร |
GetAvatarDataScRsp |
344 | 319 | ตอบข้อมูลตัวละคร |
PVEBattleResultCsReq |
188 | 174 | ผลการต่อสู้ |
PVEBattleResultScRsp |
144 | 119 | ตอบรับผลการต่อสู้ |
StartCocoonStageCsReq |
1438 | 1444 | เริ่มดันเจี้ยน |
StartCocoonStageScRsp |
1416 | 1481 | ตอบเริ่มดันเจี้ยน |
2.5 ทำไมถึงห้ามนำ binary ของ ppSR มาใส่ตรงๆ?
ในไฟล์ ppsr.pyc ของ ppSR มีการเขียนฟังก์ชันป้องกันการแจกจ่าย (check_access / qyqwiourtyqot) โดยจะนำ Timestamp มา Hash ร่วมกับคีย์ลับ (HMAC-SHA256) เพื่อสร้าง Password รายวัน/สัปดาห์ หากรหัสใน persistent["key"] ไม่ตรง ตัวโปรแกรมจะเรียก input("Enter the correct password: ") ซึ่งสำหรับแอพแบบ Embedded บน Android จะทำให้เซิร์ฟเวอร์หยุดรอ Input และไม่สามารถทำงานต่อได้
ดังนั้น: เราต้องสร้างตัว Encode และ Logic 4.6 ภายในโค้ดของเราเอง (ทั้งใน Python runner และ Go backend) โดยไม่ต้องพึ่งพาหรือก๊อปปี้ binary ของ ppSR มาใช้งาน
3. ข้อมูล CDN Hotfix 4.6 ที่แท้จริง (Official 4.6 CDN URLs)
ให้ใช้ชุดข้อมูล JSON นี้สำหรับบรรจุลงใน hotfix.json:
{
"OSBETAAndroid4.6.54": {
"asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_16927089_a94e4821d0e7_f07f57da7e245a",
"asset_bundle_url_b": "https://autopatchos.starrails.com/asb/BetaLive/output_16927089_a94e4821d0e7_f07f57da7e245a",
"ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_16932918_4a2e3f79111d_3e2af64ea2b108",
"lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_16927297_c46b951e256b_0abc31ea529d71",
"ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4",
"lua_version": "16927297",
"ifix_version": "0"
},
"OSBETAAndroid4.6.53": {
"asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_16818953_d4345bbd54a2_ae00cce42191d1",
"asset_bundle_url_b": "https://autopatchos.starrails.com/asb/BetaLive/output_16818953_d4345bbd54a2_ae00cce42191d1",
"ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_16833202_959c316ef547_335871eaabab71",
"lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_16819203_679727d45bd7_d086e33f0af119",
"ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4",
"lua_version": "16819203",
"ifix_version": "0"
},
"OSBETAWin4.6.54": {
"asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_16927089_a94e4821d0e7_f07f57da7e245a",
"asset_bundle_url_b": "https://autopatchos.starrails.com/asb/BetaLive/output_16927089_a94e4821d0e7_f07f57da7e245a",
"ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_16932918_4a2e3f79111d_3e2af64ea2b108",
"lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_16927297_c46b951e256b_0abc31ea529d71",
"ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4",
"lua_version": "16927297",
"ifix_version": "0"
}
}
4. โค้ดตัวอย่างสำหรับแก้ปัญหา (Ready-to-use Implementation)
4.1 ตัวสร้าง Wire Format Protobuf Gateserver 4.6 (Python Standalone Serializer)
ฟังก์ชันนี้ทำงานโดยเขียน Byte Stream ตามสเปก Protobuf Wire Format โดยตรง ไม่ต้องพึ่งพา classes_pb2.pyc เก่า:
import base64
def encode_varint(value: int) -> bytes:
bits = value & 0x7fffffffffffffff
buf = bytearray()
while True:
b = bits & 0x7f
bits >>= 7
if bits:
buf.append(b | 0x80)
else:
buf.append(b)
break
return bytes(buf)
def encode_field(tag: int, wire_type: int, payload: bytes) -> bytes:
key = (tag << 3) | wire_type
return encode_varint(key) + payload
def build_gateserver_4_6(gate_ip: str, game_port: int, hf_data: dict) -> bytes:
parts = []
# Tag 3: ip (string)
ip_bytes = gate_ip.encode('utf-8')
parts.append(encode_field(3, 2, encode_varint(len(ip_bytes)) + ip_bytes))
# Tag 5: ex_resource_url (string)
ex_res = hf_data.get('ex_resource_url', '').encode('utf-8')
if ex_res:
parts.append(encode_field(5, 2, encode_varint(len(ex_res)) + ex_res))
# Tag 6: asset_bundle_url (string)
asb = hf_data.get('asset_bundle_url', '').encode('utf-8')
if asb:
parts.append(encode_field(6, 2, encode_varint(len(asb)) + asb))
# Tag 7: lua_url (string)
lua_url = hf_data.get('lua_url', '').encode('utf-8')
if lua_url:
parts.append(encode_field(7, 2, encode_varint(len(lua_url)) + lua_url))
# Tag 9: port (uint32)
parts.append(encode_field(9, 0, encode_varint(game_port)))
# Boolean flags (unk1 .. unk8)
bool_tags = [10, 15, 77, 454, 802, 918, 1559, 1975]
for tag in bool_tags:
parts.append(encode_field(tag, 0, encode_varint(1)))
# Tag 694: ifix_url (string)
ifix_url = hf_data.get('ifix_url', '').encode('utf-8')
if ifix_url:
parts.append(encode_field(694, 2, encode_varint(len(ifix_url)) + ifix_url))
# Tag 1286: ifix_version (string)
ifix_ver = str(hf_data.get('ifix_version', '0')).encode('utf-8')
parts.append(encode_field(1286, 2, encode_varint(len(ifix_ver)) + ifix_ver))
# Tag 1379: asset_bundle_url_b (string)
asb_b = (hf_data.get('asset_bundle_url_b') or hf_data.get('asset_bundle_url', '')).encode('utf-8')
if asb_b:
parts.append(encode_field(1379, 2, encode_varint(len(asb_b)) + asb_b))
# Tag 1516: lua_version (string)
lua_ver = str(hf_data.get('lua_version', '16927297')).encode('utf-8')
parts.append(encode_field(1516, 2, encode_varint(len(lua_ver)) + lua_ver))
raw_protobuf = b''.join(parts)
return base64.b64encode(raw_protobuf)
4.2 โค้ด Handler สำหรับ query_gateway (Python Flask)
@app.route('/query_gateway', methods=['GET', 'HEAD'])
def query_gateway():
from flask import request
if request.method == 'HEAD':
return '', 200
version = request.args.get('version', '')
if not version:
return '', 200
# 1. ตัด build hash ออก เช่น OSBETAAndroid4.6.54_D16927089... -> OSBETAAndroid4.6.54
clean_ver = version.split('_')[0]
# 2. ค้นหา Hotfix Data
hf = hotfix_dict.get(version) or hotfix_dict.get(clean_ver)
if not hf:
# Fallback หาคีย์ 4.6 ตัวที่ใกล้เคียงที่สุด
for k in hotfix_dict:
if '4.6' in k and 'Android' in k:
hf = hotfix_dict[k]
break
# 3. กำหนด Target IP (127.0.0.1 หากเล่นบนเครื่องเดียวกัน หรือ LAN IP)
client_ip = request.remote_addr
target_ip = "127.0.0.1" if client_ip in ("127.0.0.1", "localhost") else get_local_lan_ip()
# 4. ตรวจสอบว่าเปิดส่ง Hotfix หรือไม่ (ต้องเป็น True เสมอสำหรับ 4.6)
should_send_hotfix = Manager.persistent.get("send_hotfix", True)
hotfix_payload = hf if should_send_hotfix else {}
# 5. สร้าง Base64 Protobuf 4.6 แท้
response_b64 = build_gateserver_4_6(target_ip, 23301, hotfix_payload)
return response_b64
4.3 โค้ดสำหรับเซิร์ฟเวอร์ Go (internal/sdkserver/server.go)
ใน Go backend ของ GaC-hkrpg-go:
func (a *appState) getOrInsertHotfix(version, dispatchSeed string) VersionConfig {
a.mu.Lock()
defer a.mu.Unlock()
// 1. Direct match
if cfg, ok := a.hotfixMap[version]; ok {
return cfg
}
// 2. Stripped prefix match (สำคัญมาก: ตัด _D... ออก)
cleanVer := strings.Split(version, "_")[0]
if cfg, ok := a.hotfixMap[cleanVer]; ok {
return cfg
}
// 3. Fallback to OSBETAAndroid4.6.54 if version contains 4.6
if strings.Contains(version, "4.6") {
if cfg, ok := a.hotfixMap["OSBETAAndroid4.6.54"]; ok {
return cfg
}
}
return VersionConfig{}
}
และใน struct Gateserver ของ Protobuf ใน Go (StarRail.pb.go) ให้แน่ใจว่า Tag ตรงกับสเปก 4.6:
message Gateserver {
string ip = 3;
string ex_resource_url = 5;
string asset_bundle_url = 6;
string lua_url = 7;
uint32 port = 9;
bool unk1 = 10;
bool unk2 = 15;
bool unk3 = 77;
bool unk4 = 454;
string ifix_url = 694;
bool unk5 = 802;
bool unk6 = 918;
string ifix_version = 1286;
string asset_bundle_url_b = 1379;
string lua_version = 1516;
bool unk7 = 1559;
bool unk8 = 1975;
}
5. สรุปเช็คลิสต์สำหรับการทดสอบ (Verification Checklist)
- เปิดใช้งาน
send_hotfix: true: ตรวจสอบว่าในpersistent.jsonและโค้ดตัวอ่าน persistent ไม่มีส่วนใดไปบังคับเซฟทับเป็นfalse - Hotfix URLs ไม่ว่างเปล่า: เมื่อ Client ยิง
/query_gatewayต้องได้ URL ที่ขึ้นต้นด้วยhttps://autopatchos.starrails.com/...เสมอ - Tag 6 และ Tag 9 ถูกต้อง: ค่าของ Tag 6 ต้องเป็นสตริง URL (
asset_bundle_url) และ Tag 9 ต้องเป็นตัวเลขพอร์ต23301(ไม่ใช่สลับกัน) - รองรับคำสั่งแรกของ KCP (CmdID 5): เมนู Console ของ Gameserver ต้องไม่ขึ้นว่า
Unhandled cmd_id 5ตอนที่หลุดจากหน้า 0% เข้าสู่การเชื่อมต่อฉากเกม - ไม่มีระบบเช็ค Discord License หรือถาม Password: เซิร์ฟเวอร์ต้องเริ่มต้นทำงานได้ทันทีแบบไม่ต้องรอ Console Input ใดๆ
6. รวมบัคและข้อผิดพลาดทั้งหมดที่เกิดขึ้นในระบบ (Comprehensive Bug Registry & Failure Post-Mortem)
ส่วนนี้ได้รวบรวมทุกบัค, Error Log, สาเหตุที่แท้จริง (Root Cause), และโค้ดแก้ไข (Fix) ที่เกิดขึ้นตลอดการพัฒนาและการดีบัก GaCy และ GaC-hkrpg-go ไว้อย่างละเอียดที่สุด เพื่อให้ AI สามารถนำไปใช้ป้องกันและแก้ปัญหาได้อย่างครอบคลุม:
Bug #1: ค้าง 0% และเกิด Alert Dialog สีขาวว่างเปล่า (White Blank Dialog Loop)
- Log อาการ:
[HTTP] GET /query_gateway?version=OSBETAAndroid4.6.54_D16927089... -> 200 [Client]: หน้าจอขึ้นรูปรถไฟ Astral Express บิน แถบดาวน์โหลดค้างที่ 0 % [Client]: สักครู่มี Dialog สีขาวไม่มีตัวหนังสือเด้งขึ้นมา พอกดปุ่ม ตัวเกมสั่ง Restart - สาเหตุ:
- ค่า
send_hotfixถูกตั้งเป็นfalseหรือโค้ดส่งasset_bundle_url = ""ว่างเปล่ากลับไป - ใน Star Rail 4.6 โมดูล
PatchDownloaderของ Unity il2cpp บังคับว่าต้องมี URL ดาวน์โหลด Asset Bundle ถ้าไม่มีหรือต่อไม่ติด จะเกิด Fatal Error ทันที - เนื่องจากตัวเกมไม่ได้ผูกข้อความแปลภาษาสำหรับกรณี Asset URL ว่างไว้ ทำให้หน้าต่างแสดงผลเป็นสีขาวล้วน (Empty Text)
- ค่า
- วิธีแก้:
- ต้องเปิด
send_hotfix: trueเสมอ - ฟิลด์
asset_bundle_url,ex_resource_url,lua_urlต้องมี URL ของ CDN แท้ (เช่นhttps://autopatchos.starrails.com/asb/BetaLive/...) บรรจุอยู่เสมอ ห้ามเป็นค่าว่าง
- ต้องเปิด
Bug #2: เซิร์ฟเวอร์ส่ง IP 0.0.0.0 กลับไปใน Gateserver
- Log อาการ:
[Dispatch] Gateserver response serialized: ip="0.0.0.0", port=23301 [Client]: Connection refused / Timeout ทันทีหลังจบ HTTP Gateway - สาเหตุ:
- โค้ดของ Dispatch ไปหยิบค่า
_ds_mod.HOSTซึ่งตั้งค่าไว้เป็น"0.0.0.0"(เพื่อให้ Server Socket บายด์รับทุก Interface) ส่งกลับไปใน Protobuf - Client ในระบบ Android หรืออุปกรณ์อื่นภายนอก ไม่สามารถเชื่อมต่อไปยัง IP
0.0.0.0ได้
- โค้ดของ Dispatch ไปหยิบค่า
- วิธีแก้:
def resolve_client_gate_ip(): from flask import request client_ip = request.remote_addr if request else "127.0.0.1" if not client_ip or client_ip in ("127.0.0.1", "::1", "localhost"): return "127.0.0.1" # หากเป็นเครื่องอื่น ให้ตอบด้วย LAN IP ของเซิร์ฟเวอร์ import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(('8.8.8.8', 80)) return s.getsockname()[0] except Exception: return "127.0.0.1" finally: s.close()
Bug #3: Protobuf Tag Inversion (4.3 vs 4.6 Tag Mismatch)
- Log อาการ:
[Client Debug]: AssetBundle download failed: invalid url format "23301" [Client]: Game crash / Restart loop at 0% - สาเหตุ:
- Schema เก่า (4.3) มี
portอยู่ที่ Tag 6 และasset_bundle_urlอยู่ที่ Tag 15 - ใน 4.6 สลับเป็น
asset_bundle_urlอยู่ที่ Tag 6 และportอยู่ที่ Tag 9 - เมื่อ Client 4.6 อ่านไบต์ที่สร้างจาก 4.3 ตัวเกมจะอ่านค่าพอร์ต (
23301) ไปเป็น String URL ของ Asset Bundle
- Schema เก่า (4.3) มี
- วิธีแก้:
- แก้ไขตัวสร้าง Protobuf ให้ Tag 6 เป็น String URL และ Tag 9 เป็น uint32 พอร์ตเกม
Bug #4: Protobuf Type Crash บน lua_version และ ifix_version (TypeError: int expected str)
- Log อาการ:
TypeError: 0 has type int, but expected one of: bytes, str File "dispatchserver.py", line 113, in query_gateway response.lua_version = hf.get('lua_version', 0) - สาเหตุ:
- ในนิยาม Protobuf 4.6 ฟิลด์
lua_version(Tag 1516) และifix_version(Tag 1286) ถูกกำหนดชนิดข้อมูลเป็นstring(ไม่ใช่ uint32) - เมื่อโค้ดส่ง default value เป็นเลข
0(int) ตัวไลบรารี Protobuf ของ Python จะ RaiseTypeErrorทันที ทำให้ HTTP 500
- ในนิยาม Protobuf 4.6 ฟิลด์
- วิธีแก้:
response.lua_version = str(hf.get('lua_version', '16927297')) response.ifix_version = str(hf.get('ifix_version', '0'))
Bug #5: Protobuf AttributeError ฟิลด์ unk9 และ unk10 ไม่มีอยู่จริง
- Log อาการ:
AttributeError: Protocol message Gateserver has no "unk9" field. - สาเหตุ:
- โค้ด Patch พยายามส่งตัวแปรเดา:
unk1=True, ..., unk9=True, unk10=True - ในสเปก Protobuf ของ Star Rail 4.6 ฟิลด์ boolean unk มีถึงแค่
unk8(Tag 1975) เท่านั้น การส่งunk9ทำให้เกิด Crash
- โค้ด Patch พยายามส่งตัวแปรเดา:
- วิธีแก้:
- ส่งเฉพาะ boolean flags
unk1ถึงunk8(Tags: 10, 15, 77, 454, 802, 918, 1559, 1975)
- ส่งเฉพาะ boolean flags
Bug #6: บัคฟังก์ชัน Auto-Heal ทับค่า send_hotfix = False อัตโนมัติทุกครั้งที่เปิด
- Log อาการ:
[Patch] persistent.json validated and auto-healed with complete avatar/scene defaults (send_hotfix=False) - สาเหตุ:
- ในไฟล์
run_ppsr.pyและPersistentRepository.ktมีโค้ดตรวจสอบสุขภาพไฟล์persistent.jsonแต่คนเขียนเดิมใส่เงื่อนไขไว้ว่า:if data.get("send_hotfix") is not False: data["send_hotfix"] = False modified = True - ส่งผลให้ไม่ว่าผู้ใช้จะแก้ไฟล์เป็น
trueอย่างไร พอเปิดแอพ เซิร์ฟเวอร์จะเซฟทับกลับเป็นfalseทันที
- ในไฟล์
- วิธีแก้:
if "send_hotfix" not in data: data["send_hotfix"] = True modified = True
Bug #7: การหาคีย์ Hotfix ไม่เจอเพราะ Client ส่ง Hash ต่อท้ายเวอร์ชัน
- Log อาการ:
KeyError: 'OSBETAAndroid4.6.54_D16927089_A16927089_L16927077' Couldn't find OSBETAAndroid4.6.54_D16927089_A16927089_L16927077 in hotfix.json - สาเหตุ:
- ตัวเกม Client ยิง Query String มาเป็น
version=OSBETAAndroid4.6.54_D16927089_A16927089_L16927077 - แต่ใน
hotfix.jsonบันทึกไว้เฉพาะOSBETAAndroid4.6.54 - โค้ดดึง
hotfix[version]ตรงๆ ทำให้เกิดKeyErrorหรือได้ค่าว่าง
- ตัวเกม Client ยิง Query String มาเป็น
- วิธีแก้:
clean_ver = version.split('_')[0] if version else version hf = hotfix.get(version) or hotfix.get(clean_ver)
Bug #8: เซิร์ฟเวอร์ Beta ของทางการปิดตัว (retcode: 3 / 测试资格校验失败)
- Log อาการ:
Tag 12 (varint): 3 Tag 13 (string): client version not match Tag 252 (string): 测试资格校验失败,请重新登录 - สาเหตุ:
- ฟังก์ชัน
check_hotfixของ ppSR ดั้งเดิม ออกแบบมาให้ยิง Proxy ต่อไปยังhttps://beta-release01-asia.starrails.com/query_gatewayเพื่อดึงข้อมูล Hotfix สดๆ - แต่เมื่อระยะเวลาทดสอบ Beta จบลง เซิร์ฟเวอร์ทางการจะปฏิเสธคำขอทั้งหมดและส่ง retcode 3
- ส่งผลให้
gateserver.asset_bundle_urlที่ตอบกลับมาเป็นค่าว่าง""ถ้าเซิร์ฟเวอร์ไม่มีการ Hardcode URL สำรองไว้ในhotfix.jsonระบบจะพังทันที
- ฟังก์ชัน
- วิธีแก้:
- ต้องมี URL CDN ของ 4.6 ที่บันทึกไว้ในเครื่อง (
hotfix.json) เสมอ และหากดึงสดล้มเหลว ให้ใช้ข้อมูลในเครื่องตอบกลับ Client ทันที
- ต้องมี URL CDN ของ 4.6 ที่บันทึกไว้ในเครื่อง (
Bug #9: HTTP 405 Method Not Allowed บน /hkrpg_global/mdk/shield/api/loadConfig
- Log อาการ:
[HTTP] GET /hkrpg_global/mdk/shield/api/loadConfig -> 405 Method Not Allowed - สาเหตุ:
- เส้นทาง API Shield ของ Flask ถูกลงทะเบียนไว้เฉพาะเมธอด
POST:@app.route('/hkrpg_<region>/mdk/shield/api/<action>', methods=['POST']) - แต่ตัวเกม Star Rail Global ส่งเมธอด
GETมาที่loadConfig
- เส้นทาง API Shield ของ Flask ถูกลงทะเบียนไว้เฉพาะเมธอด
- วิธีแก้:
@app.route('/<path:dummy>/mdk/shield/api/loadConfig', methods=['GET', 'POST']) @app.route('/hkrpg_<region>/mdk/shield/api/loadConfig', methods=['GET', 'POST']) def shield_load_config(*args, **kwargs): return jsonify({ "retcode": 0, "message": "OK", "data": { "id": 6, "game_key": "hkrpg_global", "client": "ANDROID", "identity": "I_IDENTITY", "guest": False, "ignore_clean": 0, "scene": "S_NORMAL" } })
Bug #10: HTTP 404 บน Telemetry & SDK Configuration Endpoints
- Log อาการ:
[HTTP] GET /combo/box/api/config/sdk/combo -> 404 [HTTP] GET /combo/box/api/config/sdk/drmSwitch -> 404 [HTTP] POST /data_abtest_api/config/experiment/list -> 404 [HTTP] GET /admin/mi18n/plat_oversea/... -> 404 - สาเหตุ:
- ตัวเกม Star Rail Global มีการเรียก Endpoint ย่อยต่างๆ สำหรับตรวจสอบ DRM, A/B Testing และไฟล์ภาษา i18n
- หากเซิร์ฟเวอร์ตอบกลับเป็น 404 HTML ตัวเกมบางเวอร์ชันจะหยุดทำงาน
- วิธีแก้:
- เพิ่ม Universal Fallback Handler ดักจับเส้นทางเหล่านี้แล้วตอบ JSON 200 OK:
@app.errorhandler(404) def handle_404(e): from flask import request, jsonify path = request.path if any(k in path for k in ['combo', 'config', 'drm', 'experiment', 'abtest', 'mi18n', 'font']): return jsonify({"retcode": 0, "message": "OK", "data": {}}), 200 if any(k in path for k in ['login', 'passport', 'token', 'auth', 'shield']): return jsonify({ "retcode": 0, "message": "OK", "data": { "account_type": 1, "uid": "1", "token": "gacy_token_ok", "email": "player@gacy.local" } }), 200 return jsonify({"retcode": 0, "message": "OK"}), 200
Bug #11: UDP KCP Unhandled cmd_id 5 (PlayerGetTokenCsReq)
- Log อาการ:
Dispatch server listening on 0.0.0.0:21000 Forwarding to 127.0.0.1:23301 [GameServer]: Unhandled cmd_id 5 - สาเหตุ:
- ใน Star Rail 4.6 เมื่อตัวเกมผ่านหน้า 0% ไปได้ จะส่งคำสั่งแรกคือ
PlayerGetTokenCsReq - หมายเลขคำสั่งใน 4.6 คือ
5(ใน 4.3 คือ 88) - เซิร์ฟเวอร์ไม่มี Handler สำหรับหมายเลข 5 จึงทิ้งแพ็กเก็ต ทำให้ Client ตัดการเชื่อมต่อ
- ใน Star Rail 4.6 เมื่อตัวเกมผ่านหน้า 0% ไปได้ จะส่งคำสั่งแรกคือ
- วิธีแก้:
- อัปเดตตาราง
cmd_dictให้PlayerGetTokenCsReqชี้ไปที่5และPlayerGetTokenScRspชี้ไปที่71 - แมปฟังก์ชัน
PlayerGetTokenCsReq.handleเข้ากับ CmdID5ในrouter.py
- อัปเดตตาราง
Bug #12: UDP KCP Unhandled cmd_id 74 (PlayerLoginCsReq)
- Log อาการ:
PlayerGetTokenCsReq (5) -> PlayerGetTokenScRsp (71) [GameServer]: Unhandled cmd_id 74 - สาเหตุ:
- หลังจาก Token ผ่าน Client จะส่งคำสั่งล็อกอินเข้าเกม
PlayerLoginCsReq - ใน 4.6 หมายเลขคำสั่งคือ
74(ใน 4.3 คือ 94) และผลตอบรับPlayerLoginScRspคือ19(ใน 4.3 คือ 9)
- หลังจาก Token ผ่าน Client จะส่งคำสั่งล็อกอินเข้าเกม
- วิธีแก้:
- แมป
PlayerLoginCsReq: 74และPlayerLoginScRsp: 19ในrouter.py
- แมป
Bug #13: Socket Crash OSError: [Errno 98] Address already in use
- Log อาการ:
OSError: [Errno 98] Address already in use: ('0.0.0.0', 21000) - สาเหตุ:
- เมื่อผู้ใช้กด Stop Server แล้วกด Start Server ใหม่ทันที Socket ของ Werkzeug/Flask ยังอยู่ในสถานะ
TIME_WAITของระบบปฏิบัติการ
- เมื่อผู้ใช้กด Stop Server แล้วกด Start Server ใหม่ทันที Socket ของ Werkzeug/Flask ยังอยู่ในสถานะ
- วิธีแก้:
- ทำ Patch ครอบฟังก์ชัน
werkzeug.serving.make_serverเพื่อเซ็ตแฟล็ก Socket:
import socket, struct def _patched_make_server(*args, **kwargs): kwargs.pop('options', None) server = _orig_make_server(*args, **kwargs) if hasattr(server, 'socket') and server.socket: server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # ปิด Linger เพื่อให้ปล่อยพอร์ตทันทีเมื่อสั่งปิด server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) return server - ทำ Patch ครอบฟังก์ชัน
Bug #14: การหยุดทำงานของเซิร์ฟเวอร์ทำให้ทั้งแอพ Android ดับ (System.exit(0))
- Log อาการ:
[Shutdown]: Force-stopping server execution... Process 12345 exited with code 0 (App crashed/closed to home screen) - สาเหตุ:
- โค้ด Python ดั้งเดิมมักเรียก
sys.exit(0)เมื่อต้องการปิดเซิร์ฟเวอร์ - ในสภาพแวดล้อม Chaquopy บน Android กระบวนการของ Python ทำงานอยู่บน JVM Process เดียวกันกับ UI ของ Android Application การเรียก
sys.exit()จึงทำให้ Android Process ดับทั้งแอพ
- โค้ด Python ดั้งเดิมมักเรียก
- วิธีแก้:
- ดักจับ
sys.exitใน Python:
def _safe_exit(code=0): print(f"[Sys] Intercepted sys.exit({code}), prevented Android process exit.") raise KeyboardInterrupt() sys.exit = _safe_exit - ดักจับ
Bug #15: ค้าง Thread เพราะคำสั่ง input() (Headless Blocking)
- Log อาการ:
[CLI]: Enter the correct password: (Server hangs indefinitely, no logs appear, ports not listening) - สาเหตุ:
- สคริปต์เซิร์ฟเวอร์มีการเรียก
builtins.input()เพื่อรอการป้อนข้อมูลจาก Console - บน Android ไม่มี stdin ให้พิมพ์ ทำให้ Thread นั้นติดบล็อกตลอดกาล
- สคริปต์เซิร์ฟเวอร์มีการเรียก
- วิธีแก้:
- Mock คำสั่ง
builtins.input:
import builtins, time def _mock_input(prompt=""): if prompt: print(f"[CLI]: {prompt}") time.sleep(1) return "" builtins.input = _mock_input - Mock คำสั่ง
Bug #16: KeyError ใน Manager.load_persistent เมื่อ Persistent ขาดคีย์บางตัว
- Log อาการ:
KeyError: 'march_path' File "game/services/manager.py", line 45, in load_persistent - สาเหตุ:
- ในไฟล์
persistent.jsonที่ผู้ใช้แก้ไขเอง อาจไม่มีฟิลด์ย่อย เช่นtb_gender,tb_path,march_path,skin - โค้ดใน
manager.pycเข้าถึงคีย์เหล่านี้ตรงๆ ด้วยpersistent['avatar']['march_path']
- ในไฟล์
- วิธีแก้:
- สร้างคลาส
SafeDictครอบ Dictionary ของpersistent:
class SafeDict(dict): def __getitem__(self, key): if key not in self: defaults = { 'tb_gender': 'Female', 'tb_path': 'Elation', 'march_path': 'Preservation', 'leader': 0, 'send_hotfix': True, 'key': '', 'skin': {}, 'lineup': [1511, 1502, 1506, 1308], 'buff': [], 'global_passive': [], 'ex_rank': [] } if key in defaults: return defaults[key] val = super().__getitem__(key) if isinstance(val, dict) and not isinstance(val, SafeDict): val = SafeDict(val) self[key] = val return val - สร้างคลาส
Bug #17: การเกิดบัคจาก ppSR License Check (qyqwiourtyqot)
- Log อาการ:
Incorrect or expired password. You can get the password for free from https://discord.gg/ppSR Enter the correct password: - สาเหตุ:
- การนำเอาไฟล์
build.zipหรือppsr.pycจากโปรเจกต์ ppSR มาใช้ตรงๆ จะติดระบบตรวจสอบสิทธิ์ที่ผู้พัฒนา ppSR เขียนไว้ - ระบบนี้ใช้ฟังก์ชัน
qyqwiourtyqot()ในutil/binary.pycทำ HMAC-SHA256 ตรวจสอบกับรหัสที่แจกใน Discord รายสัปดาห์
- การนำเอาไฟล์
- วิธีแก้:
- ห้ามนำ binary ที่คอมไพล์แล้วของ ppSR มาใส่ในระบบ GaCy
- ใช้ตัวสร้าง Protobuf และตัวจัดการคำสั่งที่เป็นอิสระตามโค้ดในหัวข้อที่ 4