- 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)
365 lines
10 KiB
Go
365 lines
10 KiB
Go
// 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) }
|