feat: port GaC-hkrpg-ps to Go with SceneCastSkill camera-detach fix
- mihoyo-variant KCP in pure Go (28-byte conv+token header), UDP gateway with 0xFF/0x145/0x194 handshake, ms-precision KCP clock + retransmit ticker - protobuf for 4.6.51 generated from the AstralOS StarRail.proto (4932 messages) plus CmdID map from // CmdID: comments (2114 ids) - sdkserver port: query_dispatch/query_gateway (Gateserver pb + base64), hotfix fetch, auth endpoints, SRTools freesr-data.json upload - gameserver handlers: login flow, scene load/spawn with real monster entity tracking, lineup, avatar/skin, missions, tutorials (1249+855), bag/equip, challenge incl. StartChallenge 1787 layout + Tierce 8973 + peak 8948, all raw tierce replies - SceneCastSkill camera bug fix: only real scene monsters (tracked per session) trigger battle; attacking props/NPCs/air replies retcode 0 without battle_info so the camera stays locked on the character - end-to-end smoke test simulating a client over real UDP: handshake, PlayerGetToken, and both SceneCastSkill paths - reuses the Rust PS data files unchanged (freesr-data.json, persistent, res.json, teleports.json, challenge_data.json, mission_unlocks.json)
This commit is contained in:
commit
59ea97b1a6
45 files changed
+1963868
No files matched your search
@@ -0,0 +1,364 @@
|
||||
// Package sdkserver is the Go port of the Rust sdkserver: HTTP dispatch +
|
||||
// auth + SRTools save upload, listening on :21000.
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
google "google.golang.org/protobuf/proto"
|
||||
|
||||
goproto "gac-hkrpg-go/protocol/proto"
|
||||
)
|
||||
|
||||
const Port = 21000
|
||||
|
||||
// VersionConfig is one entry of versions.json.
|
||||
type VersionConfig struct {
|
||||
AssetBundleURL string `json:"asset_bundle_url"`
|
||||
ExResourceURL string `json:"ex_resource_url"`
|
||||
LuaURL string `json:"lua_url"`
|
||||
IfixURL string `json:"ifix_url"`
|
||||
}
|
||||
|
||||
type appState struct {
|
||||
mu sync.Mutex
|
||||
hotfixMap map[string]VersionConfig
|
||||
}
|
||||
|
||||
func loadHotfix() map[string]VersionConfig {
|
||||
const path = "versions.json"
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return map[string]VersionConfig{}
|
||||
}
|
||||
out := map[string]VersionConfig{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
Log("malformed versions.json: " + err.Error())
|
||||
return map[string]VersionConfig{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getOrInsertHotfix mirrors AppState::get_or_insert_hotfix — unknown versions
|
||||
// are fetched from the official gateway via a proxy, then persisted.
|
||||
func (a *appState) getOrInsertHotfix(version, dispatchSeed string) VersionConfig {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if cfg, ok := a.hotfixMap[version]; ok {
|
||||
return cfg
|
||||
}
|
||||
|
||||
Log(fmt.Sprintf("trying to fetch hotfix for version %s with dispatch seed %s", version, dispatchSeed))
|
||||
|
||||
hotfix, err := fetchHotfix(version, dispatchSeed)
|
||||
if err != nil {
|
||||
Log("failed to fetch hotfix. reason: " + err.Error())
|
||||
hotfix = VersionConfig{}
|
||||
}
|
||||
|
||||
a.hotfixMap[version] = hotfix
|
||||
if out, err := json.MarshalIndent(a.hotfixMap, "", " "); err == nil {
|
||||
_ = os.WriteFile("versions.json", out, 0o644)
|
||||
}
|
||||
return a.hotfixMap[version]
|
||||
}
|
||||
|
||||
// fetchHotfix queries the official query_gateway endpoint through a proxy.
|
||||
func fetchHotfix(version, dispatchSeed string) (VersionConfig, error) {
|
||||
const (
|
||||
proxyHost = "proxy1.neonteam.dev"
|
||||
osBeta = "beta-release01-asia.starrails.com"
|
||||
osProd = "prod-official-asia-dp01.starrails.com"
|
||||
cnBeta = "beta-release01-cn.bhsr.com"
|
||||
cnProd = "prod-gf-cn-dp01.bhsr.com"
|
||||
)
|
||||
|
||||
region, branch := parseVersionString(version)
|
||||
host := osBeta
|
||||
switch {
|
||||
case region == "OS" && branch == "PROD":
|
||||
host = osProd
|
||||
case region == "CN" && branch == "BETA":
|
||||
host = cnBeta
|
||||
case region == "CN" && branch == "PROD":
|
||||
host = cnProd
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://%s/%s/query_gateway?version=%s&platform_type=1&language_type=3&dispatch_seed=%s&channel_id=1&sub_channel_id=1&is_need_url=1",
|
||||
proxyHost, host, version, dispatchSeed)
|
||||
|
||||
Log("fetching hotfix: " + url)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return VersionConfig{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return VersionConfig{}, err
|
||||
}
|
||||
Log("raw gateway response: " + string(body))
|
||||
|
||||
bin, err := base64.StdEncoding.DecodeString(string(body))
|
||||
if err != nil {
|
||||
return VersionConfig{}, err
|
||||
}
|
||||
decoded := &goproto.Gateserver{}
|
||||
if err := google.Unmarshal(bin, decoded); err != nil {
|
||||
return VersionConfig{}, err
|
||||
}
|
||||
if decoded.AssetBundleUrl == "" && decoded.ExResourceUrl == "" {
|
||||
return VersionConfig{}, fmt.Errorf("asset_bundle_url and ex_resource_url are empty! gateway response: %+v", decoded)
|
||||
}
|
||||
|
||||
return VersionConfig{
|
||||
AssetBundleURL: decoded.AssetBundleUrl,
|
||||
ExResourceURL: decoded.ExResourceUrl,
|
||||
LuaURL: decoded.LuaUrl,
|
||||
IfixURL: decoded.IfixUrl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseVersionString extracts region/branch, e.g. "OSBETAWin4.6.51" -> OS,BETA.
|
||||
func parseVersionString(s string) (string, string) {
|
||||
branches := []string{"PREbeta", "BETA", "PROD", "DEV", "PRE", "GM", "CECREATION"}
|
||||
if len(s) < 2 {
|
||||
return "OS", "BETA"
|
||||
}
|
||||
region := s[:2]
|
||||
rest := s[2:]
|
||||
for _, b := range branches {
|
||||
if len(rest) >= len(b) && rest[:len(b)] == b {
|
||||
return region, b
|
||||
}
|
||||
}
|
||||
return region, "BETA"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func queryDispatch(w http.ResponseWriter, _ *http.Request) {
|
||||
rsp := &goproto.Dispatch{
|
||||
Retcode: 0,
|
||||
RegionList: []*goproto.RegionInfo{{
|
||||
Name: "GaC hkrpg GO",
|
||||
Title: "GaC hkrpg GO",
|
||||
EnvType: "9",
|
||||
DispatchUrl: fmt.Sprintf("http://127.0.0.1:%d/query_gateway", Port),
|
||||
}},
|
||||
}
|
||||
bin, err := google.Marshal(rsp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(base64.StdEncoding.EncodeToString(bin)))
|
||||
}
|
||||
|
||||
func queryGateway(state *appState) http.HandlerFunc {
|
||||
type params struct {
|
||||
Version string `json:"-"`
|
||||
DispatchSeed string `json:"-"`
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
version := q.Get("version")
|
||||
seed := q.Get("dispatch_seed")
|
||||
_ = params{}
|
||||
|
||||
cfg := state.getOrInsertHotfix(version, seed)
|
||||
|
||||
rsp := &goproto.Gateserver{
|
||||
Ip: "127.0.0.1",
|
||||
Port: 23301,
|
||||
AssetBundleUrl: cfg.AssetBundleURL,
|
||||
AssetBundleUrlB: cfg.AssetBundleURL,
|
||||
ExResourceUrl: cfg.ExResourceURL,
|
||||
LuaUrl: cfg.LuaURL,
|
||||
IfixVersion: "0",
|
||||
Unk1: true,
|
||||
Unk2: true,
|
||||
Unk3: true,
|
||||
Unk4: true,
|
||||
Unk5: true,
|
||||
Unk6: true,
|
||||
Unk7: true,
|
||||
}
|
||||
bin, err := google.Marshal(rsp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(base64.StdEncoding.EncodeToString(bin)))
|
||||
}
|
||||
}
|
||||
|
||||
type accountPayload struct {
|
||||
Data struct {
|
||||
Account map[string]any `json:"account"`
|
||||
} `json:"data"`
|
||||
Message string `json:"message"`
|
||||
Retcode int `json:"retcode"`
|
||||
}
|
||||
|
||||
func standardAccount() map[string]any {
|
||||
return map[string]any{
|
||||
"area_code": "**",
|
||||
"email": "ReversedRooms",
|
||||
"country": "RU",
|
||||
"is_email_verify": "1",
|
||||
"token": "mostsecuretokenever",
|
||||
"uid": "1337",
|
||||
}
|
||||
}
|
||||
|
||||
func loginReply(w http.ResponseWriter) {
|
||||
writeJSON(w, map[string]any{
|
||||
"data": map[string]any{
|
||||
"account": standardAccount(),
|
||||
"device_grant_required": false,
|
||||
"reactivate_required": false,
|
||||
"realperson_required": false,
|
||||
"safe_mobile_required": false,
|
||||
},
|
||||
"message": "OK",
|
||||
"retcode": 0,
|
||||
})
|
||||
}
|
||||
|
||||
func granterLoginReply(w http.ResponseWriter) {
|
||||
writeJSON(w, map[string]any{
|
||||
"data": map[string]any{
|
||||
"account_type": 1,
|
||||
"combo_id": "1337",
|
||||
"combo_token": "9065ad8507d5a1991cb6fddacac5999b780bbd92",
|
||||
"data": `{"guest":false}`,
|
||||
"heartbeat": false,
|
||||
"open_id": "1337",
|
||||
},
|
||||
"message": "OK",
|
||||
"retcode": 0,
|
||||
})
|
||||
}
|
||||
|
||||
func riskyCheckReply(w http.ResponseWriter) {
|
||||
writeJSON(w, map[string]any{
|
||||
"data": map[string]any{
|
||||
"id": "06611ed14c3131a676b19c0d34c0644b",
|
||||
"action": "ACTION_NONE",
|
||||
"geetest": nil,
|
||||
},
|
||||
"message": "OK",
|
||||
"retcode": 0,
|
||||
})
|
||||
}
|
||||
|
||||
func apnLoginReply(w http.ResponseWriter) {
|
||||
writeJSON(w, map[string]any{
|
||||
"data": map[string]any{
|
||||
"token": map[string]any{"token": "mostsecuretokenever", "token_type": 1},
|
||||
"user_info": map[string]any{
|
||||
"aid": "1337",
|
||||
"mid": "1337",
|
||||
"is_email_verify": 1,
|
||||
"area_code": "**",
|
||||
"country": "US",
|
||||
"is_adult": 1,
|
||||
"email": "motorized@wheel.chair",
|
||||
},
|
||||
},
|
||||
"message": "OK",
|
||||
"retcode": 0,
|
||||
})
|
||||
}
|
||||
|
||||
func apnVerifyReply(w http.ResponseWriter) {
|
||||
writeJSON(w, map[string]any{
|
||||
"data": map[string]any{
|
||||
"tokens": []any{map[string]any{"token": "mostsecuretokenever", "token_type": 1}},
|
||||
"user_info": map[string]any{
|
||||
"aid": "1337",
|
||||
"mid": "1337",
|
||||
"is_email_verify": 1,
|
||||
"area_code": "**",
|
||||
"country": "US",
|
||||
"is_adult": 1,
|
||||
"email": "motorized@wheel.chair",
|
||||
},
|
||||
},
|
||||
"message": "OK",
|
||||
"retcode": 0,
|
||||
})
|
||||
}
|
||||
|
||||
func notFound(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]any{"message": "OK", "retcode": 0})
|
||||
}
|
||||
|
||||
// srToolsSave is the /srtools upload endpoint (SRTools writes freesr-data.json).
|
||||
func srToolsSave(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err == nil && len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
}
|
||||
|
||||
if len(payload.Data) == 0 {
|
||||
writeJSON(w, map[string]any{"message": "OK", "status": 200})
|
||||
return
|
||||
}
|
||||
|
||||
// pretty-print and store; freesr-data.json uses the same layout the
|
||||
// gameserver parses, so pass through unchanged
|
||||
var pretty map[string]any
|
||||
if err := json.Unmarshal(payload.Data, &pretty); err != nil {
|
||||
writeJSON(w, map[string]any{"message": "malformed json: " + err.Error(), "status": 500})
|
||||
return
|
||||
}
|
||||
out, _ := json.MarshalIndent(pretty, "", " ")
|
||||
if err := os.WriteFile("freesr-data.json", out, 0o644); err != nil {
|
||||
writeJSON(w, map[string]any{"message": "failed to write freesr-data.json: " + err.Error(), "status": 500})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"message": "OK", "status": 200})
|
||||
}
|
||||
|
||||
// Listen starts the HTTP sdkserver.
|
||||
func Listen() error {
|
||||
state := &appState{hotfixMap: loadHotfix()}
|
||||
Log(fmt.Sprintf("loaded %d hotfix versions", len(state.hotfixMap)))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/query_dispatch", queryDispatch)
|
||||
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/verify", func(w http.ResponseWriter, r *http.Request) { loginReply(w) })
|
||||
mux.HandleFunc("/{product_name}/combo/granter/login/v2/login", func(w http.ResponseWriter, r *http.Request) { granterLoginReply(w) })
|
||||
mux.HandleFunc("/account/risky/api/check", func(w http.ResponseWriter, r *http.Request) { riskyCheckReply(w) })
|
||||
mux.HandleFunc("/account/ma-cn-passport/app/loginByPassword", func(w http.ResponseWriter, r *http.Request) { apnLoginReply(w) })
|
||||
mux.HandleFunc("/account/ma-cn-session/app/verify", func(w http.ResponseWriter, r *http.Request) { apnVerifyReply(w) })
|
||||
mux.HandleFunc("/srtools", srToolsSave)
|
||||
mux.HandleFunc("/", notFound)
|
||||
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", Port)
|
||||
Log("sdkserver is listening at " + addr)
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
|
||||
func Log(msg string) { fmt.Println("[sdkserver]", msg) }
|
||||
Reference in new issue
Block a user