Compare commits
2 Commits
ecadf002e2
...
81bae77beb
Author | SHA1 | Date | |
---|---|---|---|
81bae77beb | |||
c783176588 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -22,3 +22,6 @@ go.work
|
||||
/world_config.json
|
||||
/world_server
|
||||
/world.db
|
||||
/login_config.json
|
||||
/login_server
|
||||
/login.db
|
File diff suppressed because it is too large
Load Diff
@ -1,281 +0,0 @@
|
||||
# EQ2EMu Protocol Structure Report
|
||||
|
||||
Overview
|
||||
|
||||
The EQ2EMu protocol is a custom UDP-based networking protocol designed for EverQuest II server
|
||||
emulation. It implements reliability, compression, encryption, and packet fragmentation on top
|
||||
of UDP.
|
||||
|
||||
Core Architecture
|
||||
|
||||
1. Protocol Layers
|
||||
|
||||
Application Layer
|
||||
|
||||
- EQApplicationPacket: High-level game packets containing game logic
|
||||
- PacketStruct: Dynamic packet structure system using XML definitions
|
||||
- DataBuffer: Serialization/deserialization buffer management
|
||||
|
||||
Protocol Layer
|
||||
|
||||
- EQProtocolPacket: Low-level protocol packets with sequencing and reliability
|
||||
- EQStream: Stream management with connection state, retransmission, and flow control
|
||||
- EQPacket: Base packet class with common functionality
|
||||
|
||||
2. Packet Types
|
||||
|
||||
Protocol Control Packets (opcodes.h)
|
||||
|
||||
OP_SessionRequest = 0x01 // Client initiates connection
|
||||
OP_SessionResponse = 0x02 // Server accepts connection
|
||||
OP_Combined = 0x03 // Multiple packets combined
|
||||
OP_SessionDisconnect = 0x05 // Connection termination
|
||||
OP_KeepAlive = 0x06 // Keep connection alive
|
||||
OP_ServerKeyRequest = 0x07 // Request encryption key
|
||||
OP_SessionStatResponse = 0x08 // Connection statistics
|
||||
OP_Packet = 0x09 // Regular data packet
|
||||
OP_Fragment = 0x0d // Fragmented packet piece
|
||||
OP_OutOfOrderAck = 0x11 // Acknowledge out-of-order packet
|
||||
OP_Ack = 0x15 // Acknowledge packet receipt
|
||||
OP_AppCombined = 0x19 // Combined application packets
|
||||
OP_OutOfSession = 0x1d // Out of session notification
|
||||
|
||||
3. Connection Flow
|
||||
|
||||
1. Session Establishment
|
||||
- Client sends OP_SessionRequest with session parameters
|
||||
- Server responds with OP_SessionResponse containing session ID and encryption key
|
||||
- RC4 encryption initialized using the provided key
|
||||
2. Data Transfer
|
||||
- Packets are sequenced (16-bit sequence numbers)
|
||||
- Reliable packets require acknowledgment
|
||||
- Retransmission on timeout (default 500ms * 3.0 multiplier)
|
||||
- Packet combining for efficiency (max 256 bytes combined)
|
||||
3. Session Termination
|
||||
- Either side sends OP_SessionDisconnect
|
||||
- Graceful shutdown with cleanup of pending packets
|
||||
|
||||
4. Key Features
|
||||
|
||||
Compression
|
||||
|
||||
- Uses zlib compression (indicated by 0x5a flag byte)
|
||||
- Applied after protocol header
|
||||
- Decompression required before processing
|
||||
|
||||
Encryption
|
||||
|
||||
- RC4 stream cipher
|
||||
- Separate keys for client/server directions
|
||||
- Client uses ~key (bitwise NOT), server uses key directly
|
||||
- 20 bytes of dummy data prime both ciphers
|
||||
|
||||
CRC Validation
|
||||
|
||||
- Custom CRC16 implementation
|
||||
- Applied to entire packet except last 2 bytes
|
||||
- Session packets exempt from CRC
|
||||
|
||||
Fragmentation
|
||||
|
||||
- Large packets split into fragments
|
||||
- Each fragment marked with OP_Fragment
|
||||
- Reassembly at receiver before processing
|
||||
|
||||
5. Packet Structure
|
||||
|
||||
Basic Protocol Packet
|
||||
|
||||
[1-2 bytes] Opcode (1 byte if < 0xFF, otherwise 2 bytes)
|
||||
[variable] Payload
|
||||
[2 bytes] CRC16 (if applicable)
|
||||
|
||||
Combined Packet Format
|
||||
|
||||
[1 byte] Size of packet 1
|
||||
[variable] Packet 1 data
|
||||
[1 byte] Size of packet 2
|
||||
[variable] Packet 2 data
|
||||
...
|
||||
|
||||
6. Dynamic Packet System
|
||||
|
||||
The PacketStruct system allows runtime-defined packet structures:
|
||||
|
||||
- XML Configuration: Packet structures defined in XML files
|
||||
- Version Support: Multiple versions per packet type
|
||||
- Data Types: int8/16/32/64, float, double, string, array, color, equipment
|
||||
- Conditional Fields: Fields can be conditional based on other field values
|
||||
- Oversized Support: Dynamic sizing for variable-length fields
|
||||
|
||||
Example structure:
|
||||
<Struct Name="PlayerUpdate" ClientVersion="1096">
|
||||
<Data ElementName="activity" Type="int32"/>
|
||||
<Data ElementName="speed" Type="float"/>
|
||||
<Data ElementName="x" Type="float"/>
|
||||
<Data ElementName="y" Type="float"/>
|
||||
<Data ElementName="z" Type="float"/>
|
||||
</Struct>
|
||||
|
||||
7. Opcode Management
|
||||
|
||||
- Dynamic Mapping: Runtime opcode mapping via configuration files
|
||||
- Version Support: Different opcode sets per client version
|
||||
- Name Resolution: String names mapped to numeric opcodes
|
||||
- Shared Memory Option: Multi-process opcode sharing support
|
||||
|
||||
8. Implementation Considerations for Porting
|
||||
|
||||
1. Memory Management
|
||||
- Extensive use of dynamic allocation
|
||||
- Custom safe_delete macros for cleanup
|
||||
- Reference counting for shared packets
|
||||
2. Threading
|
||||
- Mutex protection for all shared resources
|
||||
- Separate threads for packet processing
|
||||
- Condition variables for synchronization
|
||||
3. Platform Dependencies
|
||||
- POSIX sockets and threading
|
||||
- Network byte order conversions
|
||||
- Platform-specific timing functions
|
||||
4. Performance Optimizations
|
||||
- Packet combining to reduce overhead
|
||||
- Preallocated buffers for common sizes
|
||||
- Fast CRC table lookup
|
||||
5. Error Handling
|
||||
- Graceful degradation on errors
|
||||
- Extensive logging for debugging
|
||||
- Automatic retransmission on failure
|
||||
|
||||
9. Critical Classes to Port
|
||||
|
||||
1. EQStream: Core connection management
|
||||
2. EQPacket/EQProtocolPacket: Packet representation
|
||||
3. PacketStruct: Dynamic packet structure system
|
||||
4. DataBuffer: Serialization utilities
|
||||
5. OpcodeManager: Opcode mapping system
|
||||
6. Crypto: RC4 encryption wrapper
|
||||
7. CRC16: Custom CRC implementation
|
||||
|
||||
10. Protocol Quirks
|
||||
|
||||
- Opcode byte order depends on value (0x00 prefix for opcodes < 0xFF)
|
||||
- Special handling for session control packets (no CRC)
|
||||
- 20-byte RC4 priming sequence required
|
||||
- Custom CRC polynomial (0xEDB88320 reversed)
|
||||
- Compression flag at variable offset based on opcode size
|
||||
|
||||
This protocol is designed for reliability over UDP while maintaining low latency for game
|
||||
traffic. The dynamic packet structure system allows flexibility in supporting multiple client
|
||||
versions without recompilation.
|
||||
|
||||
# UDP Network Layer Architecture
|
||||
|
||||
1. EQStreamFactory - The UDP Socket Manager
|
||||
|
||||
- Opens and manages the main UDP socket (line 148: socket(AF_INET, SOCK_DGRAM, 0))
|
||||
- Binds to a specific port (line 153: bind())
|
||||
- Sets socket to non-blocking mode (line 159: fcntl(sock, F_SETFL, O_NONBLOCK))
|
||||
- Runs three main threads:
|
||||
- ReaderLoop(): Uses recvfrom() to read incoming UDP packets (line 228)
|
||||
- WriterLoop(): Manages outgoing packet transmission
|
||||
- CombinePacketLoop(): Combines small packets for efficiency
|
||||
|
||||
2. EQStream - Per-Connection Protocol Handler
|
||||
|
||||
- Manages individual client connections over the shared UDP socket
|
||||
- Handles packet serialization/deserialization
|
||||
- Implements reliability layer (sequencing, acknowledgments, retransmission)
|
||||
- WritePacket() method uses sendto() for actual UDP transmission (line 1614)
|
||||
|
||||
3. Key UDP Operations:
|
||||
|
||||
Receiving (EQStreamFactory::ReaderLoop):
|
||||
|
||||
recvfrom(sock, buffer, 2048, 0, (struct sockaddr *)&from, (socklen_t *)&socklen)
|
||||
- Uses select() for non-blocking I/O
|
||||
- Creates new EQStream instances for new connections (OP_SessionRequest)
|
||||
- Routes packets to existing streams by IP:port mapping
|
||||
|
||||
Sending (EQStream::WritePacket):
|
||||
|
||||
sendto(eq_fd,(char *)buffer,length,0,(sockaddr *)&address,sizeof(address))
|
||||
- Serializes protocol packets to byte arrays
|
||||
- Applies compression and encryption if enabled
|
||||
- Adds CRC checksums
|
||||
- Sends via UDP to specific client address
|
||||
|
||||
4. Connection Multiplexing:
|
||||
|
||||
- Single UDP socket serves multiple clients
|
||||
- Stream identification by IP:port combination (sprintf(temp, "%u.%d",
|
||||
ntohl(from.sin_addr.s_addr), ntohs(from.sin_port)))
|
||||
- Thread-safe stream management with mutex protection
|
||||
|
||||
So yes, EQStream and EQStreamFactory together provide a complete UDP networking layer that
|
||||
implements a reliable, connection-oriented protocol on top of unreliable UDP datagrams. The
|
||||
factory manages the socket and dispatches packets to individual stream instances that handle
|
||||
the protocol logic for each client connection.
|
||||
|
||||
# TCP Usage in EQ2EMu
|
||||
|
||||
The TCP implementation serves a completely separate purpose from the main game client
|
||||
communication:
|
||||
|
||||
1. HTTP Web Server (web_server.hpp)
|
||||
|
||||
- Purpose: Administrative/monitoring interface
|
||||
- Features:
|
||||
- REST API endpoints (/version, / root)
|
||||
- SSL/TLS support with certificate-based encryption
|
||||
- HTTP Basic Authentication with session management
|
||||
- Database-driven user authentication and authorization levels
|
||||
- JSON response formatting for API calls
|
||||
- Use Cases:
|
||||
- Server status monitoring
|
||||
- Administrative controls
|
||||
- Third-party tool integration
|
||||
- Web-based dashboards
|
||||
|
||||
2. Server-to-Server Communication (tcp_connection.hpp)
|
||||
|
||||
- Purpose: Inter-server communication within EQ2EMu cluster
|
||||
- Features:
|
||||
- Custom packet framing protocol
|
||||
- Optional zlib compression for bandwidth efficiency
|
||||
- Message relay capabilities between servers
|
||||
- Connection state management
|
||||
- Use Cases:
|
||||
- Login server to world server communication
|
||||
- Zone server coordination
|
||||
- Cross-server messaging
|
||||
- Load balancing and failover
|
||||
|
||||
Key Differences: UDP vs TCP
|
||||
|
||||
| Aspect | UDP (Game Clients) | TCP (Admin/Server)
|
||||
|
|
||||
|------------|--------------------------------------------|------------------------------------
|
||||
----|
|
||||
| Protocol | Custom EQ2 protocol with reliability layer | Standard HTTP/Custom framing
|
||||
|
|
||||
| Encryption | RC4 stream cipher | SSL/TLS or none
|
||||
|
|
||||
| Clients | Game clients (players) | Web browsers/Admin tools/Other
|
||||
servers |
|
||||
| Port | 9001 (World), 9100 (Login) | Configurable web port
|
||||
|
|
||||
| Threading | 3 worker threads per factory | Thread-per-connection
|
||||
|
|
||||
|
||||
Architecture Summary
|
||||
|
||||
Game Clients ←→ [UDP EQStream] ←→ World/Login Servers
|
||||
↕
|
||||
Admin Tools ←→ [TCP WebServer] ←→ World/Login Servers
|
||||
↕
|
||||
Other Servers ←→ [TCP Connection] ←→ World/Login Servers
|
||||
|
||||
The TCP implementation provides out-of-band management and inter-server communication, while
|
||||
the UDP implementation handles all real-time game traffic. This separation allows for robust
|
||||
administration without interfering with game performance.
|
110
cmd/login_server/README.md
Normal file
110
cmd/login_server/README.md
Normal file
@ -0,0 +1,110 @@
|
||||
# EQ2Go Login Server
|
||||
|
||||
A modern Go implementation of the EverQuest II login server, providing client authentication, character management, and world server coordination.
|
||||
|
||||
## Features
|
||||
|
||||
- **Client Authentication**: MD5-hashed password authentication with account management
|
||||
- **Character Management**: Character list, creation, deletion, and play requests
|
||||
- **World Server Coordination**: Registration and status tracking of world servers
|
||||
- **Web Administration**: HTTP interface for monitoring and management
|
||||
- **Database Integration**: SQLite database with automatic table initialization
|
||||
- **UDP Protocol**: EverQuest II compatible UDP protocol implementation
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
go build ./cmd/login_server
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Run with defaults (creates login_config.json if missing)
|
||||
./login_server
|
||||
|
||||
# Run with custom configuration
|
||||
./login_server -config custom_login.json
|
||||
|
||||
# Run with overrides
|
||||
./login_server -listen-port 6000 -web-port 8082 -db custom.db
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
On first run, a default `login_config.json` will be created:
|
||||
|
||||
```json
|
||||
{
|
||||
"listen_addr": "0.0.0.0",
|
||||
"listen_port": 5999,
|
||||
"max_clients": 1000,
|
||||
"web_addr": "0.0.0.0",
|
||||
"web_port": 8081,
|
||||
"database_path": "login.db",
|
||||
"server_name": "EQ2Go Login Server",
|
||||
"log_level": "info",
|
||||
"world_servers": []
|
||||
}
|
||||
```
|
||||
|
||||
## Web Interface
|
||||
|
||||
Access the web administration interface at `http://localhost:8081` (or configured web_port).
|
||||
|
||||
Features:
|
||||
- Real-time server statistics
|
||||
- Connected client monitoring
|
||||
- World server status
|
||||
- Client management (kick clients)
|
||||
|
||||
## Database
|
||||
|
||||
The login server uses SQLite by default with the following tables:
|
||||
|
||||
- `login_accounts` - User account information
|
||||
- `characters` - Character data for character selection
|
||||
- `server_stats` - Server statistics and monitoring data
|
||||
|
||||
## Command Line Options
|
||||
|
||||
- `-config` - Path to configuration file (default: login_config.json)
|
||||
- `-listen-addr` - Override listen address
|
||||
- `-listen-port` - Override listen port
|
||||
- `-web-port` - Override web interface port
|
||||
- `-db` - Override database path
|
||||
- `-log-level` - Override log level (debug, info, warn, error)
|
||||
- `-name` - Override server name
|
||||
- `-version` - Show version information
|
||||
|
||||
## Architecture
|
||||
|
||||
The login server follows the EQ2Go architecture patterns:
|
||||
|
||||
- **Server**: Main server instance managing UDP connections and web interface
|
||||
- **ClientList**: Thread-safe management of connected clients
|
||||
- **WorldList**: Management of registered world servers
|
||||
- **Database Integration**: Uses zombiezen SQLite with proper connection pooling
|
||||
- **UDP Protocol**: Compatible with EverQuest II client expectations
|
||||
|
||||
## Development
|
||||
|
||||
The login server integrates with the broader EQ2Go ecosystem:
|
||||
|
||||
- Uses `internal/udp` for EverQuest II protocol handling
|
||||
- Uses `internal/database` for data persistence
|
||||
- Follows Go concurrency patterns with proper synchronization
|
||||
- Implements comprehensive error handling and logging
|
||||
|
||||
## Next Steps
|
||||
|
||||
To complete the login server implementation:
|
||||
|
||||
1. Add character creation functionality
|
||||
2. Add character deletion functionality
|
||||
3. Implement world server communication protocol
|
||||
4. Add user registration/account creation
|
||||
5. Add password reset functionality
|
||||
6. Add account management features
|
@ -1 +0,0 @@
|
||||
need to implement
|
196
cmd/login_server/main.go
Normal file
196
cmd/login_server/main.go
Normal file
@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"eq2emu/internal/login"
|
||||
)
|
||||
|
||||
const defaultConfigFile = "login_config.json"
|
||||
|
||||
var (
|
||||
configFile = flag.String("config", defaultConfigFile, "Path to configuration file")
|
||||
listenAddr = flag.String("listen-addr", "", "Override listen address")
|
||||
listenPort = flag.Int("listen-port", 0, "Override listen port")
|
||||
webPort = flag.Int("web-port", 0, "Override web interface port")
|
||||
logLevel = flag.String("log-level", "", "Override log level (debug, info, warn, error)")
|
||||
serverName = flag.String("name", "", "Override server name")
|
||||
showVersion = flag.Bool("version", false, "Show version and exit")
|
||||
)
|
||||
|
||||
// Version information (set at build time)
|
||||
var (
|
||||
Version = "1.0.0-dev"
|
||||
BuildTime = "unknown"
|
||||
GitCommit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("EQ2Go Login Server\n")
|
||||
fmt.Printf("Version: %s\n", Version)
|
||||
fmt.Printf("Build Time: %s\n", BuildTime)
|
||||
fmt.Printf("Git Commit: %s\n", GitCommit)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
config, err := loadConfig(*configFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
}
|
||||
|
||||
// Apply command-line overrides
|
||||
applyOverrides(config)
|
||||
|
||||
// Print startup banner
|
||||
printBanner(config)
|
||||
|
||||
// Create login server
|
||||
loginServer, err := login.NewServer(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create login server: %v", err)
|
||||
}
|
||||
|
||||
// Start login server
|
||||
if err := loginServer.Start(); err != nil {
|
||||
log.Fatalf("Failed to start login server: %v", err)
|
||||
}
|
||||
|
||||
// Setup signal handlers
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Run server in background
|
||||
go loginServer.Process()
|
||||
|
||||
// Wait for shutdown signal
|
||||
sig := <-sigChan
|
||||
fmt.Printf("\nReceived signal: %v\n", sig)
|
||||
|
||||
// Graceful shutdown
|
||||
if err := loginServer.Stop(); err != nil {
|
||||
log.Printf("Error during shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfig loads the configuration from file
|
||||
func loadConfig(filename string) (*login.ServerConfig, error) {
|
||||
// Check if config file exists
|
||||
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||
// Create default configuration
|
||||
config := createDefaultConfig()
|
||||
|
||||
// Save default configuration
|
||||
if err := saveConfig(filename, config); err != nil {
|
||||
return nil, fmt.Errorf("failed to save default config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created default configuration file: %s\n", filename)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Load existing configuration
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open config file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var config login.ServerConfig
|
||||
decoder := json.NewDecoder(file)
|
||||
if err := decoder.Decode(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// saveConfig saves the configuration to file
|
||||
func saveConfig(filename string, config *login.ServerConfig) error {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(config); err != nil {
|
||||
return fmt.Errorf("failed to encode config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDefaultConfig creates a default configuration
|
||||
func createDefaultConfig() *login.ServerConfig {
|
||||
return &login.ServerConfig{
|
||||
// Network settings
|
||||
ListenAddr: "0.0.0.0",
|
||||
ListenPort: 5999,
|
||||
MaxClients: 1000,
|
||||
|
||||
// Web interface settings
|
||||
WebAddr: "0.0.0.0",
|
||||
WebPort: 8081,
|
||||
WebCertFile: "",
|
||||
WebKeyFile: "",
|
||||
WebKeyPassword: "",
|
||||
WebUser: "",
|
||||
WebPassword: "",
|
||||
|
||||
// Database settings
|
||||
DatabaseType: "sqlite",
|
||||
DatabaseDSN: "login.db",
|
||||
|
||||
// Server settings
|
||||
ServerName: "EQ2Go Login Server",
|
||||
LogLevel: "info",
|
||||
|
||||
// World servers
|
||||
WorldServers: []login.WorldServerInfo{},
|
||||
}
|
||||
}
|
||||
|
||||
// applyOverrides applies command-line overrides to the configuration
|
||||
func applyOverrides(config *login.ServerConfig) {
|
||||
if *listenAddr != "" {
|
||||
config.ListenAddr = *listenAddr
|
||||
}
|
||||
if *listenPort > 0 {
|
||||
config.ListenPort = *listenPort
|
||||
}
|
||||
if *webPort > 0 {
|
||||
config.WebPort = *webPort
|
||||
}
|
||||
if *logLevel != "" {
|
||||
config.LogLevel = *logLevel
|
||||
}
|
||||
if *serverName != "" {
|
||||
config.ServerName = *serverName
|
||||
}
|
||||
}
|
||||
|
||||
// printBanner prints the server startup banner
|
||||
func printBanner(config *login.ServerConfig) {
|
||||
fmt.Println("================================================================================")
|
||||
fmt.Println(" EQ2Go Login Server")
|
||||
fmt.Println("================================================================================")
|
||||
fmt.Printf("Version: %s\n", Version)
|
||||
fmt.Printf("Server Name: %s\n", config.ServerName)
|
||||
fmt.Printf("Listen Address: %s:%d\n", config.ListenAddr, config.ListenPort)
|
||||
fmt.Printf("Web Interface: %s:%d\n", config.WebAddr, config.WebPort)
|
||||
fmt.Printf("Database: %s %s\n", config.DatabaseType, config.DatabaseDSN)
|
||||
fmt.Printf("Log Level: %s\n", config.LogLevel)
|
||||
fmt.Printf("World Servers: %d configured\n", len(config.WorldServers))
|
||||
fmt.Println("================================================================================")
|
||||
}
|
@ -1,312 +0,0 @@
|
||||
# EverQuest 2 Network Protocol Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The EverQuest 2 protocol is a custom UDP-based protocol that provides reliable delivery, encryption, compression, and session management. This document describes the protocol structure for reimplementation.
|
||||
|
||||
## 1. Protocol Architecture
|
||||
|
||||
### 1.1 Protocol Layers
|
||||
```
|
||||
Application Layer - Game logic packets (EQApplicationPacket)
|
||||
Protocol Layer - Session management, reliability (EQProtocolPacket)
|
||||
Transport Layer - UDP with custom reliability
|
||||
Network Layer - Standard IP
|
||||
```
|
||||
|
||||
### 1.2 Packet Types
|
||||
- **EQProtocolPacket**: Low-level protocol control packets
|
||||
- **EQApplicationPacket**: High-level game data packets
|
||||
- **EQ2Packet**: EQ2-specific application packets with login opcodes
|
||||
|
||||
## 2. Session Management
|
||||
|
||||
### 2.1 Session Establishment
|
||||
```
|
||||
Client -> Server: OP_SessionRequest
|
||||
Server -> Client: OP_SessionResponse
|
||||
```
|
||||
|
||||
#### SessionRequest Structure
|
||||
```c
|
||||
struct SessionRequest {
|
||||
uint32 UnknownA; // Usually 0
|
||||
uint32 Session; // Proposed session ID
|
||||
uint32 MaxLength; // Maximum packet length
|
||||
};
|
||||
```
|
||||
|
||||
#### SessionResponse Structure
|
||||
```c
|
||||
struct SessionResponse {
|
||||
uint32 Session; // Confirmed session ID
|
||||
uint32 Key; // Encryption key
|
||||
uint8 UnknownA; // Usually 2
|
||||
uint8 Format; // Flags: 0x01=compressed, 0x04=encoded
|
||||
uint8 UnknownB; // Usually 0
|
||||
uint32 MaxLength; // Maximum packet length
|
||||
uint32 UnknownD; // Usually 0
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 Session Termination
|
||||
```
|
||||
Either -> Other: OP_SessionDisconnect
|
||||
```
|
||||
|
||||
## 3. Protocol Opcodes
|
||||
|
||||
### 3.1 Core Protocol Opcodes
|
||||
```c
|
||||
#define OP_SessionRequest 0x01
|
||||
#define OP_SessionResponse 0x02
|
||||
#define OP_Combined 0x03
|
||||
#define OP_SessionDisconnect 0x05
|
||||
#define OP_KeepAlive 0x06
|
||||
#define OP_ServerKeyRequest 0x07
|
||||
#define OP_SessionStatResponse 0x08
|
||||
#define OP_Packet 0x09
|
||||
#define OP_Fragment 0x0D
|
||||
#define OP_OutOfOrderAck 0x11
|
||||
#define OP_Ack 0x15
|
||||
#define OP_AppCombined 0x19
|
||||
#define OP_OutOfSession 0x1D
|
||||
```
|
||||
|
||||
## 4. Reliable Delivery System
|
||||
|
||||
### 4.1 Sequence Numbers
|
||||
- 16-bit sequence numbers for ordered delivery
|
||||
- Wrap-around handling at 65536
|
||||
- Window-based flow control (default window size: 2048)
|
||||
|
||||
### 4.2 Acknowledgments
|
||||
- **OP_Ack**: Acknowledges packets up to sequence number
|
||||
- **OP_OutOfOrderAck**: Acknowledges specific out-of-order packet
|
||||
- Retransmission on timeout (default: 500ms * 3.0 multiplier, max 5000ms)
|
||||
|
||||
### 4.3 Packet Structure for Sequenced Data
|
||||
```
|
||||
[2 bytes: Sequence Number][Payload Data]
|
||||
```
|
||||
|
||||
## 5. Encryption System
|
||||
|
||||
### 5.1 Key Exchange
|
||||
1. RSA key exchange during initial handshake
|
||||
2. 8-byte encrypted key transmitted in packet
|
||||
3. RC4 encryption initialized with exchanged key
|
||||
|
||||
### 5.2 RC4 Encryption
|
||||
- Applied to packet payload after headers
|
||||
- Separate encryption state per connection
|
||||
- Encryption offset varies by packet type and compression
|
||||
|
||||
### 5.3 CRC Validation
|
||||
- 16-bit CRC appended to most packets
|
||||
- CRC calculated using session key
|
||||
- Some packets (SessionRequest, SessionResponse, OutOfSession) not CRC'd
|
||||
|
||||
## 6. Compression System
|
||||
|
||||
### 6.1 zlib Compression
|
||||
- Individual packets compressed using zlib deflate
|
||||
- Compression applied when packet size > 128 bytes
|
||||
- Compression markers:
|
||||
- `0x5A`: zlib compressed data follows
|
||||
- `0xA5`: uncompressed data (small packets)
|
||||
|
||||
### 6.2 Compression Process
|
||||
```
|
||||
1. Check if packet size > compression threshold
|
||||
2. Apply zlib deflate compression
|
||||
3. Prepend compression marker
|
||||
4. If compressed size >= original, use uncompressed with 0xA5 marker
|
||||
```
|
||||
|
||||
## 7. Packet Combination
|
||||
|
||||
### 7.1 Protocol-Level Combination (OP_Combined)
|
||||
Multiple protocol packets combined into single UDP datagram:
|
||||
```
|
||||
[1 byte: Packet1 Size][Packet1 Data]
|
||||
[1 byte: Packet2 Size][Packet2 Data]
|
||||
...
|
||||
```
|
||||
If size >= 255:
|
||||
```
|
||||
[1 byte: 0xFF][2 bytes: Actual Size][Packet Data]
|
||||
```
|
||||
|
||||
### 7.2 Application-Level Combination (OP_AppCombined)
|
||||
Multiple application packets combined:
|
||||
```
|
||||
[1 byte: Packet1 Size][Packet1 Data without opcode header]
|
||||
[1 byte: Packet2 Size][Packet2 Data without opcode header]
|
||||
...
|
||||
```
|
||||
|
||||
## 8. Fragmentation
|
||||
|
||||
### 8.1 Large Packet Handling
|
||||
Packets larger than MaxLength are fragmented using OP_Fragment:
|
||||
|
||||
**First Fragment:**
|
||||
```
|
||||
[2 bytes: Sequence][4 bytes: Total Length][Payload Chunk]
|
||||
```
|
||||
|
||||
**Subsequent Fragments:**
|
||||
```
|
||||
[2 bytes: Sequence][Payload Chunk]
|
||||
```
|
||||
|
||||
### 8.2 Reassembly
|
||||
1. Allocate buffer based on total length from first fragment
|
||||
2. Collect fragments in sequence order
|
||||
3. Reconstruct original packet when all fragments received
|
||||
|
||||
## 9. Data Structure System
|
||||
|
||||
### 9.1 Data Types
|
||||
```c
|
||||
#define DATA_STRUCT_INT8 1
|
||||
#define DATA_STRUCT_INT16 2
|
||||
#define DATA_STRUCT_INT32 3
|
||||
#define DATA_STRUCT_INT64 4
|
||||
#define DATA_STRUCT_FLOAT 5
|
||||
#define DATA_STRUCT_DOUBLE 6
|
||||
#define DATA_STRUCT_COLOR 7
|
||||
#define DATA_STRUCT_SINT8 8
|
||||
#define DATA_STRUCT_SINT16 9
|
||||
#define DATA_STRUCT_SINT32 10
|
||||
#define DATA_STRUCT_CHAR 11
|
||||
#define DATA_STRUCT_EQ2_8BIT_STRING 12
|
||||
#define DATA_STRUCT_EQ2_16BIT_STRING 13
|
||||
#define DATA_STRUCT_EQ2_32BIT_STRING 14
|
||||
#define DATA_STRUCT_EQUIPMENT 15
|
||||
#define DATA_STRUCT_ARRAY 16
|
||||
#define DATA_STRUCT_ITEM 17
|
||||
#define DATA_STRUCT_SINT64 18
|
||||
```
|
||||
|
||||
### 9.2 String Types
|
||||
- **EQ2_8BitString**: [1 byte length][string data]
|
||||
- **EQ2_16BitString**: [2 bytes length][string data]
|
||||
- **EQ2_32BitString**: [4 bytes length][string data]
|
||||
|
||||
### 9.3 Color Structure
|
||||
```c
|
||||
struct EQ2_Color {
|
||||
uint8 red;
|
||||
uint8 green;
|
||||
uint8 blue;
|
||||
};
|
||||
```
|
||||
|
||||
### 9.4 Equipment Structure
|
||||
```c
|
||||
struct EQ2_EquipmentItem {
|
||||
uint16 type;
|
||||
EQ2_Color color;
|
||||
EQ2_Color highlight;
|
||||
};
|
||||
```
|
||||
|
||||
## 10. Application Opcodes
|
||||
|
||||
### 10.1 Opcode System
|
||||
- Two-byte opcodes for game servers (WorldServer, ZoneServer)
|
||||
- One-byte opcodes for login servers
|
||||
- Version-specific opcode mappings stored in database
|
||||
- Translation between internal EmuOpcodes and client opcodes
|
||||
|
||||
### 10.2 Key Application Opcodes
|
||||
```c
|
||||
// Login Operations
|
||||
OP_LoginRequestMsg
|
||||
OP_LoginReplyMsg
|
||||
OP_AllCharactersDescRequestMsg
|
||||
OP_AllCharactersDescReplyMsg
|
||||
OP_CreateCharacterRequestMsg
|
||||
OP_CreateCharacterReplyMsg
|
||||
|
||||
// World Operations
|
||||
OP_ZoneInfoMsg
|
||||
OP_UpdateCharacterSheetMsg
|
||||
OP_UpdateInventoryMsg
|
||||
OP_ClientCmdMsg
|
||||
|
||||
// Chat Operations
|
||||
OP_ChatTellUserMsg
|
||||
OP_ChatJoinChannelMsg
|
||||
```
|
||||
|
||||
## 11. Implementation Guidelines
|
||||
|
||||
### 11.1 Connection State Machine
|
||||
```
|
||||
CLOSED -> SessionRequest -> ESTABLISHED
|
||||
ESTABLISHED -> SessionDisconnect -> CLOSING -> CLOSED
|
||||
```
|
||||
|
||||
### 11.2 Buffer Management
|
||||
- Maintain separate inbound/outbound queues
|
||||
- Implement sliding window for flow control
|
||||
- Handle out-of-order packet storage
|
||||
- Implement packet combining logic
|
||||
|
||||
### 11.3 Threading Considerations
|
||||
- Separate reader/writer threads recommended
|
||||
- Reader processes incoming UDP packets
|
||||
- Writer sends outbound packets and handles retransmission
|
||||
- Combine packet processor for optimization
|
||||
|
||||
### 11.4 Error Handling
|
||||
- Validate CRC on all received packets
|
||||
- Handle malformed packets gracefully
|
||||
- Implement connection timeout detection
|
||||
- Retry logic for failed transmissions
|
||||
|
||||
### 11.5 Performance Optimizations
|
||||
- Packet combination to reduce UDP overhead
|
||||
- Compression for large packets
|
||||
- Rate limiting and congestion control
|
||||
- Efficient data structure serialization
|
||||
|
||||
## 12. Stream Types
|
||||
|
||||
Different stream types have different characteristics:
|
||||
|
||||
```c
|
||||
enum EQStreamType {
|
||||
LoginStream, // 1-byte opcodes, no compression/encryption
|
||||
WorldStream, // 2-byte opcodes, compression, no encryption
|
||||
ZoneStream, // 2-byte opcodes, compression, no encryption
|
||||
ChatStream, // 1-byte opcodes, no compression, encoding
|
||||
EQ2Stream // 2-byte opcodes, no compression/encryption
|
||||
};
|
||||
```
|
||||
|
||||
## 13. Sample Packet Flow
|
||||
|
||||
### 13.1 Login Sequence
|
||||
```
|
||||
1. Client -> Server: OP_SessionRequest
|
||||
2. Server -> Client: OP_SessionResponse (with key, compression flags)
|
||||
3. Client -> Server: OP_Packet[OP_LoginRequestMsg] (with credentials)
|
||||
4. Server -> Client: OP_Packet[OP_LoginReplyMsg] (success/failure)
|
||||
5. Client -> Server: OP_Packet[OP_AllCharactersDescRequestMsg]
|
||||
6. Server -> Client: OP_Packet[OP_AllCharactersDescReplyMsg] (character list)
|
||||
```
|
||||
|
||||
### 13.2 Reliable Data Transfer
|
||||
```
|
||||
1. Sender: Assign sequence number, add to retransmit queue
|
||||
2. Sender: Transmit OP_Packet[seq][data]
|
||||
3. Receiver: Process packet, send OP_Ack[seq]
|
||||
4. Sender: Receive ack, remove from retransmit queue
|
||||
5. On timeout: Retransmit packet up to max attempts
|
||||
```
|
||||
|
||||
This documentation provides the foundation for implementing the EQ2 protocol in any programming language while maintaining compatibility with the existing server and client implementations.
|
@ -17,14 +17,31 @@ const (
|
||||
OpOutOfSession = 0x1D // Packet received outside valid session
|
||||
)
|
||||
|
||||
// Login server
|
||||
// Login server opcodes - matches old/LoginServer reference implementation
|
||||
const (
|
||||
// Core login operations
|
||||
OpLoginRequestMsg = 0x2000 // Initial login request from client
|
||||
OpLoginByNumRequestMsg = 0x2001 // Login request using account number
|
||||
OpWSLoginRequestMsg = 0x2002 // World server login request
|
||||
OpESLoginRequestMsg = 0x2003 // EverQuest station login request
|
||||
OpLoginReplyMsg = 0x2004 // Server response to login attempt
|
||||
// Core login and session management opcodes
|
||||
OP_Login2 = 0x0200 // Primary login authentication
|
||||
OP_GetLoginInfo = 0x0300 // Request login information from client
|
||||
OP_LoginInfo = 0x0100 // Response with login details
|
||||
OP_SessionId = 0x0900 // Session identifier assignment
|
||||
OP_SessionKey = 0x4700 // Session key exchange
|
||||
OP_Disconnect = 0x0500 // Connection termination
|
||||
OP_AllFinish = 0x0500 // Process completion acknowledgment
|
||||
OP_Ack5 = 0x1500 // Generic acknowledgment packet
|
||||
|
||||
// Server list and status management
|
||||
OP_SendServersFragment = 0x0D00 // Fragment of server list data
|
||||
OP_ServerList = 0x4600 // Complete server list
|
||||
OP_RequestServerStatus = 0x4800 // Request current server status
|
||||
OP_SendServerStatus = 0x4A00 // Response with server status
|
||||
OP_Version = 0x5900 // Client/server version verification
|
||||
|
||||
// Modern login operations from login_oplist.hpp
|
||||
OpLoginRequestMsg = 0x2000 // Initial login request from client
|
||||
OpLoginByNumRequestMsg = 0x2001 // Login request using account number
|
||||
OpWSLoginRequestMsg = 0x2002 // World server login request
|
||||
OpESLoginRequestMsg = 0x2003 // EverQuest station login request
|
||||
OpLoginReplyMsg = 0x2004 // Server response to login attempt
|
||||
|
||||
// World server operations
|
||||
OpWorldListMsg = 0x2010 // List of available world servers
|
||||
|
@ -245,6 +245,157 @@ func NewMySQL(dsn string) (*Database, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// QuerySingle executes a query that returns a single row and calls resultFn for it
|
||||
func (d *Database) QuerySingle(query string, resultFn func(stmt *sqlite.Stmt) error, args ...any) (bool, error) {
|
||||
if d.config.Type == SQLite {
|
||||
found := false
|
||||
err := d.ExecTransient(query, func(stmt *sqlite.Stmt) error {
|
||||
found = true
|
||||
return resultFn(stmt)
|
||||
}, args...)
|
||||
return found, err
|
||||
}
|
||||
|
||||
// MySQL implementation
|
||||
rows, err := d.Query(query, args...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
// Convert sql.Row to a compatible interface for the callback
|
||||
// This is a simplified approach - in practice you'd need more sophisticated conversion
|
||||
return true, fmt.Errorf("QuerySingle with MySQL not yet fully implemented - use direct Query/QueryRow")
|
||||
}
|
||||
|
||||
// Exists checks if a query returns any rows
|
||||
func (d *Database) Exists(query string, args ...any) (bool, error) {
|
||||
if d.config.Type == SQLite {
|
||||
found := false
|
||||
err := d.ExecTransient(query, func(stmt *sqlite.Stmt) error {
|
||||
found = true
|
||||
return nil
|
||||
}, args...)
|
||||
return found, err
|
||||
}
|
||||
|
||||
// MySQL implementation
|
||||
rows, err := d.Query(query, args...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return rows.Next(), rows.Err()
|
||||
}
|
||||
|
||||
// InsertReturningID executes an INSERT and returns the last insert ID
|
||||
func (d *Database) InsertReturningID(query string, args ...any) (int64, error) {
|
||||
if d.config.Type == SQLite {
|
||||
var id int64
|
||||
err := d.Execute(query, &sqlitex.ExecOptions{
|
||||
Args: args,
|
||||
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||
id = stmt.ColumnInt64(0)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
|
||||
// MySQL implementation
|
||||
result, err := d.Exec(query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
// UpdateOrInsert performs an UPSERT operation (database-specific)
|
||||
func (d *Database) UpdateOrInsert(table string, data map[string]any, conflictColumns []string) error {
|
||||
if d.config.Type == SQLite {
|
||||
// Use INSERT OR REPLACE for SQLite
|
||||
columns := make([]string, 0, len(data))
|
||||
placeholders := make([]string, 0, len(data))
|
||||
args := make([]any, 0, len(data))
|
||||
|
||||
for col, val := range data {
|
||||
columns = append(columns, col)
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, val)
|
||||
}
|
||||
|
||||
columnStr := ""
|
||||
for i, col := range columns {
|
||||
if i > 0 {
|
||||
columnStr += ", "
|
||||
}
|
||||
columnStr += fmt.Sprintf("`%s`", col)
|
||||
}
|
||||
|
||||
placeholderStr := ""
|
||||
for i := range placeholders {
|
||||
if i > 0 {
|
||||
placeholderStr += ", "
|
||||
}
|
||||
placeholderStr += "?"
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT OR REPLACE INTO `%s` (%s) VALUES (%s)",
|
||||
table, columnStr, placeholderStr)
|
||||
|
||||
return d.Execute(query, &sqlitex.ExecOptions{Args: args})
|
||||
}
|
||||
|
||||
// MySQL implementation using ON DUPLICATE KEY UPDATE
|
||||
columns := make([]string, 0, len(data))
|
||||
placeholders := make([]string, 0, len(data))
|
||||
updates := make([]string, 0, len(data))
|
||||
args := make([]any, 0, len(data)*2)
|
||||
|
||||
for col, val := range data {
|
||||
columns = append(columns, fmt.Sprintf("`%s`", col))
|
||||
placeholders = append(placeholders, "?")
|
||||
updates = append(updates, fmt.Sprintf("`%s` = VALUES(`%s`)", col, col))
|
||||
args = append(args, val)
|
||||
}
|
||||
|
||||
columnStr := ""
|
||||
for i, col := range columns {
|
||||
if i > 0 {
|
||||
columnStr += ", "
|
||||
}
|
||||
columnStr += col
|
||||
}
|
||||
|
||||
placeholderStr := ""
|
||||
for i := range placeholders {
|
||||
if i > 0 {
|
||||
placeholderStr += ", "
|
||||
}
|
||||
placeholderStr += "?"
|
||||
}
|
||||
|
||||
updateStr := ""
|
||||
for i, upd := range updates {
|
||||
if i > 0 {
|
||||
updateStr += ", "
|
||||
}
|
||||
updateStr += upd
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s",
|
||||
table, columnStr, placeholderStr, updateStr)
|
||||
|
||||
_, err := d.Exec(query, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetZones retrieves all zones from the database
|
||||
func (d *Database) GetZones() ([]map[string]any, error) {
|
||||
var zones []map[string]any
|
||||
|
@ -25,10 +25,11 @@ func createTestEntity(id int32, name string, isPlayer bool) *mockEntity {
|
||||
entity.maxPower = int32(rand.Intn(3000) + 500)
|
||||
entity.isBot = !isPlayer && rand.Intn(2) == 1
|
||||
entity.isNPC = !isPlayer && rand.Intn(2) == 0
|
||||
// Use fewer zones to reduce indexing complexity in benchmarks
|
||||
entity.zone = &mockZone{
|
||||
zoneID: int32(rand.Intn(100) + 1),
|
||||
instanceID: int32(rand.Intn(10)),
|
||||
zoneName: fmt.Sprintf("Zone %d", rand.Intn(100)+1),
|
||||
zoneID: int32(rand.Intn(5) + 1),
|
||||
instanceID: int32(rand.Intn(3)),
|
||||
zoneName: fmt.Sprintf("Zone %d", rand.Intn(5)+1),
|
||||
}
|
||||
return entity
|
||||
}
|
||||
@ -302,8 +303,8 @@ func BenchmarkGroupState(b *testing.B) {
|
||||
func BenchmarkMasterListOperations(b *testing.B) {
|
||||
ml := NewMasterList()
|
||||
|
||||
// Pre-populate with groups
|
||||
const numGroups = 1000
|
||||
// Pre-populate with groups - reduced from 1000 to avoid goroutine exhaustion
|
||||
const numGroups = 100
|
||||
groups := make([]*Group, numGroups)
|
||||
|
||||
b.StopTimer()
|
||||
@ -339,10 +340,14 @@ func BenchmarkMasterListOperations(b *testing.B) {
|
||||
ml.AddGroup(group)
|
||||
addedGroups = append(addedGroups, group)
|
||||
}
|
||||
// Cleanup added groups
|
||||
// Immediate cleanup to prevent goroutine exhaustion
|
||||
b.StopTimer()
|
||||
for _, group := range addedGroups {
|
||||
group.Disband()
|
||||
if group != nil {
|
||||
group.Disband()
|
||||
}
|
||||
}
|
||||
b.StartTimer()
|
||||
})
|
||||
|
||||
b.Run("GetAllGroups", func(b *testing.B) {
|
||||
@ -359,7 +364,7 @@ func BenchmarkMasterListOperations(b *testing.B) {
|
||||
|
||||
b.Run("GetGroupsByZone", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
zoneID := int32(rand.Intn(100) + 1)
|
||||
zoneID := int32(rand.Intn(5) + 1) // Match reduced zone range
|
||||
_ = ml.GetGroupsByZone(zoneID)
|
||||
}
|
||||
})
|
||||
|
@ -549,6 +549,22 @@ func (g *Group) GetGroupMemberByPosition(seeker Entity, mappedPosition int32) En
|
||||
return g.Members[mappedPosition].Member
|
||||
}
|
||||
|
||||
// RemoveClientReference removes client references when a client disconnects
|
||||
// This is used for cleanup when a player disconnects but stays in the group
|
||||
func (g *Group) RemoveClientReference(client any) {
|
||||
g.membersMutex.Lock()
|
||||
defer g.membersMutex.Unlock()
|
||||
|
||||
for _, gmi := range g.Members {
|
||||
if gmi.Client != nil && gmi.Client == client {
|
||||
gmi.Client = nil
|
||||
// Don't set Member to nil as the entity might still exist
|
||||
// Only clear the client reference
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetGroupOptions returns a copy of the group options
|
||||
func (g *Group) GetGroupOptions() GroupOptions {
|
||||
g.optionsMutex.RLock()
|
||||
|
@ -515,3 +515,14 @@ func (m *Manager) fireGroupInviteDeclinedEvent(leader, member Entity) {
|
||||
go handler.OnGroupInviteDeclined(leader, member)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveClientReference removes client references from all groups when a client disconnects
|
||||
func (m *Manager) RemoveClientReference(client any) {
|
||||
// Get all groups from the master list
|
||||
groups := m.MasterList.GetAllGroups()
|
||||
|
||||
// Remove client reference from all groups
|
||||
for _, group := range groups {
|
||||
group.RemoveClientReference(client)
|
||||
}
|
||||
}
|
||||
|
@ -35,14 +35,14 @@ type MasterList struct {
|
||||
byLastActivity map[time.Time][]*Group // Last activity time -> groups
|
||||
|
||||
// Cached metadata and slices
|
||||
totalMembers int32 // Total active members across all groups
|
||||
zones []int32 // Unique zones with group members
|
||||
sizes []int32 // Unique group sizes
|
||||
zoneStats map[int32]int // Zone ID -> group count
|
||||
sizeStats map[int32]int // Size -> group count
|
||||
allGroupsSlice []*Group // Cached slice of all groups
|
||||
activeGroupsSlice []*Group // Cached slice of active groups
|
||||
metaStale bool // Whether metadata cache needs refresh
|
||||
totalMembers int32 // Total active members across all groups
|
||||
zones []int32 // Unique zones with group members
|
||||
sizes []int32 // Unique group sizes
|
||||
zoneStats map[int32]int // Zone ID -> group count
|
||||
sizeStats map[int32]int // Size -> group count
|
||||
allGroupsSlice []*Group // Cached slice of all groups
|
||||
activeGroupsSlice []*Group // Cached slice of active groups
|
||||
metaStale bool // Whether metadata cache needs refresh
|
||||
}
|
||||
|
||||
// NewMasterList creates a new group master list
|
||||
@ -67,6 +67,7 @@ func NewMasterList() *MasterList {
|
||||
}
|
||||
|
||||
// refreshMetaCache updates the cached metadata
|
||||
// Note: This function assumes the caller already holds ml.mutex.Lock()
|
||||
func (ml *MasterList) refreshMetaCache() {
|
||||
if !ml.metaStale {
|
||||
return
|
||||
@ -79,8 +80,14 @@ func (ml *MasterList) refreshMetaCache() {
|
||||
sizeSet := make(map[int32]struct{})
|
||||
ml.totalMembers = 0
|
||||
|
||||
// Collect unique values and stats
|
||||
// Get snapshot of active groups to avoid holding lock while calling group methods
|
||||
activeGroupsSnapshot := make([]*Group, 0, len(ml.activeGroups))
|
||||
for _, group := range ml.activeGroups {
|
||||
activeGroupsSnapshot = append(activeGroupsSnapshot, group)
|
||||
}
|
||||
|
||||
// Collect unique values and stats
|
||||
for _, group := range activeGroupsSnapshot {
|
||||
size := group.GetSize()
|
||||
ml.sizeStats[size]++
|
||||
sizeSet[size] = struct{}{}
|
||||
@ -134,12 +141,33 @@ func (ml *MasterList) refreshMetaCache() {
|
||||
|
||||
// updateGroupIndices updates all indices for a group
|
||||
func (ml *MasterList) updateGroupIndices(group *Group, add bool) {
|
||||
// Get all group data in one go to minimize lock contention
|
||||
// This avoids holding the master list lock while calling multiple group methods
|
||||
groupID := group.GetID()
|
||||
size := group.GetSize()
|
||||
leaderName := group.GetLeaderName()
|
||||
isRaid := group.IsGroupRaid()
|
||||
isDisbanded := group.IsDisbanded()
|
||||
members := group.GetMembers()
|
||||
|
||||
// Create a snapshot of group data to avoid repeated method calls
|
||||
groupData := struct {
|
||||
id int32
|
||||
size int32
|
||||
leaderName string
|
||||
isRaid bool
|
||||
isDisbanded bool
|
||||
members []*GroupMemberInfo
|
||||
}{
|
||||
id: groupID,
|
||||
size: group.GetSize(),
|
||||
leaderName: group.GetLeaderName(),
|
||||
isRaid: group.IsGroupRaid(),
|
||||
isDisbanded: group.IsDisbanded(),
|
||||
members: group.GetMembers(),
|
||||
}
|
||||
|
||||
// Use the snapshot data for indexing
|
||||
size := groupData.size
|
||||
leaderName := groupData.leaderName
|
||||
isRaid := groupData.isRaid
|
||||
isDisbanded := groupData.isDisbanded
|
||||
members := groupData.members
|
||||
|
||||
if add {
|
||||
// Add to size index
|
||||
@ -315,10 +343,26 @@ func (ml *MasterList) RemoveGroup(groupID int32) bool {
|
||||
|
||||
// GetAllGroups returns all groups as a slice
|
||||
func (ml *MasterList) GetAllGroups() []*Group {
|
||||
ml.mutex.Lock() // Need write lock to potentially update cache
|
||||
// Use read lock first to check if we need to refresh
|
||||
ml.mutex.RLock()
|
||||
needsRefresh := ml.metaStale
|
||||
if !needsRefresh {
|
||||
// Return cached result without upgrading to write lock
|
||||
result := make([]*Group, len(ml.allGroupsSlice))
|
||||
copy(result, ml.allGroupsSlice)
|
||||
ml.mutex.RUnlock()
|
||||
return result
|
||||
}
|
||||
ml.mutex.RUnlock()
|
||||
|
||||
// Need to refresh - acquire write lock
|
||||
ml.mutex.Lock()
|
||||
defer ml.mutex.Unlock()
|
||||
|
||||
ml.refreshMetaCache()
|
||||
// Double-check pattern - someone else might have refreshed while we waited
|
||||
if ml.metaStale {
|
||||
ml.refreshMetaCache()
|
||||
}
|
||||
|
||||
// Return a copy to prevent external modification
|
||||
result := make([]*Group, len(ml.allGroupsSlice))
|
||||
@ -416,10 +460,26 @@ func (ml *MasterList) GetGroupByLeader(leaderName string) *Group {
|
||||
|
||||
// GetActiveGroups returns all non-disbanded groups (O(1))
|
||||
func (ml *MasterList) GetActiveGroups() []*Group {
|
||||
ml.mutex.Lock() // Need write lock to potentially update cache
|
||||
// Use read lock first to check if we need to refresh
|
||||
ml.mutex.RLock()
|
||||
needsRefresh := ml.metaStale
|
||||
if !needsRefresh {
|
||||
// Return cached result without upgrading to write lock
|
||||
result := make([]*Group, len(ml.activeGroupsSlice))
|
||||
copy(result, ml.activeGroupsSlice)
|
||||
ml.mutex.RUnlock()
|
||||
return result
|
||||
}
|
||||
ml.mutex.RUnlock()
|
||||
|
||||
// Need to refresh - acquire write lock
|
||||
ml.mutex.Lock()
|
||||
defer ml.mutex.Unlock()
|
||||
|
||||
ml.refreshMetaCache()
|
||||
// Double-check pattern - someone else might have refreshed while we waited
|
||||
if ml.metaStale {
|
||||
ml.refreshMetaCache()
|
||||
}
|
||||
|
||||
// Return a copy to prevent external modification
|
||||
result := make([]*Group, len(ml.activeGroupsSlice))
|
||||
@ -431,14 +491,32 @@ func (ml *MasterList) GetActiveGroups() []*Group {
|
||||
func (ml *MasterList) GetGroupsByZone(zoneID int32) []*Group {
|
||||
ml.mutex.RLock()
|
||||
defer ml.mutex.RUnlock()
|
||||
return ml.byZone[zoneID]
|
||||
|
||||
groups := ml.byZone[zoneID]
|
||||
if groups == nil {
|
||||
return []*Group{}
|
||||
}
|
||||
|
||||
// Return a copy to prevent external modification
|
||||
result := make([]*Group, len(groups))
|
||||
copy(result, groups)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetGroupsBySize returns groups of the specified size (O(1))
|
||||
func (ml *MasterList) GetGroupsBySize(size int32) []*Group {
|
||||
ml.mutex.RLock()
|
||||
defer ml.mutex.RUnlock()
|
||||
return ml.bySize[size]
|
||||
|
||||
groups := ml.bySize[size]
|
||||
if groups == nil {
|
||||
return []*Group{}
|
||||
}
|
||||
|
||||
// Return a copy to prevent external modification
|
||||
result := make([]*Group, len(groups))
|
||||
copy(result, groups)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetRaidGroups returns all groups that are part of raids (O(1))
|
||||
@ -532,10 +610,44 @@ func (ml *MasterList) GetTotalMembers() int32 {
|
||||
|
||||
// GetGroupStatistics returns statistics about the groups in the master list using cached data
|
||||
func (ml *MasterList) GetGroupStatistics() *GroupMasterListStats {
|
||||
ml.mutex.Lock() // Need write lock to potentially update cache
|
||||
// Use read lock first to check if we need to refresh
|
||||
ml.mutex.RLock()
|
||||
needsRefresh := ml.metaStale
|
||||
if !needsRefresh {
|
||||
// Calculate stats from cached data
|
||||
var totalRaidMembers int32
|
||||
for _, group := range ml.raidGroups {
|
||||
totalRaidMembers += group.GetSize()
|
||||
}
|
||||
|
||||
var averageGroupSize float64
|
||||
if len(ml.activeGroups) > 0 {
|
||||
averageGroupSize = float64(ml.totalMembers) / float64(len(ml.activeGroups))
|
||||
}
|
||||
|
||||
stats := &GroupMasterListStats{
|
||||
TotalGroups: int32(len(ml.groups)),
|
||||
ActiveGroups: int32(len(ml.activeGroups)),
|
||||
RaidGroups: int32(len(ml.raidGroups)),
|
||||
TotalMembers: ml.totalMembers,
|
||||
TotalRaidMembers: totalRaidMembers,
|
||||
AverageGroupSize: averageGroupSize,
|
||||
SoloGroups: int32(len(ml.soloGroups)),
|
||||
FullGroups: int32(len(ml.fullGroups)),
|
||||
}
|
||||
ml.mutex.RUnlock()
|
||||
return stats
|
||||
}
|
||||
ml.mutex.RUnlock()
|
||||
|
||||
// Need to refresh - acquire write lock
|
||||
ml.mutex.Lock()
|
||||
defer ml.mutex.Unlock()
|
||||
|
||||
ml.refreshMetaCache()
|
||||
// Double-check pattern
|
||||
if ml.metaStale {
|
||||
ml.refreshMetaCache()
|
||||
}
|
||||
|
||||
var totalRaidMembers int32
|
||||
for _, group := range ml.raidGroups {
|
||||
|
541
internal/login/client.go
Normal file
541
internal/login/client.go
Normal file
@ -0,0 +1,541 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"eq2emu/internal/common/opcodes"
|
||||
"eq2emu/internal/packets"
|
||||
"eq2emu/internal/udp"
|
||||
)
|
||||
|
||||
// ClientState represents the current state of a login client
|
||||
type ClientState int
|
||||
|
||||
const (
|
||||
ClientStateNew ClientState = iota
|
||||
ClientStateAuthenticating
|
||||
ClientStateAuthenticated
|
||||
ClientStateCharacterSelect
|
||||
ClientStateDisconnected
|
||||
)
|
||||
|
||||
// String returns the string representation of the client state
|
||||
func (cs ClientState) String() string {
|
||||
switch cs {
|
||||
case ClientStateNew:
|
||||
return "New"
|
||||
case ClientStateAuthenticating:
|
||||
return "Authenticating"
|
||||
case ClientStateAuthenticated:
|
||||
return "Authenticated"
|
||||
case ClientStateCharacterSelect:
|
||||
return "CharacterSelect"
|
||||
case ClientStateDisconnected:
|
||||
return "Disconnected"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Client represents a connected login client
|
||||
type Client struct {
|
||||
connection *udp.Connection
|
||||
database *LoginDB
|
||||
|
||||
// Client information
|
||||
accountID int32
|
||||
accountName string
|
||||
accountEmail string
|
||||
accessLevel int16
|
||||
|
||||
// Authentication data
|
||||
loginKey string
|
||||
sessionKey string
|
||||
ipAddress string
|
||||
|
||||
// Client state
|
||||
state ClientState
|
||||
clientVersion uint16
|
||||
protocolVersion uint16
|
||||
|
||||
// Character data
|
||||
characters []CharacterInfo
|
||||
|
||||
// Timing
|
||||
connectTime time.Time
|
||||
lastActivity time.Time
|
||||
|
||||
// Synchronization
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// CharacterInfo represents character information for the character select screen
|
||||
type CharacterInfo struct {
|
||||
ID int32 `json:"id"`
|
||||
AccountID int32 `json:"account_id"`
|
||||
Name string `json:"name"`
|
||||
Race int8 `json:"race"`
|
||||
Class int8 `json:"class"`
|
||||
Gender int8 `json:"gender"`
|
||||
Level int16 `json:"level"`
|
||||
Zone int32 `json:"zone"`
|
||||
ZoneInstance int32 `json:"zone_instance"`
|
||||
ServerID int16 `json:"server_id"`
|
||||
LastPlayed int64 `json:"last_played"`
|
||||
CreatedDate int64 `json:"created_date"`
|
||||
DeletedDate int64 `json:"deleted_date"`
|
||||
|
||||
// Appearance data
|
||||
ModelType int16 `json:"model_type"`
|
||||
SogaModelType int16 `json:"soga_model_type"`
|
||||
HeadType int16 `json:"head_type"`
|
||||
SogaHeadType int16 `json:"soga_head_type"`
|
||||
WingType int16 `json:"wing_type"`
|
||||
ChestType int16 `json:"chest_type"`
|
||||
LegsType int16 `json:"legs_type"`
|
||||
SogaChestType int16 `json:"soga_chest_type"`
|
||||
SogaLegsType int16 `json:"soga_legs_type"`
|
||||
HairType int16 `json:"hair_type"`
|
||||
FacialHairType int16 `json:"facial_hair_type"`
|
||||
SogaHairType int16 `json:"soga_hair_type"`
|
||||
SogaFacialHairType int16 `json:"soga_facial_hair_type"`
|
||||
|
||||
// Colors
|
||||
HairTypeColor int16 `json:"hair_type_color"`
|
||||
HairTypeHighlight int16 `json:"hair_type_highlight"`
|
||||
HairColor1 int16 `json:"hair_color1"`
|
||||
HairColor2 int16 `json:"hair_color2"`
|
||||
HairHighlight int16 `json:"hair_highlight"`
|
||||
EyeColor1 int16 `json:"eye_color1"`
|
||||
EyeColor2 int16 `json:"eye_color2"`
|
||||
SkinColor int16 `json:"skin_color"`
|
||||
|
||||
// Soga colors
|
||||
SogaHairTypeColor int16 `json:"soga_hair_type_color"`
|
||||
SogaHairTypeHighlight int16 `json:"soga_hair_type_highlight"`
|
||||
SogaHairColor1 int16 `json:"soga_hair_color1"`
|
||||
SogaHairColor2 int16 `json:"soga_hair_color2"`
|
||||
SogaHairHighlight int16 `json:"soga_hair_highlight"`
|
||||
SogaEyeColor1 int16 `json:"soga_eye_color1"`
|
||||
SogaEyeColor2 int16 `json:"soga_eye_color2"`
|
||||
SogaSkinColor int16 `json:"soga_skin_color"`
|
||||
|
||||
// Additional data
|
||||
CurrentLanguage int8 `json:"current_language"`
|
||||
ChosenLanguage int8 `json:"chosen_language"`
|
||||
}
|
||||
|
||||
// NewClient creates a new login client
|
||||
func NewClient(conn *udp.Connection, db *LoginDB) *Client {
|
||||
now := time.Now()
|
||||
|
||||
client := &Client{
|
||||
connection: conn,
|
||||
database: db,
|
||||
state: ClientStateNew,
|
||||
connectTime: now,
|
||||
lastActivity: now,
|
||||
ipAddress: conn.GetClientAddr().String(),
|
||||
characters: make([]CharacterInfo, 0),
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// HandlePacket processes an incoming packet from the client
|
||||
func (c *Client) HandlePacket(data []byte) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.lastActivity = time.Now()
|
||||
|
||||
if len(data) < 2 {
|
||||
return fmt.Errorf("packet too short")
|
||||
}
|
||||
|
||||
// Extract opcode
|
||||
opcode := binary.LittleEndian.Uint16(data[:2])
|
||||
payload := data[2:]
|
||||
|
||||
log.Printf("Client %s: Received opcode 0x%04x, size %d", c.ipAddress, opcode, len(payload))
|
||||
|
||||
switch opcode {
|
||||
case opcodes.OpLoginRequestMsg:
|
||||
return c.handleLoginRequest(payload)
|
||||
case opcodes.OpAllCharactersDescRequestMsg:
|
||||
return c.handleCharacterSelectRequest(payload)
|
||||
case opcodes.OpPlayCharacterRequestMsg:
|
||||
return c.handlePlayCharacterRequest(payload)
|
||||
case opcodes.OpDeleteCharacterRequestMsg:
|
||||
return c.handleDeleteCharacterRequest(payload)
|
||||
case opcodes.OpCreateCharacterRequestMsg:
|
||||
return c.handleCreateCharacterRequest(payload)
|
||||
default:
|
||||
log.Printf("Client %s: Unknown opcode 0x%04x", c.ipAddress, opcode)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleLoginRequest processes a login request from the client
|
||||
func (c *Client) handleLoginRequest(payload []byte) error {
|
||||
if c.state != ClientStateNew {
|
||||
return fmt.Errorf("invalid state for login request: %s", c.state)
|
||||
}
|
||||
|
||||
// Parse the packet using the proper packet system
|
||||
clientVersion := uint32(562) // Assume version 562 for now
|
||||
data, err := packets.ParsePacketFields(payload, "LoginRequest", clientVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse login request: %w", err)
|
||||
}
|
||||
|
||||
// Extract fields based on the XML definition
|
||||
accessCode := getStringField(data, "accesscode")
|
||||
unknown1 := getStringField(data, "unknown1")
|
||||
username := getStringField(data, "username")
|
||||
password := getStringField(data, "password")
|
||||
|
||||
log.Printf("Login request - Username: %s, Access: %s, Unknown: %s",
|
||||
username, accessCode, unknown1)
|
||||
|
||||
c.state = ClientStateAuthenticating
|
||||
|
||||
// Authenticate user
|
||||
return c.authenticateUser(username, password)
|
||||
}
|
||||
|
||||
// Helper function to safely extract string fields from packet data
|
||||
func getStringField(data map[string]any, field string) string {
|
||||
if val, ok := data[field]; ok {
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// authenticateUser authenticates the user credentials
|
||||
func (c *Client) authenticateUser(username, password string) error {
|
||||
username = strings.TrimSpace(strings.Trim(username, "\x00"))
|
||||
|
||||
// Hash the password (MD5 for EQ2 compatibility)
|
||||
hasher := md5.New()
|
||||
hasher.Write([]byte(password))
|
||||
hashedPassword := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
|
||||
// Query database for account
|
||||
account, err := c.database.GetLoginAccount(username, hashedPassword)
|
||||
if err != nil {
|
||||
log.Printf("Authentication failed for %s: %v", username, err)
|
||||
return c.sendLoginReply(0, "Invalid username or password")
|
||||
}
|
||||
|
||||
// Check account status
|
||||
if account.Status != "Active" {
|
||||
log.Printf("Account %s is not active: %s", username, account.Status)
|
||||
return c.sendLoginReply(0, "Account is suspended")
|
||||
}
|
||||
|
||||
// Store account information
|
||||
c.accountID = account.ID
|
||||
c.accountName = account.Username
|
||||
c.accountEmail = account.Email
|
||||
c.accessLevel = account.AccessLevel
|
||||
c.state = ClientStateAuthenticated
|
||||
|
||||
log.Printf("User %s (ID: %d) authenticated successfully", username, account.ID)
|
||||
|
||||
// Generate session key
|
||||
c.sessionKey = c.generateSessionKey()
|
||||
|
||||
// Update last login
|
||||
c.database.UpdateLastLogin(c.accountID, c.ipAddress)
|
||||
|
||||
// Send successful login reply
|
||||
return c.sendLoginReply(1, "Welcome to EverQuest II")
|
||||
}
|
||||
|
||||
// sendLoginReply sends a login reply to the client
|
||||
func (c *Client) sendLoginReply(success int8, message string) error {
|
||||
// Build login reply using the packet system
|
||||
clientVersion := uint32(562) // TODO: Track actual client version
|
||||
|
||||
data := map[string]any{
|
||||
"login_response": success,
|
||||
"unknown": message,
|
||||
}
|
||||
|
||||
if success == 1 {
|
||||
data["account_id"] = c.accountID
|
||||
// Add other required fields for successful login
|
||||
data["parental_control_flag"] = uint8(0)
|
||||
data["parental_control_timer"] = uint32(0)
|
||||
|
||||
// TODO: Add more fields as needed based on client version
|
||||
}
|
||||
|
||||
packet, err := packets.BuildPacket("LoginReplyMsg", data, clientVersion, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build login reply packet: %w", err)
|
||||
}
|
||||
|
||||
// Send the packet
|
||||
appPacket := &udp.ApplicationPacket{
|
||||
Data: packet,
|
||||
}
|
||||
c.connection.SendPacket(appPacket)
|
||||
|
||||
// If login successful, send character list
|
||||
if success == 1 {
|
||||
if err := c.loadCharacters(); err != nil {
|
||||
log.Printf("Failed to load characters: %v", err)
|
||||
}
|
||||
return c.sendCharacterList()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadCharacters loads the character list for this account
|
||||
func (c *Client) loadCharacters() error {
|
||||
characters, err := c.database.GetCharacters(c.accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load characters: %w", err)
|
||||
}
|
||||
|
||||
c.characters = make([]CharacterInfo, len(characters))
|
||||
for i, char := range characters {
|
||||
c.characters[i] = CharacterInfo{
|
||||
ID: char.ID,
|
||||
AccountID: char.AccountID,
|
||||
Name: strings.TrimSpace(strings.Trim(char.Name, "\x00")),
|
||||
Race: char.Race,
|
||||
Class: char.Class,
|
||||
Gender: char.Gender,
|
||||
Level: char.Level,
|
||||
Zone: char.Zone,
|
||||
ServerID: char.ServerID,
|
||||
LastPlayed: char.LastPlayed,
|
||||
CreatedDate: char.CreatedDate,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// handleCharacterSelectRequest handles character selection
|
||||
func (c *Client) handleCharacterSelectRequest(payload []byte) error {
|
||||
if c.state != ClientStateAuthenticated {
|
||||
return fmt.Errorf("invalid state for character select: %s", c.state)
|
||||
}
|
||||
|
||||
c.state = ClientStateCharacterSelect
|
||||
|
||||
// Send character list
|
||||
if err := c.loadCharacters(); err != nil {
|
||||
return fmt.Errorf("failed to load characters: %w", err)
|
||||
}
|
||||
|
||||
return c.sendCharacterList()
|
||||
}
|
||||
|
||||
// sendCharacterList sends the character list to the client
|
||||
func (c *Client) sendCharacterList() error {
|
||||
// For now, send character profiles individually using CharSelectProfile packet
|
||||
// In the real implementation, this would be sent as part of LoginReplyMsg
|
||||
|
||||
for _, char := range c.characters {
|
||||
data := map[string]any{
|
||||
"version": uint32(562),
|
||||
"charid": uint32(char.ID),
|
||||
"server_id": uint32(char.ServerID),
|
||||
"name": char.Name,
|
||||
"unknown": uint8(0),
|
||||
"race": uint8(char.Race),
|
||||
"class": uint8(char.Class),
|
||||
"gender": uint8(char.Gender),
|
||||
"level": uint32(char.Level),
|
||||
"zone": "Qeynos Harbor", // TODO: Get actual zone name
|
||||
"unknown1": uint32(0),
|
||||
"unknown2": uint32(0),
|
||||
"created_date": uint32(char.CreatedDate),
|
||||
"last_played": uint32(char.LastPlayed),
|
||||
"unknown3": uint32(0),
|
||||
"unknown4": uint32(0),
|
||||
"zonename2": "Qeynos Harbor",
|
||||
"zonedesc": "The Harbor District",
|
||||
"unknown5": uint32(0),
|
||||
"server_name": "EQ2Go Server",
|
||||
"account_id": uint32(c.accountID),
|
||||
}
|
||||
|
||||
// Add appearance data with defaults for now
|
||||
// TODO: Load actual character appearance data
|
||||
|
||||
clientVersion := uint32(562)
|
||||
packet, err := packets.BuildPacket("CharSelectProfile", data, clientVersion, 0)
|
||||
if err != nil {
|
||||
log.Printf("Failed to build character profile packet: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
appPacket := &udp.ApplicationPacket{
|
||||
Data: packet,
|
||||
}
|
||||
c.connection.SendPacket(appPacket)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlayCharacterRequest handles play character request
|
||||
func (c *Client) handlePlayCharacterRequest(payload []byte) error {
|
||||
if c.state != ClientStateCharacterSelect && c.state != ClientStateAuthenticated {
|
||||
return fmt.Errorf("invalid state for play character: %s", c.state)
|
||||
}
|
||||
|
||||
if len(payload) < 8 {
|
||||
return fmt.Errorf("play character packet too short")
|
||||
}
|
||||
|
||||
characterID := binary.LittleEndian.Uint32(payload[:4])
|
||||
serverID := binary.LittleEndian.Uint16(payload[4:6])
|
||||
|
||||
log.Printf("Play character request - Character: %d, Server: %d", characterID, serverID)
|
||||
|
||||
// Find character
|
||||
var character *CharacterInfo
|
||||
for i := range c.characters {
|
||||
if c.characters[i].ID == int32(characterID) {
|
||||
character = &c.characters[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if character == nil {
|
||||
return fmt.Errorf("character %d not found", characterID)
|
||||
}
|
||||
|
||||
// TODO: Forward to world server
|
||||
return c.sendPlayCharacterReply(character, "127.0.0.1", 9000)
|
||||
}
|
||||
|
||||
// sendPlayCharacterReply sends play character reply to client
|
||||
func (c *Client) sendPlayCharacterReply(character *CharacterInfo, worldIP string, worldPort int) error {
|
||||
data := map[string]any{
|
||||
"response": uint8(1), // Success
|
||||
"server": worldIP,
|
||||
"port": uint16(worldPort),
|
||||
"account_id": uint32(c.accountID),
|
||||
"access_code": uint32(12345), // TODO: Generate proper access code
|
||||
}
|
||||
|
||||
clientVersion := uint32(562)
|
||||
packet, err := packets.BuildPacket("PlayResponse", data, clientVersion, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build play response packet: %w", err)
|
||||
}
|
||||
|
||||
appPacket := &udp.ApplicationPacket{
|
||||
Data: packet,
|
||||
}
|
||||
c.connection.SendPacket(appPacket)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDeleteCharacterRequest handles character deletion
|
||||
func (c *Client) handleDeleteCharacterRequest(payload []byte) error {
|
||||
// TODO: Implement character deletion
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCreateCharacterRequest handles character creation
|
||||
func (c *Client) handleCreateCharacterRequest(payload []byte) error {
|
||||
// TODO: Implement character creation
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSessionKey generates a unique session key for this client
|
||||
func (c *Client) generateSessionKey() string {
|
||||
now := time.Now()
|
||||
data := fmt.Sprintf("%d-%s-%d", c.accountID, c.ipAddress, now.Unix())
|
||||
|
||||
hasher := md5.New()
|
||||
hasher.Write([]byte(data))
|
||||
|
||||
return fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
}
|
||||
|
||||
// Disconnect disconnects the client
|
||||
func (c *Client) Disconnect(reason string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.state == ClientStateDisconnected {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Disconnecting client %s: %s", c.ipAddress, reason)
|
||||
|
||||
c.state = ClientStateDisconnected
|
||||
if c.connection != nil {
|
||||
c.connection.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// GetState returns the current client state (thread-safe)
|
||||
func (c *Client) GetState() ClientState {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.state
|
||||
}
|
||||
|
||||
// GetAccountID returns the account ID (thread-safe)
|
||||
func (c *Client) GetAccountID() int32 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.accountID
|
||||
}
|
||||
|
||||
// GetAccountName returns the account name (thread-safe)
|
||||
func (c *Client) GetAccountName() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.accountName
|
||||
}
|
||||
|
||||
// GetIPAddress returns the client IP address
|
||||
func (c *Client) GetIPAddress() string {
|
||||
return c.ipAddress
|
||||
}
|
||||
|
||||
// GetConnection returns the UDP connection
|
||||
func (c *Client) GetConnection() *udp.Connection {
|
||||
return c.connection
|
||||
}
|
||||
|
||||
// GetConnectTime returns when the client connected
|
||||
func (c *Client) GetConnectTime() time.Time {
|
||||
return c.connectTime
|
||||
}
|
||||
|
||||
// GetLastActivity returns the last activity time
|
||||
func (c *Client) GetLastActivity() time.Time {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.lastActivity
|
||||
}
|
||||
|
||||
// IsTimedOut returns whether the client has timed out
|
||||
func (c *Client) IsTimedOut(timeout time.Duration) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return time.Since(c.lastActivity) > timeout
|
||||
}
|
226
internal/login/client_list.go
Normal file
226
internal/login/client_list.go
Normal file
@ -0,0 +1,226 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"eq2emu/internal/udp"
|
||||
)
|
||||
|
||||
// ClientList manages connected login clients
|
||||
type ClientList struct {
|
||||
clients map[*udp.Connection]*Client
|
||||
byAccountID map[int32]*Client
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewClientList creates a new client list
|
||||
func NewClientList() *ClientList {
|
||||
return &ClientList{
|
||||
clients: make(map[*udp.Connection]*Client),
|
||||
byAccountID: make(map[int32]*Client),
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a client to the list
|
||||
func (cl *ClientList) Add(client *Client) {
|
||||
if client == nil || client.GetConnection() == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cl.mu.Lock()
|
||||
defer cl.mu.Unlock()
|
||||
|
||||
// Remove any existing client for this connection
|
||||
if existing, exists := cl.clients[client.GetConnection()]; exists {
|
||||
existing.Disconnect("Replaced by new connection")
|
||||
if existing.GetAccountID() > 0 {
|
||||
delete(cl.byAccountID, existing.GetAccountID())
|
||||
}
|
||||
}
|
||||
|
||||
cl.clients[client.GetConnection()] = client
|
||||
}
|
||||
|
||||
// Remove removes a client from the list
|
||||
func (cl *ClientList) Remove(client *Client) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cl.mu.Lock()
|
||||
defer cl.mu.Unlock()
|
||||
|
||||
conn := client.GetConnection()
|
||||
if conn != nil {
|
||||
delete(cl.clients, conn)
|
||||
}
|
||||
|
||||
if client.GetAccountID() > 0 {
|
||||
delete(cl.byAccountID, client.GetAccountID())
|
||||
}
|
||||
}
|
||||
|
||||
// GetByConnection returns a client by connection
|
||||
func (cl *ClientList) GetByConnection(conn *udp.Connection) *Client {
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cl.mu.RLock()
|
||||
defer cl.mu.RUnlock()
|
||||
|
||||
return cl.clients[conn]
|
||||
}
|
||||
|
||||
// GetByAccountID returns a client by account ID
|
||||
func (cl *ClientList) GetByAccountID(accountID int32) *Client {
|
||||
if accountID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cl.mu.RLock()
|
||||
defer cl.mu.RUnlock()
|
||||
|
||||
return cl.byAccountID[accountID]
|
||||
}
|
||||
|
||||
// UpdateAccountMapping updates the account ID mapping for a client
|
||||
func (cl *ClientList) UpdateAccountMapping(client *Client) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cl.mu.Lock()
|
||||
defer cl.mu.Unlock()
|
||||
|
||||
accountID := client.GetAccountID()
|
||||
if accountID > 0 {
|
||||
// Remove any existing mapping for this account
|
||||
if existing, exists := cl.byAccountID[accountID]; exists && existing != client {
|
||||
existing.Disconnect("Account logged in elsewhere")
|
||||
delete(cl.clients, existing.GetConnection())
|
||||
}
|
||||
|
||||
cl.byAccountID[accountID] = client
|
||||
}
|
||||
}
|
||||
|
||||
// Process processes all clients
|
||||
func (cl *ClientList) Process() {
|
||||
cl.mu.Lock()
|
||||
defer cl.mu.Unlock()
|
||||
|
||||
// Check for timed out clients
|
||||
timeout := 5 * time.Minute
|
||||
var toRemove []*Client
|
||||
|
||||
for _, client := range cl.clients {
|
||||
if client.IsTimedOut(timeout) {
|
||||
toRemove = append(toRemove, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove timed out clients
|
||||
for _, client := range toRemove {
|
||||
client.Disconnect("Connection timeout")
|
||||
delete(cl.clients, client.GetConnection())
|
||||
|
||||
if client.GetAccountID() > 0 {
|
||||
delete(cl.byAccountID, client.GetAccountID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count returns the number of connected clients
|
||||
func (cl *ClientList) Count() int {
|
||||
cl.mu.RLock()
|
||||
defer cl.mu.RUnlock()
|
||||
return len(cl.clients)
|
||||
}
|
||||
|
||||
// GetClients returns a slice of all clients (snapshot)
|
||||
func (cl *ClientList) GetClients() []*Client {
|
||||
cl.mu.RLock()
|
||||
defer cl.mu.RUnlock()
|
||||
|
||||
clients := make([]*Client, 0, len(cl.clients))
|
||||
for _, client := range cl.clients {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients
|
||||
}
|
||||
|
||||
// GetClientsByState returns clients in a specific state
|
||||
func (cl *ClientList) GetClientsByState(state ClientState) []*Client {
|
||||
cl.mu.RLock()
|
||||
defer cl.mu.RUnlock()
|
||||
|
||||
var clients []*Client
|
||||
for _, client := range cl.clients {
|
||||
if client.GetState() == state {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
}
|
||||
|
||||
return clients
|
||||
}
|
||||
|
||||
// DisconnectAll disconnects all clients
|
||||
func (cl *ClientList) DisconnectAll(reason string) {
|
||||
cl.mu.Lock()
|
||||
defer cl.mu.Unlock()
|
||||
|
||||
for _, client := range cl.clients {
|
||||
client.Disconnect(reason)
|
||||
}
|
||||
|
||||
// Clear maps
|
||||
cl.clients = make(map[*udp.Connection]*Client)
|
||||
cl.byAccountID = make(map[int32]*Client)
|
||||
}
|
||||
|
||||
// DisconnectByAccountID disconnects a client by account ID
|
||||
func (cl *ClientList) DisconnectByAccountID(accountID int32, reason string) bool {
|
||||
client := cl.GetByAccountID(accountID)
|
||||
if client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client.Disconnect(reason)
|
||||
cl.Remove(client)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetStats returns client list statistics
|
||||
func (cl *ClientList) GetStats() ClientListStats {
|
||||
cl.mu.RLock()
|
||||
defer cl.mu.RUnlock()
|
||||
|
||||
stats := ClientListStats{
|
||||
Total: len(cl.clients),
|
||||
States: make(map[ClientState]int),
|
||||
}
|
||||
|
||||
for _, client := range cl.clients {
|
||||
state := client.GetState()
|
||||
stats.States[state]++
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// ClientListStats represents statistics about the client list
|
||||
type ClientListStats struct {
|
||||
Total int `json:"total"`
|
||||
States map[ClientState]int `json:"states"`
|
||||
}
|
||||
|
||||
// ForEach executes a function for each client (thread-safe)
|
||||
func (cl *ClientList) ForEach(fn func(*Client)) {
|
||||
clients := cl.GetClients()
|
||||
for _, client := range clients {
|
||||
fn(client)
|
||||
}
|
||||
}
|
221
internal/login/config.go
Normal file
221
internal/login/config.go
Normal file
@ -0,0 +1,221 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ServerConfig represents the login server configuration
|
||||
type ServerConfig struct {
|
||||
// Network settings
|
||||
ListenAddr string `json:"listen_addr"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
MaxClients int `json:"max_clients"`
|
||||
|
||||
// Web interface settings
|
||||
WebAddr string `json:"web_addr"`
|
||||
WebPort int `json:"web_port"`
|
||||
WebCertFile string `json:"web_cert_file"`
|
||||
WebKeyFile string `json:"web_key_file"`
|
||||
WebKeyPassword string `json:"web_key_password"`
|
||||
WebUser string `json:"web_user"`
|
||||
WebPassword string `json:"web_password"`
|
||||
|
||||
// Database settings
|
||||
DatabaseType string `json:"database_type"` // "sqlite" or "mysql"
|
||||
DatabaseDSN string `json:"database_dsn"` // Connection string
|
||||
|
||||
// Server settings
|
||||
ServerName string `json:"server_name"`
|
||||
LogLevel string `json:"log_level"`
|
||||
|
||||
// World servers configuration
|
||||
WorldServers []WorldServerInfo `json:"world_servers"`
|
||||
}
|
||||
|
||||
// WorldServerInfo represents information about a world server
|
||||
type WorldServerInfo struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
AdminPort int `json:"admin_port"`
|
||||
Key string `json:"key"`
|
||||
Status string `json:"status"` // "up", "down", "locked"
|
||||
Population int `json:"population"` // Current player count
|
||||
MaxPlayers int `json:"max_players"` // Maximum allowed players
|
||||
Description string `json:"description"`
|
||||
|
||||
// Server flags
|
||||
Locked bool `json:"locked"`
|
||||
Hidden bool `json:"hidden"`
|
||||
|
||||
// Connection tracking
|
||||
LastHeartbeat int64 `json:"last_heartbeat"`
|
||||
}
|
||||
|
||||
// Validate validates the server configuration
|
||||
func (c *ServerConfig) Validate() error {
|
||||
if c.ListenAddr == "" {
|
||||
return fmt.Errorf("listen_addr is required")
|
||||
}
|
||||
|
||||
if c.ListenPort <= 0 || c.ListenPort > 65535 {
|
||||
return fmt.Errorf("listen_port must be between 1 and 65535")
|
||||
}
|
||||
|
||||
if c.MaxClients <= 0 {
|
||||
c.MaxClients = 1000 // Default value
|
||||
}
|
||||
|
||||
// Database configuration validation
|
||||
dbType := strings.ToLower(c.DatabaseType)
|
||||
switch dbType {
|
||||
case "sqlite", "":
|
||||
c.DatabaseType = "sqlite"
|
||||
if c.DatabaseDSN == "" {
|
||||
return fmt.Errorf("database_dsn is required")
|
||||
}
|
||||
case "mysql":
|
||||
c.DatabaseType = "mysql"
|
||||
if c.DatabaseDSN == "" {
|
||||
return fmt.Errorf("database_dsn is required")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid database_type: %s (must be sqlite or mysql)", c.DatabaseType)
|
||||
}
|
||||
|
||||
if c.ServerName == "" {
|
||||
c.ServerName = "EQ2Go Login Server"
|
||||
}
|
||||
|
||||
// Validate log level
|
||||
logLevel := strings.ToLower(c.LogLevel)
|
||||
switch logLevel {
|
||||
case "debug", "info", "warn", "error":
|
||||
c.LogLevel = logLevel
|
||||
case "":
|
||||
c.LogLevel = "info" // Default
|
||||
default:
|
||||
return fmt.Errorf("invalid log_level: %s (must be debug, info, warn, or error)", c.LogLevel)
|
||||
}
|
||||
|
||||
// Validate web configuration
|
||||
if c.WebPort > 0 {
|
||||
if c.WebPort <= 0 || c.WebPort > 65535 {
|
||||
return fmt.Errorf("web_port must be between 1 and 65535")
|
||||
}
|
||||
|
||||
if c.WebAddr == "" {
|
||||
c.WebAddr = "0.0.0.0"
|
||||
}
|
||||
|
||||
// If TLS files are specified, both cert and key must be provided
|
||||
if c.WebCertFile != "" && c.WebKeyFile == "" {
|
||||
return fmt.Errorf("web_key_file is required when web_cert_file is specified")
|
||||
}
|
||||
if c.WebKeyFile != "" && c.WebCertFile == "" {
|
||||
return fmt.Errorf("web_cert_file is required when web_key_file is specified")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate world servers
|
||||
for i, ws := range c.WorldServers {
|
||||
if err := ws.Validate(); err != nil {
|
||||
return fmt.Errorf("world_server[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates a world server configuration
|
||||
func (w *WorldServerInfo) Validate() error {
|
||||
if w.ID <= 0 {
|
||||
return fmt.Errorf("id must be positive")
|
||||
}
|
||||
|
||||
if w.Name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
|
||||
if w.Address == "" {
|
||||
return fmt.Errorf("address is required")
|
||||
}
|
||||
|
||||
if w.Port <= 0 || w.Port > 65535 {
|
||||
return fmt.Errorf("port must be between 1 and 65535")
|
||||
}
|
||||
|
||||
if w.AdminPort <= 0 || w.AdminPort > 65535 {
|
||||
return fmt.Errorf("admin_port must be between 1 and 65535")
|
||||
}
|
||||
|
||||
if w.AdminPort == w.Port {
|
||||
return fmt.Errorf("admin_port cannot be the same as port")
|
||||
}
|
||||
|
||||
if w.MaxPlayers <= 0 {
|
||||
w.MaxPlayers = 1000 // Default value
|
||||
}
|
||||
|
||||
// Normalize status
|
||||
status := strings.ToLower(w.Status)
|
||||
switch status {
|
||||
case "up", "down", "locked":
|
||||
w.Status = status
|
||||
case "":
|
||||
w.Status = "down" // Default
|
||||
default:
|
||||
return fmt.Errorf("invalid status: %s (must be up, down, or locked)", w.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsOnline returns whether the world server is currently online
|
||||
func (w *WorldServerInfo) IsOnline() bool {
|
||||
return w.Status == "up"
|
||||
}
|
||||
|
||||
// IsLocked returns whether the world server is locked
|
||||
func (w *WorldServerInfo) IsLocked() bool {
|
||||
return w.Locked || w.Status == "locked"
|
||||
}
|
||||
|
||||
// IsHidden returns whether the world server should be hidden from the server list
|
||||
func (w *WorldServerInfo) IsHidden() bool {
|
||||
return w.Hidden
|
||||
}
|
||||
|
||||
// GetPopulationPercentage returns the current population as a percentage of max capacity
|
||||
func (w *WorldServerInfo) GetPopulationPercentage() float64 {
|
||||
if w.MaxPlayers <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(w.Population) / float64(w.MaxPlayers) * 100
|
||||
}
|
||||
|
||||
// GetPopulationLevel returns a string representation of the population level
|
||||
func (w *WorldServerInfo) GetPopulationLevel() string {
|
||||
pct := w.GetPopulationPercentage()
|
||||
|
||||
switch {
|
||||
case pct >= 95:
|
||||
return "FULL"
|
||||
case pct >= 80:
|
||||
return "HIGH"
|
||||
case pct >= 50:
|
||||
return "MEDIUM"
|
||||
case pct >= 25:
|
||||
return "LOW"
|
||||
default:
|
||||
return "LIGHT"
|
||||
}
|
||||
}
|
||||
|
||||
// Clone creates a deep copy of the WorldServerInfo
|
||||
func (w *WorldServerInfo) Clone() *WorldServerInfo {
|
||||
clone := *w
|
||||
return &clone
|
||||
}
|
350
internal/login/database.go
Normal file
350
internal/login/database.go
Normal file
@ -0,0 +1,350 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"eq2emu/internal/database"
|
||||
"zombiezen.com/go/sqlite"
|
||||
"zombiezen.com/go/sqlite/sqlitex"
|
||||
)
|
||||
|
||||
// LoginAccount represents a login account
|
||||
type LoginAccount struct {
|
||||
ID int32 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"` // MD5 hash
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"` // Active, Suspended, Banned
|
||||
AccessLevel int16 `json:"access_level"`
|
||||
CreatedDate int64 `json:"created_date"`
|
||||
LastLogin int64 `json:"last_login"`
|
||||
LastIP string `json:"last_ip"`
|
||||
}
|
||||
|
||||
// Character represents a character
|
||||
type Character struct {
|
||||
ID int32 `json:"id"`
|
||||
AccountID int32 `json:"account_id"`
|
||||
Name string `json:"name"`
|
||||
Race int8 `json:"race"`
|
||||
Class int8 `json:"class"`
|
||||
Gender int8 `json:"gender"`
|
||||
Level int16 `json:"level"`
|
||||
Zone int32 `json:"zone"`
|
||||
ZoneInstance int32 `json:"zone_instance"`
|
||||
ServerID int16 `json:"server_id"`
|
||||
LastPlayed int64 `json:"last_played"`
|
||||
CreatedDate int64 `json:"created_date"`
|
||||
DeletedDate int64 `json:"deleted_date"`
|
||||
}
|
||||
|
||||
// LoginDB wraps the base Database with login-specific methods
|
||||
type LoginDB struct {
|
||||
*database.Database
|
||||
}
|
||||
|
||||
// NewLoginDB creates a new database connection for login server
|
||||
func NewLoginDB(dbType, dsn string) (*LoginDB, error) {
|
||||
var db *database.Database
|
||||
var err error
|
||||
|
||||
switch strings.ToLower(dbType) {
|
||||
case "sqlite":
|
||||
db, err = database.NewSQLite(dsn)
|
||||
case "mysql":
|
||||
db, err = database.NewMySQL(dsn)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loginDB := &LoginDB{Database: db}
|
||||
return loginDB, nil
|
||||
}
|
||||
|
||||
|
||||
// GetLoginAccount retrieves a login account by username and password
|
||||
func (db *LoginDB) GetLoginAccount(username, hashedPassword string) (*LoginAccount, error) {
|
||||
var account LoginAccount
|
||||
query := "SELECT id, username, password, email, status, access_level, created_date, last_login, last_ip FROM login_accounts WHERE username = ? AND password = ?"
|
||||
|
||||
if db.GetType() == database.SQLite {
|
||||
found := false
|
||||
err := db.ExecTransient(query,
|
||||
func(stmt *sqlite.Stmt) error {
|
||||
account.ID = int32(stmt.ColumnInt64(0))
|
||||
account.Username = stmt.ColumnText(1)
|
||||
account.Password = stmt.ColumnText(2)
|
||||
account.Email = stmt.ColumnText(3)
|
||||
account.Status = stmt.ColumnText(4)
|
||||
account.AccessLevel = int16(stmt.ColumnInt64(5))
|
||||
account.CreatedDate = stmt.ColumnInt64(6)
|
||||
account.LastLogin = stmt.ColumnInt64(7)
|
||||
account.LastIP = stmt.ColumnText(8)
|
||||
found = true
|
||||
return nil
|
||||
},
|
||||
username, hashedPassword,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database query error: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("account not found")
|
||||
}
|
||||
} else {
|
||||
// MySQL implementation
|
||||
row := db.QueryRow(query, username, hashedPassword)
|
||||
err := row.Scan(
|
||||
&account.ID,
|
||||
&account.Username,
|
||||
&account.Password,
|
||||
&account.Email,
|
||||
&account.Status,
|
||||
&account.AccessLevel,
|
||||
&account.CreatedDate,
|
||||
&account.LastLogin,
|
||||
&account.LastIP,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("account not found or database error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// GetCharacters retrieves all characters for an account
|
||||
func (db *LoginDB) GetCharacters(accountID int32) ([]*Character, error) {
|
||||
var characters []*Character
|
||||
|
||||
err := db.ExecTransient(
|
||||
`SELECT id, account_id, name, race, class, gender, level, zone_id, zone_instance,
|
||||
server_id, last_played, created_date, deleted_date
|
||||
FROM characters
|
||||
WHERE account_id = ? AND deleted_date = 0
|
||||
ORDER BY last_played DESC`,
|
||||
func(stmt *sqlite.Stmt) error {
|
||||
char := &Character{
|
||||
ID: int32(stmt.ColumnInt64(0)),
|
||||
AccountID: int32(stmt.ColumnInt64(1)),
|
||||
Name: stmt.ColumnText(2),
|
||||
Race: int8(stmt.ColumnInt64(3)),
|
||||
Class: int8(stmt.ColumnInt64(4)),
|
||||
Gender: int8(stmt.ColumnInt64(5)),
|
||||
Level: int16(stmt.ColumnInt64(6)),
|
||||
Zone: int32(stmt.ColumnInt64(7)),
|
||||
ZoneInstance: int32(stmt.ColumnInt64(8)),
|
||||
ServerID: int16(stmt.ColumnInt64(9)),
|
||||
LastPlayed: stmt.ColumnInt64(10),
|
||||
CreatedDate: stmt.ColumnInt64(11),
|
||||
DeletedDate: stmt.ColumnInt64(12),
|
||||
}
|
||||
characters = append(characters, char)
|
||||
return nil
|
||||
},
|
||||
accountID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load characters: %w", err)
|
||||
}
|
||||
|
||||
return characters, nil
|
||||
}
|
||||
|
||||
// UpdateLastLogin updates the last login time and IP for an account
|
||||
func (db *LoginDB) UpdateLastLogin(accountID int32, ipAddress string) error {
|
||||
now := time.Now().Unix()
|
||||
query := "UPDATE login_accounts SET last_login = ?, last_ip = ? WHERE id = ?"
|
||||
|
||||
if db.GetType() == database.SQLite {
|
||||
return db.Execute(query, &sqlitex.ExecOptions{
|
||||
Args: []any{now, ipAddress, accountID},
|
||||
})
|
||||
} else {
|
||||
// MySQL implementation
|
||||
_, err := db.Exec(query, now, ipAddress, accountID)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateServerStats updates server statistics
|
||||
func (db *LoginDB) UpdateServerStats(serverType string, clientCount, worldCount int) error {
|
||||
now := time.Now().Unix()
|
||||
|
||||
if db.GetType() == database.SQLite {
|
||||
return db.Execute(
|
||||
`INSERT OR REPLACE INTO server_stats (server_type, client_count, world_count, last_update)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
&sqlitex.ExecOptions{
|
||||
Args: []any{serverType, clientCount, worldCount, now},
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// MySQL implementation using ON DUPLICATE KEY UPDATE
|
||||
query := `INSERT INTO server_stats (server_type, client_count, world_count, last_update)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
client_count = VALUES(client_count),
|
||||
world_count = VALUES(world_count),
|
||||
last_update = VALUES(last_update)`
|
||||
_, err := db.Exec(query, serverType, clientCount, worldCount, now)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAccount creates a new login account
|
||||
func (db *LoginDB) CreateAccount(username, hashedPassword, email string, accessLevel int16) (*LoginAccount, error) {
|
||||
now := time.Now().Unix()
|
||||
|
||||
// Check if username already exists
|
||||
exists := false
|
||||
err := db.ExecTransient(
|
||||
"SELECT 1 FROM login_accounts WHERE username = ?",
|
||||
func(stmt *sqlite.Stmt) error {
|
||||
exists = true
|
||||
return nil
|
||||
},
|
||||
username,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check username: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return nil, fmt.Errorf("username already exists")
|
||||
}
|
||||
|
||||
// Insert new account
|
||||
var accountID int32
|
||||
err = db.Execute(
|
||||
`INSERT INTO login_accounts (username, password, email, access_level, created_date, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'Active')`,
|
||||
&sqlitex.ExecOptions{
|
||||
Args: []any{username, hashedPassword, email, accessLevel, now},
|
||||
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||
accountID = int32(stmt.ColumnInt64(0))
|
||||
return nil
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create account: %w", err)
|
||||
}
|
||||
|
||||
// Return the created account
|
||||
return &LoginAccount{
|
||||
ID: accountID,
|
||||
Username: username,
|
||||
Password: hashedPassword,
|
||||
Email: email,
|
||||
Status: "Active",
|
||||
AccessLevel: accessLevel,
|
||||
CreatedDate: now,
|
||||
LastLogin: 0,
|
||||
LastIP: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCharacterByID retrieves a character by ID
|
||||
func (db *LoginDB) GetCharacterByID(characterID int32) (*Character, error) {
|
||||
var character Character
|
||||
found := false
|
||||
|
||||
err := db.ExecTransient(
|
||||
`SELECT id, account_id, name, race, class, gender, level, zone_id, zone_instance,
|
||||
server_id, last_played, created_date, deleted_date
|
||||
FROM characters WHERE id = ?`,
|
||||
func(stmt *sqlite.Stmt) error {
|
||||
character.ID = int32(stmt.ColumnInt64(0))
|
||||
character.AccountID = int32(stmt.ColumnInt64(1))
|
||||
character.Name = stmt.ColumnText(2)
|
||||
character.Race = int8(stmt.ColumnInt64(3))
|
||||
character.Class = int8(stmt.ColumnInt64(4))
|
||||
character.Gender = int8(stmt.ColumnInt64(5))
|
||||
character.Level = int16(stmt.ColumnInt64(6))
|
||||
character.Zone = int32(stmt.ColumnInt64(7))
|
||||
character.ZoneInstance = int32(stmt.ColumnInt64(8))
|
||||
character.ServerID = int16(stmt.ColumnInt64(9))
|
||||
character.LastPlayed = stmt.ColumnInt64(10)
|
||||
character.CreatedDate = stmt.ColumnInt64(11)
|
||||
character.DeletedDate = stmt.ColumnInt64(12)
|
||||
found = true
|
||||
return nil
|
||||
},
|
||||
characterID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database query error: %w", err)
|
||||
}
|
||||
|
||||
if !found {
|
||||
return nil, fmt.Errorf("character not found")
|
||||
}
|
||||
|
||||
return &character, nil
|
||||
}
|
||||
|
||||
// DeleteCharacter marks a character as deleted
|
||||
func (db *LoginDB) DeleteCharacter(characterID int32) error {
|
||||
now := time.Now().Unix()
|
||||
|
||||
return db.Execute(
|
||||
"UPDATE characters SET deleted_date = ? WHERE id = ?",
|
||||
&sqlitex.ExecOptions{
|
||||
Args: []any{now, characterID},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// GetAccountStats retrieves statistics about login accounts
|
||||
func (db *LoginDB) GetAccountStats() (map[string]int, error) {
|
||||
stats := make(map[string]int)
|
||||
|
||||
// Count total accounts
|
||||
err := db.ExecTransient(
|
||||
"SELECT COUNT(*) FROM login_accounts",
|
||||
func(stmt *sqlite.Stmt) error {
|
||||
stats["total_accounts"] = int(stmt.ColumnInt64(0))
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Count active accounts
|
||||
err = db.ExecTransient(
|
||||
"SELECT COUNT(*) FROM login_accounts WHERE status = 'Active'",
|
||||
func(stmt *sqlite.Stmt) error {
|
||||
stats["active_accounts"] = int(stmt.ColumnInt64(0))
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Count total characters
|
||||
err = db.ExecTransient(
|
||||
"SELECT COUNT(*) FROM characters WHERE deleted_date = 0",
|
||||
func(stmt *sqlite.Stmt) error {
|
||||
stats["total_characters"] = int(stmt.ColumnInt64(0))
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
279
internal/login/server.go
Normal file
279
internal/login/server.go
Normal file
@ -0,0 +1,279 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"eq2emu/internal/udp"
|
||||
)
|
||||
|
||||
// Server represents the login server instance
|
||||
type Server struct {
|
||||
config *ServerConfig
|
||||
database *LoginDB
|
||||
udpServer *udp.Server
|
||||
webServer *http.Server
|
||||
clientList *ClientList
|
||||
worldList *WorldList
|
||||
running bool
|
||||
startTime time.Time
|
||||
|
||||
// Synchronization
|
||||
mu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewServer creates a new login server instance
|
||||
func NewServer(config *ServerConfig) (*Server, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("configuration cannot be nil")
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
db, err := NewLoginDB(config.DatabaseType, config.DatabaseDSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
// Create login server instance first
|
||||
server := &Server{
|
||||
config: config,
|
||||
database: db,
|
||||
clientList: NewClientList(),
|
||||
worldList: NewWorldList(),
|
||||
running: false,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Create UDP server for EverQuest II protocol
|
||||
addr := fmt.Sprintf("%s:%d", config.ListenAddr, config.ListenPort)
|
||||
udpConfig := udp.Config{
|
||||
MaxConnections: config.MaxClients,
|
||||
BufferSize: 1024 * 64, // 64KB buffer
|
||||
EnableCompression: true,
|
||||
EnableEncryption: true,
|
||||
}
|
||||
|
||||
udpServer, err := udp.NewServer(addr, server.handleUDPPacket, udpConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create UDP server: %w", err)
|
||||
}
|
||||
|
||||
server.udpServer = udpServer
|
||||
|
||||
// Initialize web server if enabled
|
||||
if config.WebPort > 0 {
|
||||
server.initWebServer()
|
||||
}
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// Start starts the login server
|
||||
func (s *Server) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return fmt.Errorf("server is already running")
|
||||
}
|
||||
|
||||
log.Printf("Starting login server on %s:%d", s.config.ListenAddr, s.config.ListenPort)
|
||||
|
||||
// Start UDP server (it doesn't return an error in this implementation)
|
||||
go s.udpServer.Start()
|
||||
|
||||
// Start web server if configured
|
||||
if s.webServer != nil {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
addr := fmt.Sprintf("%s:%d", s.config.WebAddr, s.config.WebPort)
|
||||
log.Printf("Starting web server on %s", addr)
|
||||
|
||||
if err := s.webServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("Web server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Initialize world servers
|
||||
for _, worldInfo := range s.config.WorldServers {
|
||||
world := NewWorldServer(worldInfo)
|
||||
s.worldList.Add(world)
|
||||
}
|
||||
|
||||
s.running = true
|
||||
s.startTime = time.Now()
|
||||
|
||||
log.Println("Login server started successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the login server gracefully
|
||||
func (s *Server) Stop() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Stopping login server...")
|
||||
|
||||
// Signal shutdown
|
||||
close(s.stopChan)
|
||||
|
||||
// Stop UDP server
|
||||
s.udpServer.Stop()
|
||||
|
||||
// Stop web server
|
||||
if s.webServer != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.webServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("Error stopping web server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect all clients
|
||||
s.clientList.DisconnectAll("Server shutdown")
|
||||
|
||||
// Shutdown world servers
|
||||
s.worldList.Shutdown()
|
||||
|
||||
// Close database
|
||||
if err := s.database.Close(); err != nil {
|
||||
log.Printf("Error closing database: %v", err)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to finish
|
||||
s.wg.Wait()
|
||||
|
||||
s.running = false
|
||||
log.Println("Login server stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Process runs the main server processing loop
|
||||
func (s *Server) Process() {
|
||||
ticker := time.NewTicker(time.Millisecond * 50) // 20 FPS processing
|
||||
defer ticker.Stop()
|
||||
|
||||
statsTicker := time.NewTicker(time.Minute) // Statistics every minute
|
||||
defer statsTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
// Process clients
|
||||
s.clientList.Process()
|
||||
|
||||
// Process world servers
|
||||
s.worldList.Process()
|
||||
|
||||
case <-statsTicker.C:
|
||||
// Update statistics
|
||||
s.updateStatistics()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleUDPPacket handles incoming UDP packets from clients
|
||||
func (s *Server) handleUDPPacket(conn *udp.Connection, packet *udp.ApplicationPacket) {
|
||||
// Find or create client for this connection
|
||||
client := s.clientList.GetByConnection(conn)
|
||||
if client == nil {
|
||||
client = NewClient(conn, s.database)
|
||||
s.clientList.Add(client)
|
||||
log.Printf("New client connected from %s", conn.GetClientAddr())
|
||||
}
|
||||
|
||||
// Process packet
|
||||
if err := client.HandlePacket(packet.Data); err != nil {
|
||||
log.Printf("Error handling packet from %s: %v", conn.GetClientAddr(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// updateStatistics updates server statistics
|
||||
func (s *Server) updateStatistics() {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
|
||||
clientCount := s.clientList.Count()
|
||||
worldCount := s.worldList.Count()
|
||||
uptime := time.Since(s.startTime)
|
||||
|
||||
log.Printf("Stats - Clients: %d, Worlds: %d, Uptime: %v",
|
||||
clientCount, worldCount, uptime.Truncate(time.Second))
|
||||
|
||||
// Update database statistics
|
||||
if err := s.database.UpdateServerStats("login", clientCount, worldCount); err != nil {
|
||||
log.Printf("Failed to update server stats: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// initWebServer initializes the web interface server
|
||||
func (s *Server) initWebServer() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Register web routes
|
||||
mux.HandleFunc("/", s.handleWebRoot)
|
||||
mux.HandleFunc("/api/status", s.handleAPIStatus)
|
||||
mux.HandleFunc("/api/clients", s.handleAPIClients)
|
||||
mux.HandleFunc("/api/worlds", s.handleAPIWorlds)
|
||||
|
||||
s.webServer = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", s.config.WebAddr, s.config.WebPort),
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning returns whether the server is currently running
|
||||
func (s *Server) IsRunning() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.running
|
||||
}
|
||||
|
||||
// GetUptime returns how long the server has been running
|
||||
func (s *Server) GetUptime() time.Duration {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if !s.running {
|
||||
return 0
|
||||
}
|
||||
return time.Since(s.startTime)
|
||||
}
|
||||
|
||||
// GetClientCount returns the current number of connected clients
|
||||
func (s *Server) GetClientCount() int {
|
||||
return s.clientList.Count()
|
||||
}
|
||||
|
||||
// GetWorldCount returns the current number of registered world servers
|
||||
func (s *Server) GetWorldCount() int {
|
||||
return s.worldList.Count()
|
||||
}
|
252
internal/login/web_handlers.go
Normal file
252
internal/login/web_handlers.go
Normal file
@ -0,0 +1,252 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleWebRoot handles the root web interface page
|
||||
func (s *Server) handleWebRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authenticateWebRequest(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
html := `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>EQ2Go Login Server</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
.header { background: #2c3e50; color: white; padding: 20px; border-radius: 5px; }
|
||||
.stats { display: flex; gap: 20px; margin: 20px 0; }
|
||||
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 5px; flex: 1; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; color: #2c3e50; }
|
||||
.table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
.table th, .table td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
.table th { background-color: #f2f2f2; }
|
||||
.status-up { color: green; font-weight: bold; }
|
||||
.status-down { color: red; font-weight: bold; }
|
||||
.status-locked { color: orange; font-weight: bold; }
|
||||
</style>
|
||||
<script>
|
||||
function refreshData() {
|
||||
fetch('/api/status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('clients').textContent = data.clients;
|
||||
document.getElementById('worlds').textContent = data.worlds;
|
||||
document.getElementById('uptime').textContent = data.uptime;
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(refreshData, 5000); // Refresh every 5 seconds
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>EQ2Go Login Server Administration</h1>
|
||||
<p>Version: 1.0.0-dev</p>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="clients">-</div>
|
||||
<div>Connected Clients</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="worlds">-</div>
|
||||
<div>World Servers</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="uptime">-</div>
|
||||
<div>Uptime</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>API Endpoints</h2>
|
||||
<ul>
|
||||
<li><a href="/api/status">Server Status</a></li>
|
||||
<li><a href="/api/clients">Connected Clients</a></li>
|
||||
<li><a href="/api/worlds">World Servers</a></li>
|
||||
</ul>
|
||||
|
||||
<script>refreshData();</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// handleAPIStatus handles the status API endpoint
|
||||
func (s *Server) handleAPIStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authenticateWebRequest(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
status := map[string]interface{}{
|
||||
"server_name": s.config.ServerName,
|
||||
"version": "1.0.0-dev",
|
||||
"running": s.IsRunning(),
|
||||
"uptime": s.formatUptime(s.GetUptime()),
|
||||
"clients": s.GetClientCount(),
|
||||
"worlds": s.GetWorldCount(),
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
// handleAPIClients handles the clients API endpoint
|
||||
func (s *Server) handleAPIClients(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authenticateWebRequest(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
clients := s.clientList.GetClients()
|
||||
clientInfo := make([]map[string]interface{}, len(clients))
|
||||
|
||||
for i, client := range clients {
|
||||
clientInfo[i] = map[string]interface{}{
|
||||
"ip_address": client.GetIPAddress(),
|
||||
"account_id": client.GetAccountID(),
|
||||
"account_name": client.GetAccountName(),
|
||||
"state": client.GetState().String(),
|
||||
"connect_time": client.GetConnectTime().Unix(),
|
||||
"last_activity": client.GetLastActivity().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"total_clients": len(clients),
|
||||
"clients": clientInfo,
|
||||
"stats": s.clientList.GetStats(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// handleAPIWorlds handles the worlds API endpoint
|
||||
func (s *Server) handleAPIWorlds(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authenticateWebRequest(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
worlds := s.worldList.GetAllWorlds()
|
||||
worldInfo := make([]map[string]interface{}, len(worlds))
|
||||
|
||||
for i, world := range worlds {
|
||||
worldInfo[i] = map[string]interface{}{
|
||||
"id": world.ID,
|
||||
"name": world.Name,
|
||||
"address": world.Address,
|
||||
"port": world.Port,
|
||||
"status": world.Status,
|
||||
"population": world.Population,
|
||||
"max_players": world.MaxPlayers,
|
||||
"population_pct": world.GetPopulationPercentage(),
|
||||
"population_level": world.GetPopulationLevel(),
|
||||
"locked": world.IsLocked(),
|
||||
"hidden": world.IsHidden(),
|
||||
"last_heartbeat": world.LastHeartbeat,
|
||||
"description": world.Description,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"total_worlds": len(worlds),
|
||||
"worlds": worldInfo,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// authenticateWebRequest performs basic authentication for web requests
|
||||
func (s *Server) authenticateWebRequest(w http.ResponseWriter, r *http.Request) bool {
|
||||
// Skip authentication if no credentials are configured
|
||||
if s.config.WebUser == "" || s.config.WebPassword == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="EQ2Go Login Server"`)
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte("Unauthorized"))
|
||||
return false
|
||||
}
|
||||
|
||||
if username != s.config.WebUser || password != s.config.WebPassword {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="EQ2Go Login Server"`)
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte("Unauthorized"))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// formatUptime formats uptime duration into a readable string
|
||||
func (s *Server) formatUptime(duration time.Duration) string {
|
||||
if duration == 0 {
|
||||
return "Not running"
|
||||
}
|
||||
|
||||
days := int(duration.Hours()) / 24
|
||||
hours := int(duration.Hours()) % 24
|
||||
minutes := int(duration.Minutes()) % 60
|
||||
seconds := int(duration.Seconds()) % 60
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh %dm %ds", days, hours, minutes, seconds)
|
||||
} else if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds)
|
||||
} else if minutes > 0 {
|
||||
return fmt.Sprintf("%dm %ds", minutes, seconds)
|
||||
} else {
|
||||
return fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
}
|
||||
|
||||
// handleKickClient handles kicking a client (admin endpoint)
|
||||
func (s *Server) handleKickClient(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authenticateWebRequest(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(405)
|
||||
w.Write([]byte("Method not allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
accountIDStr := r.FormValue("account_id")
|
||||
reason := r.FormValue("reason")
|
||||
|
||||
if reason == "" {
|
||||
reason = "Kicked by administrator"
|
||||
}
|
||||
|
||||
accountID, err := strconv.ParseInt(accountIDStr, 10, 32)
|
||||
if err != nil {
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte("Invalid account ID"))
|
||||
return
|
||||
}
|
||||
|
||||
success := s.clientList.DisconnectByAccountID(int32(accountID), reason)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": success,
|
||||
"message": "Client disconnected",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
317
internal/login/world_list.go
Normal file
317
internal/login/world_list.go
Normal file
@ -0,0 +1,317 @@
|
||||
package login
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorldList manages connected world servers
|
||||
type WorldList struct {
|
||||
worlds map[int]*WorldServer
|
||||
byName map[string]*WorldServer
|
||||
mu sync.RWMutex
|
||||
heartbeatTicker *time.Ticker
|
||||
}
|
||||
|
||||
// WorldServer represents a connected world server
|
||||
type WorldServer struct {
|
||||
info WorldServerInfo
|
||||
connection interface{} // TODO: Replace with actual connection type
|
||||
lastPing time.Time
|
||||
isConnected bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewWorldList creates a new world list
|
||||
func NewWorldList() *WorldList {
|
||||
wl := &WorldList{
|
||||
worlds: make(map[int]*WorldServer),
|
||||
byName: make(map[string]*WorldServer),
|
||||
heartbeatTicker: time.NewTicker(30 * time.Second),
|
||||
}
|
||||
|
||||
return wl
|
||||
}
|
||||
|
||||
// NewWorldServer creates a new world server instance
|
||||
func NewWorldServer(info WorldServerInfo) *WorldServer {
|
||||
return &WorldServer{
|
||||
info: info,
|
||||
lastPing: time.Now(),
|
||||
isConnected: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a world server to the list
|
||||
func (wl *WorldList) Add(world *WorldServer) {
|
||||
if world == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wl.mu.Lock()
|
||||
defer wl.mu.Unlock()
|
||||
|
||||
// Remove any existing world with the same ID
|
||||
if existing, exists := wl.worlds[world.info.ID]; exists {
|
||||
existing.Disconnect("Replaced by new registration")
|
||||
delete(wl.byName, existing.info.Name)
|
||||
}
|
||||
|
||||
// Remove any existing world with the same name
|
||||
if existing, exists := wl.byName[world.info.Name]; exists {
|
||||
existing.Disconnect("Name conflict")
|
||||
delete(wl.worlds, existing.info.ID)
|
||||
}
|
||||
|
||||
wl.worlds[world.info.ID] = world
|
||||
wl.byName[world.info.Name] = world
|
||||
|
||||
log.Printf("Added world server: %s (ID: %d)", world.info.Name, world.info.ID)
|
||||
}
|
||||
|
||||
// Remove removes a world server from the list
|
||||
func (wl *WorldList) Remove(world *WorldServer) {
|
||||
if world == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wl.mu.Lock()
|
||||
defer wl.mu.Unlock()
|
||||
|
||||
delete(wl.worlds, world.info.ID)
|
||||
delete(wl.byName, world.info.Name)
|
||||
|
||||
log.Printf("Removed world server: %s (ID: %d)", world.info.Name, world.info.ID)
|
||||
}
|
||||
|
||||
// GetByID returns a world server by ID
|
||||
func (wl *WorldList) GetByID(id int) *WorldServer {
|
||||
wl.mu.RLock()
|
||||
defer wl.mu.RUnlock()
|
||||
|
||||
return wl.worlds[id]
|
||||
}
|
||||
|
||||
// GetByName returns a world server by name
|
||||
func (wl *WorldList) GetByName(name string) *WorldServer {
|
||||
wl.mu.RLock()
|
||||
defer wl.mu.RUnlock()
|
||||
|
||||
return wl.byName[name]
|
||||
}
|
||||
|
||||
// GetAvailableWorlds returns a list of worlds available for login
|
||||
func (wl *WorldList) GetAvailableWorlds() []WorldServerInfo {
|
||||
wl.mu.RLock()
|
||||
defer wl.mu.RUnlock()
|
||||
|
||||
var available []WorldServerInfo
|
||||
for _, world := range wl.worlds {
|
||||
if !world.info.IsHidden() && world.IsOnline() {
|
||||
available = append(available, world.info)
|
||||
}
|
||||
}
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
// GetAllWorlds returns all world servers
|
||||
func (wl *WorldList) GetAllWorlds() []WorldServerInfo {
|
||||
wl.mu.RLock()
|
||||
defer wl.mu.RUnlock()
|
||||
|
||||
worlds := make([]WorldServerInfo, 0, len(wl.worlds))
|
||||
for _, world := range wl.worlds {
|
||||
worlds = append(worlds, world.info)
|
||||
}
|
||||
|
||||
return worlds
|
||||
}
|
||||
|
||||
// Process processes the world list
|
||||
func (wl *WorldList) Process() {
|
||||
select {
|
||||
case <-wl.heartbeatTicker.C:
|
||||
wl.checkHeartbeats()
|
||||
default:
|
||||
// No heartbeat check needed this cycle
|
||||
}
|
||||
}
|
||||
|
||||
// checkHeartbeats checks for world servers that haven't sent heartbeats
|
||||
func (wl *WorldList) checkHeartbeats() {
|
||||
wl.mu.Lock()
|
||||
defer wl.mu.Unlock()
|
||||
|
||||
timeout := 2 * time.Minute
|
||||
var toRemove []*WorldServer
|
||||
|
||||
for _, world := range wl.worlds {
|
||||
if world.IsTimedOut(timeout) {
|
||||
toRemove = append(toRemove, world)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove timed out worlds
|
||||
for _, world := range toRemove {
|
||||
world.SetStatus("down")
|
||||
log.Printf("World server %s (ID: %d) timed out", world.info.Name, world.info.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateWorldStatus updates the status of a world server
|
||||
func (wl *WorldList) UpdateWorldStatus(id int, status string, population int) {
|
||||
world := wl.GetByID(id)
|
||||
if world == nil {
|
||||
return
|
||||
}
|
||||
|
||||
world.UpdateStatus(status, population)
|
||||
log.Printf("World server %s (ID: %d) status updated: %s (%d players)",
|
||||
world.info.Name, world.info.ID, status, population)
|
||||
}
|
||||
|
||||
// Count returns the number of registered world servers
|
||||
func (wl *WorldList) Count() int {
|
||||
wl.mu.RLock()
|
||||
defer wl.mu.RUnlock()
|
||||
return len(wl.worlds)
|
||||
}
|
||||
|
||||
// Shutdown shuts down the world list
|
||||
func (wl *WorldList) Shutdown() {
|
||||
if wl.heartbeatTicker != nil {
|
||||
wl.heartbeatTicker.Stop()
|
||||
}
|
||||
|
||||
wl.mu.Lock()
|
||||
defer wl.mu.Unlock()
|
||||
|
||||
// Disconnect all world servers
|
||||
for _, world := range wl.worlds {
|
||||
world.Disconnect("Login server shutdown")
|
||||
}
|
||||
|
||||
// Clear maps
|
||||
wl.worlds = make(map[int]*WorldServer)
|
||||
wl.byName = make(map[string]*WorldServer)
|
||||
}
|
||||
|
||||
// WorldServer methods
|
||||
|
||||
// GetInfo returns the world server information
|
||||
func (ws *WorldServer) GetInfo() WorldServerInfo {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
return ws.info
|
||||
}
|
||||
|
||||
// UpdateInfo updates the world server information
|
||||
func (ws *WorldServer) UpdateInfo(info WorldServerInfo) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
ws.info = info
|
||||
}
|
||||
|
||||
// UpdateStatus updates the world server status and population
|
||||
func (ws *WorldServer) UpdateStatus(status string, population int) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
ws.info.Status = status
|
||||
ws.info.Population = population
|
||||
ws.info.LastHeartbeat = time.Now().Unix()
|
||||
ws.lastPing = time.Now()
|
||||
}
|
||||
|
||||
// SetStatus sets the world server status
|
||||
func (ws *WorldServer) SetStatus(status string) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
ws.info.Status = status
|
||||
}
|
||||
|
||||
// IsOnline returns whether the world server is online
|
||||
func (ws *WorldServer) IsOnline() bool {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
return ws.info.IsOnline() && ws.isConnected
|
||||
}
|
||||
|
||||
// IsConnected returns whether the world server is connected
|
||||
func (ws *WorldServer) IsConnected() bool {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
return ws.isConnected
|
||||
}
|
||||
|
||||
// SetConnected sets the connection status
|
||||
func (ws *WorldServer) SetConnected(connected bool) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
ws.isConnected = connected
|
||||
|
||||
if connected {
|
||||
ws.info.Status = "up"
|
||||
} else {
|
||||
ws.info.Status = "down"
|
||||
}
|
||||
}
|
||||
|
||||
// IsTimedOut returns whether the world server has timed out
|
||||
func (ws *WorldServer) IsTimedOut(timeout time.Duration) bool {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
return time.Since(ws.lastPing) > timeout
|
||||
}
|
||||
|
||||
// Disconnect disconnects the world server
|
||||
func (ws *WorldServer) Disconnect(reason string) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
log.Printf("Disconnecting world server %s (ID: %d): %s",
|
||||
ws.info.Name, ws.info.ID, reason)
|
||||
|
||||
ws.isConnected = false
|
||||
ws.info.Status = "down"
|
||||
ws.info.Population = 0
|
||||
|
||||
// TODO: Close actual connection
|
||||
}
|
||||
|
||||
// Ping updates the last ping time
|
||||
func (ws *WorldServer) Ping() {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
ws.lastPing = time.Now()
|
||||
ws.info.LastHeartbeat = time.Now().Unix()
|
||||
}
|
||||
|
||||
// GetLastPing returns the last ping time
|
||||
func (ws *WorldServer) GetLastPing() time.Time {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
return ws.lastPing
|
||||
}
|
||||
|
||||
// CanAcceptPlayer returns whether the world server can accept a new player
|
||||
func (ws *WorldServer) CanAcceptPlayer() bool {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
|
||||
if !ws.IsOnline() || ws.info.IsLocked() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check population limit
|
||||
return ws.info.Population < ws.info.MaxPlayers
|
||||
}
|
||||
|
||||
// GetConnectionString returns the connection string for this world server
|
||||
func (ws *WorldServer) GetConnectionString() (string, int) {
|
||||
ws.mu.RLock()
|
||||
defer ws.mu.RUnlock()
|
||||
return ws.info.Address, ws.info.Port
|
||||
}
|
255
internal/packets/reader.go
Normal file
255
internal/packets/reader.go
Normal file
@ -0,0 +1,255 @@
|
||||
package packets
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"eq2emu/internal/common"
|
||||
"eq2emu/internal/packets/parser"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
)
|
||||
|
||||
// PacketReader reads packet data based on packet definitions
|
||||
type PacketReader struct {
|
||||
data []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
// NewPacketReader creates a new packet reader
|
||||
func NewPacketReader(data []byte) *PacketReader {
|
||||
return &PacketReader{
|
||||
data: data,
|
||||
pos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePacketFields parses packet data using a packet definition
|
||||
func ParsePacketFields(data []byte, packetName string, version uint32) (map[string]any, error) {
|
||||
def, exists := GetPacket(packetName)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("packet definition '%s' not found", packetName)
|
||||
}
|
||||
|
||||
reader := NewPacketReader(data)
|
||||
return reader.parseStruct(def, version)
|
||||
}
|
||||
|
||||
// parseStruct parses a struct according to packet definition
|
||||
func (r *PacketReader) parseStruct(def *parser.PacketDef, version uint32) (map[string]any, error) {
|
||||
result := make(map[string]any)
|
||||
|
||||
// Get field order for this version
|
||||
order := r.getVersionOrder(def, version)
|
||||
|
||||
for _, fieldName := range order {
|
||||
field, exists := def.Fields[fieldName]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// For simplicity, skip conditional fields for now
|
||||
if field.Condition != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldType := field.Type
|
||||
if field.Type2 != 0 {
|
||||
fieldType = field.Type2
|
||||
}
|
||||
|
||||
value, err := r.readField(field, fieldType, fieldName, result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading field '%s': %w", fieldName, err)
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
result[fieldName] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// readField reads a single field from the packet data
|
||||
func (r *PacketReader) readField(field parser.FieldDesc, fieldType common.EQ2DataType, fieldName string, context map[string]any) (any, error) {
|
||||
switch fieldType {
|
||||
case common.TypeInt8:
|
||||
return r.readUint8()
|
||||
case common.TypeInt16:
|
||||
return r.readUint16()
|
||||
case common.TypeInt32:
|
||||
return r.readUint32()
|
||||
case common.TypeInt64:
|
||||
return r.readUint64()
|
||||
case common.TypeSInt8:
|
||||
return r.readInt8()
|
||||
case common.TypeSInt16:
|
||||
return r.readInt16()
|
||||
case common.TypeSInt32:
|
||||
return r.readInt32()
|
||||
case common.TypeSInt64:
|
||||
return r.readInt64()
|
||||
case common.TypeString8:
|
||||
return r.readEQ2String8()
|
||||
case common.TypeString16:
|
||||
return r.readEQ2String16()
|
||||
case common.TypeString32:
|
||||
return r.readEQ2String32()
|
||||
case common.TypeFloat:
|
||||
return r.readFloat32()
|
||||
case common.TypeDouble:
|
||||
return r.readFloat64()
|
||||
case common.TypeChar:
|
||||
if field.Length > 0 {
|
||||
return r.readBytes(field.Length)
|
||||
}
|
||||
return nil, fmt.Errorf("char field '%s' has no length specified", fieldName)
|
||||
default:
|
||||
// For unsupported types, skip the field
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Low-level read functions
|
||||
func (r *PacketReader) readUint8() (uint8, error) {
|
||||
if r.pos+1 > len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
value := r.data[r.pos]
|
||||
r.pos++
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *PacketReader) readInt8() (int8, error) {
|
||||
value, err := r.readUint8()
|
||||
return int8(value), err
|
||||
}
|
||||
|
||||
func (r *PacketReader) readUint16() (uint16, error) {
|
||||
if r.pos+2 > len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
value := binary.LittleEndian.Uint16(r.data[r.pos:])
|
||||
r.pos += 2
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *PacketReader) readInt16() (int16, error) {
|
||||
value, err := r.readUint16()
|
||||
return int16(value), err
|
||||
}
|
||||
|
||||
func (r *PacketReader) readUint32() (uint32, error) {
|
||||
if r.pos+4 > len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
value := binary.LittleEndian.Uint32(r.data[r.pos:])
|
||||
r.pos += 4
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *PacketReader) readInt32() (int32, error) {
|
||||
value, err := r.readUint32()
|
||||
return int32(value), err
|
||||
}
|
||||
|
||||
func (r *PacketReader) readUint64() (uint64, error) {
|
||||
if r.pos+8 > len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
value := binary.LittleEndian.Uint64(r.data[r.pos:])
|
||||
r.pos += 8
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *PacketReader) readInt64() (int64, error) {
|
||||
value, err := r.readUint64()
|
||||
return int64(value), err
|
||||
}
|
||||
|
||||
func (r *PacketReader) readFloat32() (float32, error) {
|
||||
if r.pos+4 > len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
bits := binary.LittleEndian.Uint32(r.data[r.pos:])
|
||||
r.pos += 4
|
||||
return math.Float32frombits(bits), nil
|
||||
}
|
||||
|
||||
func (r *PacketReader) readFloat64() (float64, error) {
|
||||
if r.pos+8 > len(r.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
bits := binary.LittleEndian.Uint64(r.data[r.pos:])
|
||||
r.pos += 8
|
||||
return math.Float64frombits(bits), nil
|
||||
}
|
||||
|
||||
func (r *PacketReader) readBytes(n int) ([]byte, error) {
|
||||
if r.pos+n > len(r.data) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
data := make([]byte, n)
|
||||
copy(data, r.data[r.pos:r.pos+n])
|
||||
r.pos += n
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *PacketReader) readEQ2String8() (string, error) {
|
||||
length, err := r.readUint8()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if length == 0 {
|
||||
return "", nil
|
||||
}
|
||||
data, err := r.readBytes(int(length))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (r *PacketReader) readEQ2String16() (string, error) {
|
||||
length, err := r.readUint16()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if length == 0 {
|
||||
return "", nil
|
||||
}
|
||||
data, err := r.readBytes(int(length))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (r *PacketReader) readEQ2String32() (string, error) {
|
||||
length, err := r.readUint32()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if length == 0 {
|
||||
return "", nil
|
||||
}
|
||||
data, err := r.readBytes(int(length))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// getVersionOrder returns the field order for the specified version
|
||||
func (r *PacketReader) getVersionOrder(def *parser.PacketDef, version uint32) []string {
|
||||
var bestVersion uint32
|
||||
for v := range def.Orders {
|
||||
if v <= version && v > bestVersion {
|
||||
bestVersion = v
|
||||
}
|
||||
}
|
||||
if order, exists := def.Orders[bestVersion]; exists {
|
||||
return order
|
||||
}
|
||||
return []string{}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user