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
+4599 -1402

No files matched your search

+1 -1
View File
@@ -111,7 +111,7 @@ func (m *MultiPathAvatar) UnmarshalJSON(data []byte) error {
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
// value is simply the numeric id (0 for Unk).
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 {
// Match PearlSR's standalone battle relic format. Inventory UniqueID is
// only sent by ToProtoRelic/ToEquipRelic; battle carries the full affixes.
return &goproto.BattleRelic{
Id: r.RelicID,
Level: r.Level,
MainAffixId: r.MainAffixID,
UniqueId: r.UniqueID(),
SubAffixList: relicAffixes(r.SubAffixes),
}
}
@@ -316,19 +317,26 @@ func (m *Monster) ToSceneMonster() *goproto.SceneMonster {
return &goproto.SceneMonster{
MonsterId: m.MonsterID,
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{
WaveId: waveID,
MonsterList: make([]*goproto.SceneMonster, 0, len(monsters)),
// NOTE: WaveParam MUST be nil! In client 4.6.53 GameAssembly.dll
// AdventureModule.CreateBattleServerCheckResult (RVA 0x166b2ef6),
// if WaveParam is non-nil it tries to dereference [wave_param + 0x10]
// and throws NullReferenceException. When nil, it safely jumps over.
WaveParam: nil,
// Include a level so the client can validate this wave directly instead
// of looking up monster data from the battle stage's static table. This
// matters when a Calyx request reuses a custom battle stage ID.
WaveParam: &goproto.SceneMonsterWaveParam{Level: maxLevel},
}
for i := range monsters {
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 {
out := make([]*goproto.SceneMonsterWave, 0, len(waves))
for i, wave := range waves {
out = append(out, monsterWave(uint32(i+1), wave))
for _, wave := range waves {
out = append(out, monsterWave(wave))
}
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")
}
}