fix(go): repair migration wire contracts and runtime lifecycle; document incomplete port

This commit is contained in:
CAtlas0784 committed 2026-10-29 15:35:35 +07:00
1 parent e43a310617
commit 32e47014b5
35 files changed
+16826 -438

No files matched your search

+53 -55
View File
@@ -1,81 +1,79 @@
package catalogs
package catalogs
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
var _ = json.RawMessage{}
var _ = sync.Mutex{}
var _ = os.ReadFile
var _ = filepath.Join
// One grantable item row surfaced by the 道具 panel section.
type ItemCatalogEntry4229938 struct {
Id uint32 `json:"id"`
Name string `json:"name"`
Id uint32 `json:"id"`
Name string `json:"name"`
Quality uint32 `json:"quality"`
SubType uint32 `json:"subType"`
}
//
// Build-4229938 backpack item catalog, read from ItemCatalog.json in the configured client-data
// directory.
// That file is a slim projection of the client's ConsumableConfig.json — the only table in the
// config dump whose rows carry the LT.ConfigGen.ICommonItemConfig interface fields, i.e.
// the real backpack item table (Id / Name / Quality / SubType). Ids live in 36000000..36999999,
// which also contains the three wallet currencies (36920001/2/3); callers that grant "everything"
// must skip those so the wallet and the backpack do not disagree.
//
var (
ItemCatalog4229938_cacheLock sync.RWMutex
ItemCatalog4229938_cached = false
ItemCatalog4229938_entries []any
)
var itemCatalogState = struct {
sync.RWMutex
byPath map[string][]ItemCatalogEntry4229938
current []ItemCatalogEntry4229938
}{byPath: make(map[string][]ItemCatalogEntry4229938)}
// LoadItemCatalog4229938 loads catalog records from client config directory
// LoadItemCatalog4229938 accepts the legacy slim ItemCatalog table or the
// ConsumableConfig projection shipped in this Go repository. Cache keys include
// the source path, so loading another configuration cannot reuse the first table.
func LoadItemCatalog4229938(configDir string) ([]any, error) {
ItemCatalog4229938_cacheLock.RLock()
if ItemCatalog4229938_cached {
defer ItemCatalog4229938_cacheLock.RUnlock()
return ItemCatalog4229938_entries, nil
}
ItemCatalog4229938_cacheLock.RUnlock()
ItemCatalog4229938_cacheLock.Lock()
defer ItemCatalog4229938_cacheLock.Unlock()
if ItemCatalog4229938_cached { return ItemCatalog4229938_entries, nil }
filePath := filepath.Join(configDir, "ItemCatalog.json.json")
data, err := os.ReadFile(filePath)
path, err := filepath.Abs(filepath.Join(configDir, "ItemCatalog.json"))
if err != nil {
return nil, err
}
var raw struct {
Records []json.RawMessage `json:"records"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
var result []any
for _, item := range raw.Records {
var m map[string]any
if err := json.Unmarshal(item, &m); err == nil {
result = append(result, m)
itemCatalogState.Lock()
defer itemCatalogState.Unlock()
entries, ok := itemCatalogState.byPath[path]
if !ok {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
data, err = os.ReadFile(filepath.Join(configDir, "ConsumableConfig.json"))
}
if err != nil {
return nil, err
}
var raw struct {
Records []ItemCatalogEntry4229938 `json:"records"`
}
if err = json.Unmarshal(data, &raw); err != nil {
return nil, err
}
if raw.Records == nil {
return nil, fmt.Errorf("item catalog missing records array")
}
entries = make([]ItemCatalogEntry4229938, 0, len(raw.Records))
for _, entry := range raw.Records {
if entry.Id != 0 {
entries = append(entries, entry)
}
}
itemCatalogState.byPath[path] = entries
}
itemCatalogState.current = entries
result := make([]any, len(entries))
for i, entry := range entries {
result[i] = entry
}
ItemCatalog4229938_entries = result
ItemCatalog4229938_cached = true
return result, nil
}
func ItemCatalog4229938_Find(id uint32) *ItemCatalogEntry4229938 {
// Catalog helper ItemCatalog4229938_Find
itemCatalogState.RLock()
defer itemCatalogState.RUnlock()
for _, entry := range itemCatalogState.current {
if entry.Id == id {
copy := entry
return &copy
}
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package catalogs
import (
"os"
"path/filepath"
"testing"
)
func TestItemCatalogPathsAndLookup(t *testing.T) {
for _, file := range []string{"ItemCatalog.json", "ConsumableConfig.json"} {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, file), []byte(`{"records":[{"Id":36000000,"Name":"Test","Quality":2,"SubType":18}]}`), 0600); err != nil {
t.Fatal(err)
}
entries, err := LoadItemCatalog4229938(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("%s: %v %v", file, entries, err)
}
entry := ItemCatalog4229938_Find(36000000)
if entry == nil || entry.Name != "Test" || entry.Quality != 2 {
t.Fatalf("bad projection: %+v", entry)
}
entry.Name = "mutated"
if ItemCatalog4229938_Find(36000000).Name != "Test" {
t.Fatal("caller mutated cache")
}
}
empty := t.TempDir()
if _, err := LoadItemCatalog4229938(empty); err == nil {
t.Fatal("reused another directory's cache")
}
if err := os.WriteFile(filepath.Join(empty, "ItemCatalog.json"), []byte(`{"records":[]}`), 0600); err != nil {
t.Fatal(err)
}
if entries, err := LoadItemCatalog4229938(empty); err != nil || len(entries) != 0 {
t.Fatalf("retry after missing file: %v %v", entries, err)
}
if ItemCatalog4229938_Find(36000000) != nil {
t.Fatal("stale current catalog")
}
}
func TestShippedItemCatalog(t *testing.T) {
entries, err := LoadItemCatalog4229938("../../data/client_data/Configs")
if err != nil || len(entries) == 0 {
t.Fatalf("shipped consumables not loadable: %v", err)
}
if entry := ItemCatalog4229938_Find(36000000); entry == nil || entry.SubType != 18 {
t.Fatalf("missing known item: %+v", entry)
}
}
+4
View File
@@ -8,6 +8,7 @@ import (
)
type Config struct {
Login LoginSettings `json:"login"`
Client ClientConfig `json:"client"`
Network NetworkConfig `json:"network"`
Admin AdminConfig `json:"admin"`
@@ -117,6 +118,9 @@ func LoadConfig(path string) (*Config, error) {
return nil, fmt.Errorf("failed to parse json config from %s: %w", absPath, err)
}
if cfg.Login.RoleListMode == "" {
cfg.Login.RoleListMode = "list"
}
// Apply default values if omitted
if cfg.Network.BindHost == "" {
cfg.Network.BindHost = "0.0.0.0"
+68 -20
View File
@@ -3,7 +3,9 @@ package gameserver
import (
"fmt"
"log"
"math"
"strings"
"time"
"ananta-ps/pkg/protocol"
)
@@ -48,6 +50,8 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
return "", fmt.Errorf("no active players in the game world")
}
targetSess := sessions[0] // Apply to primary active player
targetSess.stateMu.Lock()
defer targetSess.stateMu.Unlock()
switch cmd {
case "teleport", "tp":
@@ -55,15 +59,28 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
return "", fmt.Errorf("usage: teleport <poi_name> OR teleport <x> <y> <z>")
}
if poi, ok := KnownLandmarks[strings.ToLower(parts[1])]; ok {
_ = targetSess.Teleport(poi.X, poi.Y, poi.Z, poi.Facing)
if err := targetSess.teleport(poi.X, poi.Y, poi.Z, poi.Facing); err != nil {
return "", err
}
return fmt.Sprintf("Teleported to %s (%.1f, %.1f, %.1f)", poi.Name, poi.X, poi.Y, poi.Z), nil
}
if len(parts) >= 4 {
var x, y, z float32
_, _ = fmt.Sscanf(parts[1], "%f", &x)
_, _ = fmt.Sscanf(parts[2], "%f", &y)
_, _ = fmt.Sscanf(parts[3], "%f", &z)
_ = targetSess.Teleport(x, y, z, 0)
if _, err := fmt.Sscanf(parts[1], "%f", &x); err != nil {
return "", fmt.Errorf("invalid x coordinate")
}
if _, err := fmt.Sscanf(parts[2], "%f", &y); err != nil {
return "", fmt.Errorf("invalid y coordinate")
}
if _, err := fmt.Sscanf(parts[3], "%f", &z); err != nil {
return "", fmt.Errorf("invalid z coordinate")
}
if math.IsNaN(float64(x)) || math.IsInf(float64(x), 0) || math.IsNaN(float64(y)) || math.IsInf(float64(y), 0) || math.IsNaN(float64(z)) || math.IsInf(float64(z), 0) {
return "", fmt.Errorf("coordinates must be finite")
}
if err := targetSess.teleport(x, y, z, 0); err != nil {
return "", err
}
return fmt.Sprintf("Teleported to coordinates (%.1f, %.1f, %.1f)", x, y, z), nil
}
return "", fmt.Errorf("unknown POI landmark '%s'", parts[1])
@@ -73,8 +90,12 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
return "", fmt.Errorf("usage: weather <id> (e.g. 1=Sunny, 2=Rain, 3=Fog)")
}
var wId uint32
_, _ = fmt.Sscanf(parts[1], "%d", &wId)
ab.server.SetWorldWeather(wId)
if _, err := fmt.Sscanf(parts[1], "%d", &wId); err != nil {
return "", fmt.Errorf("invalid weather id")
}
if err := ab.server.SetWorldWeather(wId); err != nil {
return "", err
}
return fmt.Sprintf("World weather set to ID %d", wId), nil
case "time":
@@ -82,8 +103,12 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
return "", fmt.Errorf("usage: time <hour> (e.g. 12.0 for noon, 0.0 for midnight)")
}
var hour float32
_, _ = fmt.Sscanf(parts[1], "%f", &hour)
ab.server.SetWorldTimeOfDay(hour)
if _, err := fmt.Sscanf(parts[1], "%f", &hour); err != nil {
return "", fmt.Errorf("invalid hour")
}
if err := ab.server.SetWorldTimeOfDay(hour); err != nil {
return "", err
}
return fmt.Sprintf("World time set to %.1f:00", hour), nil
case "wanted":
@@ -95,7 +120,9 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
targetSess.wantedLevel = stars
w := protocol.NewUxWriter()
w.U64(targetSess.pid).U32(stars)
_ = targetSess.NotifyData(protocol.MethodGetMethodIDDefault("SyncWantedLevel", 68582860), w.Bytes())
if err := targetSess.NotifyData(protocol.MethodGetMethodIDDefault("SyncWantedLevel", 68582860), w.Bytes()); err != nil {
return "", err
}
return fmt.Sprintf("Player wanted level set to %d stars", stars), nil
case "spawn_vehicle", "veh":
@@ -103,11 +130,13 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
if len(parts) >= 2 {
_, _ = fmt.Sscanf(parts[1], "%d", &templateId)
}
vehUnitId := uint64(0x6000000000000000) | uint64(len(ab.server.sessions)+1)
vehUnitId := uint64(0x6000000000000000) | uint64(time.Now().UnixNano())
targetSess.activeVehicleId = vehUnitId
w := protocol.NewUxWriter()
w.U64(vehUnitId).U32(templateId).F32(targetSess.posX + 3.0).F32(targetSess.posY).F32(targetSess.posZ + 3.0).F32(targetSess.facing)
_ = targetSess.NotifyData(protocol.MethodGetMethodIDDefault("SyncSpawnVehicle", 68582851), w.Bytes())
if err := targetSess.NotifyData(protocol.MethodGetMethodIDDefault("SyncSpawnVehicle", 68582851), w.Bytes()); err != nil {
return "", err
}
return fmt.Sprintf("Spawned vehicle template %d (unit %d)", templateId, vehUnitId), nil
case "spawn_enemy":
@@ -119,15 +148,30 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
return "", fmt.Errorf("usage: switch <spirit_template_id>")
}
var newId uint32
_, _ = fmt.Sscanf(parts[1], "%d", &newId)
if _, err := fmt.Sscanf(parts[1], "%d", &newId); err != nil || newId == 0 {
return "", fmt.Errorf("invalid spirit template")
}
// Perform clean character swap
newUnitId := targetSess.unitId + 1
_ = targetSess.NotifyData(protocol.MethodSyncLogicAgentLeave, protocol.EncodeLogicAgentLeave(targetSess.unitId))
_ = targetSess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(newUnitId))
_ = targetSess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(newUnitId, targetSess.pid, 0))
if err := targetSess.NotifyData(protocol.MethodSyncLogicAgentLeave, protocol.EncodeLogicAgentLeave(targetSess.unitId)); err != nil {
return "", err
}
if err := targetSess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(targetSess.pid, newId, newUnitId, true)); err != nil {
return "", err
}
if err := targetSess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(newUnitId)); err != nil {
return "", err
}
if err := targetSess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(newUnitId, targetSess.pid, 0)); err != nil {
return "", err
}
newProj := protocol.EncodeRaidBattleUnitSpirit(newUnitId, newId, targetSess.posX, targetSess.posY, targetSess.posZ, targetSess.facing, targetSess.pid)
_ = targetSess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, newProj)
_ = targetSess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(targetSess.pid, newId, newUnitId, true))
if err := targetSess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, newProj); err != nil {
return "", err
}
if err := targetSess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(targetSess.pid, newId, newUnitId, true)); err != nil {
return "", err
}
targetSess.templateId = newId
targetSess.unitId = newUnitId
return fmt.Sprintf("Switched player character to template %d", newId), nil
@@ -135,13 +179,17 @@ func (ab *AdminBridge) ExecuteCommand(cmdLine string) (string, error) {
case "fog_unlock":
wFog := protocol.NewUxWriter()
wFog.U32(uint32(targetSess.activeScene)).Bool(true)
_ = targetSess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes())
if err := targetSess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes()); err != nil {
return "", err
}
return "All map fog unlocked", nil
case "dive_unlock":
wDive := protocol.NewUxWriter()
wDive.UxString("EnableDive").U8(0xFF).Int7(1).UxString("true")
_ = targetSess.NotifyData(protocol.MethodSyncRunGmUtils, wDive.Bytes())
if err := targetSess.NotifyData(protocol.MethodSyncRunGmUtils, wDive.Bytes()); err != nil {
return "", err
}
return "Diving mechanics enabled", nil
default:
+193
View File
@@ -0,0 +1,193 @@
package gameserver
import (
"bytes"
"encoding/binary"
"math"
"net"
"testing"
"time"
"ananta-ps/internal/config"
"ananta-ps/pkg/protocol"
)
func TestWeatherAndTimeUseActualClientContracts(t *testing.T) {
srv := New(&config.Config{}, "")
conn := &recordingConn{}
srv.sessions["test"] = &Session{conn: conn}
if err := srv.SetWorldWeather(3); err != nil {
t.Fatal(err)
}
f, err := protocol.ReadFrame(conn, nil)
if err != nil {
t.Fatal(err)
}
pkt := protocol.ParseRpcPacket(f.Payload)
if pkt.MethodID != 68712141 || !bytes.Equal(pkt.Body, []byte{3, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0}) {
t.Fatalf("weather contract: %+v", pkt)
}
if err := srv.SetWorldTimeOfDay(12.5); err != nil {
t.Fatal(err)
}
f, err = protocol.ReadFrame(conn, nil)
if err != nil {
t.Fatal(err)
}
pkt = protocol.ParseRpcPacket(f.Payload)
if pkt.MethodID != 68337673 || len(pkt.Body) != 22 || binary.LittleEndian.Uint32(pkt.Body[4:8]) != 45000 || pkt.Body[8] != 1 {
t.Fatalf("time contract: %+v", pkt)
}
for _, hour := range []float32{-1, 24, float32(math.NaN()), float32(math.Inf(1))} {
if err := srv.SetWorldTimeOfDay(hour); err == nil {
t.Fatalf("invalid hour: %v", hour)
}
}
if conn.Len() != 0 {
t.Fatal("invalid time emitted a packet")
}
}
func TestAdminRejectsInvalidTeleport(t *testing.T) {
srv := New(&config.Config{}, "")
conn := &recordingConn{}
srv.sessions["test"] = &Session{conn: conn, posX: 123}
for _, cmd := range []string{"tp garbage 0 0", "tp NaN 0 0", "tp 0 +Inf 0"} {
if _, err := srv.GetAdminBridge().ExecuteCommand(cmd); err == nil {
t.Fatalf("accepted %s", cmd)
}
}
if conn.Len() != 0 || srv.sessions["test"].posX != 123 {
t.Fatal("invalid command changed position")
}
}
type recordingConn struct{ bytes.Buffer }
func (c *recordingConn) Close() error { return nil }
func (c *recordingConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
func (c *recordingConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (c *recordingConn) SetDeadline(time.Time) error { return nil }
func (c *recordingConn) SetReadDeadline(time.Time) error { return nil }
func (c *recordingConn) SetWriteDeadline(time.Time) error { return nil }
func TestDispatchContractFallbackAndPacketKinds(t *testing.T) {
srv := New(&config.Config{}, "")
conn := &recordingConn{}
sess := &Session{conn: conn}
// An unimplemented RPC returning bool needs one byte, not an empty OK.
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindInvoke, MethodID: 67709054, InvokeID: 12}); err != nil {
t.Fatal(err)
}
f, err := protocol.ReadFrame(conn, nil)
if err != nil {
t.Fatal(err)
}
pkt := protocol.ParseRpcPacket(f.Payload)
if pkt.Kind != protocol.PacketKindReturn || pkt.InvokeID != 12 || !bytes.Equal(pkt.Body, []byte{0}) {
t.Fatalf("bad bool fallback: %+v", pkt)
}
for _, kind := range []protocol.RpcPacketKind{protocol.PacketKindReturn, protocol.PacketKindUnknown} {
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: kind, MethodID: protocol.MethodAskSwitchSpirit}); err != nil {
t.Fatal(err)
}
if conn.Len() != 0 {
t.Fatal("dispatched a return or malformed packet as a request")
}
}
if err := sess.ReturnEmptyOk(0, protocol.MethodLoginGame); err != nil {
t.Fatal(err)
}
if conn.Len() != 0 {
t.Fatal("unsolicited return for a notification")
}
// Fabricated old AskMoveStart collided with a real client ID; it must not move state.
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindNotify, MethodID: 68412852}); err != nil {
t.Fatal(err)
}
if conn.Len() != 0 {
t.Fatal("invented route still active")
}
}
func TestHangupAndActualMovementContract(t *testing.T) {
srv := New(&config.Config{}, "")
sess := &Session{conn: &recordingConn{}, unitId: 9}
for _, tc := range []struct {
id uint32
paused bool
}{{63842476, true}, {63910160, false}} {
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindInvoke, MethodID: tc.id, InvokeID: 1}); err != nil {
t.Fatal(err)
}
if sess.hangup.Load() != tc.paused {
t.Fatal("hangup state not updated")
}
}
body := protocol.NewUxWriter().U8(0xff).Int7(1).U64(9).F32(1).F32(2).F32(3).F32(0).F32(90).F32(0).Bytes()
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindNotify, MethodID: 67543012, Body: body}); err != nil {
t.Fatal(err)
}
if sess.posX != 1 || sess.posY != 2 || sess.posZ != 3 || sess.facing != 90 {
t.Fatal("actual movement contract not applied")
}
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindNotify, MethodID: 67543012, Body: body[:15]}); err == nil {
t.Fatal("truncated position accepted")
}
if sess.posX != 1 || sess.posY != 2 {
t.Fatal("partial position update")
}
}
func TestTeleportIncludesNullableTail(t *testing.T) {
conn := &recordingConn{}
sess := &Session{conn: conn, unitId: 9}
if err := sess.Teleport(1, 2, 3, 90); err != nil {
t.Fatal(err)
}
f, err := protocol.ReadFrame(conn, nil)
if err != nil {
t.Fatal(err)
}
pkt := protocol.ParseRpcPacket(f.Payload)
if pkt.MethodID != 68030312 || len(pkt.Body) != 29 || !bytes.Equal(pkt.Body[24:], []byte{0, 0, 6, 0, 0}) {
t.Fatalf("invalid position contract: %+v", pkt)
}
}
func TestStopClosesIdleHandshakeConnections(t *testing.T) {
srv := New(&config.Config{Network: config.NetworkConfig{BindHost: "127.0.0.1"}}, "")
if err := srv.Start(); err != nil {
t.Fatal(err)
}
conn, err := net.DialTimeout("tcp", srv.ln.Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
// Wait for the server to own the socket before stopping.
deadline := time.Now().Add(time.Second)
for {
srv.mu.RLock()
n := len(srv.connections)
srv.mu.RUnlock()
if n == 1 {
break
}
if time.Now().After(deadline) {
t.Fatal("connection not accepted")
}
time.Sleep(time.Millisecond)
}
done := make(chan struct{})
go func() { srv.Stop(); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Stop blocked on idle handshake")
}
conn.SetReadDeadline(time.Now().Add(time.Second))
if _, err := conn.Read(make([]byte, 1)); err == nil {
t.Fatal("idle connection survived Stop")
}
}
+54
View File
@@ -0,0 +1,54 @@
package gameserver
import (
"fmt"
"math"
"ananta-ps/pkg/protocol"
)
// Both movement RPCs carry a List7Bit of inline structs. LogicAgentSyncData
// has no MoveId; SimpleMoveActionData adds one byte after the two vectors.
func (s *Server) handleMovementReport(sess *Session, pkt *protocol.RpcPacket, simple bool) error {
r := protocol.NewUxReader(pkt.Body)
mark, err := r.U8()
if err != nil {
return err
}
if mark == 0 && r.Remaining() == 0 {
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
}
if mark != 0xff {
return fmt.Errorf("invalid movement list marker")
}
count, err := r.Int7()
if err != nil {
return err
}
size := 32
if simple {
size++
}
if count > r.Remaining()/size || count*size != r.Remaining() {
return fmt.Errorf("invalid movement sample count")
}
x, y, z, yaw := sess.posX, sess.posY, sess.posZ, sess.facing
for i := 0; i < count; i++ {
id, _ := r.U64()
var v [6]float32
for j := range v {
v[j], err = r.F32()
if err != nil || math.IsNaN(float64(v[j])) || math.IsInf(float64(v[j]), 0) {
return fmt.Errorf("invalid movement coordinate")
}
}
if simple {
_, _ = r.U8()
}
if id == sess.unitId {
x, y, z, yaw = v[0], v[1], v[2], v[4]
}
}
sess.posX, sess.posY, sess.posZ, sess.facing = x, y, z, yaw
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
}
+200 -122
View File
@@ -6,8 +6,11 @@ import (
"io"
"log"
"net"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"ananta-ps/internal/config"
"ananta-ps/pkg/crypto/chacha8"
@@ -20,6 +23,7 @@ type Server struct {
ln net.Listener
cancelFunc context.CancelFunc
wg sync.WaitGroup
connections map[net.Conn]struct{}
sessions map[string]*Session
mu sync.RWMutex
trafficEngine *CityTrafficEngine
@@ -28,28 +32,34 @@ type Server struct {
}
type Session struct {
id string
conn net.Conn
readCipher *chacha8.UxChaCha8
sendCipher *chacha8.UxChaCha8
sendMu sync.Mutex
pid uint64
unitId uint64
templateId uint32
posX float32
posY float32
posZ float32
facing float32
activeScene uint64
activeRaid uint32
universeId uint32
lastShopExitMs int64
pendingTeleport *PreTeleportOption
activeVehicleId uint64
isDriving bool
wantedLevel uint32
balloonScore uint32
racing *RacingSession
replyKind protocol.RpcPacketKind
stateMu sync.Mutex
ready atomic.Bool
hangup atomic.Bool
initialStateSent bool
writeTimeout time.Duration
id string
conn net.Conn
readCipher *chacha8.UxChaCha8
sendCipher *chacha8.UxChaCha8
sendMu sync.Mutex
pid uint64
unitId uint64
templateId uint32
posX float32
posY float32
posZ float32
facing float32
activeScene uint64
activeRaid uint32
universeId uint32
lastShopExitMs int64
pendingTeleport *PreTeleportOption
activeVehicleId uint64
isDriving bool
wantedLevel uint32
balloonScore uint32
racing *RacingSession
}
func New(cfg *config.Config, templatePath string) *Server {
@@ -57,11 +67,13 @@ func New(cfg *config.Config, templatePath string) *Server {
candidates := []string{
"data/player_info_template.bin",
"../data/player_info_template.bin",
"../../data/player_info_template.bin",
}
for _, c := range candidates {
abs, _ := filepath.Abs(c)
templatePath = abs
break
if _, err := os.Stat(c); err == nil {
templatePath, _ = filepath.Abs(c)
break
}
}
}
@@ -69,6 +81,7 @@ func New(cfg *config.Config, templatePath string) *Server {
cfg: cfg,
templatePath: templatePath,
sessions: make(map[string]*Session),
connections: make(map[net.Conn]struct{}),
}
srv.trafficEngine = NewCityTrafficEngine(srv)
srv.crowdEngine = NewUrbanCrowdEngine(srv)
@@ -84,16 +97,15 @@ func (s *Server) Start() error {
ctx, cancel := context.WithCancel(context.Background())
s.cancelFunc = cancel
// Load Road Network for city traffic simulation
_ = s.trafficEngine.LoadRoadNetwork("data/client_data/World/RoadNetwork.json")
s.trafficEngine.Start(ctx)
addr := fmt.Sprintf("%s:%d", s.cfg.Network.BindHost, s.cfg.Network.GamePort)
ln, err := net.Listen("tcp", addr)
if err != nil {
cancel()
return fmt.Errorf("failed to listen on game server %s: %w", addr, err)
}
s.ln = ln
_ = s.trafficEngine.LoadRoadNetwork("data/client_data/World/RoadNetwork.json")
s.trafficEngine.Start(ctx)
log.Printf("[GAME-SERVER] Listening on %s:%d", s.cfg.Network.AdvertisedHost, s.cfg.Network.GamePort)
s.wg.Add(1)
@@ -103,15 +115,20 @@ func (s *Server) Start() error {
}
func (s *Server) Stop() error {
if s.trafficEngine != nil {
s.trafficEngine.Stop()
}
if s.cancelFunc != nil {
s.cancelFunc()
}
if s.ln != nil {
_ = s.ln.Close()
}
s.mu.Lock()
for conn := range s.connections {
_ = conn.Close()
}
s.mu.Unlock()
if s.trafficEngine != nil {
s.trafficEngine.Stop()
}
s.wg.Wait()
return nil
}
@@ -140,7 +157,20 @@ func (s *Server) acceptLoop(ctx context.Context) {
}
}
go s.handleConnection(ctx, conn)
s.mu.Lock()
if ctx.Err() != nil {
s.mu.Unlock()
conn.Close()
return
}
s.connections[conn] = struct{}{}
s.wg.Add(1)
s.mu.Unlock()
go func() {
defer s.wg.Done()
defer func() { s.mu.Lock(); delete(s.connections, conn); s.mu.Unlock() }()
s.handleConnection(ctx, conn)
}()
}
}
@@ -158,10 +188,29 @@ func (sess *Session) SendPacket(packet *protocol.RpcPacket) error {
payload = protocol.BuildNotify(packet.MethodID, packet.Body)
}
return protocol.WriteFrame(sess.conn, 0x09, payload, sess.sendCipher)
return sess.writeFrameLocked(0x09, payload)
}
func (sess *Session) writeFrameLocked(mode byte, payload []byte) error {
timeout := sess.writeTimeout
if timeout <= 0 {
timeout = 15 * time.Second
}
if err := sess.conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
return err
}
if err := protocol.WriteFrame(sess.conn, mode, payload, sess.sendCipher); err != nil {
// The cipher has advanced; this stream cannot be reused after a failed write.
sess.conn.Close()
return err
}
return nil
}
func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
if sess.replyKind != protocol.PacketKindInvoke {
return nil
}
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindReturn,
InvokeID: invokeID,
@@ -172,6 +221,9 @@ func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
}
func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) error {
if sess.replyKind != protocol.PacketKindInvoke {
return nil
}
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindReturn,
InvokeID: invokeID,
@@ -182,6 +234,9 @@ func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) er
}
func (sess *Session) NotifyData(methodID uint32, body []byte) error {
if methodID == 0 {
return fmt.Errorf("unverified notification method")
}
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindNotify,
MethodID: methodID,
@@ -201,20 +256,21 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
}
sess := &Session{
id: conn.RemoteAddr().String(),
conn: conn,
readCipher: readCipher,
sendCipher: writeCipher,
pid: s.cfg.Player.Pid,
unitId: s.cfg.Player.InitialUnitId,
templateId: s.cfg.Player.InitialSpiritTemplateId,
posX: float32(s.cfg.World.Spawn.X),
posY: float32(s.cfg.World.Spawn.Y),
posZ: float32(s.cfg.World.Spawn.Z),
facing: s.cfg.World.Facing,
activeRaid: s.cfg.World.RaidId,
activeScene: s.cfg.World.SceneInstanceId,
universeId: s.cfg.World.UniverseId,
id: conn.RemoteAddr().String(),
writeTimeout: time.Duration(s.cfg.Network.WriteTimeoutMs) * time.Millisecond,
conn: conn,
readCipher: readCipher,
sendCipher: writeCipher,
pid: s.cfg.Player.Pid,
unitId: s.cfg.Player.InitialUnitId,
templateId: s.cfg.Player.InitialSpiritTemplateId,
posX: float32(s.cfg.World.Spawn.X),
posY: float32(s.cfg.World.Spawn.Y),
posZ: float32(s.cfg.World.Spawn.Z),
facing: s.cfg.World.Facing,
activeRaid: s.cfg.World.RaidId,
activeScene: s.cfg.World.SceneInstanceId,
universeId: s.cfg.World.UniverseId,
}
s.mu.Lock()
@@ -243,10 +299,18 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
}
if frame.Mode == 0x03 {
_ = protocol.WriteFrame(conn, 0x03, frame.Payload, sess.sendCipher)
sess.sendMu.Lock()
err = sess.writeFrameLocked(0x03, frame.Payload)
sess.sendMu.Unlock()
if err != nil {
return
}
continue
}
if frame.Mode != 0x09 {
continue
}
packet := protocol.ParseRpcPacket(frame.Payload)
if err := s.dispatch(sess, &packet); err != nil {
log.Printf("[GAME-SERVER] Dispatch error for method %d (%s): %v",
@@ -256,19 +320,34 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
}
func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
if pkt.Kind != protocol.PacketKindInvoke && pkt.Kind != protocol.PacketKindNotify {
return nil
}
sess.stateMu.Lock()
defer sess.stateMu.Unlock()
sess.replyKind = pkt.Kind
defer func() { sess.replyKind = protocol.PacketKindUnknown }()
switch pkt.MethodID {
// ---------------- Login / World Entry ----------------
case protocol.MethodLoginGame:
if sess.initialStateSent {
return nil
}
sess.initialStateSent = true
log.Printf("[WORLD] LoginGame received, preparing world state for player %d", sess.pid)
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
return err
}
_ = sess.NotifyData(protocol.MethodSendServerTimeGame, protocol.EncodeServerTime(0))
if err := sess.NotifyData(protocol.MethodSendServerTimeGame, protocol.EncodeServerTime(0)); err != nil {
return err
}
playerInfoBytes, err := protocol.EncodePlayerInfo(s.templatePath, s.cfg.Client.Aid, sess.pid, s.cfg.Player.AccountId, s.cfg.Player.DisplayName)
if err == nil {
_ = sess.NotifyData(protocol.MethodSyncPlayerInfo, playerInfoBytes)
if err := sess.NotifyData(protocol.MethodSyncPlayerInfo, playerInfoBytes); err != nil {
return err
}
} else {
log.Printf("[WORLD] Warning: player_info_template.bin load error: %v", err)
}
@@ -295,10 +374,16 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
return err
}
_ = sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(sess.unitId))
_ = sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(sess.unitId, sess.pid, 0))
if err := sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(sess.unitId)); err != nil {
return err
}
if err := sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(sess.unitId, sess.pid, 0)); err != nil {
return err
}
unitProjection := protocol.EncodeRaidBattleUnitSpirit(sess.unitId, sess.templateId, sess.posX, sess.posY, sess.posZ, sess.facing, sess.pid)
_ = sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, unitProjection)
if err := sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, unitProjection); err != nil {
return err
}
return sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, sess.templateId, sess.unitId, false))
case protocol.MethodAskLoadingFinished:
@@ -307,19 +392,28 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
return err
}
_ = sess.NotifyData(protocol.MethodSyncSceneLoadCompleted, protocol.EncodeSceneLoadCompleted(sess.activeScene))
if err := sess.NotifyData(protocol.MethodSyncSceneLoadCompleted, protocol.EncodeSceneLoadCompleted(sess.activeScene)); err != nil {
return err
}
sess.ready.Store(true)
wPause := protocol.NewUxWriter()
wPause.Bool(false)
_ = sess.NotifyData(protocol.MethodSyncGamePause, wPause.Bytes())
if err := sess.NotifyData(protocol.MethodSyncGamePause, wPause.Bytes()); err != nil {
return err
}
wFog := protocol.NewUxWriter()
wFog.U32(uint32(sess.activeScene)).Bool(true)
_ = sess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes())
if err := sess.NotifyData(protocol.MethodSyncSceneFogMapAllUnlock, wFog.Bytes()); err != nil {
return err
}
wPower := protocol.NewUxWriter()
wPower.U8(0xFF).Int7(1).U32(sess.templateId).F32(1000.0)
_ = sess.NotifyData(protocol.MethodSyncAllSpiritCombatPower, wPower.Bytes())
if err := sess.NotifyData(protocol.MethodSyncAllSpiritCombatPower, wPower.Bytes()); err != nil {
return err
}
wDive := protocol.NewUxWriter()
wDive.UxString("EnableDive").U8(0xFF).Int7(1).UxString("true")
@@ -330,32 +424,29 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
w.U64(sess.pid).F64(1.0)
return sess.NotifyData(protocol.MethodSyncPlayerLoadRate, w.Bytes())
// ---------------- Movement & Position ----------------
case protocol.MethodAskMoveStart, protocol.MethodAskMoveStop:
case protocol.MethodRequestPlayerStartHangup:
sess.hangup.Store(true)
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
case protocol.MethodRequestPlayerStopHangup:
sess.hangup.Store(false)
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
case protocol.MethodAskReportLogicAgentSyncData:
return s.handleMovementReport(sess, pkt, false)
case protocol.MethodAskUnitMoveActionSimple:
return s.handleMovementReport(sess, pkt, true)
case protocol.MethodAskUpdatePlayerPosition:
r := protocol.NewUxReader(pkt.Body)
if x, err := r.F32(); err == nil {
sess.posX = x
}
if y, err := r.F32(); err == nil {
sess.posY = y
}
if z, err := r.F32(); err == nil {
sess.posZ = z
}
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
// ---------------- Movement & Position ----------------
// ---------------- Character Switching ----------------
case protocol.MethodAskPreSwitchSpirit:
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
case protocol.MethodAskSwitchSpirit:
r := protocol.NewUxReader(pkt.Body)
newTemplateId := sess.templateId
if tid, err := r.U32(); err == nil {
newTemplateId = tid
newTemplateId, err := r.U32()
if err != nil {
return err
}
if newTemplateId == 0 {
return fmt.Errorf("spirit template must be nonzero")
}
newUnitId := sess.unitId + 1
@@ -364,17 +455,32 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
return err
}
_ = sess.NotifyData(protocol.MethodSyncLogicAgentLeave, protocol.EncodeLogicAgentLeave(sess.unitId))
_ = sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(newUnitId))
_ = sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(newUnitId, sess.pid, 0))
if err := sess.NotifyData(protocol.MethodSyncLogicAgentLeave, protocol.EncodeLogicAgentLeave(sess.unitId)); err != nil {
return err
}
if err := sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, newTemplateId, newUnitId, true)); err != nil {
return err
}
if err := sess.NotifyData(protocol.MethodSyncLogicAgentEnter, protocol.EncodeLogicAgentEnter(newUnitId)); err != nil {
return err
}
if err := sess.NotifyData(protocol.MethodSyncManagedLogicAgent, protocol.EncodeManagedLogicAgent(newUnitId, sess.pid, 0)); err != nil {
return err
}
newProj := protocol.EncodeRaidBattleUnitSpirit(newUnitId, newTemplateId, sess.posX, sess.posY, sess.posZ, sess.facing, sess.pid)
_ = sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, newProj)
_ = sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, newTemplateId, newUnitId, true))
if err := sess.NotifyData(protocol.MethodSyncRaidBattleUnitSpirit, newProj); err != nil {
return err
}
if err := sess.NotifyData(protocol.MethodSyncPlayerCurrentSpirit, protocol.EncodePlayerCurrentSpirit(sess.pid, newTemplateId, newUnitId, true)); err != nil {
return err
}
wInSitu := protocol.NewUxWriter()
wInSitu.U32(newTemplateId).U64(newUnitId)
_ = sess.NotifyData(protocol.MethodSyncSwitchSpiritInSitu, wInSitu.Bytes())
if err := sess.NotifyData(protocol.MethodSyncSwitchSpiritInSitu, wInSitu.Bytes()); err != nil {
return err
}
sess.templateId = newTemplateId
sess.unitId = newUnitId
@@ -397,12 +503,6 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
case protocol.MethodAskSummonVehicle:
return s.handleAskSummonVehicle(sess, pkt)
case protocol.MethodAskDriveVehicle:
return s.handleAskDriveVehicle(sess, pkt)
case protocol.MethodAskExitVehicle:
return s.handleAskExitVehicle(sess, pkt)
case protocol.MethodAskVehicleMove:
return s.handleAskVehicleMove(sess, pkt)
@@ -413,20 +513,6 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
return s.handleAskVehicleSendGamePlaySignal(sess, pkt)
// ---------------- Police & Crime ----------------
case protocol.MethodAskSetWantedLevel:
return s.handleAskSetWantedLevel(sess, pkt)
case protocol.MethodAskPoliceArrest:
return s.handleAskPoliceArrest(sess, pkt)
case protocol.MethodReportPoliceArrestFinish:
return s.handleReportPoliceArrestFinish(sess, pkt)
case protocol.MethodAskPayFine:
return s.handleAskPayFine(sess, pkt)
case protocol.MethodAskReleaseFromPrison:
return s.handleAskReleaseFromPrison(sess, pkt)
// ---------------- Dialog & NPC & Crowd ----------------
case protocol.MethodAskStartDialog:
@@ -447,9 +533,6 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
case protocol.MethodAskDoNpcAction:
return s.handleAskDoNpcAction(sess, pkt)
case protocol.MethodAskSetCrowdDensity:
return s.handleAskSetCrowdDensity(sess, pkt)
case protocol.MethodAskPhoneAddCallRecord:
return s.handleAskPhoneAddCallRecord(sess, pkt)
@@ -484,21 +567,7 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
case protocol.MethodAskControlPowerHoldEnemyFallGrou:
return s.handleAskControlPowerHoldEnemyFallGrou(sess, pkt)
case protocol.MethodAskPlayerAttack:
return s.handleAskPlayerAttack(sess, pkt)
case protocol.MethodAskPlayerCastSkill:
return s.handleAskPlayerCastSkill(sess, pkt)
case protocol.MethodAskUnlockFightStyle:
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
// ---------------- Mini-games ----------------
case protocol.MethodAskChineseChessMove:
return s.handleAskChineseChessMove(sess, pkt)
case protocol.MethodAskMahjongAction:
return s.handleAskMahjongAction(sess, pkt)
case protocol.MethodEnterBalloonZone:
return s.handleEnterBalloonZone(sess, pkt)
@@ -554,7 +623,10 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
default:
// Safe neutral typed fallback for unrouted RPC invokes
if pkt.Kind == protocol.PacketKindInvoke {
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
if body, ok := protocol.DefaultReturnBody(pkt.MethodID); ok {
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, body)
}
return sess.SendPacket(&protocol.RpcPacket{Kind: protocol.PacketKindReturn, InvokeID: pkt.InvokeID, MethodID: pkt.MethodID, Error: -1})
}
}
@@ -563,12 +635,18 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
// Teleport moves an active session to the specified coordinates.
func (sess *Session) Teleport(x, y, z, facing float32) error {
sess.stateMu.Lock()
defer sess.stateMu.Unlock()
return sess.teleport(x, y, z, facing)
}
func (sess *Session) teleport(x, y, z, facing float32) error {
sess.posX = x
sess.posY = y
sess.posZ = z
sess.facing = facing
w := protocol.NewUxWriter()
w.U64(sess.unitId).F32(x).F32(y).F32(z).F32(facing).U8(0).Bool(false).U8(6) // SetPositionType.Teleport = 6
w.U64(sess.unitId).F32(x).F32(y).F32(z).F32(facing).U8(0).Bool(false).U8(6).U8(0).U8(0) // nullable moveGroundInfo and loadingInfo
return sess.NotifyData(protocol.MethodSyncUnitPositionAndFacing, w.Bytes())
}
+1 -1
View File
@@ -133,7 +133,7 @@ func (s *Server) handleReportPreTeleportFinish(sess *Session, pkt *protocol.RpcP
if opt != nil {
dest := opt.Position
if dest.X != 0 || dest.Y != 0 || dest.Z != 0 {
_ = sess.Teleport(dest.X, dest.Y, dest.Z, opt.Facing)
_ = sess.teleport(dest.X, dest.Y, dest.Z, opt.Facing)
}
}
return nil
+12 -2
View File
@@ -30,6 +30,7 @@ type RoadNetworkData struct {
}
type CityTrafficEngine struct {
wg sync.WaitGroup
mu sync.RWMutex
vehicles map[uint64]*TrafficVehicle
lanes []RoadLane
@@ -97,23 +98,29 @@ func (te *CityTrafficEngine) Start(ctx context.Context) {
LaneLength: lane.Length,
}
}
count := len(te.vehicles)
te.wg.Add(1)
te.mu.Unlock()
log.Printf("[TRAFFIC] CityTrafficEngine started with %d simulated AI vehicles", len(te.vehicles))
log.Printf("[TRAFFIC] CityTrafficEngine started with %d simulated AI vehicles", count)
go te.runLoop(ctx)
}
func (te *CityTrafficEngine) Stop() {
te.mu.Lock()
defer te.mu.Unlock()
if te.cancel != nil {
te.cancel()
}
te.isRunning = false
te.mu.Unlock()
te.wg.Wait()
te.mu.Lock()
te.vehicles = make(map[uint64]*TrafficVehicle)
te.mu.Unlock()
}
func (te *CityTrafficEngine) runLoop(ctx context.Context) {
defer te.wg.Done()
ticker := time.NewTicker(150 * time.Millisecond) // ~6.7 Hz downsampled traffic lane updates
defer ticker.Stop()
@@ -151,6 +158,9 @@ func (te *CityTrafficEngine) tick(dt float32) {
methodID := protocol.MethodGetMethodIDDefault("SyncAetherAIVehicleLaneDatas", 68582850)
sessions := te.server.GetActiveSessions()
for _, sess := range sessions {
if !sess.ready.Load() || sess.hangup.Load() {
continue
}
_ = sess.NotifyData(methodID, payload)
}
}
+20 -8
View File
@@ -1,33 +1,45 @@
package gameserver
import (
"fmt"
"log"
"math"
"time"
"ananta-ps/pkg/protocol"
)
func (s *Server) SetWorldWeather(weatherId uint32) {
func (s *Server) SetWorldWeather(weatherId uint32) error {
log.Printf("[WEATHER] Setting world weather to %d", weatherId)
w := protocol.NewUxWriter()
w.U32(weatherId)
w.U32(weatherId).U32(weatherId).U32(0)
payload := w.Bytes()
methodID := protocol.MethodGetMethodIDDefault("SyncTimeWeather", 68582910)
methodID := protocol.MethodGetMethodIDDefault("SyncPlayerWeather", 68712141)
for _, sess := range s.GetActiveSessions() {
_ = sess.NotifyData(methodID, payload)
if err := sess.NotifyData(methodID, payload); err != nil {
return err
}
}
return nil
}
func (s *Server) SetWorldTimeOfDay(hour float32) {
func (s *Server) SetWorldTimeOfDay(hour float32) error {
if math.IsNaN(float64(hour)) || math.IsInf(float64(hour), 0) || hour < 0 || hour >= 24 {
return fmt.Errorf("hour must be in [0,24)")
}
log.Printf("[WEATHER] Setting world time of day to %.1f:00", hour)
w := protocol.NewUxWriter()
w.F32(hour)
w.U32(uint32(time.Now().Unix())).U32(uint32(hour * 3600)).Bool(true).Bool(false).U32(0).I32(0).I32(0)
payload := w.Bytes()
methodID := protocol.MethodGetMethodIDDefault("SyncTimeOfDay", 68582911)
methodID := protocol.MethodGetMethodIDDefault("SyncPlayerCurrentTime", 68337673)
for _, sess := range s.GetActiveSessions() {
_ = sess.NotifyData(methodID, payload)
if err := sess.NotifyData(methodID, payload); err != nil {
return err
}
}
return nil
}
func (s *Server) handleGmUnlockAllFogMap(sess *Session, pkt *protocol.RpcPacket) error {
+30 -69
View File
@@ -60,80 +60,41 @@ func New(cfg *config.Config, publicDir, certDir string) *Gateway {
}
func (g *Gateway) Start() error {
ctx, cancel := context.WithCancel(context.Background())
g.cancelFunc = cancel
cert, err := tls.LoadX509KeyPair(filepath.Join(g.certDir, "cert.pem"), filepath.Join(g.certDir, "key.pem"))
if err != nil {
return fmt.Errorf("load gateway TLS certificate: %w", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", g.handleDispatch)
// 1. Port 5801: LoginList & Mobile Dispatch Server
listAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.LoginListPort)
g.listSrv = &http.Server{
Addr: listAddr,
Handler: mux,
}
go func() {
log.Printf("[GATEWAY] LoginList & Mobile Dispatch listening on http://%s (Port %d)", g.cfg.Network.AdvertisedHost, g.cfg.Network.Proxy.LoginListPort)
if err := g.listSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[GATEWAY] LoginList server error: %v", err)
}
}()
// 2. Port 80: HTTP Gateway
httpAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.HttpPort)
g.httpSrv = &http.Server{
Addr: httpAddr,
Handler: mux,
}
go func() {
log.Printf("[GATEWAY] HTTP Proxy listening on port %d", g.cfg.Network.Proxy.HttpPort)
if err := g.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[GATEWAY] HTTP Proxy error (might need Admin privileges for port 80): %v", err)
}
}()
// 3. Port 443: HTTPS Proxy with TLS SNI
certFile := filepath.Join(g.certDir, "cert.pem")
keyFile := filepath.Join(g.certDir, "key.pem")
if _, err := os.Stat(certFile); err == nil {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err == nil {
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
return &cert, nil
},
addr := func(port int) string { return net.JoinHostPort(g.cfg.Network.BindHost, fmt.Sprint(port)) }
g.listSrv = &http.Server{Addr: addr(g.cfg.Network.Proxy.LoginListPort), Handler: mux}
g.httpSrv = &http.Server{Addr: addr(g.cfg.Network.Proxy.HttpPort), Handler: mux}
g.httpsSrv = &http.Server{Addr: addr(g.cfg.Network.Proxy.HttpsPort), Handler: mux,
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}}
servers := []*http.Server{g.listSrv, g.httpSrv, g.httpsSrv}
listeners := make([]net.Listener, 0, len(servers))
for _, srv := range servers {
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
for _, opened := range listeners {
opened.Close()
}
httpsAddr := fmt.Sprintf("%s:%d", g.cfg.Network.BindHost, g.cfg.Network.Proxy.HttpsPort)
g.httpsSrv = &http.Server{
Addr: httpsAddr,
Handler: mux,
TLSConfig: tlsConfig,
}
go func() {
log.Printf("[GATEWAY] HTTPS SNI Proxy listening on port %d", g.cfg.Network.Proxy.HttpsPort)
ln, err := net.Listen("tcp", httpsAddr)
if err != nil {
log.Printf("[GATEWAY] HTTPS Proxy listen error (port 443): %v", err)
return
}
tlsLn := tls.NewListener(ln, tlsConfig)
if err := g.httpsSrv.Serve(tlsLn); err != nil && err != http.ErrServerClosed {
log.Printf("[GATEWAY] HTTPS Proxy error: %v", err)
}
}()
} else {
log.Printf("[GATEWAY] Warning: failed to load TLS certificate %s: %v", certFile, err)
return fmt.Errorf("listen gateway %s: %w", srv.Addr, err)
}
} else {
log.Printf("[GATEWAY] Warning: cert.pem not found in %s; HTTPS port 443 disabled", g.certDir)
listeners = append(listeners, ln)
}
for i, srv := range servers {
ln := listeners[i]
if srv == g.httpsSrv {
ln = tls.NewListener(ln, srv.TLSConfig)
}
log.Printf("[GATEWAY] Listening on %s", ln.Addr())
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Printf("[GATEWAY] Serve error: %v", err)
}
}()
}
_ = ctx
return nil
}
+37
View File
@@ -1,6 +1,8 @@
package gateway
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
@@ -9,6 +11,41 @@ import (
"ananta-ps/internal/config"
)
func TestGatewayReportsBindFailureAndRollsBack(t *testing.T) {
blocker, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer blocker.Close()
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
listPort := probe.Addr().(*net.TCPAddr).Port
probe.Close()
cfg := &config.Config{Network: config.NetworkConfig{BindHost: "127.0.0.1", Proxy: config.ProxyConfig{
LoginListPort: listPort, HttpPort: blocker.Addr().(*net.TCPAddr).Port,
}}}
gw := New(cfg, "../../public", "../../certs")
if err := gw.Start(); err == nil {
gw.Stop()
t.Fatal("occupied HTTP port reported as ready")
}
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", fmt.Sprint(listPort)))
if err != nil {
t.Fatalf("partial startup leaked login-list listener: %v", err)
}
ln.Close()
}
func TestGatewayRequiresUsableTLSCertificate(t *testing.T) {
gw := New(&config.Config{}, t.TempDir(), t.TempDir())
if err := gw.Start(); err == nil {
gw.Stop()
t.Fatal("missing certificate silently disabled HTTPS")
}
}
func TestGatewayDispatch(t *testing.T) {
cfg := &config.Config{
Client: config.ClientConfig{
+88
View File
@@ -0,0 +1,88 @@
package logingate
import (
"bytes"
"net"
"testing"
"time"
"ananta-ps/internal/config"
"ananta-ps/pkg/protocol"
)
type recordingConn struct{ bytes.Buffer }
func (c *recordingConn) Close() error { return nil }
func (c *recordingConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
func (c *recordingConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (c *recordingConn) SetDeadline(time.Time) error { return nil }
func (c *recordingConn) SetReadDeadline(time.Time) error { return nil }
func (c *recordingConn) SetWriteDeadline(time.Time) error { return nil }
func TestColdLoginRoleList(t *testing.T) {
cfg := &config.Config{Login: config.LoginSettings{RoleListMode: "list"}, Player: config.PlayerConfig{Pid: 100}}
srv := New(cfg)
conn := &recordingConn{}
sess := &Session{conn: conn}
// Invoke ID zero is valid: packet kind, rather than ID, controls replies.
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindInvoke, MethodID: 34529582}); err != nil {
t.Fatal(err)
}
f, err := protocol.ReadFrame(conn, nil)
if err != nil {
t.Fatal(err)
}
if pkt := protocol.ParseRpcPacket(f.Payload); pkt.Kind != protocol.PacketKindReturn || pkt.InvokeID != 0 {
t.Fatalf("missing invoke response: %+v", pkt)
}
f, err = protocol.ReadFrame(conn, nil)
if err != nil {
t.Fatal(err)
}
pkt := protocol.ParseRpcPacket(f.Payload)
if pkt.MethodID != 35167428 || !bytes.Equal(pkt.Body, []byte{0xff, 1}) {
t.Fatalf("malformed role list: %+v", pkt)
}
if err := srv.dispatch(sess, &protocol.RpcPacket{Kind: protocol.PacketKindNotify, MethodID: 34700853}); err != nil {
t.Fatal(err)
}
if conn.Len() != 0 {
t.Fatal("reply emitted for notify")
}
}
func TestStopClosesIdleHandshake(t *testing.T) {
srv := New(&config.Config{Network: config.NetworkConfig{BindHost: "127.0.0.1", LoginPorts: []int{0}}})
if err := srv.Start(); err != nil {
t.Fatal(err)
}
conn, err := net.DialTimeout("tcp", srv.listeners[0].Addr().String(), time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
deadline := time.Now().Add(time.Second)
for {
srv.mu.Lock()
n := len(srv.connections)
srv.mu.Unlock()
if n == 1 {
break
}
if time.Now().After(deadline) {
t.Fatal("connection not accepted")
}
time.Sleep(time.Millisecond)
}
done := make(chan struct{})
go func() { srv.Stop(); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Stop blocked")
}
conn.SetReadDeadline(time.Now().Add(time.Second))
if _, err := conn.Read(make([]byte, 1)); err == nil {
t.Fatal("idle socket survived Stop")
}
}
+101 -22
View File
@@ -7,6 +7,7 @@ import (
"log"
"net"
"sync"
"time"
"ananta-ps/internal/config"
"ananta-ps/pkg/crypto/chacha8"
@@ -14,15 +15,18 @@ import (
)
type Server struct {
cfg *config.Config
listeners []net.Listener
cancelFunc context.CancelFunc
wg sync.WaitGroup
cfg *config.Config
listeners []net.Listener
cancelFunc context.CancelFunc
wg sync.WaitGroup
mu sync.Mutex
connections map[net.Conn]struct{}
}
func New(cfg *config.Config) *Server {
return &Server{
cfg: cfg,
cfg: cfg,
connections: make(map[net.Conn]struct{}),
}
}
@@ -54,6 +58,11 @@ func (s *Server) Stop() error {
for _, ln := range s.listeners {
_ = ln.Close()
}
s.mu.Lock()
for conn := range s.connections {
_ = conn.Close()
}
s.mu.Unlock()
s.wg.Wait()
return nil
}
@@ -72,15 +81,30 @@ func (s *Server) acceptLoop(ctx context.Context, ln net.Listener, port int) {
}
}
go s.handleConnection(ctx, conn, port)
s.mu.Lock()
if ctx.Err() != nil {
s.mu.Unlock()
conn.Close()
return
}
s.connections[conn] = struct{}{}
s.wg.Add(1)
s.mu.Unlock()
go func() {
defer s.wg.Done()
defer func() { s.mu.Lock(); delete(s.connections, conn); s.mu.Unlock() }()
s.handleConnection(ctx, conn, port)
}()
}
}
type Session struct {
conn net.Conn
readCipher *chacha8.UxChaCha8
sendCipher *chacha8.UxChaCha8
mu sync.Mutex
replyKind protocol.RpcPacketKind
writeTimeout time.Duration
conn net.Conn
readCipher *chacha8.UxChaCha8
sendCipher *chacha8.UxChaCha8
mu sync.Mutex
}
func (sess *Session) SendPacket(packet *protocol.RpcPacket) error {
@@ -97,10 +121,28 @@ func (sess *Session) SendPacket(packet *protocol.RpcPacket) error {
payload = protocol.BuildNotify(packet.MethodID, packet.Body)
}
return protocol.WriteFrame(sess.conn, 0x09, payload, sess.sendCipher)
return sess.writeFrameLocked(0x09, payload)
}
func (sess *Session) writeFrameLocked(mode byte, payload []byte) error {
timeout := sess.writeTimeout
if timeout <= 0 {
timeout = 15 * time.Second
}
if err := sess.conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
return err
}
if err := protocol.WriteFrame(sess.conn, mode, payload, sess.sendCipher); err != nil {
sess.conn.Close()
return err
}
return nil
}
func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
if sess.replyKind != protocol.PacketKindInvoke {
return nil
}
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindReturn,
InvokeID: invokeID,
@@ -111,6 +153,9 @@ func (sess *Session) ReturnEmptyOk(invokeID int32, methodID uint32) error {
}
func (sess *Session) ReturnEmpty(invokeID int32, methodID uint32, errCode int32) error {
if sess.replyKind != protocol.PacketKindInvoke {
return nil
}
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindReturn,
InvokeID: invokeID,
@@ -121,6 +166,9 @@ func (sess *Session) ReturnEmpty(invokeID int32, methodID uint32, errCode int32)
}
func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) error {
if sess.replyKind != protocol.PacketKindInvoke {
return nil
}
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindReturn,
InvokeID: invokeID,
@@ -131,6 +179,9 @@ func (sess *Session) ReturnData(invokeID int32, methodID uint32, body []byte) er
}
func (sess *Session) NotifyData(methodID uint32, body []byte) error {
if methodID == 0 {
return fmt.Errorf("unverified notification method")
}
return sess.SendPacket(&protocol.RpcPacket{
Kind: protocol.PacketKindNotify,
MethodID: methodID,
@@ -150,9 +201,10 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn, port int)
}
sess := &Session{
conn: conn,
readCipher: readCipher,
sendCipher: writeCipher,
conn: conn,
writeTimeout: time.Duration(s.cfg.Network.WriteTimeoutMs) * time.Millisecond,
readCipher: readCipher,
sendCipher: writeCipher,
}
for {
@@ -173,10 +225,18 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn, port int)
// Frame mode 0x03 is heartbeat
if frame.Mode == 0x03 {
// Echo heartbeat back
_ = protocol.WriteFrame(conn, 0x03, frame.Payload, sess.sendCipher)
sess.mu.Lock()
err = sess.writeFrameLocked(0x03, frame.Payload)
sess.mu.Unlock()
if err != nil {
return
}
continue
}
if frame.Mode != 0x09 {
continue
}
packet := protocol.ParseRpcPacket(frame.Payload)
if err := s.dispatch(sess, &packet); err != nil {
log.Printf("[LOGIN-GATE] Dispatch error for method %d: %v", packet.MethodID, err)
@@ -185,6 +245,11 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn, port int)
}
func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
if pkt.Kind != protocol.PacketKindInvoke && pkt.Kind != protocol.PacketKindNotify {
return nil
}
sess.replyKind = pkt.Kind
defer func() { sess.replyKind = protocol.PacketKindUnknown }()
switch pkt.MethodID {
case protocol.MethodCheckVersion, protocol.MethodHasOnlinePlayer:
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
@@ -213,6 +278,7 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
s.cfg.Player.AccountId,
s.cfg.Player.UserName,
s.cfg.Player.LoginToken,
s.cfg.Player.DisplayName,
s.cfg.Client.Aid,
s.cfg.Client.ServerID,
s.cfg.Player.Pid,
@@ -223,10 +289,7 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
if err := sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID); err != nil {
return err
}
// Send SyncRoleList (35167428)
w := protocol.NewUxWriter()
w.U64(s.cfg.Player.Pid)
return sess.NotifyData(protocol.MethodSyncRoleList, w.Bytes())
return s.syncRoleList(sess)
case protocol.MethodRequestCreateRoleEx:
w := protocol.NewUxWriter()
@@ -234,7 +297,7 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
if err := sess.ReturnData(pkt.InvokeID, pkt.MethodID, w.Bytes()); err != nil {
return err
}
return sess.NotifyData(protocol.MethodSyncRoleList, w.Bytes())
return s.syncRoleList(sess)
case protocol.MethodRequestEnterGame, protocol.MethodDebugRequestEnterGame:
body := protocol.EncodeEnterGame(
@@ -294,10 +357,26 @@ func (s *Server) dispatch(sess *Session, pkt *protocol.RpcPacket) error {
default:
if pkt.Kind == protocol.PacketKindInvoke {
log.Printf("[LOGIN-GATE] Unhandled Invoke MethodID %d, returning empty OK", pkt.MethodID)
return sess.ReturnEmptyOk(pkt.InvokeID, pkt.MethodID)
log.Printf("[LOGIN-GATE] Unhandled Invoke MethodID %d, using contract fallback", pkt.MethodID)
if body, ok := protocol.DefaultReturnBody(pkt.MethodID); ok {
return sess.ReturnData(pkt.InvokeID, pkt.MethodID, body)
}
return sess.SendPacket(&protocol.RpcPacket{Kind: protocol.PacketKindReturn, InvokeID: pkt.InvokeID, MethodID: pkt.MethodID, Error: -1})
}
}
return nil
}
// Match the original server: unsolicited role lists are opt-in because the legacy pid
// payload is not a list and can stall a cold login.
func (s *Server) syncRoleList(sess *Session) error {
switch s.cfg.Login.RoleListMode {
case "pid":
return sess.NotifyData(protocol.MethodSyncRoleList, protocol.NewUxWriter().U64(s.cfg.Player.Pid).Bytes())
case "list":
return sess.NotifyData(protocol.MethodSyncRoleList, protocol.EncodeEmptyList())
default:
return nil
}
}
+12 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"html/template"
"log"
"net"
"net/http"
"sync"
"time"
@@ -43,15 +44,24 @@ func (a *AdminServer) Start() error {
mux.HandleFunc("/api/command", a.handleCommand)
mux.HandleFunc("/api/sessions", a.handleSessions)
addr := fmt.Sprintf("%s:%d", a.cfg.Network.BindHost, a.cfg.Admin.Port)
host := a.cfg.Admin.Host
if host == "" {
host = "127.0.0.1"
}
addr := net.JoinHostPort(host, fmt.Sprint(a.cfg.Admin.Port))
a.httpSrv = &http.Server{
Addr: addr,
Handler: mux,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
cancel()
return err
}
go func() {
log.Printf("[ADMIN-GM] Admin Web Panel running at http://%s:%d/", a.cfg.Network.AdvertisedHost, a.cfg.Admin.Port)
if err := a.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
if err := a.httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Printf("[ADMIN-GM] Web server error: %v", err)
}
}()
+7 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"time"
@@ -40,9 +41,14 @@ func (d *DebugServer) Start() error {
Handler: mux,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
cancel()
return err
}
go func() {
log.Printf("[DEBUG-API] Debug WebUI panel at http://%s:5809/", d.cfg.Network.AdvertisedHost)
if err := d.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
if err := d.httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Printf("[DEBUG-API] Debug server error: %v", err)
}
}()
+18
View File
@@ -0,0 +1,18 @@
package web
import (
"ananta-ps/internal/config"
"testing"
)
func TestAdminHonorsConfiguredHost(t *testing.T) {
cfg := &config.Config{Network: config.NetworkConfig{BindHost: "0.0.0.0"}, Admin: config.AdminConfig{Enabled: true, Host: "127.0.0.1"}}
a := NewAdmin(cfg, nil)
if err := a.Start(); err != nil {
t.Fatal(err)
}
defer a.Stop()
if a.httpSrv.Addr != "127.0.0.1:0" {
t.Fatalf("admin bind ignored: %s", a.httpSrv.Addr)
}
}