fix(go): repair migration wire contracts and runtime lifecycle; document incomplete port
This commit is contained in:
1 parent
e43a310617
commit
32e47014b5
35 files changed
+16826
-438
No files matched your search
@@ -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/)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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 ©
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+4546
File diff suppressed because it is too large.
Load diff
+14
-10
@@ -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) {
|
||||
|
||||
Vendored
+52
@@ -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)
|
||||
Vendored
+44
@@ -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
@@ -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"
|
||||
]
|
||||
Reference in new issue
Block a user