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

+3 -1
View File
@@ -1,5 +1,7 @@
# Ananta (Project Mugen) CBT 4229938 Private Server
> **สถานะ Go port:** ยังไม่เทียบเท่า C# เดิม การตรวจพบฟังก์ชันที่ยังเป็น stub 2,127 ตัวใน 223 ไฟล์ และ router ที่สร้างจาก transpiler ยังไม่เชื่อมกับ executable ดูบั๊กที่แก้แล้วและงานที่ยังขาดใน [GO_MIGRATION_AUDIT.md](docs/GO_MIGRATION_AUDIT.md) ก่อนอ้างอิงรายการฟีเจอร์ด้านล่าง
เซิร์ฟเวอร์ส่วนตัว (Private Server) และระบบ Gateway Proxy สำหรับเกม **Ananta (Project Mugen / 代号:无限大)** ไคลเอนต์เวอร์ชัน **CBT 4229938** พัฒนาขึ้นมาด้วย **Native Go Engine (`ananta-server.exe`)** ประสิทธิภาพสูง เบา กินแรมน้อย และรันได้ในไฟล์เดียว (Single Binary) เพื่อจำลองการเชื่อมต่อและเล่นเกมในโหมด Sandbox / Free-Roam ได้อย่างสมบูรณ์แบบ รองรับทั้งการเข้าเล่นผ่าน PC และ Mobile APK
> 📖 **รายการระบบและฟีเจอร์ทั้งหมดของเซิร์ฟเวอร์**: ดูรายละเอียดเชิงลึกและสถานะฟีเจอร์ทั้งหมดได้ที่ [docs/FEATURES.md](docs/FEATURES.md)
@@ -10,7 +12,7 @@
1. **Windows 10 / 11 (64-bit)**
2. ตัวเกม **Ananta / Project Mugen (CBT Client build 4229938 หรือ Mobile APK 4063976)**
3. (ตัวเลือกเสริมสำหรับการพัฒนา/คอมไพล์) **Go 1.22+**: [ดาวน์โหลดที่นี่](https://go.dev/dl/)
3. (สำหรับการพัฒนา/คอมไพล์) **Go 1.27+** ตาม `go.mod`: [ดาวน์โหลดที่นี่](https://go.dev/dl/)
---
BIN
View File
Binary file not shown.
+5 -3
View File
@@ -9,11 +9,11 @@ import (
"syscall"
"ananta-ps/internal/config"
"ananta-ps/pkg/protocol"
"ananta-ps/internal/gameserver"
"ananta-ps/internal/gateway"
"ananta-ps/internal/logingate"
"ananta-ps/internal/web"
"ananta-ps/pkg/protocol"
)
const banner = `
@@ -34,7 +34,9 @@ func main() {
fmt.Print(banner)
cfg, err := config.LoadConfig(*configPath)
_ = protocol.LoadMethodCatalog("data/client_data/MethodIds.json")
if err := protocol.LoadMethodCatalog("data/client_data/MethodIds.json"); err != nil {
log.Printf("[PROTOCOL] Using compiled method catalog: %v", err)
}
if err != nil {
log.Fatalf("[FATAL] Could not load configuration: %v", err)
}
@@ -45,7 +47,7 @@ func main() {
// 1. Start Gateway (Port 443, 80, 5801)
gw := gateway.New(cfg, "", "")
if err := gw.Start(); err != nil {
log.Printf("[GATEWAY] Error starting gateway: %v", err)
log.Fatalf("[GATEWAY] Failed to start gateway: %v", err)
}
defer gw.Stop()
+85
View File
@@ -0,0 +1,85 @@
# Go migration audit — 2026-10-29
Compared Gemini's Go branch (`e43a310`) with the last pre-migration C# revision
`ca2e80f`, the shipped `MethodIds.json`, and the original wire contracts.
Compilation does not establish feature parity. This port is **incomplete**.
## Fixed in this review
- Corrected 65 active method IDs against the C# reference, including world entry,
character switching, teleport, vehicle, dialog and wardrobe methods. Removed
23 fabricated constants/routes rather than assigning unrelated retail messages
to invented handlers. `GateLogin` is an alias for the verified `Login` method.
- Restored 4,544 verified method names and 2,356 return contracts from C# metadata.
Unknown invokes now use the exact neutral payload required by their return
reader. A truly unknown invoke returns an error. Neutral replies do not implement
gameplay or persistence.
- Unknown notification names resolve to zero and are rejected before writing.
Guessed fallback IDs can no longer invoke an unrelated client handler.
- Corrected `EnterSceneInfo` loading/link type markers, integer widths and list
counts, ground-data byte fields, and nullable teleport tail fields.
- Player login data now patches account and Unicode display name as well as
aid/pid, parsing offsets and rebuilding variable-length strings. Remaining
template bytes are preserved. This is still a template, not a replacement for
the original dynamic `RuntimePayloadFactory`.
- Restored configured `SyncRoleList` behavior and the default empty-list format;
the legacy bare-pid shape is available only when explicitly configured.
- Returns are emitted only for invokes, including valid invoke ID zero.
Return/unknown packets and non-RPC frame modes are not dispatched as requests.
- Movement now handles the actual list contracts for `AskReportLogicAgentSyncData`
and `AskUnitMoveActionSimple`, validating the entire body before changing state.
- Hangup/resume gates background traffic; traffic waits for scene readiness.
Character switching sets current-spirit identity before creating the new entity.
- Heartbeats share the encrypted write lock with RPC output. Failed writes close
the stream because its cipher state has advanced. Writes have deadlines; short
writes are completed. Oversized/unterminated varints return errors.
- Login/game shutdown closes tracked sockets, including clients stalled in
handshake, and waits for connection handlers. Traffic starts only after binding
succeeds and stops with its worker joined.
- Gateway startup reports TLS/bind failures synchronously and rolls back partially
opened listeners. Admin/debug binding errors are returned to the caller. Admin
binding respects `admin.host` instead of using the public network bind host.
- Admin coordinate parsing rejects invalid/non-finite input and propagates
teleport failures. Time/weather commands use the verified client IDs and wire
fields instead of nonexistent `SyncTimeWeather`/`SyncTimeOfDay` messages.
- The item catalog reads `ItemCatalog.json` or the shipped `ConsumableConfig.json`,
projects typed entries, implements lookup, and caches by source path rather than
sharing the first directory's table with every caller.
## Remaining migration gaps
The reproducible static inventory is [GO_MIGRATION_INVENTORY.json](GO_MIGRATION_INVENTORY.json).
Run `python tools/migration/audit_go.py` to refresh it, or add `--check` to fail
while incomplete artifacts remain. Its counts detect explicit generator markers;
unmarked omissions and incorrect gameplay behavior require further review.
| Finding | Evidence / impact |
| --- | --- |
| 2,127 placeholder functions across 223 files | `Router implementation for` and `Catalog helper` bodies return nil, zero, true or false without the original logic. |
| Generated router package disconnected | `go list -deps ./cmd/ananta-server` does not contain `internal/gameserver/routers`. Its 155-file port claim does not describe the running server. |
| Catalog package disconnected | The running binary does not import `internal/catalogs`; repairing item loading alone does not restore inventory grants. |
| 74 generated catalog input references missing from shipped Configs | Includes `.json.json` paths and fabricated catalog filenames. Some require data export or joining several real config tables, not simply removing an extension. |
| 16 unresolved notification call sites in the active server | Calls remain in simplified vehicle/NPC/combat/police handlers. They now fail instead of sending guessed IDs; the missing contracts still need porting. |
| Most original configuration ignored | The active `Config` only binds a subset. Generated settings structs are not equivalent to integrating content/gameplay/UI/path settings into runtime behavior. |
| State/persistence and gameplay simplifications | Original progress, wallet, inventory, gacha, ownership, NPC behavior, police, minigames, scene transitions and many other handlers need their real logic ported and connected. A generic OK cannot restore them. |
| Fixed player template and hardcoded content | Login payloads, clothing, spawn presets, traffic lanes and several responses still contain simplified/static data. Rebuilding the original dynamic factories remains necessary. |
| Proxy parity incomplete | Existing HTTP response tests do not establish equivalence to all legacy proxy endpoints, MPay flows, patch assets or mobile clients. |
Do not connect the generated routers wholesale: many helper signatures use `any`,
drop C# `out`/`ref` behavior, or have placeholder success values. Restore one
subsystem's contracts, catalog transformations, state and handlers together, then
register it and validate it with a real client.
## Validation
- `go test ./...` and `go vet ./...` pass after the fixes.
- Regression tests check independently referenced IDs and payload bytes, variable
player strings, fallback shapes, role-list behavior, invoke/notify distinction,
movement validation, socket shutdown, bind rollback, admin host and item loading.
- `go test -race ./...` could not run: this host has CGO disabled and no C compiler
available on PATH. Passing ordinary tests is not evidence of race-detector coverage.
- No real game-client or mobile playthrough was performed. The fixes are not a
claim that world entry or all advertised gameplay now works end to end.
Regenerate recovered protocol metadata with
`python tools/migration/generate_protocol.py`; its input is pinned to `ca2e80f`.
File diff suppressed because it is too large. Load diff
+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)
}
}
+47 -22
View File
@@ -1,7 +1,6 @@
package protocol
import (
"encoding/binary"
"encoding/json"
"fmt"
"os"
@@ -25,9 +24,9 @@ func EncodeServerTime(clientUnixTime float64) []byte {
// Format (Inline): [I32 patchVersion (0)] [0xFF marker] [Int7 count (0)]
func EncodePatchCheck() []byte {
w := NewUxWriter()
w.I32(0) // patchVersion
w.U8(0xFF) // list marker
w.Int7(0) // empty list
w.I32(0) // patchVersion
w.U8(0xFF) // list marker
w.Int7(0) // empty list
return w.Bytes()
}
@@ -40,7 +39,7 @@ func EncodeEmptyList() []byte {
}
// EncodeCheckAccount encodes CheckAccountResult.
func EncodeCheckAccount(accountId, userName, token string, aid, serverId int, pid uint64) []byte {
func EncodeCheckAccount(accountId, userName, token, displayName string, aid, serverId int, pid uint64) []byte {
loginData := map[string]interface{}{
"code": 200,
"subcode": 0,
@@ -52,7 +51,7 @@ func EncodeCheckAccount(accountId, userName, token string, aid, serverId int, pi
"pid": pid,
"player_id": fmt.Sprintf("%d", pid),
"role_id": fmt.Sprintf("%d", pid),
"role_name": "辰宿",
"role_name": displayName,
"server_id": fmt.Sprintf("%d", serverId),
"host_id": serverId,
"username": userName,
@@ -144,10 +143,10 @@ func EncodeEnterScene(pid uint64, raidId uint32, instanceId uint64, universeId u
// SectorControlIds (empty list)
w.U8(0xFF).Int7(0)
w.U32(0) // DynamicLayerId
// LoadingType (Complex 0xFF: Type, Members list, Ripple)
w.U8(0xFF).U32(0).U8(0xFF).Int7(0).Bool(false)
// LinkSimpleInfo (Complex 0xFF: Id, Mode, DeviceLevel, Tag, PublicEventId, LastPublicEventTime, HostPid)
w.U8(0xFF).U32(0).U32(0).U32(1).U32(0).U32(0).U64(0).U64(0)
// LoadingType (TypeMark 1: byte Type, Int32 Members list, Ripple)
w.U8(1).U8(0).U8(0xFF).I32(0).Bool(false)
// LinkSimpleInfo (TypeMark 1: U64 Id, three bytes, U32 event/time, U64 host)
w.U8(1).U64(0).U8(0).U8(1).U8(0).U32(0).U32(0).U64(0)
// DataLayerIndices (empty list)
w.U8(0xFF).Int7(0)
w.Bool(false) // Seamless
@@ -230,7 +229,7 @@ func EncodeRaidBattleUnitSpirit(unitId uint64, templateId uint32, posX, posY, po
w.U8(0).U32(0)
// GroundData (MoveActionGroundData Complex 0xFF)
w.U8(0xFF).U32(0).U64(0).U32(0)
w.U8(0xFF).U8(0).U64(0).U8(0)
// LocalPos (UXVector3)
w.F32(0).F32(0).F32(0)
@@ -244,17 +243,43 @@ func EncodePlayerInfo(templatePath string, aid int, pid uint64, accountId, name
return nil, err
}
res := make([]byte, len(data))
copy(res, data)
// Patch Aid at offset 4 (4 bytes)
if len(res) > 8 {
binary.LittleEndian.PutUint32(res[4:8], uint32(aid))
// PlayerClientInfo: complex marker, Config buffer (Int7), InfoLogin marker,
// Aid, Pid, AccountId, Name. String lengths may change; preserve the rest verbatim.
r := NewUxReader(data)
mark, err := r.U8()
if err != nil || mark != 0xff {
return nil, fmt.Errorf("invalid PlayerClientInfo marker")
}
// Patch Pid at offset 8 (8 bytes)
if len(res) > 16 {
binary.LittleEndian.PutUint64(res[8:16], pid)
mark, err = r.U8()
if err != nil || mark != 0xff {
return nil, fmt.Errorf("invalid Config buffer marker")
}
return res, nil
n, err := r.Int7()
if err != nil {
return nil, err
}
if _, err = r.Bytes(n); err != nil {
return nil, err
}
mark, err = r.U8()
if err != nil || mark != 0xff {
return nil, fmt.Errorf("invalid InfoLogin marker")
}
prefixEnd := r.Position()
if _, err = r.I32(); err != nil {
return nil, err
}
if _, err = r.U64(); err != nil {
return nil, err
}
if _, err = r.UxString(); err != nil {
return nil, err
}
if _, err = r.UxString(); err != nil {
return nil, err
}
w := NewUxWriter()
w.Raw(data[:prefixEnd]).I32(int32(aid)).U64(pid).UxString(accountId).UxString(name)
w.Raw(data[r.Position():])
return w.Bytes(), nil
}
File diff suppressed because it is too large. Load diff
+12 -3
View File
@@ -24,7 +24,7 @@ func ReadFrame(r io.Reader, cryptor *chacha8.UxChaCha8) (*Frame, error) {
}
payloadLength := int(binary.LittleEndian.Uint32(head[0:4]))
if payloadLength < 0 || payloadLength > 16*1024*1024 {
if payloadLength < 0 || payloadLength > 8*1024*1024 {
return nil, errors.New("invalid frame payload length after decrypt")
}
@@ -54,8 +54,17 @@ func WriteFrame(w io.Writer, mode byte, payload []byte, cryptor *chacha8.UxChaCh
cryptor.XorInPlace(buf)
}
_, err := w.Write(buf)
return err
for len(buf) > 0 {
n, err := w.Write(buf)
if err != nil {
return err
}
if n <= 0 || n > len(buf) {
return io.ErrShortWrite
}
buf = buf[n:]
}
return nil
}
func ParseRpcPacket(payload []byte) RpcPacket {
+3
View File
@@ -20,6 +20,9 @@ func HandleHandshake(conn net.Conn) (*chacha8.UxChaCha8, *chacha8.UxChaCha8, err
if err != nil {
return nil, nil, err
}
if frame.Mode != 0x01 {
return nil, nil, errors.New("invalid handshake frame mode")
}
if len(frame.Payload) < 20 {
return nil, nil, errors.New("client handshake too short")
}
+80 -95
View File
@@ -21,124 +21,109 @@ const (
MethodDebugRequestEnterGame uint32 = 34478273
MethodAskCloseConnectionToGate uint32 = 52140059
MethodGetServerTime uint32 = 52951195
MethodSendServerTime uint32 = 52296181
MethodGateLogin uint32 = 52023760
MethodSyncPlayerGameServerInfo uint32 = 52737039
MethodSendServerTime uint32 = 53477227
MethodGateLogin uint32 = 52023760 // Legacy name: Login
MethodSyncPlayerGameServerInfo uint32 = 154693736
MethodSyncGameSwitchToClient_3 uint32 = 53940944
MethodQuerySkey uint32 = 52367757
MethodReportNetworkQuality uint32 = 52623348
MethodDavinciCode uint32 = 52458997
MethodGetFriendApplicationListToMe uint32 = 52123985
MethodGetAllChatGroupLatestMessage uint32 = 52345719
MethodGetSimplePlayerInfoByPidList uint32 = 52781934
MethodQuerySkey uint32 = 153090217
MethodReportNetworkQuality uint32 = 52443839
MethodDavinciCode uint32 = 153804824
MethodGetFriendApplicationListToMe uint32 = 153718831
MethodGetAllChatGroupLatestMessage uint32 = 153934570
MethodGetSimplePlayerInfoByPidList uint32 = 153582555
// Game / World
MethodLoginGame uint32 = 63142082
MethodAskEnterWorld uint32 = 63142082
MethodSendServerTimeGame uint32 = 63853503
MethodSyncPlayerInfo uint32 = 63200762
MethodSyncEnterScene uint32 = 67711467
MethodAskLoadSceneCompleted uint32 = 66879899
MethodAskLoadingFinished uint32 = 67017252
MethodSyncLogicAgentEnter uint32 = 68057283
MethodSyncLogicAgentLeave uint32 = 68057315
MethodSyncManagedLogicAgent uint32 = 68611146
MethodSyncRaidBattleUnitSpirit uint32 = 68953151
MethodSyncPlayerCurrentSpirit uint32 = 68582738
MethodSyncSceneLoadCompleted uint32 = 67610360
MethodSyncGamePause uint32 = 67039327
MethodSyncPlayerLoadRate uint32 = 68383503
MethodAskLoadedInSameScene uint32 = 68838634
MethodSyncUnitPositionAndFacing uint32 = 68664188
MethodSyncPlayerPositionAndFacing uint32 = 68702330
MethodSyncSceneFogMapAllUnlock uint32 = 68677150
MethodSyncRunGmUtils uint32 = 66042045
MethodSyncAllSpiritCombatPower uint32 = 68798150
MethodSyncSwitchSpiritInSitu uint32 = 68582770
MethodLoginGame uint32 = 63142082
MethodAskEnterWorld uint32 = 63142082
MethodSendServerTimeGame uint32 = 64114176
MethodSyncPlayerInfo uint32 = 64682159
MethodSyncEnterScene uint32 = 64406364
MethodAskLoadSceneCompleted uint32 = 67933251
MethodAskLoadingFinished uint32 = 67722670
MethodSyncLogicAgentEnter uint32 = 68719890
MethodSyncLogicAgentLeave uint32 = 68281244
MethodSyncManagedLogicAgent uint32 = 68771737
MethodSyncRaidBattleUnitSpirit uint32 = 68984102
MethodSyncPlayerCurrentSpirit uint32 = 68830687
MethodSyncSceneLoadCompleted uint32 = 68772583
MethodSyncGamePause uint32 = 68031250
MethodSyncPlayerLoadRate uint32 = 68547190
MethodAskLoadedInSameScene uint32 = 67741470
MethodSyncUnitPositionAndFacing uint32 = 68030312
MethodSyncSceneFogMapAllUnlock uint32 = 64915741
MethodSyncRunGmUtils uint32 = 66042045
MethodSyncAllSpiritCombatPower uint32 = 64826316
MethodSyncSwitchSpiritInSitu uint32 = 68412586
// Movement
MethodAskMoveStart uint32 = 68412852
MethodAskMoveStop uint32 = 68412853
MethodAskUpdatePlayerPosition uint32 = 68058291
// Character Switching
MethodAskPreSwitchSpirit uint32 = 68415783
MethodAskSwitchSpirit uint32 = 68415784
MethodAskSwitchSpirit uint32 = 67482531
// Teleportation & Scene
MethodAskTeleport uint32 = 67891230
MethodReportPreTeleportFinish uint32 = 67891231
MethodReportPostTeleportFinish uint32 = 67891232
MethodAskEnterRaidByMapEntrance uint32 = 67891233
MethodAskTeleportToScene uint32 = 67891234
MethodSwitchSceneDirect uint32 = 67891235
MethodAskTeleport uint32 = 63175104
MethodReportPreTeleportFinish uint32 = 63032762
MethodReportPostTeleportFinish uint32 = 63682240
MethodAskEnterRaidByMapEntrance uint32 = 63510026
// Vehicles
MethodAskSummonVehicle uint32 = 68221895
MethodAskDriveVehicle uint32 = 68221893
MethodAskExitVehicle uint32 = 68221894
MethodAskVehicleMove uint32 = 68221896
MethodChangeVehicleDoorState uint32 = 68221897
MethodAskVehicleSendGamePlaySignal uint32 = 68221898
MethodSyncPlayerVehicleInfo uint32 = 68221926
MethodSyncAetherVehicleInit uint32 = 68221958
MethodAskSummonVehicle uint32 = 67012447
MethodAskVehicleMove uint32 = 67743393
MethodChangeVehicleDoorState uint32 = 67018217
MethodAskVehicleSendGamePlaySignal uint32 = 63816400
// Police & Wanted
MethodAskSetWantedLevel uint32 = 67600001
MethodAskPoliceArrest uint32 = 67600002
MethodReportPoliceArrestFinish uint32 = 67600003
MethodAskPayFine uint32 = 67600004
MethodAskReleaseFromPrison uint32 = 67600005
// Dialog & NPC & Crowd
MethodAskStartDialog uint32 = 67400001
MethodAskDoDialogAction uint32 = 67400002
MethodAskForceFinishDialog uint32 = 67400003
MethodGetCharacterRandomDialog uint32 = 67400004
MethodAskAgentStartNpcSound uint32 = 67400005
MethodAskDoNpcAction uint32 = 67400006
MethodAskSetCrowdDensity uint32 = 67400007
MethodAskPhoneAddCallRecord uint32 = 67400010
MethodAskPhoneContactOptionAction uint32 = 67400011
MethodAskSummonGangMember uint32 = 67400020
MethodAskDestroyAllGangMember uint32 = 67400021
MethodAskStartDialog uint32 = 63754798
MethodAskDoDialogAction uint32 = 63723634
MethodAskForceFinishDialog uint32 = 63010951
MethodGetCharacterRandomDialog uint32 = 63175540
MethodAskAgentStartNpcSound uint32 = 67003057
MethodAskDoNpcAction uint32 = 67257034
MethodAskPhoneAddCallRecord uint32 = 63487875
MethodAskPhoneContactOptionAction uint32 = 63918677
MethodAskSummonGangMember uint32 = 63745420
MethodAskDestroyAllGangMember uint32 = 63243016
// Combat & Skills
MethodAskPlayerAttack uint32 = 68412884
MethodAskPlayerCastSkill uint32 = 68412916
MethodAskUnlockFightStyle uint32 = 68412948
MethodSyncChangeSpirit uint32 = 68582802
MethodGmWorldRefreshEnemyGroup uint32 = 67500001
MethodReportUnitHitFly uint32 = 67500002
MethodReportUnitHitFlyEnd uint32 = 67500003
MethodReportUnitFallGround uint32 = 67500004
MethodReportUnitFallGroundEnd uint32 = 67500005
MethodAskControlPowerHoldEnemyUp uint32 = 67500006
MethodAskControlPowerHoldEnemyFallGrou uint32 = 67500007
MethodGmWorldRefreshEnemyGroup uint32 = 69676618
MethodReportUnitHitFly uint32 = 67430580
MethodReportUnitHitFlyEnd uint32 = 67015647
MethodReportUnitFallGround uint32 = 67719880
MethodReportUnitFallGroundEnd uint32 = 67516089
MethodAskControlPowerHoldEnemyUp uint32 = 67802701
MethodAskControlPowerHoldEnemyFallGrou uint32 = 67043294
// Mini-games
MethodAskChineseChessMove uint32 = 67700001
MethodAskMahjongAction uint32 = 67700002
MethodEnterBalloonZone uint32 = 67700003
MethodLeaveBalloon uint32 = 67700004
MethodAskBreakDestructibleObject uint32 = 67700005
MethodEnterBowlingZone uint32 = 67700006
MethodAskClawBuyTicket uint32 = 67700007
MethodAskClawSettlement uint32 = 67700008
MethodAskBuyMiniGameTicket uint32 = 67700009
MethodEnterBalloonZone uint32 = 67417935
MethodLeaveBalloon uint32 = 67075062
MethodAskBreakDestructibleObject uint32 = 67463592
MethodEnterBowlingZone uint32 = 67832701
MethodAskClawBuyTicket uint32 = 63690710
MethodAskClawSettlement uint32 = 63451209
MethodAskBuyMiniGameTicket uint32 = 63606259
// Racing
MethodAskAcceptAndSetCurrentTask uint32 = 63961538
MethodAskSetRacingCompetitionVehicle uint32 = 63764162
MethodAskSetTaskCounterValue uint32 = 63753212
MethodAskAcceptAndSetCurrentTask uint32 = 63961538
MethodAskSetRacingCompetitionVehicle uint32 = 63764162
MethodAskSetTaskCounterValue uint32 = 63753212
// Fashion & Wardrobe
MethodAskSetSpiritFashionsWithSource uint32 = 67800001
MethodAskApplyFashionColoringSchemeInfos uint32 = 67800002
MethodAskSetWeaponSkins uint32 = 67800003
MethodAskSyncWeaponSkinToSpirits uint32 = 67800004
MethodAskSetSpiritFashionsWithSource uint32 = 63167148
MethodAskApplyFashionColoringSchemeInfos uint32 = 63045354
MethodAskSetWeaponSkins uint32 = 63900850
MethodAskSyncWeaponSkinToSpirits uint32 = 63623831
// Weather & Fog
MethodGmUnlockAllFogMap uint32 = 67900001
MethodGmSetAmbianceEnabled uint32 = 67900002
MethodGmUnlockAllFogMap uint32 = 65914419
MethodGmSetAmbianceEnabled uint32 = 69166506
)
const (
MethodRequestPlayerStartHangup uint32 = 63842476
MethodRequestPlayerStopHangup uint32 = 63910160
MethodAskReportLogicAgentSyncData uint32 = 67543012
)
const MethodAskUnitMoveActionSimple uint32 = 67158757
File diff suppressed because it is too large. Load diff
+10 -2
View File
@@ -27,6 +27,13 @@ var (
isRegistryLoaded bool
)
func init() {
for name, id := range canonicalMethodIDs {
nameToIdMap[name] = id
idToNameMap[id] = name
}
}
// LoadMethodCatalog loads the method catalog from a JSON file (e.g. data/client_data/MethodIds.json).
func LoadMethodCatalog(path string) error {
data, err := os.ReadFile(path)
@@ -72,10 +79,11 @@ func GetMethodID(name string) (uint32, bool) {
return id, ok
}
// MethodGetMethodIDDefault returns the numeric ID for a method name or a fallback.
// MethodGetMethodIDDefault resolves a verified name. Legacy callers still pass
// a fallback, but guessed IDs must never be sent to an unrelated client handler.
func MethodGetMethodIDDefault(name string, fallback uint32) uint32 {
if id, ok := GetMethodID(name); ok {
return id
}
return fallback
return 0
}
+197
View File
@@ -0,0 +1,197 @@
package protocol
import (
"bytes"
"encoding/binary"
"encoding/hex"
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestActiveMethodIDsMatchLegacyContracts(t *testing.T) {
data, err := os.ReadFile("testdata/legacy_method_ids.json")
if err != nil {
t.Fatal(err)
}
var expected map[string]uint32
if err := json.Unmarshal(data, &expected); err != nil {
t.Fatal(err)
}
aliases := map[string]string{"GateLogin": "Login", "AskEnterWorld": "LoginGame", "SyncSwitchSpiritInSitu": "SyncPlayerSwitchSpiritInSitu"}
f, err := parser.ParseFile(token.NewFileSet(), "method_id.go", nil, 0)
if err != nil {
t.Fatal(err)
}
ast.Inspect(f, func(n ast.Node) bool {
v, ok := n.(*ast.ValueSpec)
if !ok {
return true
}
for i, id := range v.Names {
name := strings.TrimPrefix(id.Name, "Method")
if alias, ok := aliases[name]; ok {
name = alias
}
lit, ok := v.Values[i].(*ast.BasicLit)
if !ok {
t.Fatalf("nonliteral method %s", name)
}
value, err := strconv.ParseUint(lit.Value, 10, 32)
want, exists := expected[name]
if err != nil || !exists || uint32(value) != want {
t.Errorf("%s = %d, legacy = %d (exists=%v)", name, value, want, exists)
}
}
return true
})
}
func TestDefaultReturnContracts(t *testing.T) {
if len(defaultReturnBodies) != 2356 {
t.Fatalf("missing return contracts: %d", len(defaultReturnBodies))
}
// Independent examples from the original Lua-reader catalog, not round trips.
for _, tc := range []struct {
id uint32
hex string
}{
{34700853, ""}, {34022227, "ff01"}, {13224129, "00000000"},
{12279476, "01"}, {34383517, "0000000000000000"}, {34601301, "00000000ff01"},
} {
body, ok := DefaultReturnBody(tc.id)
if !ok || hex.EncodeToString(body) != tc.hex {
t.Errorf("%d: %x, exists=%v", tc.id, body, ok)
}
}
if _, ok := DefaultReturnBody(0xffffffff); ok {
t.Fatal("unknown contract accepted")
}
a, _ := DefaultReturnBody(34022227)
a[0] = 0
b, _ := DefaultReturnBody(34022227)
if b[0] != 0xff {
t.Fatal("shared body mutated")
}
}
func TestUnknownMethodCannotUseGuessedID(t *testing.T) {
if id := MethodGetMethodIDDefault("InventedMethod", 68030312); id != 0 {
t.Fatalf("guessed id accepted: %d", id)
}
if id := MethodGetMethodIDDefault("SyncEnterScene", 1); id != 64406364 {
t.Fatalf("wrong canonical id: %d", id)
}
}
func TestEnterSceneWireLayout(t *testing.T) {
b := EncodeEnterScene(100, 23300888, 20001222, 76000888, 9, 15020967, 1, 2, 3, 4)
// Offsets derived from C# EnterSceneInfo, SpiritInitData and ServerSimpleGridInfo.
// LoadingTypeInfo uses TypeMark=1, byte Type and an Int32 member count.
if len(b) != 146 {
t.Fatalf("EnterSceneInfo size = %d, want 146", len(b))
}
if got := hex.EncodeToString(b[103:111]); got != "0100ff0000000000" {
t.Fatalf("LoadingTypeInfo: %s", got)
}
// LinkSimpleInfo: mark, ulong id, three bytes, uint event, uint time, ulong host.
if got := hex.EncodeToString(b[111:139]); got != "01000000000000000000010000000000000000000000000000000000" {
t.Fatalf("LinkSimpleInfo: %s", got)
}
if got := hex.EncodeToString(b[139:]); got != "ff01000000ff01" {
t.Fatalf("scene tail: %s", got)
}
}
func TestPlayerInfoPatchesVariableLengthLogin(t *testing.T) {
tail := []byte{10, 0, 0, 0, 1, 0xff, 0, 0, 0, 0, 0xff}
// Nonempty config buffer exercises dynamic offsets rather than fixed offsets 4/8.
original := NewUxWriter().U8(0xff).U8(0xff).Int7(3).Raw([]byte{7, 8, 9}).U8(0xff).
I32(1).U64(2).UxString("old").UxString("old name").Raw(tail).Bytes()
path := filepath.Join(t.TempDir(), "player.bin")
if err := os.WriteFile(path, original, 0600); err != nil {
t.Fatal(err)
}
b, err := EncodePlayerInfo(path, 5944, 100, "longer-account", "辰宿")
if err != nil {
t.Fatal(err)
}
r := NewUxReader(b)
_, _ = r.Bytes(7)
aid, _ := r.I32()
pid, _ := r.U64()
account, _ := r.UxString()
name, _ := r.UxString()
if aid != 5944 || pid != 100 || account != "longer-account" || name != "辰宿" {
t.Fatalf("login fields: %d %d %s %s", aid, pid, account, name)
}
if !bytes.Equal(b[r.Position():], tail) {
t.Fatal("unrelated player data changed")
}
for _, bad := range [][]byte{nil, {0}, {0xff, 0xff, 1, 0xff, 0}} {
if err := os.WriteFile(path, bad, 0600); err != nil {
t.Fatal(err)
}
if _, err := EncodePlayerInfo(path, 1, 2, "", ""); err == nil {
t.Fatalf("accepted malformed template: %x", bad)
}
}
}
type shortWriter struct {
bytes.Buffer
limit int
}
func (w *shortWriter) Write(b []byte) (int, error) {
if len(b) > w.limit {
b = b[:w.limit]
}
return w.Buffer.Write(b)
}
type zeroWriter struct{}
func (zeroWriter) Write([]byte) (int, error) { return 0, nil }
func TestWriteFrameHandlesShortWrites(t *testing.T) {
w := &shortWriter{limit: 2}
if err := WriteFrame(w, 9, []byte{1, 2, 3}, nil); err != nil {
t.Fatal(err)
}
if hex.EncodeToString(w.Bytes()) != "0300000009010203" {
t.Fatalf("truncated frame: %x", w.Bytes())
}
if err := WriteFrame(zeroWriter{}, 9, nil, nil); err != io.ErrShortWrite {
t.Fatalf("zero writer: %v", err)
}
}
func TestVarIntRejectsOverflow(t *testing.T) {
for _, b := range [][]byte{{0x80}, {0xff, 0xff, 0xff, 0xff, 0x08}, {0x80, 0x80, 0x80, 0x80, 0x80, 0}} {
if _, err := NewUxReader(b).UxString(); err == nil {
t.Fatalf("overflow accepted: %x", b)
}
}
if n, err := NewUxReader([]byte{0xff, 0xff, 0xff, 0xff, 0x07}).Raw7(); err != nil || n != 2147483647 {
t.Fatalf("max Int32: %d, %v", n, err)
}
}
func TestGroundDataUsesByteFields(t *testing.T) {
b := EncodeRaidBattleUnitSpirit(9, 15020967, 1, 2, 3, 4, 100)
// GroundData is the last field: marker + byte + ulong + byte + Vector3.
if len(b) < 23 || b[len(b)-23] != 0xff {
t.Fatalf("invalid GroundData tail: %x", b)
}
if binary.LittleEndian.Uint64(b[len(b)-21:]) != 0 {
t.Fatal("nonzero ground id")
}
}
File diff suppressed because it is too large. Load diff
+14 -10
View File
@@ -103,18 +103,22 @@ func (r *UxReader) F64() (float64, error) {
}
func (r *UxReader) ReadVarInt() (int, error) {
shift := 0
result := 0
for r.pos < len(r.data) {
b := r.data[r.pos]
r.pos++
result |= int(b&0x7F) << shift
if (b & 0x80) == 0 {
return result, nil
// UX lengths/counts are nonnegative Int32 values, at most five bytes.
var result uint32
for shift := uint(0); shift < 35; shift += 7 {
b, err := r.U8()
if err != nil {
return 0, errors.New("unterminated varint")
}
if shift == 28 && b > 7 {
return 0, errors.New("varint exceeds Int32")
}
result |= uint32(b&0x7f) << shift
if b&0x80 == 0 {
return int(result), nil
}
shift += 7
}
return result, errors.New("unterminated varint")
return 0, errors.New("varint exceeds Int32")
}
func (r *UxReader) Int7() (int, error) {
+52
View File
@@ -0,0 +1,52 @@
"""List known incomplete migration artifacts; --check fails until they are ported.
This is a conservative static inventory, not a claim of gameplay compatibility.
"""
import argparse
import json
from pathlib import Path
import re
import subprocess
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
root = Path(__file__).resolve().parents[2]
ids = json.loads((root / "pkg/protocol/testdata/legacy_method_ids.json").read_text(encoding="utf-8"))
deps = set(subprocess.check_output(
["go", "list", "-deps", "./cmd/ananta-server"], cwd=root).decode().splitlines())
stubs, missing_tables, unknown_calls = [], [], []
for base in ("internal", "pkg"):
for path in sorted((root / base).rglob("*.go")):
if path.name.endswith("_test.go"):
continue
text = path.read_text(encoding="utf-8-sig")
rel = path.relative_to(root).as_posix()
names = re.findall(r"// (?:Router implementation for|Catalog helper) (\w+)", text)
if names:
stubs.append({"file": rel, "count": len(names), "functions": names})
for filename in re.findall(r'filepath.Join\(configDir, "([^"]+)"\)', text):
if not (root / "data/client_data/Configs" / filename).exists():
missing_tables.append({"file": rel, "table": filename})
# Only inspect the active server, not the unused generated skeletons.
if rel.startswith("internal/gameserver/") and "/routers/" not in rel:
for name in re.findall(r'MethodGetMethodIDDefault\("([^"]+)"', text):
if name not in ids:
unknown_calls.append({"file": rel, "method": name})
report = {
"baseline": "ca2e80f",
"stub_functions": sum(row["count"] for row in stubs),
"stub_files": len(stubs),
"generated_router_connected_to_main": "ananta-ps/internal/gameserver/routers" in deps,
"catalogs_connected_to_main": "ananta-ps/internal/catalogs" in deps,
"stubs": stubs,
"missing_catalog_inputs": missing_tables,
"unverified_notification_names": unknown_calls,
}
out = root / "docs/GO_MIGRATION_INVENTORY.json"
out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"{report['stub_functions']} stub functions in {len(stubs)} files; "
f"{len(missing_tables)} missing catalog inputs; {len(unknown_calls)} unverified notifications")
print(f"Inventory: {out.relative_to(root)}")
if args.check and (stubs or missing_tables or unknown_calls):
raise SystemExit(1)
+44
View File
@@ -0,0 +1,44 @@
"""Recover wire metadata from the last C# revision, without transpiling method bodies.
Run from the repository root: python tools/migration/generate_protocol.py
"""
import json
from pathlib import Path
import re
import subprocess
BASE = "ca2e80f"
PREFIX = "Ananta.Server/Ananta.Core/Protocol/Client4229938/"
def source(name):
return subprocess.check_output(["git", "show", f"{BASE}:{PREFIX}{name}"]).decode("utf-8-sig")
ids = {name: int(value) for name, value in re.findall(
r"const uint (\w+)\s*=\s*(\d+)", source("MethodId.cs"))}
Path("pkg/protocol/testdata/legacy_method_ids.json").write_text(
json.dumps(ids, indent=2) + "\n", encoding="utf-8")
header = f"// Code generated from {BASE} C# protocol metadata; DO NOT EDIT.\npackage protocol\n\n"
names = header + "var canonicalMethodIDs = map[string]uint32{\n"
names += "".join(f'{json.dumps(name)}: {value},\n' for name, value in ids.items())
names += "}\n"
Path("pkg/protocol/method_names_generated.go").write_text(names, encoding="utf-8")
rows = re.findall(
r'case (\d+)u: body = (Array.Empty<byte>\(\)|\[[^\]]*\]); shape = "([^"]*)"; return true;',
source("DefaultReturnCatalog4229938.cs"))
assert len(rows) == 2356, "C# return catalog changed; review before generating"
out = [header, "var defaultReturnBodies = map[uint32]string{"]
for mid, body, shape in rows:
data = b"" if body.startswith("Array") else bytes(
int(value.strip(), 16) for value in body[1:-1].split(",") if value.strip())
encoded = "".join(f"\\x{value:02x}" for value in data)
out.append(f'{mid}: "{encoded}", // {shape}')
out += ["}", "", "// DefaultReturnBody returns an independent payload for the client return reader.",
"func DefaultReturnBody(methodID uint32) ([]byte, bool) {",
"body, ok := defaultReturnBodies[methodID]", "return []byte(body), ok", "}"]
Path("pkg/protocol/default_returns_generated.go").write_text("\n".join(out) + "\n", encoding="utf-8")
subprocess.run(["gofmt", "-w", "pkg/protocol/default_returns_generated.go",
"pkg/protocol/method_names_generated.go"], check=True)
print(f"Recovered {len(ids)} names and {len(rows)} return contracts from {BASE}")
+25
View File
@@ -0,0 +1,25 @@
[
"SyncPlayerPositionAndFacing",
"AskMoveStart",
"AskMoveStop",
"AskUpdatePlayerPosition",
"AskPreSwitchSpirit",
"AskTeleportToScene",
"SwitchSceneDirect",
"AskDriveVehicle",
"AskExitVehicle",
"SyncPlayerVehicleInfo",
"SyncAetherVehicleInit",
"AskSetWantedLevel",
"AskPoliceArrest",
"ReportPoliceArrestFinish",
"AskPayFine",
"AskReleaseFromPrison",
"AskSetCrowdDensity",
"AskPlayerAttack",
"AskPlayerCastSkill",
"AskUnlockFightStyle",
"SyncChangeSpirit",
"AskChineseChessMove",
"AskMahjongAction"
]