fix(channelserver): post-RC1 stabilization sprint

Fix rasta_id=0 overwriting NULL in SaveMercenary, which prevented
game state saving for characters without a mercenary (#163).

Also includes:
- CHANGELOG updated with all 10 post-RC1 commits
- Setup wizard fmt.Printf replaced with zap structured logging
- technical-debt.md updated with 6 newly completed items
- Scenario binary format documented (docs/scenario-format.md)
- Tests: alliance nil-guard (#171), handler dispatch table,
  migrations (sorted/SQL/baseline), setup wizard (10 tests),
  protbot protocol sign/entrance/channel (23 tests)
This commit is contained in:
Houmgaor
2026-03-05 16:39:15 +01:00
parent 10ac803a45
commit 03adb21e99
13 changed files with 1314 additions and 6 deletions

View File

@@ -0,0 +1,303 @@
package protocol
import (
"encoding/binary"
"testing"
"time"
)
func TestHandleAck_SimpleAck(t *testing.T) {
ch := &ChannelConn{}
ackHandle := uint32(1)
waitCh := make(chan *AckResponse, 1)
ch.waiters.Store(ackHandle, waitCh)
// Build simple ACK data (after opcode has been stripped).
// Format: uint32 ackHandle + uint8 isBuffer(0) + uint8 errorCode + uint16 ignored + uint32 data
data := make([]byte, 12)
binary.BigEndian.PutUint32(data[0:4], ackHandle)
data[4] = 0 // isBuffer = false
data[5] = 0 // errorCode = 0
binary.BigEndian.PutUint16(data[6:8], 0)
binary.BigEndian.PutUint32(data[8:12], 0xDEADBEEF) // simple ACK data
ch.handleAck(data)
select {
case resp := <-waitCh:
if resp.AckHandle != ackHandle {
t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle)
}
if resp.IsBufferResponse {
t.Error("IsBufferResponse: got true, want false")
}
if resp.ErrorCode != 0 {
t.Errorf("ErrorCode: got %d, want 0", resp.ErrorCode)
}
if len(resp.Data) != 4 {
t.Fatalf("Data length: got %d, want 4", len(resp.Data))
}
val := binary.BigEndian.Uint32(resp.Data)
if val != 0xDEADBEEF {
t.Errorf("Data value: got 0x%08X, want 0xDEADBEEF", val)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timed out waiting for ACK response")
}
}
func TestHandleAck_BufferAck(t *testing.T) {
ch := &ChannelConn{}
ackHandle := uint32(2)
waitCh := make(chan *AckResponse, 1)
ch.waiters.Store(ackHandle, waitCh)
payload := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
// Build buffer ACK data.
// Format: uint32 ackHandle + uint8 isBuffer(1) + uint8 errorCode + uint16 payloadSize + payload
data := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(data[0:4], ackHandle)
data[4] = 1 // isBuffer = true
data[5] = 0 // errorCode = 0
binary.BigEndian.PutUint16(data[6:8], uint16(len(payload)))
copy(data[8:], payload)
ch.handleAck(data)
select {
case resp := <-waitCh:
if resp.AckHandle != ackHandle {
t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle)
}
if !resp.IsBufferResponse {
t.Error("IsBufferResponse: got false, want true")
}
if resp.ErrorCode != 0 {
t.Errorf("ErrorCode: got %d, want 0", resp.ErrorCode)
}
if len(resp.Data) != len(payload) {
t.Fatalf("Data length: got %d, want %d", len(resp.Data), len(payload))
}
for i, b := range payload {
if resp.Data[i] != b {
t.Errorf("Data[%d]: got 0x%02X, want 0x%02X", i, resp.Data[i], b)
}
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timed out waiting for ACK response")
}
}
func TestHandleAck_ExtendedBuffer(t *testing.T) {
ch := &ChannelConn{}
ackHandle := uint32(3)
waitCh := make(chan *AckResponse, 1)
ch.waiters.Store(ackHandle, waitCh)
payload := make([]byte, 10)
for i := range payload {
payload[i] = byte(i)
}
// Build extended buffer ACK data (payloadSize == 0xFFFF).
// Format: uint32 ackHandle + uint8 isBuffer(1) + uint8 errorCode + uint16(0xFFFF) + uint32 realSize + payload
data := make([]byte, 12+len(payload))
binary.BigEndian.PutUint32(data[0:4], ackHandle)
data[4] = 1 // isBuffer = true
data[5] = 0 // errorCode = 0
binary.BigEndian.PutUint16(data[6:8], 0xFFFF)
binary.BigEndian.PutUint32(data[8:12], uint32(len(payload)))
copy(data[12:], payload)
ch.handleAck(data)
select {
case resp := <-waitCh:
if resp.AckHandle != ackHandle {
t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle)
}
if !resp.IsBufferResponse {
t.Error("IsBufferResponse: got false, want true")
}
if len(resp.Data) != len(payload) {
t.Fatalf("Data length: got %d, want %d", len(resp.Data), len(payload))
}
for i, b := range payload {
if resp.Data[i] != b {
t.Errorf("Data[%d]: got 0x%02X, want 0x%02X", i, resp.Data[i], b)
}
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timed out waiting for ACK response")
}
}
func TestHandleAck_TooShort(t *testing.T) {
ch := &ChannelConn{}
// Should not panic with data shorter than 8 bytes.
ch.handleAck(nil)
ch.handleAck([]byte{})
ch.handleAck([]byte{0x00, 0x01, 0x02})
ch.handleAck([]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06})
// 7 bytes: still < 8, should return silently.
data := make([]byte, 7)
binary.BigEndian.PutUint32(data[0:4], 99)
ch.handleAck(data)
}
func TestHandleAck_ExtendedBuffer_TooShortForSize(t *testing.T) {
ch := &ChannelConn{}
ackHandle := uint32(4)
waitCh := make(chan *AckResponse, 1)
ch.waiters.Store(ackHandle, waitCh)
// payloadSize=0xFFFF but data too short for the uint32 real size field (only 8 bytes total).
data := make([]byte, 8)
binary.BigEndian.PutUint32(data[0:4], ackHandle)
data[4] = 1 // isBuffer = true
data[5] = 0
binary.BigEndian.PutUint16(data[6:8], 0xFFFF)
ch.handleAck(data)
// The handler should return early (no payload), but still dispatch the response
// with nil Data since the early return is only for reading payload.
select {
case resp := <-waitCh:
// Response dispatched but with nil data since len(data) < 12.
if resp.Data != nil {
t.Errorf("expected nil Data for truncated extended buffer, got %d bytes", len(resp.Data))
}
case <-time.After(100 * time.Millisecond):
// The handler returns before dispatching if len(data) < 12 for 0xFFFF path.
// This is also acceptable behavior.
}
}
func TestHandleAck_WithErrorCode(t *testing.T) {
ch := &ChannelConn{}
ackHandle := uint32(5)
waitCh := make(chan *AckResponse, 1)
ch.waiters.Store(ackHandle, waitCh)
data := make([]byte, 12)
binary.BigEndian.PutUint32(data[0:4], ackHandle)
data[4] = 0 // isBuffer = false
data[5] = 42 // errorCode = 42
binary.BigEndian.PutUint16(data[6:8], 0)
binary.BigEndian.PutUint32(data[8:12], 0)
ch.handleAck(data)
select {
case resp := <-waitCh:
if resp.ErrorCode != 42 {
t.Errorf("ErrorCode: got %d, want 42", resp.ErrorCode)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("timed out waiting for ACK response")
}
}
func TestHandleAck_NoWaiter(t *testing.T) {
ch := &ChannelConn{}
// No waiter registered for handle 999 — should not panic.
data := make([]byte, 12)
binary.BigEndian.PutUint32(data[0:4], 999)
data[4] = 0
data[5] = 0
binary.BigEndian.PutUint16(data[6:8], 0)
binary.BigEndian.PutUint32(data[8:12], 0)
// This should log but not panic.
ch.handleAck(data)
}
func TestNextAckHandle(t *testing.T) {
ch := &ChannelConn{}
h1 := ch.NextAckHandle()
h2 := ch.NextAckHandle()
h3 := ch.NextAckHandle()
if h1 != 1 {
t.Errorf("first handle: got %d, want 1", h1)
}
if h2 != 2 {
t.Errorf("second handle: got %d, want 2", h2)
}
if h3 != 3 {
t.Errorf("third handle: got %d, want 3", h3)
}
}
func TestNextAckHandle_Concurrent(t *testing.T) {
ch := &ChannelConn{}
const goroutines = 100
results := make(chan uint32, goroutines)
for i := 0; i < goroutines; i++ {
go func() {
results <- ch.NextAckHandle()
}()
}
seen := make(map[uint32]bool)
for i := 0; i < goroutines; i++ {
h := <-results
if seen[h] {
t.Errorf("duplicate handle: %d", h)
}
seen[h] = true
}
if len(seen) != goroutines {
t.Errorf("unique handles: got %d, want %d", len(seen), goroutines)
}
}
func TestWaitForAck_Timeout(t *testing.T) {
ch := &ChannelConn{}
// No handleAck call will be made, so this should time out.
_, err := ch.WaitForAck(999, 50*time.Millisecond)
if err == nil {
t.Fatal("expected timeout error, got nil")
}
}
func TestWaitForAck_Success(t *testing.T) {
ch := &ChannelConn{}
ackHandle := uint32(10)
// Dispatch the ACK from another goroutine after a short delay.
go func() {
time.Sleep(10 * time.Millisecond)
data := make([]byte, 12)
binary.BigEndian.PutUint32(data[0:4], ackHandle)
data[4] = 0 // isBuffer = false
data[5] = 0 // errorCode
binary.BigEndian.PutUint16(data[6:8], 0)
binary.BigEndian.PutUint32(data[8:12], 0x12345678)
ch.handleAck(data)
}()
resp, err := ch.WaitForAck(ackHandle, 1*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.AckHandle != ackHandle {
t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle)
}
}

View File

@@ -0,0 +1,247 @@
package protocol
import (
"testing"
"erupe-ce/cmd/protbot/conn"
"erupe-ce/common/byteframe"
)
// encryptBin8 encrypts plaintext using the Bin8 algorithm.
// Since Bin8 is a symmetric XOR cipher, DecryptBin8(plaintext, key) produces ciphertext.
func encryptBin8(plaintext []byte, key byte) []byte {
return conn.DecryptBin8(plaintext, key)
}
// buildEntranceResponse constructs a valid Bin8-encrypted entrance server response.
// Format: [key byte] [encrypted: "SV2" + uint16 entryCount + uint16 dataLen + uint32 checksum + serverData]
func buildEntranceResponse(key byte, respType string, entries []testServerEntry) []byte {
// Build server data blob first (to compute checksum and length).
serverData := buildServerData(entries)
// Build the plaintext (before encryption).
bf := byteframe.NewByteFrame()
bf.WriteBytes([]byte(respType)) // "SV2" or "SVR"
bf.WriteUint16(uint16(len(entries)))
bf.WriteUint16(uint16(len(serverData)))
if len(serverData) > 0 {
bf.WriteUint32(conn.CalcSum32(serverData))
bf.WriteBytes(serverData)
}
plaintext := bf.Data()
encrypted := encryptBin8(plaintext, key)
// Final response: key byte + encrypted data.
result := make([]byte, 1+len(encrypted))
result[0] = key
copy(result[1:], encrypted)
return result
}
type testServerEntry struct {
ip [4]byte // big-endian IP bytes (reversed for MHF format)
name string
channelCount uint16
channelPorts []uint16
}
// buildServerData constructs the binary server entry data blob.
// Format mirrors Erupe server/entranceserver/make_resp.go:encodeServerInfo.
func buildServerData(entries []testServerEntry) []byte {
if len(entries) == 0 {
return nil
}
bf := byteframe.NewByteFrame()
for _, e := range entries {
// IP bytes (stored reversed in the protocol — client reads and reverses)
bf.WriteBytes(e.ip[:])
bf.WriteUint16(0x0010) // serverIdx | 16
bf.WriteUint16(0) // zero
bf.WriteUint16(e.channelCount)
bf.WriteUint8(0) // Type
bf.WriteUint8(0) // Season
// G1+ recommended
bf.WriteUint8(0)
// G51+ (ZZ): skip byte + 65-byte name
bf.WriteUint8(0)
nameBytes := make([]byte, 65)
copy(nameBytes, []byte(e.name))
bf.WriteBytes(nameBytes)
// GG+: AllowedClientFlags
bf.WriteUint32(0)
// Channel entries (28 bytes each)
for j := uint16(0); j < e.channelCount; j++ {
port := uint16(54001)
if j < uint16(len(e.channelPorts)) {
port = e.channelPorts[j]
}
bf.WriteUint16(port) // port
bf.WriteUint16(0x0010) // channelIdx | 16
bf.WriteUint16(100) // maxPlayers
bf.WriteUint16(5) // currentPlayers
bf.WriteBytes(make([]byte, 18)) // remaining fields (9 x uint16)
bf.WriteUint16(12345) // sentinel
}
}
return bf.Data()
}
func TestParseEntranceResponse_ValidSV2(t *testing.T) {
entries := []testServerEntry{
{
ip: [4]byte{1, 0, 0, 127}, // 127.0.0.1 reversed
name: "TestServer",
channelCount: 2,
channelPorts: []uint16{54001, 54002},
},
}
data := buildEntranceResponse(0x42, "SV2", entries)
result, err := parseEntranceResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("entry count: got %d, want 2", len(result))
}
if result[0].Port != 54001 {
t.Errorf("entry[0].Port: got %d, want 54001", result[0].Port)
}
if result[1].Port != 54002 {
t.Errorf("entry[1].Port: got %d, want 54002", result[1].Port)
}
if result[0].Name != "TestServer ch1" {
t.Errorf("entry[0].Name: got %q, want %q", result[0].Name, "TestServer ch1")
}
if result[1].Name != "TestServer ch2" {
t.Errorf("entry[1].Name: got %q, want %q", result[1].Name, "TestServer ch2")
}
}
func TestParseEntranceResponse_ValidSVR(t *testing.T) {
entries := []testServerEntry{
{
ip: [4]byte{1, 0, 0, 127},
name: "World1",
channelCount: 1,
channelPorts: []uint16{54001},
},
}
data := buildEntranceResponse(0x10, "SVR", entries)
result, err := parseEntranceResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 1 {
t.Fatalf("entry count: got %d, want 1", len(result))
}
if result[0].Port != 54001 {
t.Errorf("entry[0].Port: got %d, want 54001", result[0].Port)
}
}
func TestParseEntranceResponse_InvalidType(t *testing.T) {
// Build a response with an invalid type string "BAD" instead of "SV2"/"SVR".
bf := byteframe.NewByteFrame()
bf.WriteBytes([]byte("BAD"))
bf.WriteUint16(0) // entryCount
bf.WriteUint16(0) // dataLen
plaintext := bf.Data()
key := byte(0x55)
encrypted := encryptBin8(plaintext, key)
data := make([]byte, 1+len(encrypted))
data[0] = key
copy(data[1:], encrypted)
_, err := parseEntranceResponse(data)
if err == nil {
t.Fatal("expected error for invalid response type, got nil")
}
}
func TestParseEntranceResponse_EmptyData(t *testing.T) {
_, err := parseEntranceResponse(nil)
if err == nil {
t.Fatal("expected error for nil data, got nil")
}
_, err = parseEntranceResponse([]byte{})
if err == nil {
t.Fatal("expected error for empty data, got nil")
}
_, err = parseEntranceResponse([]byte{0x42}) // only key, no encrypted data
if err == nil {
t.Fatal("expected error for single-byte data, got nil")
}
}
func TestParseEntranceResponse_ZeroEntries(t *testing.T) {
// Valid response with zero entries and zero data length.
data := buildEntranceResponse(0x30, "SV2", nil)
result, err := parseEntranceResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Errorf("expected nil result for zero entries, got %v", result)
}
}
func TestParseServerEntries_MultipleServers(t *testing.T) {
entries := []testServerEntry{
{
ip: [4]byte{100, 1, 168, 192}, // 192.168.1.100 reversed
name: "Server1",
channelCount: 1,
channelPorts: []uint16{54001},
},
{
ip: [4]byte{200, 1, 168, 192}, // 192.168.1.200 reversed
name: "Server2",
channelCount: 1,
channelPorts: []uint16{54010},
},
}
serverData := buildServerData(entries)
result, err := parseServerEntries(serverData, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("entry count: got %d, want 2", len(result))
}
if result[0].Port != 54001 {
t.Errorf("entry[0].Port: got %d, want 54001", result[0].Port)
}
if result[0].Name != "Server1 ch1" {
t.Errorf("entry[0].Name: got %q, want %q", result[0].Name, "Server1 ch1")
}
if result[1].Port != 54010 {
t.Errorf("entry[1].Port: got %d, want 54010", result[1].Port)
}
if result[1].Name != "Server2 ch1" {
t.Errorf("entry[1].Name: got %q, want %q", result[1].Name, "Server2 ch1")
}
}

View File

@@ -0,0 +1,196 @@
package protocol
import (
"testing"
"erupe-ce/common/byteframe"
)
// buildSignResponse constructs a binary sign server response for testing.
// Format mirrors Erupe server/signserver/dsgn_resp.go:makeSignResponse.
func buildSignResponse(resultCode uint8, tokenID uint32, tokenString [16]byte, timestamp uint32, patchURLs []string, entranceAddr string, chars []testCharEntry) []byte {
bf := byteframe.NewByteFrame()
bf.WriteUint8(resultCode)
bf.WriteUint8(uint8(len(patchURLs))) // patchCount
bf.WriteUint8(1) // entranceCount (always 1 in tests)
bf.WriteUint8(uint8(len(chars))) // charCount
bf.WriteUint32(tokenID)
bf.WriteBytes(tokenString[:])
bf.WriteUint32(timestamp)
// Patch server URLs (pascal strings with uint8 length prefix)
for _, url := range patchURLs {
bf.WriteUint8(uint8(len(url)))
bf.WriteBytes([]byte(url))
}
// Entrance server address (pascal string with null terminator included in length)
bf.WriteUint8(uint8(len(entranceAddr) + 1))
bf.WriteBytes([]byte(entranceAddr))
bf.WriteUint8(0) // null terminator
// Character entries
for _, c := range chars {
bf.WriteUint32(c.charID)
bf.WriteUint16(c.hr)
bf.WriteUint16(c.weaponType)
bf.WriteUint32(c.lastLogin)
bf.WriteUint8(c.isFemale)
bf.WriteUint8(c.isNewChar)
bf.WriteUint8(c.oldGR)
bf.WriteUint8(c.useU16GR)
// Name: 16 bytes padded
name := make([]byte, 16)
copy(name, []byte(c.name))
bf.WriteBytes(name)
// Desc: 32 bytes padded
desc := make([]byte, 32)
copy(desc, []byte(c.desc))
bf.WriteBytes(desc)
bf.WriteUint16(c.gr)
bf.WriteUint8(c.unk1)
bf.WriteUint8(c.unk2)
}
return bf.Data()
}
type testCharEntry struct {
charID uint32
hr uint16
weaponType uint16
lastLogin uint32
isFemale uint8
isNewChar uint8
oldGR uint8
useU16GR uint8
name string
desc string
gr uint16
unk1 uint8
unk2 uint8
}
func TestParseSignResponse_Success(t *testing.T) {
tokenID := uint32(12345)
var tokenString [16]byte
copy(tokenString[:], []byte("ABCDEFGHIJKLMNOP"))
timestamp := uint32(1700000000)
patchURLs := []string{"http://patch1.example.com", "http://patch2.example.com"}
entranceAddr := "192.168.1.1:53310"
chars := []testCharEntry{
{
charID: 100,
hr: 999,
weaponType: 3,
lastLogin: 1699999999,
isFemale: 1,
isNewChar: 0,
oldGR: 50,
useU16GR: 1,
name: "Hunter",
desc: "A brave hunter",
gr: 200,
unk1: 0,
unk2: 0,
},
}
data := buildSignResponse(1, tokenID, tokenString, timestamp, patchURLs, entranceAddr, chars)
result, err := parseSignResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.TokenID != tokenID {
t.Errorf("TokenID: got %d, want %d", result.TokenID, tokenID)
}
if result.TokenString != string(tokenString[:]) {
t.Errorf("TokenString: got %q, want %q", result.TokenString, string(tokenString[:]))
}
if result.Timestamp != timestamp {
t.Errorf("Timestamp: got %d, want %d", result.Timestamp, timestamp)
}
if result.EntranceAddr != entranceAddr {
t.Errorf("EntranceAddr: got %q, want %q", result.EntranceAddr, entranceAddr)
}
if len(result.CharIDs) != 1 {
t.Fatalf("CharIDs length: got %d, want 1", len(result.CharIDs))
}
if result.CharIDs[0] != 100 {
t.Errorf("CharIDs[0]: got %d, want 100", result.CharIDs[0])
}
}
func TestParseSignResponse_MultipleCharacters(t *testing.T) {
var tokenString [16]byte
copy(tokenString[:], []byte("0123456789ABCDEF"))
chars := []testCharEntry{
{charID: 10, name: "Char1"},
{charID: 20, name: "Char2"},
{charID: 30, name: "Char3"},
}
data := buildSignResponse(1, 42, tokenString, 0, nil, "127.0.0.1:53310", chars)
result, err := parseSignResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.CharIDs) != 3 {
t.Fatalf("CharIDs length: got %d, want 3", len(result.CharIDs))
}
expectedIDs := []uint32{10, 20, 30}
for i, want := range expectedIDs {
if result.CharIDs[i] != want {
t.Errorf("CharIDs[%d]: got %d, want %d", i, result.CharIDs[i], want)
}
}
}
func TestParseSignResponse_NoCharacters(t *testing.T) {
var tokenString [16]byte
data := buildSignResponse(1, 1, tokenString, 0, nil, "127.0.0.1:53310", nil)
result, err := parseSignResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.CharIDs) != 0 {
t.Errorf("CharIDs length: got %d, want 0", len(result.CharIDs))
}
}
func TestParseSignResponse_FailCode(t *testing.T) {
// resultCode=0 means failure; the rest of the data is irrelevant
// but we still need the 3 count bytes for the parser to read before checking
data := []byte{0} // resultCode = 0
_, err := parseSignResponse(data)
if err == nil {
t.Fatal("expected error for failure result code, got nil")
}
}
func TestParseSignResponse_FailCode5(t *testing.T) {
data := []byte{5} // resultCode = 5 (some other failure code)
_, err := parseSignResponse(data)
if err == nil {
t.Fatal("expected error for result code 5, got nil")
}
}
func TestParseSignResponse_Empty(t *testing.T) {
_, err := parseSignResponse(nil)
if err == nil {
t.Fatal("expected error for nil data, got nil")
}
_, err = parseSignResponse([]byte{})
if err == nil {
t.Fatal("expected error for empty data, got nil")
}
}