服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
7454 lines
295 KiB
Go
7454 lines
295 KiB
Go
// service_test.go 验证 dnfbridge 对最新 DNF 客户端频道服和 game 端口兼容行为。
|
|
package dnfbridge
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
|
"longheng.io/server/internal/modules/dnf/channelinfo"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
"longheng.io/server/internal/platform/config"
|
|
)
|
|
|
|
func TestConfigFileLoads(t *testing.T) {
|
|
path := filepath.Join("testdata", "dnfbridge.toml")
|
|
if _, err := config.Load(path, "dnfbridge"); err != nil {
|
|
t.Fatalf("load dnfbridge config: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentRequestOverseerBodyForSessionUsesInventoryRows(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := testRepositoryGroup()
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "12",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"100:2": {
|
|
ItemID: 1001,
|
|
Count: 5,
|
|
Extra: map[string]string{
|
|
"packed_flag_byte": "3",
|
|
"durability": "27",
|
|
"instance_value": "0x4D2",
|
|
"unused_byte_after_value_a": "9",
|
|
"value_c": "7",
|
|
"value_d": "321",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
service := &Service{
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
session := &gameSession{connID: "game-test", selectedCharacterID: 12}
|
|
|
|
body := service.buildCurrentRequestOverseerBodyForSession(ctx, session, buildCurrentRequestOverseerBody(0))
|
|
if len(body) != 37 {
|
|
t.Fatalf("op358 body len = %d, want 37 body=%x", len(body), body)
|
|
}
|
|
if body[0] != 0 ||
|
|
binary.LittleEndian.Uint16(body[1:3]) != 0 ||
|
|
binary.LittleEndian.Uint32(body[3:7]) != 0 ||
|
|
binary.LittleEndian.Uint32(body[7:11]) != 1 {
|
|
t.Fatalf("op358 header mismatch: %x", body[:11])
|
|
}
|
|
row := body[11:]
|
|
// row[13] 是封装标记:该称号没有任何 seal extra、Bind=false,所以是 0(已解封)。
|
|
// 旧实现在这里写死 1(读的是从来没人写过的 create_flag,缺省 1),导致已解封的称号
|
|
// 一进称号簿、小退重登就显示成封装(2026-09-18 玩家报障);官方 Inven_Item::reset
|
|
// 也是把这一位清 0,只有「再封装」才写 1。封装态见
|
|
// TestTitleBookPageKeepsItemSealState。
|
|
if binary.LittleEndian.Uint16(row[0:2]) != 2 ||
|
|
binary.LittleEndian.Uint32(row[2:6]) != 1001 ||
|
|
binary.LittleEndian.Uint32(row[6:10]) != 5 ||
|
|
row[10] != 3 ||
|
|
binary.LittleEndian.Uint16(row[11:13]) != 27 ||
|
|
row[13] != 0 ||
|
|
binary.LittleEndian.Uint32(row[14:18]) != 0x4D2 ||
|
|
row[18] != 9 ||
|
|
row[19] != 7 ||
|
|
binary.LittleEndian.Uint16(row[20:22]) != 321 ||
|
|
binary.LittleEndian.Uint32(row[22:26]) != 0 {
|
|
t.Fatalf("op358 row mismatch: %x", row)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentRequestOverseerBodyForSessionDoesNotProjectOrdinaryLegacyInventory(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := testRepositoryGroup()
|
|
repos.LegacyInventory = &fakeLegacyInventoryStore{
|
|
items: map[byte][]dnfrepo.LegacyInventoryItem{
|
|
1: {
|
|
{
|
|
ListType: 1,
|
|
SlotIndex: 4,
|
|
ItemTemplateID: 777,
|
|
StackCount: 1,
|
|
InstanceValue: 0x12345678,
|
|
Durability: 42,
|
|
SealFlag: 2,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
service := &Service{
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
session := &gameSession{connID: "game-test", selectedCharacterID: 12}
|
|
|
|
body := service.buildCurrentRequestOverseerBodyForSession(ctx, session, buildCurrentRequestOverseerBody(1))
|
|
if len(body) != 11 {
|
|
t.Fatalf("legacy op358 body len = %d, want empty 11-byte page body=%x", len(body), body)
|
|
}
|
|
if binary.LittleEndian.Uint32(body[3:7]) != 1 || binary.LittleEndian.Uint32(body[7:11]) != 0 {
|
|
t.Fatalf("legacy op358 header mismatch: %x", body[:11])
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentRequestOverseerBodyForSessionUsesLegacyAchievementChunks(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := testRepositoryGroup()
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "12",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"0:2": {ItemID: 9999, Count: 1},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
var entry packetWriter
|
|
entry.writeUint16(6)
|
|
entry.writeUint32(1200401)
|
|
entry.writeUint32(3)
|
|
entry.writeByte(4)
|
|
entry.writeUint16(55)
|
|
entry.writeByte(1)
|
|
entry.writeUint32(0x11223344)
|
|
entry.writeByte(8)
|
|
entry.writeByte(9)
|
|
entry.writeUint16(0x5566)
|
|
repos.LegacyUserInfo = fakeLegacyUserInfoRepo{rows: map[string][]dnfrepo.LegacyUserInfoRow{
|
|
"legacy_character_achievement_chunks": {
|
|
{
|
|
"chunk_index": "0",
|
|
"mode_byte": "1",
|
|
"owner_id16": "77",
|
|
"entries_blob": string(entry.bytes()),
|
|
},
|
|
},
|
|
}}
|
|
service := &Service{
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
session := &gameSession{connID: "game-test", selectedCharacterID: 12}
|
|
|
|
body := service.buildCurrentRequestOverseerBodyForSession(ctx, session, buildCurrentRequestOverseerBody(0))
|
|
if len(body) != 37 {
|
|
t.Fatalf("achievement op358 body len = %d, want 37 body=%x", len(body), body)
|
|
}
|
|
if body[0] != 1 ||
|
|
binary.LittleEndian.Uint16(body[1:3]) != 77 ||
|
|
binary.LittleEndian.Uint32(body[3:7]) != 0 ||
|
|
binary.LittleEndian.Uint32(body[7:11]) != 1 {
|
|
t.Fatalf("achievement op358 header mismatch: %x", body[:11])
|
|
}
|
|
row := body[11:]
|
|
if binary.LittleEndian.Uint16(row[0:2]) != 6 ||
|
|
binary.LittleEndian.Uint32(row[2:6]) != 1200401 ||
|
|
binary.LittleEndian.Uint32(row[6:10]) != 3 ||
|
|
row[10] != 4 ||
|
|
binary.LittleEndian.Uint16(row[11:13]) != 55 ||
|
|
row[13] != 1 ||
|
|
binary.LittleEndian.Uint32(row[14:18]) != 0x11223344 ||
|
|
row[18] != 8 ||
|
|
row[19] != 9 ||
|
|
binary.LittleEndian.Uint16(row[20:22]) != 0x5566 ||
|
|
binary.LittleEndian.Uint32(row[22:26]) != 0 {
|
|
t.Fatalf("achievement op358 row mismatch: %x", row)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentInsertOverseerBodyForSessionUsesLegacyAchievementComplete(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := testRepositoryGroup()
|
|
repos.LegacyUserInfo = fakeLegacyUserInfoRepo{rows: map[string][]dnfrepo.LegacyUserInfoRow{
|
|
"legacy_character_achievement_complete": {
|
|
{
|
|
"sort_order": "0",
|
|
"achievement_id": "5001",
|
|
"p1": "11",
|
|
"p2": "22",
|
|
"p3": "33",
|
|
"p4": "44",
|
|
},
|
|
},
|
|
}}
|
|
service := &Service{
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
session := &gameSession{connID: "game-test", selectedCharacterID: 12}
|
|
|
|
body := service.buildCurrentInsertOverseerBodyForSession(ctx, session, buildCurrentInsertOverseerBody())
|
|
if len(body) != 30 {
|
|
t.Fatalf("op359 body len = %d, want 30 body=%x", len(body), body)
|
|
}
|
|
if binary.LittleEndian.Uint32(body[:4]) != 1 {
|
|
t.Fatalf("op359 count mismatch: %x", body[:4])
|
|
}
|
|
row := body[4 : 4+currentInsertOverseerRowWireSize]
|
|
if binary.LittleEndian.Uint32(row[0:4]) != 5001 ||
|
|
binary.LittleEndian.Uint16(row[4:6]) != 11 ||
|
|
binary.LittleEndian.Uint16(row[6:8]) != 22 ||
|
|
binary.LittleEndian.Uint16(row[8:10]) != 33 {
|
|
t.Fatalf("op359 row mismatch: %x", row)
|
|
}
|
|
if tail := body[4+currentInsertOverseerRowWireSize:]; len(tail) != currentInsertOverseerTailWireSize ||
|
|
!bytes.Equal(tail, make([]byte, currentInsertOverseerTailWireSize)) {
|
|
t.Fatalf("op359 fixed tail mismatch: %x", tail)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentInsertOverseerEmptyBodyCarriesFixedTail(t *testing.T) {
|
|
body := buildCurrentInsertOverseerBody()
|
|
if len(body) != 4+currentInsertOverseerTailWireSize ||
|
|
binary.LittleEndian.Uint32(body[:4]) != 0 ||
|
|
!bytes.Equal(body[4:], make([]byte, currentInsertOverseerTailWireSize)) {
|
|
t.Fatalf("empty op359 body=%x, want count=0 plus raw[16]", body)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentItemListBodyForSessionUsesExe119ByteRows(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := testRepositoryGroup()
|
|
rawEntry := make([]byte, currentItemListEntryWireSize)
|
|
rawEntry[0x60] = 0xA5
|
|
binary.LittleEndian.PutUint32(rawEntry[legacyWrongCurrentItemListExpireTimeOffset:legacyWrongCurrentItemListExpireTimeOffset+4], 123456)
|
|
binary.LittleEndian.PutUint32(rawEntry[0x6E:0x72], 123456)
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "12",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"0:2": {
|
|
ItemID: 1001,
|
|
Count: 5,
|
|
RawEntry: rawEntry,
|
|
Extra: map[string]string{
|
|
"packed_flag_byte": "3",
|
|
"durability": "27",
|
|
"bind_flag": "1",
|
|
"instance_value": "0x4D2",
|
|
"value_c": "7",
|
|
"value_d": "321",
|
|
"value_b": "777",
|
|
"expire_time": "123456",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
service := &Service{
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
session := &gameSession{connID: "game-test", selectedCharacterID: 12}
|
|
|
|
body, source, count, ok := service.buildCurrentItemListBodyForSession(ctx, session, 0)
|
|
if !ok || source != "inventory" || count != 1 {
|
|
t.Fatalf("current item list ok=%v source=%q count=%d", ok, source, count)
|
|
}
|
|
if len(body) != 5+currentItemListEntryWireSize {
|
|
t.Fatalf("op13 body len = %d, want %d body=%x", len(body), 5+currentItemListEntryWireSize, body)
|
|
}
|
|
if body[0] != 0 || binary.LittleEndian.Uint16(body[1:3]) != currentExeInitialMainSlotCount || binary.LittleEndian.Uint16(body[3:5]) != 1 {
|
|
t.Fatalf("op13 header mismatch: %x", body[:5])
|
|
}
|
|
entry := body[5:]
|
|
if binary.LittleEndian.Uint16(entry[0x00:0x02]) != 2 ||
|
|
binary.LittleEndian.Uint32(entry[0x02:0x06]) != 1001 ||
|
|
binary.LittleEndian.Uint32(entry[0x06:0x0A]) != 5 ||
|
|
entry[0x0A] != 3 ||
|
|
binary.LittleEndian.Uint16(entry[0x0B:0x0D]) != 27 ||
|
|
entry[0x0D] != 1 ||
|
|
binary.LittleEndian.Uint32(entry[0x0E:0x12]) != 0x4D2 ||
|
|
entry[0x13] != 7 ||
|
|
binary.LittleEndian.Uint16(entry[0x14:0x16]) != 321 ||
|
|
binary.LittleEndian.Uint32(entry[currentItemListExpireTimeOffset:currentItemListExpireTimeOffset+4]) != 123456 ||
|
|
binary.LittleEndian.Uint32(entry[legacyWrongCurrentItemListExpireTimeOffset:legacyWrongCurrentItemListExpireTimeOffset+4]) != 0 ||
|
|
entry[0x60] != 0xA5 ||
|
|
binary.LittleEndian.Uint32(entry[0x6E:0x72]) != 123456 {
|
|
t.Fatalf("op13 entry mismatch: %x", entry)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentItemListBodyForSessionUsesPersistedCurrentExeContainerState(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := testRepositoryGroup()
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "27",
|
|
Slots: map[string]dnfrepo.ItemStack{},
|
|
Warehouse: map[string]dnfrepo.ItemStack{},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
if err := repos.Settings.Save(ctx, dnfrepo.SettingsRecord{
|
|
Scope: dnfrepo.CharacterContainerStateScope("27"),
|
|
Values: map[string]string{
|
|
"source": "current_exe_86jp_op13_container_state",
|
|
"main_list_param16": "24",
|
|
"avatar_list_param16": "0",
|
|
"personal_cargo_list_param16": "8",
|
|
"account_cargo_selection_key": "3",
|
|
"account_cargo_value32": "4660",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save container state: %v", err)
|
|
}
|
|
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
|
AccountID: "dnf:27",
|
|
Metadata: map[string]string{
|
|
"account_cargo_created": "true",
|
|
"account_cargo_level": "3",
|
|
"account_cargo_gold": "4660",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save account cargo state: %v", err)
|
|
}
|
|
service := &Service{
|
|
options: options{accountID: "dnf:27"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
session := &gameSession{connID: "game-test", selectedCharacterID: 27}
|
|
|
|
tests := []struct {
|
|
listType byte
|
|
want []byte
|
|
}{
|
|
{listType: 0, want: []byte{0, 24, 0, 0, 0}},
|
|
{listType: 1, want: []byte{1, 0, 0, 0, 0}},
|
|
{listType: 2, want: []byte{2, 8, 0, 0, 0, 0}},
|
|
{listType: 12, want: []byte{12, 3, 0, 0x34, 0x12, 0, 0, 0, 0}},
|
|
}
|
|
for _, test := range tests {
|
|
body, source, count, ok := service.buildCurrentItemListBodyForSession(ctx, session, test.listType)
|
|
wantSource := "inventory"
|
|
if test.listType == 12 {
|
|
wantSource = "account_metadata+account_inventory_absent_empty"
|
|
}
|
|
if !ok || source != wantSource || count != 0 {
|
|
t.Fatalf("list=%d ok=%v source=%q count=%d", test.listType, ok, source, count)
|
|
}
|
|
if !bytes.Equal(body, test.want) {
|
|
t.Fatalf("list=%d body=%x want=%x", test.listType, body, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentItemListBodyForSessionDoesNotMirrorEquipmentIntoListType1(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := testRepositoryGroup()
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "12", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
|
CharacterID: "12",
|
|
Entries: map[string]dnfrepo.EquipmentEntry{
|
|
"11": {
|
|
SlotIndex: 11,
|
|
ItemID: 900001,
|
|
Bind: true,
|
|
Extra: map[string]string{
|
|
"durability": "45",
|
|
"instance_value": "999999998",
|
|
"value_c": "7",
|
|
"current_exe_equipment_type": "12",
|
|
"current_exe_runtime_move": "1",
|
|
},
|
|
},
|
|
"13": {
|
|
SlotIndex: 13,
|
|
ItemID: 900002,
|
|
Extra: map[string]string{
|
|
"durability": "44",
|
|
"current_exe_equipment_type": "14",
|
|
"current_exe_runtime_move": "1",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save equipment: %v", err)
|
|
}
|
|
service := &Service{
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
session := &gameSession{connID: "game-test", selectedCharacterID: 12}
|
|
|
|
inventoryBody, inventorySource, inventoryCount, ok := service.buildCurrentItemListBodyForSession(ctx, session, 0)
|
|
if !ok || inventorySource != "inventory" || inventoryCount != 0 {
|
|
t.Fatalf("inventory list ok=%v source=%q count=%d", ok, inventorySource, inventoryCount)
|
|
}
|
|
if len(inventoryBody) != 5 || inventoryBody[0] != 0 || binary.LittleEndian.Uint16(inventoryBody[3:5]) != 0 {
|
|
t.Fatalf("inventory list should remain empty, body=%x", inventoryBody)
|
|
}
|
|
|
|
body, source, count, ok := service.buildCurrentItemListBodyForSession(ctx, session, 1)
|
|
if !ok || source != "inventory" || count != 0 {
|
|
t.Fatalf("list type 1 ok=%v source=%q count=%d", ok, source, count)
|
|
}
|
|
if len(body) != 5 || body[0] != 1 || binary.LittleEndian.Uint16(body[1:3]) != 0 || binary.LittleEndian.Uint16(body[3:5]) != 0 {
|
|
t.Fatalf("list type 1 should stay empty instead of receiving equipped rows, body=%x", body)
|
|
}
|
|
|
|
equipmentBody, equipmentSource, equipmentCount, ok := service.buildCurrentItemListBodyForSession(ctx, session, 3)
|
|
if !ok || equipmentSource != "equipment" || equipmentCount != 2 {
|
|
t.Fatalf("list type 3 ok=%v source=%q count=%d", ok, equipmentSource, equipmentCount)
|
|
}
|
|
if len(equipmentBody) != 3+2*currentItemListEntryWireSize || equipmentBody[0] != 3 || binary.LittleEndian.Uint16(equipmentBody[1:3]) != 2 {
|
|
t.Fatalf("list type 3 equipped body mismatch len=%d body=%x", len(equipmentBody), equipmentBody)
|
|
}
|
|
firstEquipped := equipmentBody[3 : 3+currentItemListEntryWireSize]
|
|
if binary.LittleEndian.Uint16(firstEquipped[0x00:0x02]) != 12 ||
|
|
binary.LittleEndian.Uint32(firstEquipped[0x02:0x06]) != 900001 ||
|
|
binary.LittleEndian.Uint32(firstEquipped[0x06:0x0A]) != 999999998 ||
|
|
binary.LittleEndian.Uint16(firstEquipped[0x0B:0x0D]) != 45 ||
|
|
firstEquipped[0x0D] != 1 ||
|
|
binary.LittleEndian.Uint32(firstEquipped[0x0E:0x12]) != 999999998 ||
|
|
firstEquipped[0x13] != 7 {
|
|
t.Fatalf("list type 3 first equipped row mismatch: %x", firstEquipped)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentEquipmentItemUpdateBodyForSessionUsesEquippedRows(t *testing.T) {
|
|
ctx := context.Background()
|
|
rawWeapon := buildInitialEquipmentRawEntry(11, 900001, 45)
|
|
rawWeapon[16] = 0x34
|
|
rawWeapon[17] = 0x12
|
|
rawWeapon[20] = 2
|
|
rawWeapon[21] = 3
|
|
rawWeapon[22] = 0x78
|
|
rawWeapon[23] = 0x56
|
|
repos := testRepositoryGroup()
|
|
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
|
CharacterID: "12",
|
|
Entries: map[string]dnfrepo.EquipmentEntry{
|
|
"11": {
|
|
SlotIndex: 11,
|
|
ItemID: 900001,
|
|
Bind: true,
|
|
RawEntry: rawWeapon,
|
|
Extra: map[string]string{
|
|
"durability": "45",
|
|
"instance_value": "999999998",
|
|
"expire_time": "1849989600",
|
|
},
|
|
},
|
|
"13": {
|
|
SlotIndex: 13,
|
|
ItemID: 900002,
|
|
Extra: map[string]string{
|
|
"durability": "44",
|
|
"instance_value": "999999997",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save equipment: %v", err)
|
|
}
|
|
service := &Service{
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
session := &gameSession{connID: "game-test", selectedCharacterID: 12}
|
|
|
|
body, source, count, ok := service.buildCurrentEquipmentItemUpdateBodyForSession(ctx, session)
|
|
if !ok || source != "equipment" || count != 2 {
|
|
t.Fatalf("equipment update ok=%v source=%q count=%d", ok, source, count)
|
|
}
|
|
if len(body) != 3+2*currentEquipmentUpdateEntryWireSize || body[0] != 3 || binary.LittleEndian.Uint16(body[1:3]) != 2 {
|
|
t.Fatalf("equipment update body mismatch len=%d body=%x", len(body), body)
|
|
}
|
|
firstEquipped := body[3 : 3+currentEquipmentUpdateEntryWireSize]
|
|
if binary.LittleEndian.Uint16(firstEquipped[0x00:0x02]) != 11 ||
|
|
binary.LittleEndian.Uint32(firstEquipped[0x02:0x06]) != 900001 ||
|
|
binary.LittleEndian.Uint32(firstEquipped[0x06:0x0A]) != 999999998 ||
|
|
binary.LittleEndian.Uint16(firstEquipped[0x0B:0x0D]) != 45 ||
|
|
firstEquipped[0x0D] != 0 ||
|
|
binary.LittleEndian.Uint32(firstEquipped[0x16:0x1A]) != 0xFFFFFFFF ||
|
|
binary.LittleEndian.Uint32(firstEquipped[currentEquipmentUpdateExpireTimeOffset:currentEquipmentUpdateExpireTimeOffset+4]) != 1849989600 {
|
|
t.Fatalf("equipment update first row mismatch: %x", firstEquipped)
|
|
}
|
|
if got := firstEquipped[0x0E:0x16]; !bytes.Equal(got, []byte{0x34, 0x12, 0, 0, 2, 3, 0x78, 0x56}) {
|
|
t.Fatalf("equipment update prefix = %x", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentPetItemListBodyHasNoSelectorPrefix(t *testing.T) {
|
|
ctx := context.Background()
|
|
repos := testRepositoryGroup()
|
|
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
|
CharacterID: "12",
|
|
Slots: map[string]dnfrepo.ItemStack{
|
|
"7:5": {ItemID: 9001, Count: 1, Extra: map[string]string{"creature_serial_or_handle": "37"}},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save inventory: %v", err)
|
|
}
|
|
service := &Service{
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
session := &gameSession{connID: "game-test", selectedCharacterID: 12}
|
|
|
|
body, source, count, ok := service.buildCurrentItemListBodyForSession(ctx, session, 7)
|
|
if !ok || source != "inventory" || count != 1 {
|
|
t.Fatalf("pet item list ok=%v source=%q count=%d", ok, source, count)
|
|
}
|
|
if len(body) != 3+currentItemListEntryWireSize {
|
|
t.Fatalf("pet op13 body len = %d, want %d body=%x", len(body), 3+currentItemListEntryWireSize, body)
|
|
}
|
|
if body[0] != 7 || binary.LittleEndian.Uint16(body[1:3]) != 1 {
|
|
t.Fatalf("pet op13 header mismatch: %x", body[:3])
|
|
}
|
|
entry := body[3:]
|
|
if binary.LittleEndian.Uint16(entry[0x00:0x02]) != 5 ||
|
|
binary.LittleEndian.Uint32(entry[0x02:0x06]) != 9001 ||
|
|
binary.LittleEndian.Uint32(entry[0x06:0x0A]) != 37 {
|
|
t.Fatalf("pet op13 entry mismatch: %x", entry[:0x0A])
|
|
}
|
|
}
|
|
|
|
func TestPacketLoggerIncludesConnectionFields(t *testing.T) {
|
|
packetLogTestKeyEnv(t)
|
|
path := filepath.Join(t.TempDir(), "packet_log.txt")
|
|
logger, err := openPacketLogger(path)
|
|
if err != nil {
|
|
t.Fatalf("open packet logger: %v", err)
|
|
}
|
|
logger.Log("SEND", "game", []byte{0x01, 0x02, 0x03}, "conn_id", "game-000001", "pkt_seq", 2, "cmd", 1)
|
|
if err := logger.Close(); err != nil {
|
|
t.Fatalf("close packet logger: %v", err)
|
|
}
|
|
// 自 2026-09-30 起包日志落盘即密文(logcrypt 容器),断言前先解密。
|
|
line := packetLogPlaintext(t, path)
|
|
for _, want := range []string{
|
|
"SEND kind=game",
|
|
"conn_id=game-000001",
|
|
"pkt_seq=2",
|
|
"cmd=1",
|
|
"len=3",
|
|
"hex=010203",
|
|
} {
|
|
if !strings.Contains(line, want) {
|
|
t.Fatalf("packet log missing %q in:\n%s", want, line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPacketLoggerCloseDrainsAcceptedAsyncLines(t *testing.T) {
|
|
packetLogTestKeyEnv(t)
|
|
path := filepath.Join(t.TempDir(), "packet_log.txt")
|
|
logger, err := openPacketLogger(path)
|
|
if err != nil {
|
|
t.Fatalf("open packet logger: %v", err)
|
|
}
|
|
for index := 0; index < 256; index++ {
|
|
logger.Event("async-drain", "index", index)
|
|
}
|
|
if err := logger.Close(); err != nil {
|
|
t.Fatalf("close packet logger: %v", err)
|
|
}
|
|
text := packetLogPlaintext(t, path)
|
|
for _, want := range []string{
|
|
"EVENT kind=async-drain index=0",
|
|
"EVENT kind=async-drain index=255",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("drained packet log missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBundledChannelInfoMatchesCompleteVersionChannelSet(t *testing.T) {
|
|
path := filepath.Join("testdata", "channel_info.etc")
|
|
index, err := channelinfo.LoadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("load bundled channel info: %v", err)
|
|
}
|
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: 1})
|
|
if err != nil {
|
|
t.Fatalf("build server 1 catalog: %v", err)
|
|
}
|
|
if channel, ok := catalog.Channel(dnfenum.DefaultAutoChannelID); ok {
|
|
t.Fatalf("server 1 unexpectedly contains legacy auto channel: %+v", channel)
|
|
}
|
|
if got := len(catalog.Channels()); got != 118 {
|
|
t.Fatalf("complete channel count = %d, want 118", got)
|
|
}
|
|
ordinary, ok := catalog.Channel(201)
|
|
if !ok || ordinary.Type != 11 || ordinary.Group != "metro" {
|
|
t.Fatalf("channel 201 = %+v found=%t, want source type 11 metro", ordinary, ok)
|
|
}
|
|
hiddenRaid, ok := catalog.Channel(241)
|
|
if !ok || hiddenRaid.Type != dnfenum.HiddenRaidType || hiddenRaid.Group != "luke_raid" {
|
|
t.Fatalf("channel 241 = %+v found=%t, want source type %d luke_raid", hiddenRaid, ok, dnfenum.HiddenRaidType)
|
|
}
|
|
}
|
|
|
|
func TestChannelForPortMatchesExact90CNCatalogAndRejectsUnknownPorts(t *testing.T) {
|
|
const fixture = `
|
|
[dungeon]
|
|
` + "`[trade]` `交易频道` 1" + `
|
|
[/dungeon]
|
|
[server]
|
|
1 1 ` + "`洛兰`" + ` 2 ` + "`[trade]`" + ` 0 0 38 ` + "`自动频道`" + ` 3 ` + "`[trade]`" + ` 0 0
|
|
[/server]
|
|
`
|
|
index, err := channelinfo.Parse([]byte(fixture))
|
|
if err != nil {
|
|
t.Fatalf("parse channel info: %v", err)
|
|
}
|
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: 1})
|
|
if err != nil {
|
|
t.Fatalf("build catalog: %v", err)
|
|
}
|
|
service := &Service{options: options{channelServerID: 1}, catalog: catalog}
|
|
|
|
cases := []struct {
|
|
name string
|
|
port int
|
|
wantFound bool
|
|
wantID int
|
|
wantType uint8
|
|
wantName string
|
|
wantPort int
|
|
wantGroup string
|
|
}{
|
|
{name: "catalog channel from local game port", port: dnfenum.GamePortBase + 38, wantFound: true, wantID: 38, wantType: 3, wantName: "ch.38", wantPort: dnfenum.GamePortBase + 38, wantGroup: "trade"},
|
|
{name: "unknown game port", port: dnfenum.GamePortBase + 99, wantFound: false},
|
|
{name: "non game port", port: 0, wantFound: false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
channel, found := service.channelForPort(tc.port)
|
|
if found != tc.wantFound {
|
|
t.Fatalf("channelForPort(%d) found=%t want=%t channel=%+v", tc.port, found, tc.wantFound, channel)
|
|
}
|
|
if !found {
|
|
return
|
|
}
|
|
if channel.ID != tc.wantID || channel.Type != tc.wantType || channel.Name != tc.wantName || channel.NoticeName != tc.wantName || channel.Port != tc.wantPort || channel.Group != tc.wantGroup {
|
|
t.Fatalf("channelForPort(%d) = %+v", tc.port, channel)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOptionsPreserveExplicitZeroAdvertiseServerIndex(t *testing.T) {
|
|
t.Setenv("DNFBRIDGE_CHANNEL_SERVER_INDEX", "1")
|
|
t.Setenv("DNF_CHANNEL_SOURCE_SERVER_INDEX", "")
|
|
t.Setenv("DNFBRIDGE_CHANNEL_ADVERTISE_SERVER_INDEX", "0")
|
|
t.Setenv("DFO_CHANNEL_SERVER_INDEX", "")
|
|
|
|
opts := optionsFromConfig(config.ServiceConfig{})
|
|
service := &Service{options: opts}
|
|
if got := service.channelServerID(); got != 1 {
|
|
t.Fatalf("channelServerID() = %d, want 1", got)
|
|
}
|
|
if got := service.channelAdvertiseID(); got != 0 {
|
|
t.Fatalf("channelAdvertiseID() = %d, want online server 0", got)
|
|
}
|
|
}
|
|
|
|
func TestOptionsRegionDefaultsAndRejectsOutOfRange(t *testing.T) {
|
|
for _, test := range []struct {
|
|
raw string
|
|
want int
|
|
}{
|
|
{raw: "", want: 1},
|
|
{raw: "77", want: 77},
|
|
{raw: "0", want: 1},
|
|
{raw: "250", want: 1},
|
|
{raw: "257", want: 1},
|
|
} {
|
|
t.Run(test.raw, func(t *testing.T) {
|
|
t.Setenv("DNFBRIDGE_REGION_ID", test.raw)
|
|
if got := optionsFromConfig(config.ServiceConfig{}).regionID; got != test.want {
|
|
t.Fatalf("regionID=%d want=%d", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOptionsFallBackToSourceAdvertiseIndexWhenUnset(t *testing.T) {
|
|
t.Setenv("DNFBRIDGE_CHANNEL_SERVER_INDEX", "1")
|
|
t.Setenv("DNF_CHANNEL_SOURCE_SERVER_INDEX", "")
|
|
t.Setenv("DNFBRIDGE_CHANNEL_ADVERTISE_SERVER_INDEX", "")
|
|
t.Setenv("DFO_CHANNEL_SERVER_INDEX", "")
|
|
|
|
opts := optionsFromConfig(config.ServiceConfig{})
|
|
service := &Service{options: opts}
|
|
if got := service.channelAdvertiseID(); got != 1 {
|
|
t.Fatalf("channelAdvertiseID() = %d, want source server 1", got)
|
|
}
|
|
}
|
|
|
|
func TestOptionsIgnoreLegacyCSharpChannelInfoBodyMode(t *testing.T) {
|
|
t.Setenv("DNFBRIDGE_CHANNEL_INFO_BODY_MODE", "legacy_csharp")
|
|
|
|
opts := optionsFromConfig(config.ServiceConfig{})
|
|
service := &Service{options: opts}
|
|
if got := service.channelInfoBodyMode(); got != channelInfoBodyLatest {
|
|
t.Fatalf("channelInfoBodyMode() = %q, want %q", got, channelInfoBodyLatest)
|
|
}
|
|
}
|
|
|
|
func TestInitialModeNotice(t *testing.T) {
|
|
for _, raw := range []string{"", "upper", "msg1_endpoint", "login", "wait_client", "none"} {
|
|
t.Run(raw, func(t *testing.T) {
|
|
t.Setenv("DNFBRIDGE_GAME_INITIAL_MODE", raw)
|
|
|
|
opts := optionsFromConfig(config.ServiceConfig{})
|
|
if opts.gameInitialMode != gameInitialModeNotice {
|
|
t.Fatalf("gameInitialMode = %q, want %q", opts.gameInitialMode, gameInitialModeNotice)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPostBootstrapMode(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
want string
|
|
}{
|
|
{name: "client check alias disabled", raw: "upper_client_check", want: gamePostBootstrapNone},
|
|
{name: "roster init alias disabled", raw: "character_roster", want: gamePostBootstrapNone},
|
|
{name: "upper roster alias disabled", raw: "upper_roster", want: gamePostBootstrapNone},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Setenv("DNFBRIDGE_GAME_POST_BOOTSTRAP", tc.raw)
|
|
|
|
opts := optionsFromConfig(config.ServiceConfig{})
|
|
if opts.gamePostBootstrap != tc.want {
|
|
t.Fatalf("gamePostBootstrap = %q, want %q", opts.gamePostBootstrap, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOptionsNormalizeGameUpperHeader(t *testing.T) {
|
|
t.Setenv("DNFBRIDGE_GAME_UPPER_HEADER", "16")
|
|
|
|
opts := optionsFromConfig(config.ServiceConfig{})
|
|
if opts.gameUpperHeader != gameUpperHeaderServer16 {
|
|
t.Fatalf("gameUpperHeader = %q, want %q", opts.gameUpperHeader, gameUpperHeaderServer16)
|
|
}
|
|
}
|
|
|
|
func TestOptionsNormalizeGameUpperBodyCodec(t *testing.T) {
|
|
t.Setenv("DNFBRIDGE_GAME_UPPER_BODY_CODEC", "plain")
|
|
|
|
opts := optionsFromConfig(config.ServiceConfig{})
|
|
if opts.gameUpperBodyCodec != gameUpperBodyCodecPlain {
|
|
t.Fatalf("gameUpperBodyCodec = %q, want %q", opts.gameUpperBodyCodec, gameUpperBodyCodecPlain)
|
|
}
|
|
}
|
|
|
|
func TestOptionsParseGameOuterTokenAliasHex(t *testing.T) {
|
|
t.Setenv("DNFBRIDGE_GAME_OUTER_TOKEN", "de509f65e9ccaae621cb7278fc2b8e6c")
|
|
t.Setenv("DFO_GAME_OUTER_TOKEN", "")
|
|
|
|
opts := optionsFromConfig(config.ServiceConfig{})
|
|
if opts.gameOuterToken != 0xde509f65 {
|
|
t.Fatalf("gameOuterToken = %#x, want %#x", opts.gameOuterToken, uint32(0xde509f65))
|
|
}
|
|
}
|
|
|
|
func TestBuildChannelInfoBodyEncryptsLatestCatalog(t *testing.T) {
|
|
service := &Service{options: options{serverIP: "127.0.0.1", scriptVersion: "60"}}
|
|
body, err := service.buildScriptVersionBody()
|
|
if err != nil {
|
|
t.Fatalf("build script version body: %v", err)
|
|
}
|
|
if len(body) == 0 {
|
|
t.Fatal("expected encrypted script version")
|
|
}
|
|
}
|
|
|
|
func TestBuildChannelInfoBodyUsesLatestHeader(t *testing.T) {
|
|
const fixture = `
|
|
[dungeon]
|
|
` + "`[elven_guard]` `艾尔文防线` 1" + `
|
|
[/dungeon]
|
|
[server]
|
|
1 11 ` + "`普通频道`" + ` 1 ` + "`[elven_guard]`" + ` 0 0 38 ` + "`推荐频道`" + ` 1 ` + "`[elven_guard]`" + ` 0 0
|
|
[/server]
|
|
`
|
|
index, err := channelinfo.Parse([]byte(fixture))
|
|
if err != nil {
|
|
t.Fatalf("parse channel info: %v", err)
|
|
}
|
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: 1})
|
|
if err != nil {
|
|
t.Fatalf("build catalog: %v", err)
|
|
}
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
channelServerID: 1,
|
|
channelAdvertiseID: 0,
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
|
|
encrypted, err := service.buildChannelInfoBody(nil)
|
|
if err != nil {
|
|
t.Fatalf("build channel info body: %v", err)
|
|
}
|
|
raw, err := decryptCompressedChannelData(encrypted, service.aesKey())
|
|
if err != nil {
|
|
t.Fatalf("decrypt channel info body: %v", err)
|
|
}
|
|
if len(raw) < 12 {
|
|
t.Fatalf("raw body too short: %d", len(raw))
|
|
}
|
|
if got := binary.LittleEndian.Uint32(raw[0:4]); got != 1 {
|
|
t.Fatalf("header[0] = %d, want 1", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(raw[4:8]); got != 0 {
|
|
t.Fatalf("server index = %d, want online server 0", got)
|
|
}
|
|
assertChannelInfoHasEntries(t, raw, 2,
|
|
channelEntryWant{name: "#ch.11", port: dnfenum.GamePortBase + 11},
|
|
channelEntryWant{name: "#ch.38", port: dnfenum.GamePortBase + 38},
|
|
)
|
|
}
|
|
|
|
func TestBuildChannelInfoBodyIgnoresCSharpPrefix(t *testing.T) {
|
|
catalog := testChannelCatalog(t)
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
channelServerID: 1,
|
|
channelAdvertiseID: 0,
|
|
channelInfoBodyMode: "legacy_csharp",
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
|
|
encrypted, err := service.buildChannelInfoBody([]byte("cain"))
|
|
if err != nil {
|
|
t.Fatalf("build channel info body: %v", err)
|
|
}
|
|
raw, err := decryptCompressedChannelData(encrypted, service.aesKey())
|
|
if err != nil {
|
|
t.Fatalf("decrypt channel info body: %v", err)
|
|
}
|
|
if len(raw) < 12 {
|
|
t.Fatalf("raw body too short: %d", len(raw))
|
|
}
|
|
if got := binary.LittleEndian.Uint32(raw[0:4]); got != 1 {
|
|
t.Fatalf("header[0] = %d, want 1", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(raw[4:8]); got != 0 {
|
|
t.Fatalf("server index = %d, want online server 0", got)
|
|
}
|
|
assertChannelInfoHasEntries(t, raw, 2,
|
|
channelEntryWant{name: "#ch.11", port: dnfenum.GamePortBase + 11},
|
|
channelEntryWant{name: "#ch.38", port: dnfenum.GamePortBase + 38},
|
|
)
|
|
}
|
|
|
|
func TestBuildChannelInfoBodyUsesCatalogOnly(t *testing.T) {
|
|
catalog := testCatalogWithoutAutoChannel(t)
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
channelServerID: 1,
|
|
channelAdvertiseID: 0,
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
|
|
encrypted, err := service.buildChannelInfoBody([]byte("cain"))
|
|
if err != nil {
|
|
t.Fatalf("build channel info body: %v", err)
|
|
}
|
|
raw, err := decryptCompressedChannelData(encrypted, service.aesKey())
|
|
if err != nil {
|
|
t.Fatalf("decrypt channel info body: %v", err)
|
|
}
|
|
assertChannelInfoHasEntries(t, raw, 2,
|
|
channelEntryWant{name: "#ch.1", port: dnfenum.GamePortBase + 1},
|
|
channelEntryWant{name: "#ch.11", port: dnfenum.GamePortBase + 11},
|
|
)
|
|
}
|
|
|
|
func TestBootstrapAdsSkipTowerTrade(t *testing.T) {
|
|
catalog := testTowerCatalog(t)
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
channelServerID: 1,
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
|
|
encrypted, err := service.buildChannelInfoBodyFor([]byte("cain"), true)
|
|
if err != nil {
|
|
t.Fatalf("build channel info body: %v", err)
|
|
}
|
|
raw, err := decryptCompressedChannelData(encrypted, service.aesKey())
|
|
if err != nil {
|
|
t.Fatalf("decrypt channel info body: %v", err)
|
|
}
|
|
count := int(binary.LittleEndian.Uint32(raw[8:12]))
|
|
if count != 2 {
|
|
t.Fatalf("channel count = %d, want 2", count)
|
|
}
|
|
if hasChannelEntryFrom(raw, 12, count, "#ch.1", dnfenum.GamePortBase+1) {
|
|
t.Fatalf("default ads must not include death tower channel: %x", raw)
|
|
}
|
|
if hasChannelEntryFrom(raw, 12, count, "#ch.6", dnfenum.GamePortBase+6) ||
|
|
hasChannelEntryFrom(raw, 12, count, "#ch.38", dnfenum.GamePortBase+38) {
|
|
t.Fatalf("default ads must not include trade channel: %x", raw)
|
|
}
|
|
name, port := channelEntryNamePort(raw, 12)
|
|
if name != "#ch.10" || port != dnfenum.GamePortBase+10 {
|
|
t.Fatalf("first channel = %s/%d, want #ch.10/%d", name, port, dnfenum.GamePortBase+10)
|
|
}
|
|
assertChannelInfoHasEntries(t, raw, 2,
|
|
channelEntryWant{name: "#ch.10", port: dnfenum.GamePortBase + 10},
|
|
channelEntryWant{name: "#ch.11", port: dnfenum.GamePortBase + 11},
|
|
)
|
|
}
|
|
|
|
func TestRefreshAdsKeepTowerTrade(t *testing.T) {
|
|
catalog := testTowerCatalog(t)
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
channelServerID: 1,
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
|
|
encrypted, err := service.buildChannelInfoBody([]byte("cain"))
|
|
if err != nil {
|
|
t.Fatalf("build channel info body: %v", err)
|
|
}
|
|
raw, err := decryptCompressedChannelData(encrypted, service.aesKey())
|
|
if err != nil {
|
|
t.Fatalf("decrypt channel info body: %v", err)
|
|
}
|
|
assertChannelInfoHasEntries(t, raw, 5,
|
|
channelEntryWant{name: "#ch.1", port: dnfenum.GamePortBase + 1},
|
|
channelEntryWant{name: "#ch.6", port: dnfenum.GamePortBase + 6},
|
|
channelEntryWant{name: "#ch.38", port: dnfenum.GamePortBase + 38},
|
|
channelEntryWant{name: "#ch.10", port: dnfenum.GamePortBase + 10},
|
|
channelEntryWant{name: "#ch.11", port: dnfenum.GamePortBase + 11},
|
|
)
|
|
}
|
|
|
|
func TestBundledDefaultAdsKeepCrackBootstrapChannel(t *testing.T) {
|
|
path := filepath.Join("testdata", "channel_info.etc")
|
|
index, err := channelinfo.LoadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("load bundled channel info: %v", err)
|
|
}
|
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: 1})
|
|
if err != nil {
|
|
t.Fatalf("build bundled catalog: %v", err)
|
|
}
|
|
service := &Service{
|
|
options: options{serverIP: "203.0.113.10", channelServerID: 1},
|
|
catalog: catalog,
|
|
}
|
|
|
|
channels := service.channelsForRequestMode(dnfenum.GroupCain, true)
|
|
if len(channels) == 0 {
|
|
t.Fatal("default channel advertisement is empty")
|
|
}
|
|
found := false
|
|
foundTeamAnchor := false
|
|
for _, channel := range channels {
|
|
if channel.Type == dnfenum.HiddenRaidType {
|
|
foundTeamAnchor = true
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(channel.Group), dnfenum.GroupCrack) {
|
|
continue
|
|
}
|
|
found = true
|
|
if channel.Port <= 0 || strings.TrimSpace(channel.Name) == "" {
|
|
t.Fatalf("crack bootstrap channel is incomplete: %+v", channel)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("default channel advertisement is missing the current EXE crack bootstrap entry")
|
|
}
|
|
if !foundTeamAnchor {
|
|
t.Fatal("default channel advertisement is missing the type-32 team-channel anchor")
|
|
}
|
|
}
|
|
|
|
func TestBuildChannelInfoBodyPreservesCatalogOrder(t *testing.T) {
|
|
catalog := testChannelCatalog(t)
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
channelServerID: 1,
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
|
|
encrypted, err := service.buildChannelInfoBody([]byte("cain"))
|
|
if err != nil {
|
|
t.Fatalf("build channel info body: %v", err)
|
|
}
|
|
raw, err := decryptCompressedChannelData(encrypted, service.aesKey())
|
|
if err != nil {
|
|
t.Fatalf("decrypt channel info body: %v", err)
|
|
}
|
|
name, port := channelEntryNamePort(raw, 12)
|
|
if name != "#ch.11" || port != dnfenum.GamePortBase+11 {
|
|
t.Fatalf("first channel = %s/%d, want #ch.11/%d", name, port, dnfenum.GamePortBase+11)
|
|
}
|
|
}
|
|
|
|
func TestBuildNoticeBody(t *testing.T) {
|
|
service := &Service{options: options{
|
|
channelServerID: 1,
|
|
channelAdvertiseID: 0,
|
|
serverIP: "203.0.113.10",
|
|
initialUDPPort1: defaultInitialUDPPort1,
|
|
initialUDPPort2: defaultInitialUDPPort2,
|
|
commandCount: defaultCommandCount,
|
|
notificationCount: defaultNotificationCount,
|
|
}}
|
|
channel := channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", NoticeName: "ch.2", Port: 10038}
|
|
body := service.buildInitialLoginNotice(channel)
|
|
if got, want := len(body), 65; got != want {
|
|
t.Fatalf("initial notice body len = %d, want %d", got, want)
|
|
}
|
|
if body[0] != 1 {
|
|
t.Fatalf("initial notice marker = %d, want 1", body[0])
|
|
}
|
|
offset := 1
|
|
nameLen := int(binary.LittleEndian.Uint32(body[offset : offset+4]))
|
|
offset += 4
|
|
if got := string(body[offset : offset+nameLen]); got != "ch.38" {
|
|
t.Fatalf("channel name = %q, want ch.38", got)
|
|
}
|
|
offset += nameLen
|
|
for _, want := range []uint32{0, 0} {
|
|
got := binary.LittleEndian.Uint32(body[offset : offset+4])
|
|
if got != want {
|
|
t.Fatalf("reserved u32 at %d = %d, want %d", offset, got, want)
|
|
}
|
|
offset += 4
|
|
}
|
|
if got := body[offset]; got != byte(service.channelAdvertiseID()) {
|
|
t.Fatalf("advertise id = %d, want %d", got, service.channelAdvertiseID())
|
|
}
|
|
offset++
|
|
if got := body[offset]; got != 38 {
|
|
t.Fatalf("channel id = %d, want 38", got)
|
|
}
|
|
offset++
|
|
if got := body[offset]; got != 0 {
|
|
t.Fatalf("reserved byte = %d, want 0", got)
|
|
}
|
|
offset++
|
|
offset += 4 // 时间戳是运行期字段,只校验后续固定形态。
|
|
if got := binary.LittleEndian.Uint32(body[offset : offset+4]); got != 1 {
|
|
t.Fatalf("post-time flag = %d, want 1", got)
|
|
}
|
|
offset += 4
|
|
ipLen := int(binary.LittleEndian.Uint32(body[offset : offset+4]))
|
|
offset += 4
|
|
if got := string(body[offset : offset+ipLen]); got != "203.0.113.10" {
|
|
t.Fatalf("server ip = %q, want 203.0.113.10", got)
|
|
}
|
|
offset += ipLen
|
|
for _, want := range []uint32{defaultInitialUDPPort1, defaultInitialUDPPort2} {
|
|
if offset+4 > len(body) {
|
|
t.Fatalf("initial notice tail ended before %d", want)
|
|
}
|
|
got := binary.LittleEndian.Uint32(body[offset : offset+4])
|
|
if got != want {
|
|
t.Fatalf("tail u32 at %d = %d, want %d", offset, got, want)
|
|
}
|
|
offset += 4
|
|
}
|
|
if body[offset] != 0 || body[offset+1] != 0 {
|
|
t.Fatalf("tail flags = %02x %02x, want 00 00", body[offset], body[offset+1])
|
|
}
|
|
offset += 2
|
|
for _, want := range []uint32{uint32(defaultCommandCount), uint32(defaultNotificationCount)} {
|
|
if offset+4 > len(body) {
|
|
t.Fatalf("initial notice tail ended before %d", want)
|
|
}
|
|
got := binary.LittleEndian.Uint32(body[offset : offset+4])
|
|
if got != want {
|
|
t.Fatalf("tail u32 at %d = %d, want %d", offset, got, want)
|
|
}
|
|
offset += 4
|
|
}
|
|
if offset != len(body) {
|
|
t.Fatalf("initial notice parse ended at %d, len %d", offset, len(body))
|
|
}
|
|
frame, err := dnfproto.BuildLatestGameTCP(0, 1, body, dnfproto.TransportOptions{})
|
|
if err != nil {
|
|
t.Fatalf("build latest game tcp: %v", err)
|
|
}
|
|
records, err := dnfproto.ParseLatestGameTCPRecords(frame)
|
|
if err != nil {
|
|
t.Fatalf("parse latest game tcp: %v", err)
|
|
}
|
|
if len(records) != 1 || records[0].GameHeader.Cmd != 0 || records[0].GameHeader.Type != 1 {
|
|
t.Fatalf("unexpected record: %+v", records)
|
|
}
|
|
if !bytes.Equal(records[0].Body, body) {
|
|
t.Fatal("body mismatch")
|
|
}
|
|
}
|
|
|
|
func TestSendGameInitial(t *testing.T) {
|
|
service := &Service{options: options{
|
|
serverIP: "203.0.113.10",
|
|
gameInitialMode: gameInitialModeUpper,
|
|
}}
|
|
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channel}
|
|
|
|
mode, bodyLen, sent, err := service.sendGameInitial(session)
|
|
if err != nil {
|
|
t.Fatalf("send initial: %v", err)
|
|
}
|
|
if mode != gameInitialModeUpper || !sent || bodyLen != len(service.buildLoginSuccessForSession(session)) {
|
|
t.Fatalf("mode=%q sent=%v bodyLen=%d", mode, sent, bodyLen)
|
|
}
|
|
assertUpperInitialWire(t, conn.write.Bytes(), service.gameUpperHeaderSize(), upperSuccessBody(service.buildLoginSuccessForSession(session)), true)
|
|
}
|
|
|
|
func TestBuildLoginSuccessMatchesCurrentExeMsg1Layout(t *testing.T) {
|
|
service := &Service{options: options{
|
|
serverIP: "203.0.113.10",
|
|
gameOuterToken: 0x11223344,
|
|
}}
|
|
channel := channelcatalog.Channel{ID: 19, Type: 22, Name: "ch.19", Port: 10019}
|
|
|
|
body := service.buildLoginSuccess(channel)
|
|
if len(body) < 25 {
|
|
t.Fatalf("body len = %d, want at least 25", len(body))
|
|
}
|
|
if body[0] != 1 || body[1] != 1 {
|
|
t.Fatalf("msg1 leading flags = %x, want 01 01", body[:2])
|
|
}
|
|
if body[2] != channel.Type {
|
|
t.Fatalf("msg1 channel type byte = %d, want %d", body[2], channel.Type)
|
|
}
|
|
if body[3] != 1 {
|
|
t.Fatalf("msg1 entry flag = %d, want 1", body[3])
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[4:8]); got != service.options.gameOuterToken {
|
|
t.Fatalf("routing key = 0x%08x, want 0x%08x", got, service.options.gameOuterToken)
|
|
}
|
|
ipLen := int(binary.LittleEndian.Uint32(body[8:12]))
|
|
if ipLen != len(service.options.serverIP) {
|
|
t.Fatalf("ip len = %d, want %d", ipLen, len(service.options.serverIP))
|
|
}
|
|
portOffset := 12 + ipLen
|
|
if got := binary.LittleEndian.Uint32(body[portOffset : portOffset+4]); got != uint32(channel.Port) {
|
|
t.Fatalf("port = %d, want %d", got, channel.Port)
|
|
}
|
|
tailOffset := portOffset + 8
|
|
if len(body) != tailOffset+24 {
|
|
t.Fatalf("msg1 body len = %d, want %d-byte account state tail", len(body), 24)
|
|
}
|
|
tail := body[tailOffset:]
|
|
if tail[2] != 1 || tail[3] != 1 {
|
|
t.Fatalf("security-card flags = %x, want offsets 2/3 enabled", tail[:4])
|
|
}
|
|
tail[2], tail[3] = 0, 0
|
|
if !bytes.Equal(tail, make([]byte, 24)) {
|
|
t.Fatalf("unexpected account state tail: %x", body[tailOffset:])
|
|
}
|
|
}
|
|
|
|
func TestSendGameInitialHeader(t *testing.T) {
|
|
service := &Service{options: options{
|
|
serverIP: "203.0.113.10",
|
|
gameInitialMode: gameInitialModeUpper,
|
|
gameUpperHeader: gameUpperHeaderServer16,
|
|
}}
|
|
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channel}
|
|
|
|
_, bodyLen, sent, err := service.sendGameInitial(session)
|
|
if err != nil {
|
|
t.Fatalf("send initial: %v", err)
|
|
}
|
|
if !sent || bodyLen == 0 {
|
|
t.Fatalf("sent=%v bodyLen=%d", sent, bodyLen)
|
|
}
|
|
assertUpperInitialWire(t, conn.write.Bytes(), service.gameUpperHeaderSize(), upperSuccessBody(service.buildLoginSuccessForSession(session)), true)
|
|
}
|
|
|
|
func TestSendGameInitialCodec(t *testing.T) {
|
|
service := &Service{options: options{
|
|
serverIP: "203.0.113.10",
|
|
gameInitialMode: gameInitialModeUpper,
|
|
gameUpperHeader: gameUpperHeaderServer16,
|
|
gameUpperBodyCodec: gameUpperBodyCodecPlain,
|
|
}}
|
|
channel := channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channel}
|
|
|
|
_, bodyLen, sent, err := service.sendGameInitial(session)
|
|
if err != nil {
|
|
t.Fatalf("send initial: %v", err)
|
|
}
|
|
if !sent || bodyLen == 0 {
|
|
t.Fatalf("sent=%v bodyLen=%d", sent, bodyLen)
|
|
}
|
|
assertUpperInitialWire(t, conn.write.Bytes(), service.gameUpperHeaderSize(), upperSuccessBody(service.buildLoginSuccessForSession(session)), false)
|
|
}
|
|
|
|
func assertUpperInitialWire(t *testing.T, raw []byte, headerSize int, wantBody []byte, wantEncoded bool) {
|
|
t.Helper()
|
|
packet, rest := splitGameServerUpperPacketWithHeader(t, raw, headerSize)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected bytes after initial upper packet: %d", len(rest))
|
|
}
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification {
|
|
t.Fatalf("initial class = %d, want %d", packet.Header.Classification, dnfproto.DefaultChannelClassification)
|
|
}
|
|
if packet.Header.MsgID != uint16(dnfenum.UpperMsgGameEndpoint) {
|
|
t.Fatalf("initial msg = %d, want %d", packet.Header.MsgID, dnfenum.UpperMsgGameEndpoint)
|
|
}
|
|
if packet.Header.Seq != 0 {
|
|
t.Fatalf("endpoint success seq = %d, want 0", packet.Header.Seq)
|
|
}
|
|
wantWireBody := wantBody
|
|
if wantEncoded {
|
|
encodedBody, encoded, err := dnfproto.EncodeLatestUpperServerBody(uint16(dnfenum.UpperMsgGameEndpoint), wantBody)
|
|
if err != nil {
|
|
t.Fatalf("encode initial body: %v", err)
|
|
}
|
|
if !encoded {
|
|
t.Fatal("initial upper body should be encoded")
|
|
}
|
|
wantWireBody = encodedBody
|
|
}
|
|
if !bytes.Equal(packet.Body, wantWireBody) {
|
|
t.Fatalf("initial upper body = %x, want %x", packet.Body, wantWireBody)
|
|
}
|
|
}
|
|
|
|
func assertNoticeWire(t *testing.T, raw []byte, wantBody []byte, wantEncoded bool) {
|
|
t.Helper()
|
|
if len(raw) != 15+len(wantBody) {
|
|
t.Fatalf("initial raw len = %d, want %d", len(raw), 15+len(wantBody))
|
|
}
|
|
if raw[0] != 0 {
|
|
t.Fatalf("initial class = %d, want 0", raw[0])
|
|
}
|
|
if got := binary.LittleEndian.Uint16(raw[1:3]); got != 1 {
|
|
t.Fatalf("initial msg = %d, want 1", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(raw[3:7]); got != uint32(len(raw)) {
|
|
t.Fatalf("initial packet len = %d, want %d", got, len(raw))
|
|
}
|
|
if got := binary.LittleEndian.Uint32(raw[7:11]); got != 0 {
|
|
t.Fatalf("initial marker = %d, want 0", got)
|
|
}
|
|
if !bytes.Equal(raw[11:15], []byte{0, 0, 0, 0}) {
|
|
t.Fatalf("initial reserved = %x, want 00000000", raw[11:15])
|
|
}
|
|
wantWireBody := wantBody
|
|
if wantEncoded {
|
|
wantWireBody = encNoticeBody(wantBody)
|
|
if bytes.Equal(wantWireBody, wantBody) {
|
|
t.Fatal("initial wire body should be encoded, not handler plaintext")
|
|
}
|
|
}
|
|
if !bytes.Equal(raw[15:], wantWireBody) {
|
|
t.Fatalf("initial wire body = %x, want %x", raw[15:], wantWireBody)
|
|
}
|
|
}
|
|
|
|
func TestEncNoticeBody(t *testing.T) {
|
|
plain := []byte{0x00, 0x01, 0x04, 0x06, 0x34, 0x2e, 0x63, 0x68}
|
|
want := []byte{0xd6, 0xd2, 0xc6, 0xce, 0x06, 0x6e, 0x5b, 0x77}
|
|
|
|
if got := encNoticeBody(plain); !bytes.Equal(got, want) {
|
|
t.Fatalf("encoded body = %x, want %x", got, want)
|
|
}
|
|
if !bytes.Equal(plain, []byte{0x00, 0x01, 0x04, 0x06, 0x34, 0x2e, 0x63, 0x68}) {
|
|
t.Fatalf("encode mutated input: %x", plain)
|
|
}
|
|
}
|
|
|
|
func TestNoticeChanName(t *testing.T) {
|
|
channel := channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", NoticeName: "ch.2", Port: 10038}
|
|
|
|
if got := noticeChanName(channel); got != "ch.38" {
|
|
t.Fatalf("initial channel name = %q, want ch.38", got)
|
|
}
|
|
}
|
|
|
|
func TestNoticeChanNameIgnoresName(t *testing.T) {
|
|
channel := channelcatalog.Channel{ID: 19, Type: 1, Name: "ignored.by.initial.notice", Port: 10019}
|
|
|
|
if got := noticeChanName(channel); got != "ch.19" {
|
|
t.Fatalf("initial channel name = %q, want ch.19", got)
|
|
}
|
|
}
|
|
|
|
func TestNoticeChanNameUsesID(t *testing.T) {
|
|
channel := channelcatalog.Channel{ID: 6, Type: 3, Port: 10006}
|
|
|
|
if got := noticeChanName(channel); got != "ch.6" {
|
|
t.Fatalf("initial channel name = %q, want ch.6", got)
|
|
}
|
|
}
|
|
|
|
func TestPostBootstrapWaits(t *testing.T) {
|
|
service := &Service{options: options{gamePostBootstrap: gamePostBootstrapNone}}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
|
|
if err := service.sendPostInitialBootstrap(session, gameInitialModeUpper, true); err != nil {
|
|
t.Fatalf("post initial bootstrap: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("post initial bootstrap wrote unexpected bytes: %x", conn.write.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestPreBootstrapIsWriteSilentDespiteLegacyOption(t *testing.T) {
|
|
service := &Service{options: options{
|
|
gameInitialMode: gameInitialModeUpper,
|
|
gamePreBootstrap: gamePreBootstrapGuard,
|
|
}}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}}
|
|
|
|
if err := service.sendPreInitialBootstrap(session); err != nil {
|
|
t.Fatalf("pre initial bootstrap: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("pre bootstrap wrote unexpected bytes: %x", conn.write.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestPostCheckWaits(t *testing.T) {
|
|
service := &Service{options: options{gamePostBootstrap: gamePostBootstrapCheck}}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
|
|
if err := service.sendPostInitialBootstrap(session, gameInitialModeUpper, true); err != nil {
|
|
t.Fatalf("post initial bootstrap: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("post initial client-check wrote unexpected bytes: %x", conn.write.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestPostRosterWaits(t *testing.T) {
|
|
service := &Service{options: options{gamePostBootstrap: gamePostBootstrapRoster}}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
|
|
if err := service.sendPostInitialBootstrap(session, gameInitialModeUpper, true); err != nil {
|
|
t.Fatalf("post initial bootstrap: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("post initial roster wrote unexpected bytes: %x", conn.write.Bytes())
|
|
}
|
|
}
|
|
|
|
func assertRuntimeAfterBlacklistSafePrefix(t *testing.T, service *Service, session *gameSession, rest []byte) []byte {
|
|
t.Helper()
|
|
_ = service
|
|
_ = session
|
|
for idx, want := range longHengSceneRuntimeAfterBlacklistSafePrefixPackets() {
|
|
var packet dnfproto.ChannelPacket
|
|
packet, rest = splitCSharpSelectInitPacket(t, rest, want)
|
|
wantBody := want.body
|
|
if packet.Header.Classification != want.class ||
|
|
packet.Header.MsgID != want.msgID ||
|
|
!bytes.Equal(packet.Body, wantBody) {
|
|
t.Fatalf("runtime after blacklist safe prefix[%d] = class %d msg %d len=%d", idx, packet.Header.Classification, packet.Header.MsgID, len(packet.Body))
|
|
}
|
|
assertNoUnsafeRuntimeScenePacket(t, "runtime after blacklist safe prefix", idx, packet.Header.Classification, packet.Header.MsgID)
|
|
}
|
|
return rest
|
|
}
|
|
|
|
func assertNoActiveFinishLoadingPacket(t *testing.T, data []byte, label string) []byte {
|
|
t.Helper()
|
|
rest := data
|
|
for len(rest) > 0 {
|
|
packet, next := splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketFinishLoading) {
|
|
t.Fatalf("%s actively sent class0/op37 body=%x", label, packet.Body)
|
|
}
|
|
rest = next
|
|
}
|
|
return data
|
|
}
|
|
|
|
func assertSelectedSceneUserInfoRefresh(t *testing.T, service *Service, session *gameSession, data []byte) []byte {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
charID, _, character, hasCharacter := service.selectedCharacterForEnter(ctx, session)
|
|
var legacyRepo dnfrepo.LegacyUserInfoRepository
|
|
if repos, ok := service.repositoryGroup(); ok {
|
|
legacyRepo = repos.LegacyUserInfo
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, data)
|
|
wantBody := service.buildCurrentSelectedUserInfoMode1Body(ctx, session, legacyRepo, character, hasCharacter, charID)
|
|
if packet.Header.Classification != 0 ||
|
|
packet.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
|
|
!bytes.Equal(packet.Body, wantBody) {
|
|
t.Fatalf("selected scene userinfo mode1 refresh = class %d msg %d body=%x want=%x", packet.Header.Classification, packet.Header.MsgID, packet.Body, wantBody)
|
|
}
|
|
if len(packet.Body) < 23 ||
|
|
packet.Body[0] != 1 ||
|
|
binary.LittleEndian.Uint16(packet.Body[1:3]) != 1 ||
|
|
packet.Body[3] != currentSceneObjectRoute ||
|
|
packet.Body[4] != currentSceneObjectContext ||
|
|
!bytes.Equal(packet.Body[5:21], make([]byte, 16)) ||
|
|
binary.LittleEndian.Uint16(packet.Body[21:23]) != currentSceneActorObjectKey(charID) {
|
|
t.Fatalf("selected scene userinfo mode1 layout invalid body=%x", packet.Body)
|
|
}
|
|
if !session.selectedUserInfoRefreshSent {
|
|
t.Fatalf("selectedUserInfoRefreshSent = false after userinfo mode1")
|
|
}
|
|
return rest
|
|
}
|
|
|
|
func assertSelectedSceneUserInfoMode3Refresh(t *testing.T, service *Service, session *gameSession, data []byte) []byte {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
charID, _, character, hasCharacter := service.selectedCharacterForEnter(ctx, session)
|
|
var legacyRepo dnfrepo.LegacyUserInfoRepository
|
|
if repos, ok := service.repositoryGroup(); ok {
|
|
legacyRepo = repos.LegacyUserInfo
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, data)
|
|
wantBody := service.buildCurrentSelectedUserInfoMode3Body(ctx, session, legacyRepo, character, hasCharacter, charID)
|
|
if packet.Header.Classification != 0 ||
|
|
packet.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
|
|
!bytes.Equal(packet.Body, wantBody) {
|
|
t.Fatalf("selected scene userinfo mode3 refresh = class %d msg %d body=%x want=%x", packet.Header.Classification, packet.Header.MsgID, packet.Body, wantBody)
|
|
}
|
|
if len(packet.Body) < 172 ||
|
|
packet.Body[0] != 3 ||
|
|
binary.LittleEndian.Uint16(packet.Body[1:3]) != 1 ||
|
|
packet.Body[3] != currentSceneObjectRoute ||
|
|
packet.Body[4] != currentSceneObjectContext ||
|
|
binary.LittleEndian.Uint16(packet.Body[0x0d:0x0f]) != currentSceneActorObjectKey(charID) ||
|
|
binary.LittleEndian.Uint32(packet.Body[0x0f:0x13]) != 0 ||
|
|
binary.LittleEndian.Uint32(packet.Body[0x13:0x17]) != 92 {
|
|
t.Fatalf("selected scene userinfo mode3 layout invalid body=%x", packet.Body)
|
|
}
|
|
if !session.selectedUserInfoMode3Sent {
|
|
t.Fatalf("selectedUserInfoMode3Sent = false after mode3")
|
|
}
|
|
return rest
|
|
}
|
|
|
|
func assertSelectedSceneUserInfoRefreshSkipped(t *testing.T, session *gameSession, data []byte) []byte {
|
|
t.Helper()
|
|
if !session.selectedUserInfoRefreshSent {
|
|
t.Fatalf("selectedUserInfoRefreshSent = false, want skipped marker")
|
|
}
|
|
if len(data) != 0 {
|
|
packet, _ := splitGameServerUpperPacket(t, data)
|
|
t.Fatalf("selected scene userinfo refresh wrote unexpected packet class %d msg %d body_len=%d", packet.Header.Classification, packet.Header.MsgID, len(packet.Body))
|
|
}
|
|
return data
|
|
}
|
|
|
|
func assertNoUnsafeProactiveReplayPacket(t *testing.T, stage string, idx int, class byte, msgID uint16) {
|
|
t.Helper()
|
|
if longHengSceneProactiveReplayPacketBlocked(class, msgID) {
|
|
t.Fatalf("%s[%d] sent unsafe proactive replay packet class=%d msg=%d", stage, idx, class, msgID)
|
|
}
|
|
}
|
|
|
|
func assertNoUnsafeRuntimeScenePacket(t *testing.T, stage string, idx int, class byte, msgID uint16) {
|
|
t.Helper()
|
|
assertNoUnsafeProactiveReplayPacket(t, stage, idx, class, msgID)
|
|
if class == 0 && msgID == uint16(dnfenum.CmdPacketSellItem) {
|
|
t.Fatalf("%s[%d] replayed old class0/op22 body instead of a typed current actor-position update", stage, idx)
|
|
}
|
|
if class == dnfproto.DefaultChannelClassification && msgID == uint16(dnfenum.CmdPacketExit) {
|
|
t.Fatalf("%s[%d] sent class1 exit packet", stage, idx)
|
|
}
|
|
if class == 0 && msgID == longHengCurrentRuntimeObjectStateMsgID {
|
|
t.Fatalf("%s[%d] replayed historical object row as current op356 clear-quest list", stage, idx)
|
|
}
|
|
if class == 0 && msgID == uint16(dnfenum.CmdPacketSetUDPIPPort) {
|
|
t.Fatalf("%s[%d] sent old DOVE op2 runtime object stream before current structure is rebuilt", stage, idx)
|
|
}
|
|
if class == 0 && msgID == uint16(dnfenum.CmdPacketRepairEquipment) {
|
|
t.Fatalf("%s[%d] replayed old class0/op23 body instead of a typed current actor-scene update", stage, idx)
|
|
}
|
|
if class == dnfproto.DefaultChannelClassification && msgID == uint16(dnfenum.CmdPacketWeddingResponse) {
|
|
t.Fatalf("%s[%d] sent request-driven class1/op1033 wedding UI packet in runtime scene prefix", stage, idx)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeAfterBlacklistSafePrefixSkipsSyntheticObjectBitsets(t *testing.T) {
|
|
packets := longHengSceneRuntimeAfterBlacklistSafePrefixPackets()
|
|
if len(packets) != 0 {
|
|
t.Fatalf("runtime after blacklist active prefix = %d packets, want no proactive DOVE-order packets", len(packets))
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneRuntimeAfterBlacklistLedgerCoversFilteredDebt(t *testing.T) {
|
|
ledger := longHengSceneRuntimeAfterBlacklistPrefixLedger()
|
|
rawCount := longHengRuntimeAfterBlacklistSafeRawCount
|
|
if rawCount > len(longHengSceneRuntimeAfterBlacklistPackets) {
|
|
rawCount = len(longHengSceneRuntimeAfterBlacklistPackets)
|
|
}
|
|
if len(ledger) != rawCount {
|
|
t.Fatalf("runtime ledger count = %d, want raw prefix count %d", len(ledger), rawCount)
|
|
}
|
|
implemented := 0
|
|
var op9Excluded, objectDebt, op22Debt, op23Verdict, op1033Verdict, class1ExitDebt, op380Verdict, op12Verdict bool
|
|
for idx, entry := range ledger {
|
|
if entry.idx != 149+idx {
|
|
t.Fatalf("runtime ledger[%d] idx = %d, want %d", idx, entry.idx, 149+idx)
|
|
}
|
|
if entry.phase != "08_in_scene_runtime_updates" || entry.status == "" || entry.reason == "" {
|
|
t.Fatalf("runtime ledger[%d] incomplete entry: %+v", idx, entry)
|
|
}
|
|
if entry.implemented {
|
|
implemented++
|
|
if entry.status != longHengPacketImplementedCurrentBody {
|
|
t.Fatalf("runtime ledger[%d] implemented with status %q", idx, entry.status)
|
|
}
|
|
}
|
|
if entry.class == 0 && entry.msgID == uint16(dnfenum.CmdPacketRecoverStamina) &&
|
|
entry.status == longHengPacketNotUsedCurrentClient &&
|
|
strings.Contains(entry.reason, "no_runtime_state_owner") {
|
|
op9Excluded = true
|
|
}
|
|
if entry.class == 0 && entry.msgID == uint16(dnfenum.CmdPacketSetUDPIPPort) &&
|
|
entry.status == longHengPacketPendingCurrentStruct &&
|
|
strings.Contains(entry.reason, "old_dove_op2_runtime_object_stream") {
|
|
objectDebt = true
|
|
}
|
|
if entry.class == 0 && entry.msgID == uint16(dnfenum.CmdPacketSellItem) &&
|
|
entry.status == longHengPacketPendingCurrentStruct &&
|
|
strings.Contains(entry.reason, "sub_1D83990_reads") {
|
|
op22Debt = true
|
|
}
|
|
if entry.class == 0 && entry.msgID == uint16(dnfenum.CmdPacketRepairEquipment) &&
|
|
entry.status == longHengPacketPendingCurrentStruct &&
|
|
strings.Contains(entry.reason, "sub_1D89590_reads") {
|
|
op23Verdict = true
|
|
}
|
|
if entry.class == dnfproto.DefaultChannelClassification && entry.msgID == uint16(dnfenum.CmdPacketWeddingResponse) &&
|
|
entry.status == longHengPacketRequestDriven &&
|
|
strings.Contains(entry.reason, "not_runtime_scene_state") {
|
|
op1033Verdict = true
|
|
}
|
|
if entry.class == dnfproto.DefaultChannelClassification &&
|
|
entry.msgID == uint16(dnfenum.CmdPacketExit) &&
|
|
entry.status == longHengPacketNotUsedCurrentClient &&
|
|
strings.Contains(entry.reason, "ui_transition") {
|
|
class1ExitDebt = true
|
|
}
|
|
if entry.class == 0 &&
|
|
entry.msgID == uint16(dnfenum.CmdPacketSetLabyrinthSeatState) &&
|
|
entry.status == longHengPacketRequestDriven &&
|
|
strings.Contains(entry.reason, "current_op380_reads") {
|
|
op380Verdict = true
|
|
}
|
|
if entry.class == 0 &&
|
|
entry.msgID == uint16(dnfenum.CmdPacketSetPartyInfo) &&
|
|
entry.status == longHengPacketRequestDriven &&
|
|
strings.Contains(entry.reason, "current_op12_reads_u8_u16_u8_wstr") {
|
|
op12Verdict = true
|
|
}
|
|
}
|
|
if implemented != len(longHengSceneRuntimeAfterBlacklistSafePrefixPackets()) {
|
|
t.Fatalf("runtime ledger implemented count = %d, want current sent count %d", implemented, len(longHengSceneRuntimeAfterBlacklistSafePrefixPackets()))
|
|
}
|
|
if !op9Excluded || !objectDebt || !op22Debt || !op23Verdict || !op1033Verdict || !class1ExitDebt || !op380Verdict || !op12Verdict {
|
|
t.Fatalf("runtime ledger missing markers op9Excluded=%v object=%v op22=%v op23=%v op1033=%v class1Exit=%v op380=%v op12=%v", op9Excluded, objectDebt, op22Debt, op23Verdict, op1033Verdict, class1ExitDebt, op380Verdict, op12Verdict)
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneBeforeHudLedgerCoversLongHengOrder(t *testing.T) {
|
|
ledger := longHengSceneBeforeHudLedger()
|
|
if len(ledger) != 43 {
|
|
t.Fatalf("before-HUD ledger count = %d, want DOVE idx25-67 count 43", len(ledger))
|
|
}
|
|
|
|
implemented := 0
|
|
implementedOutsideBootstrap := 0
|
|
cargoPadResetMarked := false
|
|
for idx, entry := range ledger {
|
|
wantIdx := longHengSceneBeforeHudStartIndex + idx
|
|
if entry.idx != wantIdx {
|
|
t.Fatalf("before-HUD ledger[%d] idx = %d, want %d", idx, entry.idx, wantIdx)
|
|
}
|
|
if entry.phase != "05_scene_bootstrap_before_hud" || entry.status == "" || entry.reason == "" {
|
|
t.Fatalf("before-HUD ledger[%d] incomplete entry: %+v", idx, entry)
|
|
}
|
|
if entry.implemented {
|
|
implemented++
|
|
if entry.status != longHengPacketImplementedCurrentBody {
|
|
t.Fatalf("before-HUD ledger[%d] implemented with status %q", idx, entry.status)
|
|
}
|
|
if entry.msgID == uint16(dnfenum.CmdPacketCancelCargoPad) {
|
|
implementedOutsideBootstrap++
|
|
cargoPadResetMarked = strings.Contains(entry.reason, "before_real_item_lists")
|
|
}
|
|
}
|
|
}
|
|
|
|
activeLongHengOrderPackets := 0
|
|
for _, packet := range longHengSceneBootstrapBeforeHudPackets {
|
|
switch packet.kind {
|
|
case csharpLongHengSceneBootstrapKind, csharpCurrentSceneStageStateKind, csharpCurrentSceneOp9ActorDisplayKind, currentAcceptableQuestListKind:
|
|
activeLongHengOrderPackets++
|
|
}
|
|
}
|
|
if implemented-implementedOutsideBootstrap != activeLongHengOrderPackets {
|
|
t.Fatalf("before-HUD bootstrap implemented count = %d, active DOVE-order send count %d", implemented-implementedOutsideBootstrap, activeLongHengOrderPackets)
|
|
}
|
|
if implementedOutsideBootstrap != 1 || !cargoPadResetMarked {
|
|
t.Fatalf("before-HUD cargo-pad reset ledger marker count=%d marked=%v", implementedOutsideBootstrap, cargoPadResetMarked)
|
|
}
|
|
|
|
wantTail := []struct {
|
|
idx int
|
|
msgID uint16
|
|
status longHengScenePacketStatus
|
|
}{
|
|
{64, uint16(dnfenum.CmdPacketUseSharedEffectItem), longHengPacketRequestDriven},
|
|
{65, uint16(dnfenum.CmdPacketFrameLagStatistics), longHengPacketPendingCurrentStruct},
|
|
{66, uint16(dnfenum.CmdPacketAuctionRegistItem), longHengPacketRequestDriven},
|
|
{67, uint16(dnfenum.CmdPacketAuctionRegistItem), longHengPacketRequestDriven},
|
|
}
|
|
for _, want := range wantTail {
|
|
entry := ledger[want.idx-longHengSceneBeforeHudStartIndex]
|
|
if entry.idx != want.idx || entry.msgID != want.msgID || entry.status != want.status || entry.implemented {
|
|
t.Fatalf("before-HUD ledger idx%d = %+v", want.idx, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneBeforeHudOrderDoesNotInsertNonLongHengTriggers(t *testing.T) {
|
|
reportClientSpecIdx := -1
|
|
op9Idx := -1
|
|
for idx, packet := range longHengSceneBootstrapBeforeHudPackets {
|
|
switch packet.msgID {
|
|
case uint16(dnfenum.CmdPacketPVPMissionHpPercent), uint16(dnfenum.CmdPacketMissionRouletteTrigger):
|
|
if packet.kind != csharpCurrentActionTableStateKind {
|
|
t.Fatalf("before-HUD packet[%d] inserted non-DOVE trigger msg=%d", idx, packet.msgID)
|
|
}
|
|
case uint16(dnfenum.CmdPacketReportClientSpec):
|
|
reportClientSpecIdx = idx
|
|
case uint16(dnfenum.CmdPacketRecoverStamina):
|
|
op9Idx = idx
|
|
case uint16(dnfenum.CmdPacketGuildCargoPushItem):
|
|
t.Fatalf("before-HUD packet[%d] replayed old guild-cargo body", idx)
|
|
case uint16(dnfenum.CmdPacketReqRepresentCharacter):
|
|
t.Fatalf("before-HUD packet[%d] replayed old represent-character body", idx)
|
|
}
|
|
}
|
|
if reportClientSpecIdx < 0 || op9Idx < 0 {
|
|
t.Fatalf("before-HUD required indexes op124=%d op9=%d", reportClientSpecIdx, op9Idx)
|
|
}
|
|
if reportClientSpecIdx+1 != op9Idx {
|
|
t.Fatalf("before-HUD current tail order indexes op124=%d op9=%d", reportClientSpecIdx, op9Idx)
|
|
}
|
|
}
|
|
|
|
func TestSceneBootstrapExcludesAllDirectLongHengFixtureBodies(t *testing.T) {
|
|
excluded := map[uint16]bool{
|
|
uint16(dnfenum.CmdPacketCreateCharacter): true,
|
|
uint16(dnfenum.CmdPacketLeaveParty): true,
|
|
uint16(dnfenum.CmdPacketGatheringPartyStatus): true,
|
|
uint16(dnfenum.CmdPacketWalkoutPartyMember): true,
|
|
uint16(dnfenum.CmdPacketGetAvatarSpecEvent): true,
|
|
uint16(dnfenum.CmdPacketReport4Hack): true,
|
|
uint16(dnfenum.CmdPacketPeerConnectResult): true,
|
|
uint16(dnfenum.CmdPacketMoveItemspace): true,
|
|
uint16(dnfenum.CmdPacketChangeDeckInfo): true,
|
|
uint16(dnfenum.CmdPacketDungeonNPCBuffInfo): true,
|
|
uint16(dnfenum.CmdPacketApproveJoinGuild): true,
|
|
uint16(dnfenum.CmdPacketCancelJoinGuild): true,
|
|
uint16(dnfenum.CmdPacketCancelCargoPad): true,
|
|
uint16(dnfenum.CmdPacketGuildCargoPushItem): true,
|
|
uint16(dnfenum.CmdPacketReqRepresentCharacter): true,
|
|
}
|
|
for idx, packet := range longHengSceneBootstrapBeforeHudPackets {
|
|
if excluded[packet.msgID] {
|
|
t.Fatalf("scene bootstrap[%d] still sends excluded direct DOVE fixture msg=%d", idx, packet.msgID)
|
|
}
|
|
if strings.HasPrefix(packet.bodyCodec, "dove_") {
|
|
t.Fatalf("scene bootstrap[%d] still exposes DOVE transport codec %q", idx, packet.bodyCodec)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneOp12OldBodiesDoNotMatchCurrentReaderShape(t *testing.T) {
|
|
fixtures := []string{
|
|
"game_54226_s2c_0134_class0_op12_seq0_body32.bin",
|
|
"game_54226_s2c_0135_class0_op12_seq0_body48.bin",
|
|
"game_54226_s2c_0136_class0_op12_seq0_body32.bin",
|
|
"game_54226_s2c_0137_class0_op12_seq0_body32.bin",
|
|
"game_54226_s2c_0138_class0_op12_seq0_body32.bin",
|
|
"game_54226_s2c_0139_class0_op12_seq0_body48.bin",
|
|
"game_54226_s2c_0140_class0_op12_seq0_body64.bin",
|
|
"runtime_after_blacklist_000170_class0_op12_body48.bin",
|
|
"runtime_after_blacklist_000178_class0_op12_body48.bin",
|
|
"runtime_after_blacklist_000248_class0_op12_body48.bin",
|
|
}
|
|
for _, file := range fixtures {
|
|
body := mustLongHengSceneBody(longHengSceneFixtureSpec{file: file})
|
|
if len(body) < 8 {
|
|
t.Fatalf("%s len = %d, want enough bytes for current op12 header", file, len(body))
|
|
}
|
|
wstrBytes := binary.LittleEndian.Uint32(body[4:8])
|
|
if wstrBytes <= uint32(len(body)-8) {
|
|
t.Fatalf("%s unexpectedly fits current op12 u8,u16,u8,wstr shape: body_len=%d wstr_bytes=%d body=%x", file, len(body), wstrBytes, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneSharedEffectEquipmentOldBodiesAreNotCurrentBootstrapBodies(t *testing.T) {
|
|
oldOp251 := mustLongHengSceneBody(longHengSceneFixtureSpec{file: "000064_05_scene_bootstrap_before_hud_cls0_op0251_ENUM_CMDPACKET_USE_SHARED_EFFECT_ITEM_body8.bin"})
|
|
if len(oldOp251) != 8 {
|
|
t.Fatalf("old op251 body len = %d, want 8", len(oldOp251))
|
|
}
|
|
if count := binary.LittleEndian.Uint16(oldOp251[:2]); count != 0x3455 {
|
|
t.Fatalf("old op251 first u16 = %#x, want DOVE sample count-like value 0x3455", count)
|
|
}
|
|
|
|
oldOp194 := mustLongHengSceneBody(longHengSceneFixtureSpec{file: "000065_05_scene_bootstrap_before_hud_cls0_op0194_ENUM_CMDPACKET_FRAME_LAG_STATISTICS_body174.bin"})
|
|
if len(oldOp194) != 174 {
|
|
t.Fatalf("old op194 body len = %d, want 174", len(oldOp194))
|
|
}
|
|
if count := binary.LittleEndian.Uint16(oldOp194[:2]); count != 0x1c45 {
|
|
t.Fatalf("old op194 first u16 = %#x, want DOVE sample count-like value 0x1c45", count)
|
|
}
|
|
if need := 2 + int(binary.LittleEndian.Uint16(oldOp194[:2]))*4; need <= len(oldOp194) {
|
|
t.Fatalf("old op194 unexpectedly fits current u16-count/u16-u16 reader: need %d len %d", need, len(oldOp194))
|
|
}
|
|
|
|
oldOp183A := mustLongHengSceneBody(longHengSceneFixtureSpec{file: "000066_05_scene_bootstrap_before_hud_cls0_op0183_ENUM_CMDPACKET_AUCTION_REGIST_ITEM_body8.bin"})
|
|
oldOp183B := mustLongHengSceneBody(longHengSceneFixtureSpec{file: "000067_05_scene_bootstrap_before_hud_cls0_op0183_ENUM_CMDPACKET_AUCTION_REGIST_ITEM_body8.bin"})
|
|
if len(oldOp183A) != 8 || len(oldOp183B) != 8 {
|
|
t.Fatalf("old op183 body lens = %d/%d, want 8/8", len(oldOp183A), len(oldOp183B))
|
|
}
|
|
if oldOp183A[0] == 0 || oldOp183A[0] == 1 || oldOp183B[0] == 0 || oldOp183B[0] == 1 {
|
|
t.Fatalf("old op183 samples unexpectedly start with current small status values: %x / %x", oldOp183A[:2], oldOp183B[:2])
|
|
}
|
|
|
|
for idx, packet := range longHengSceneBootstrapBeforeHudPackets {
|
|
switch packet.msgID {
|
|
case uint16(dnfenum.CmdPacketUseSharedEffectItem):
|
|
if bytes.Equal(packet.body, oldOp251) {
|
|
t.Fatalf("before-HUD packet[%d] replays old DOVE op251 shared-effect body", idx)
|
|
}
|
|
case uint16(dnfenum.CmdPacketFrameLagStatistics):
|
|
if bytes.Equal(packet.body, oldOp194) {
|
|
t.Fatalf("before-HUD packet[%d] replays old DOVE op194 frame-lag body", idx)
|
|
}
|
|
case uint16(dnfenum.CmdPacketAuctionRegistItem):
|
|
if bytes.Equal(packet.body, oldOp183A) || bytes.Equal(packet.body, oldOp183B) {
|
|
t.Fatalf("before-HUD packet[%d] replays old DOVE op183 auction/equipment body", idx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneOp53OldBodyIsPVPReadyStateNotSceneBootstrap(t *testing.T) {
|
|
body := mustLongHengSceneBody(longHengSceneFixtureSpec{file: "game_54226_s2c_0141_class0_op53_seq0_body16.bin"})
|
|
if len(body) != 16 {
|
|
t.Fatalf("op53 DOVE body len = %d, want 16", len(body))
|
|
}
|
|
if got := body[0]; got != 0x86 {
|
|
t.Fatalf("op53 DOVE first current u8 = %#x, want 0x86", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[1:5]); got != 0x762ce52d {
|
|
t.Fatalf("op53 DOVE first current u32 = %#x, want 0x762ce52d", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[5:9]); got != 0x8983f9ec {
|
|
t.Fatalf("op53 DOVE second current u32 = %#x, want 0x8983f9ec", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[9:13]); got != 0x533ab215 {
|
|
t.Fatalf("op53 DOVE third current u32 = %#x, want 0x533ab215", got)
|
|
}
|
|
if got := body[13:]; !bytes.Equal(got, []byte{0xf0, 0x1a, 0xcf}) {
|
|
t.Fatalf("op53 DOVE extra tail = %x, want f01acf", got)
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneOp1292OldBodyIsJoustResultStateNotSceneBootstrap(t *testing.T) {
|
|
body := mustLongHengSceneBody(longHengSceneFixtureSpec{file: "game_54226_s2c_0143_class0_op1292_seq0_body16.bin"})
|
|
if len(body) != 16 {
|
|
t.Fatalf("op1292 DOVE body len = %d, want 16", len(body))
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[:4]); got != 0x477c1f2b {
|
|
t.Fatalf("op1292 DOVE raw4 result field = %#x, want 0x477c1f2b", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[:2]); got != 0x1f2b {
|
|
t.Fatalf("op1292 DOVE u16 UI field = %#x, want 0x1f2b", got)
|
|
}
|
|
if got := body[4:]; !bytes.Equal(got, []byte{0x70, 0x75, 0x37, 0xd4, 0xa5, 0x80, 0x3d, 0xc3, 0xea, 0x89, 0x49, 0xc1}) {
|
|
t.Fatalf("op1292 DOVE extra tail = %x, want 707537d4a5803dc3ea8949c1", got)
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneClass1Op140OldBodyIsLargeGuildListNotSceneBootstrap(t *testing.T) {
|
|
body := mustLongHengSceneBody(longHengSceneFixtureSpec{file: "passive_legacy_0140_class1_op140_guild_all_member_list_body15962.bin"})
|
|
if len(body) != 15962 {
|
|
t.Fatalf("op140 DOVE body len = %d, want 15962", len(body))
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[:4]); got != 0x9d8dd3fb {
|
|
t.Fatalf("op140 DOVE first current u32/guild field = %#x, want 0x9d8dd3fb", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[4:8]); got != 0x7c5177a4 {
|
|
t.Fatalf("op140 DOVE next raw field = %#x, want 0x7c5177a4", got)
|
|
}
|
|
}
|
|
|
|
func TestCurrentClearQuestListDefaultBodyDoesNotActivateSceneObjectKey(t *testing.T) {
|
|
body := buildCurrentPassGateObjectBody()
|
|
if len(body) != 4+30000 {
|
|
t.Fatalf("clear quest list body len = %d, want %d", len(body), 4+30000)
|
|
}
|
|
rawLen := binary.LittleEndian.Uint32(body[:4])
|
|
if rawLen != 30000 {
|
|
t.Fatalf("clear quest list raw len = %d, want 30000", rawLen)
|
|
}
|
|
raw := body[4:]
|
|
if got := raw[currentSceneBootstrapObjectKey]; got != 0 {
|
|
t.Fatalf("clear quest list wrote scene object key %#x flag=%d", currentSceneBootstrapObjectKey, got)
|
|
}
|
|
if raw[longHengSceneStageFixtureObjectKey] != 0 {
|
|
t.Fatalf("clear quest list must not activate DOVE fixture key %#x", longHengSceneStageFixtureObjectKey)
|
|
}
|
|
}
|
|
|
|
func TestCurrentClearQuestListDefaultTransportDoesNotActivateSceneObjectKey(t *testing.T) {
|
|
body := buildCurrentPassGateObjectTransportBody()
|
|
if len(body) < 2 {
|
|
t.Fatalf("clear quest list transport must stay zlib encoded, got len=%d", len(body))
|
|
}
|
|
if body[0] != 0x78 || body[1] != 0x9c {
|
|
t.Fatalf("clear quest list transport must stay zlib encoded, got prefix=%x", body[:2])
|
|
}
|
|
plain, err := zlibDecompress(body)
|
|
if err != nil {
|
|
t.Fatalf("decompress clear quest list transport: %v", err)
|
|
}
|
|
if len(plain) != 4+30000 {
|
|
t.Fatalf("clear quest list transport expanded len = %d, want %d", len(plain), 4+30000)
|
|
}
|
|
rawLen := binary.LittleEndian.Uint32(plain[:4])
|
|
if rawLen != 30000 {
|
|
t.Fatalf("clear quest list transport raw len = %d, want 30000", rawLen)
|
|
}
|
|
if got := plain[4+currentSceneBootstrapObjectKey]; got != 0 {
|
|
t.Fatalf("clear quest list transport wrote scene object key %#x flag=%d", currentSceneBootstrapObjectKey, got)
|
|
}
|
|
if got := plain[4+longHengSceneStageFixtureObjectKey]; got != 0 {
|
|
t.Fatalf("clear quest list transport must not retain DOVE fixture key %#x, got %d", longHengSceneStageFixtureObjectKey, got)
|
|
}
|
|
}
|
|
|
|
func TestCurrentCancelCargoPadTransportBodyUsesTypedUnusedSlotReset(t *testing.T) {
|
|
body := buildCurrentCancelCargoPadTransportBody()
|
|
if want := 24; len(body) != want {
|
|
t.Fatalf("cargo-pad protected body len=%d, want %d", len(body), want)
|
|
}
|
|
compressedEnd := len(body) - currentCancelCargoPadProtectedTailSize
|
|
if compressedEnd < 2 || body[0] != 0x78 || body[1] != 0x9c {
|
|
t.Fatalf("cargo-pad reset transport must stay zlib encoded, got %x", body)
|
|
}
|
|
if got := body[compressedEnd:]; !bytes.Equal(got, bytes.Repeat([]byte{0xff}, currentCancelCargoPadProtectedTailSize)) {
|
|
t.Fatalf("cargo-pad protected tail=%x, want %d unused sentinels", got, currentCancelCargoPadProtectedTailSize)
|
|
}
|
|
plain, err := zlibDecompress(body[:compressedEnd])
|
|
if err != nil {
|
|
t.Fatalf("decompress cargo-pad reset transport: %v", err)
|
|
}
|
|
if want := currentCancelCargoPadHeaderSize + currentCancelCargoPadSlotCount*4; len(plain) != want {
|
|
t.Fatalf("cargo-pad reset expanded len = %d, want %d", len(plain), want)
|
|
}
|
|
if got := plain[:currentCancelCargoPadHeaderSize]; !bytes.Equal(got, []byte{0, 2, 0, 0, 0, 0, 0, 0}) {
|
|
t.Fatalf("cargo-pad reset header = %x", got)
|
|
}
|
|
for offset := currentCancelCargoPadHeaderSize; offset < len(plain); offset += 4 {
|
|
if got := binary.LittleEndian.Uint32(plain[offset : offset+4]); got != ^uint32(0) {
|
|
t.Fatalf("cargo-pad reset slot %d = %#x, want unused", (offset-currentCancelCargoPadHeaderSize)/4, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRuntimeAfterBlacklistSmallScenePacketsUseCurrentReaders(t *testing.T) {
|
|
if packets := longHengSceneRuntimeAfterBlacklistSafePrefixPackets(); len(packets) != 0 {
|
|
t.Fatalf("runtime after blacklist still sends %d small replay-order packets", len(packets))
|
|
}
|
|
}
|
|
|
|
func TestRuntimeAfterBlacklistOp9RowsUseCurrentNoopBodyAfterValidation(t *testing.T) {
|
|
rawCount := longHengRuntimeAfterBlacklistSafeRawCount
|
|
if rawCount > len(longHengSceneRuntimeAfterBlacklistPackets) {
|
|
rawCount = len(longHengSceneRuntimeAfterBlacklistPackets)
|
|
}
|
|
wantBody := buildCurrentSceneOp9NoopBody()
|
|
rawOp9 := 0
|
|
for idx, packet := range longHengSceneRuntimeAfterBlacklistPackets[:rawCount] {
|
|
if packet.class != 0 || packet.msgID != uint16(dnfenum.CmdPacketRecoverStamina) {
|
|
continue
|
|
}
|
|
rawOp9++
|
|
if !bytes.Equal(packet.body, wantBody) {
|
|
t.Fatalf("runtime raw op9[%d] body = %x, want current noop body %x", idx, packet.body, wantBody)
|
|
}
|
|
}
|
|
if rawOp9 == 0 {
|
|
t.Fatalf("runtime raw prefix missing op9 rows")
|
|
}
|
|
if packets := longHengSceneRuntimeAfterBlacklistSafePrefixPackets(); len(packets) != 0 {
|
|
t.Fatalf("runtime safe prefix still sends %d replay-order packets", len(packets))
|
|
}
|
|
}
|
|
|
|
func TestRuntimeAfterBlacklistRawOldOp2RowsStayOldBodiesWhileGated(t *testing.T) {
|
|
rawCount := longHengRuntimeAfterBlacklistSafeRawCount
|
|
if rawCount > len(longHengSceneRuntimeAfterBlacklistPackets) {
|
|
rawCount = len(longHengSceneRuntimeAfterBlacklistPackets)
|
|
}
|
|
rawObjectRows := 0
|
|
for idx, packet := range longHengSceneRuntimeAfterBlacklistPackets[:rawCount] {
|
|
if packet.class != 0 || packet.msgID != uint16(dnfenum.CmdPacketSetUDPIPPort) {
|
|
continue
|
|
}
|
|
rawObjectRows++
|
|
wantBody := mustLongHengSceneBody(longHengSceneFixtureSpec{file: packet.file})
|
|
if !bytes.Equal(packet.body, wantBody) {
|
|
t.Fatalf("runtime raw old op2[%d] body len=%d want original DOVE body len=%d", idx, len(packet.body), len(wantBody))
|
|
}
|
|
if bytes.Equal(packet.body, buildCurrentPassGateObjectBody()) {
|
|
t.Fatalf("runtime raw old op2[%d] was rewritten to current op356 clear-quest table", idx)
|
|
}
|
|
}
|
|
if rawObjectRows == 0 {
|
|
t.Fatalf("runtime raw prefix missing old op2 rows")
|
|
}
|
|
for idx, packet := range longHengSceneRuntimeAfterBlacklistSafePrefixPackets() {
|
|
if packet.class == 0 && (packet.msgID == uint16(dnfenum.CmdPacketSetUDPIPPort) || packet.msgID == longHengCurrentRuntimeObjectStateMsgID) {
|
|
t.Fatalf("runtime safe prefix[%d] sent object-stream debt before current structure is rebuilt", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLongHengRuntimeOldOp2BodiesDoNotFitCurrentObjectDispatchers(t *testing.T) {
|
|
fixtures := []string{
|
|
"runtime_after_blacklist_000163_class0_op2_body415.bin",
|
|
"runtime_after_blacklist_000177_class0_op2_body417.bin",
|
|
"runtime_after_blacklist_000288_class0_op2_body377.bin",
|
|
"runtime_after_blacklist_000303_class0_op2_body396.bin",
|
|
}
|
|
for _, file := range fixtures {
|
|
body := mustLongHengSceneBody(longHengSceneFixtureSpec{file: file})
|
|
if len(body) < 4 {
|
|
t.Fatalf("%s len = %d, want at least current dispatcher header", file, len(body))
|
|
}
|
|
op356RawLen := binary.LittleEndian.Uint32(body[:4])
|
|
if op356RawLen == 30000 && len(body) == 30004 {
|
|
t.Fatalf("%s unexpectedly fits current op356 bitmap shape", file)
|
|
}
|
|
mode := body[0]
|
|
if currentMsg2ObjectModePlausibleForOldLongHengBody(body) {
|
|
t.Fatalf("%s unexpectedly fits current static msg2/sub_200BEA0 object dispatcher shape: mode=%d len=%d head=%x", file, mode, len(body), body[:min(len(body), 16)])
|
|
}
|
|
}
|
|
}
|
|
|
|
func currentMsg2ObjectModePlausibleForOldLongHengBody(body []byte) bool {
|
|
if len(body) < 3 {
|
|
return false
|
|
}
|
|
mode := body[0]
|
|
switch mode {
|
|
case 0:
|
|
count := int(binary.LittleEndian.Uint16(body[1:3]))
|
|
return count <= (len(body)-3)/3
|
|
case 1, 3, 6:
|
|
count := int(binary.LittleEndian.Uint16(body[1:3]))
|
|
return count <= (len(body)-3)/3
|
|
case 8:
|
|
count := int(binary.LittleEndian.Uint16(body[1:3]))
|
|
return count <= (len(body)-3)/6
|
|
case 2, 4, 5, 7, 9, 10:
|
|
return len(body) >= 3
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneProactiveReplayGuardCoversExactGiftAndEventClass(t *testing.T) {
|
|
for _, packet := range []struct {
|
|
class byte
|
|
msgID uint16
|
|
}{
|
|
{class: 0, msgID: uint16(dnfenum.CmdPacketLeaveParty)},
|
|
{class: 0, msgID: uint16(dnfenum.CmdPacketWalkoutPartyMember)},
|
|
{class: dnfproto.DefaultChannelClassification, msgID: uint16(dnfenum.CmdPacketLetsPickPresent)},
|
|
{class: 0, msgID: uint16(dnfenum.CmdPacketReport4Hack)},
|
|
{class: 0, msgID: uint16(dnfenum.CmdPacketGetAvatarSpecEvent)},
|
|
{class: 0, msgID: uint16(dnfenum.CmdPacketAttendanceCheck)},
|
|
{class: 0, msgID: uint16(dnfenum.CmdPacketSetLabyrinthSeatState)},
|
|
{class: 0, msgID: uint16(dnfenum.CmdPacketWelcombackAttendance)},
|
|
{class: 0, msgID: uint16(dnfenum.CmdPacketGiftOfSeria)},
|
|
{class: 0, msgID: uint16(dnfenum.CmdPacketLuckyBalloon)},
|
|
} {
|
|
if !longHengSceneProactiveReplayPacketBlocked(packet.class, packet.msgID) {
|
|
t.Fatalf("gift/event class=%d msg=%d must not be replayed during scene bootstrap", packet.class, packet.msgID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneProactiveReplayGuardDoesNotBanSameNumericOpcodeInOtherClass(t *testing.T) {
|
|
for _, packet := range []struct {
|
|
class byte
|
|
msgID uint16
|
|
}{
|
|
{class: dnfproto.DefaultChannelClassification, msgID: uint16(dnfenum.CmdPacketSetPartyInfo)},
|
|
{class: 0, msgID: uint16(dnfenum.CmdPacketMercenaryCompetition)},
|
|
{class: 0, msgID: uint16(dnfenum.UpperMsgLoadExtendCharacs)},
|
|
} {
|
|
if longHengSceneProactiveReplayPacketBlocked(packet.class, packet.msgID) {
|
|
t.Fatalf("same numeric opcode was globally banned class=%d msg=%d", packet.class, packet.msgID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneBootstrapOp104UsesCurrentEmptyList(t *testing.T) {
|
|
found := false
|
|
for _, packet := range longHengSceneBootstrapBeforeHudPackets {
|
|
if packet.msgID != uint16(dnfenum.CmdPacketRequestAvagachaCoupon) {
|
|
continue
|
|
}
|
|
found = true
|
|
if !bytes.Equal(packet.body, []byte{0}) {
|
|
t.Fatalf("scene bootstrap op104 body = %x, want current empty-list count=0", packet.body)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("scene bootstrap missing current op104 empty-list packet")
|
|
}
|
|
}
|
|
|
|
func TestCurrentSceneObjectListUsesFullTemplateBeforeFinalizer(t *testing.T) {
|
|
raw, ok := buildCurrentSceneObjectRawState(dnfrepo.CharacterRecord{}, false, "hero", "")
|
|
if !ok {
|
|
t.Fatalf("missing current scene object raw state")
|
|
}
|
|
body := buildCurrentSceneObjectListBody(currentSceneBootstrapObjectKey, dnfrepo.CharacterRecord{}, false, "hero")
|
|
tailStart := currentSceneObjectTailStartForTest("hero")
|
|
wantTail := buildCurrentSceneObjectEntryTail(dnfrepo.CharacterRecord{}, false)
|
|
wantLen := tailStart + len(wantTail)
|
|
if len(body) != wantLen ||
|
|
body[0] != 0 ||
|
|
binary.LittleEndian.Uint16(body[1:3]) != 1 ||
|
|
body[3] != currentSceneObjectRoute ||
|
|
body[4] != currentSceneObjectContext ||
|
|
binary.LittleEndian.Uint16(body[0x4c:0x4e]) != currentSceneBootstrapObjectKey {
|
|
t.Fatalf("current scene object template body len=%d head=%x key=%x", len(body), body[:8], body[0x4c:0x4e])
|
|
}
|
|
if got := body[5:0x4c]; !bytes.Equal(got, raw) {
|
|
t.Fatalf("current scene object raw state = %x, want structured raw %x", got, raw)
|
|
}
|
|
wantName := rosterDstrName("hero")
|
|
if got := body[0x4e:tailStart]; !bytes.Equal(got, wantName) {
|
|
t.Fatalf("current scene object dstr name = %x, want %x", got, wantName)
|
|
}
|
|
if got := body[tailStart:]; !bytes.Equal(got, wantTail) {
|
|
t.Fatalf("current scene object tail = %x, want current structured tail %x", got, wantTail)
|
|
}
|
|
equipEnd, ok := currentSceneObjectEquipSummaryEnd(body[tailStart:], 6)
|
|
if !ok {
|
|
t.Fatalf("current scene object creature state missing equipment boundary")
|
|
}
|
|
creatureOffset := tailStart + equipEnd + 22
|
|
if creatureOffset+9 > len(body) {
|
|
t.Fatalf("current scene object creature state truncated at %d of %d", creatureOffset, len(body))
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[creatureOffset : creatureOffset+4]); got != 0 {
|
|
t.Fatalf("current scene object no-creature template = %#x, want 0; UINT32_MAX resolves to runtime creature type 8", got)
|
|
}
|
|
if nameLen := binary.LittleEndian.Uint32(body[creatureOffset+4 : creatureOffset+8]); nameLen != 0 {
|
|
t.Fatalf("current scene object no-creature name len = %d, want 0", nameLen)
|
|
}
|
|
if visibleWire := body[creatureOffset+8]; visibleWire != 0 {
|
|
t.Fatalf("current scene object no-creature visibility wire = %d, want 0", visibleWire)
|
|
}
|
|
if got := body[tailStart+0x02]; got != 0 {
|
|
t.Fatalf("current scene object missing-character level = %d, want 0", got)
|
|
}
|
|
if noResource, ok := currentSceneObjectTailNoAttachedUIResourceForLog(body); !ok || !noResource {
|
|
t.Fatalf("current scene object no-attached-ui-resource=%v ok=%v, want true true", noResource, ok)
|
|
}
|
|
if stateEventArg, ok := currentSceneObjectTailActorStateEventArgForLog(body); !ok || stateEventArg != currentSceneActorStateEventArg {
|
|
t.Fatalf("current scene object actor state event arg=%d ok=%v, want %d true", stateEventArg, ok, currentSceneActorStateEventArg)
|
|
}
|
|
if visualState, ok := currentSceneObjectTailVisualStateForLog(body); !ok || visualState != currentSceneNormalTownVisualState {
|
|
t.Fatalf("current scene object sub_20042F0 visual state=%#x ok=%v, want %#x true", visualState, ok, currentSceneNormalTownVisualState)
|
|
}
|
|
if level, parsedTailStart, ok := currentSceneObjectLevelForLog(body); !ok || parsedTailStart != tailStart || level != 0 {
|
|
t.Fatalf("current scene object level log parse level=%d tailStart=%d ok=%v, want level=0 tailStart=%d",
|
|
level, parsedTailStart, ok, tailStart)
|
|
}
|
|
highLevel := dnfrepo.CharacterRecord{
|
|
Name: "hero",
|
|
Job: "15",
|
|
Level: 86,
|
|
Stats: map[string]int64{"grow_type": 1},
|
|
}
|
|
highLevelBody := buildCurrentSceneObjectListBody(currentSceneBootstrapObjectKey, highLevel, true, "")
|
|
highLevelTailStart := currentSceneObjectTailStartForTest("hero")
|
|
wantHighLevelTail := buildCurrentSceneObjectEntryTail(highLevel, true)
|
|
if len(highLevelBody) != highLevelTailStart+len(wantHighLevelTail) {
|
|
t.Fatalf("current scene object high-level body len = %d, want %d", len(highLevelBody), highLevelTailStart+len(wantHighLevelTail))
|
|
}
|
|
wantCurrentRaw, ok := buildCurrentSceneObjectRawState(highLevel, true, "hero", "")
|
|
if !ok {
|
|
t.Fatalf("missing current scene object raw state")
|
|
}
|
|
if got := highLevelBody[5:0x4c]; !bytes.Equal(got, wantCurrentRaw) {
|
|
t.Fatalf("current scene object high-level raw = %x, want current raw %x", got, wantCurrentRaw)
|
|
}
|
|
if source, head, rawWord2, rawWord5, raw0F, raw19, raw25, raw26, raw2C, raw43, ok := currentSceneObjectRawStateForLog(highLevelBody); !ok ||
|
|
source != "current_exe_structured_raw" || head == "00000000000000000000000000000000" ||
|
|
bytesAllZero(highLevelBody[5:0x4c]) {
|
|
t.Fatalf("current scene object high-level raw log source=%s head=%s word2=%d word5=%d raw0f=%d raw19=%d raw25=%d raw26=%d raw2c=%d raw43=%d ok=%v",
|
|
source, head, rawWord2, rawWord5, raw0F, raw19, raw25, raw26, raw2C, raw43, ok)
|
|
}
|
|
if got := highLevelBody[highLevelTailStart]; got != 15 {
|
|
t.Fatalf("current scene object high-level job = %d, want 15", got)
|
|
}
|
|
if got := highLevelBody[highLevelTailStart+1]; got != packCurrentSceneObjectGrow(1, 0) {
|
|
t.Fatalf("current scene object high-level grow pack = %d, want %d", got, packCurrentSceneObjectGrow(1, 0))
|
|
}
|
|
awakened := highLevel
|
|
awakened.Stats = map[string]int64{"grow_type": 0x12}
|
|
awakenedBody := buildCurrentSceneObjectListBody(currentSceneBootstrapObjectKey, awakened, true, "")
|
|
if got := awakenedBody[highLevelTailStart+1]; got != 0x12 {
|
|
t.Fatalf("current scene object awakening grow pack = %#x, want 0x12", got)
|
|
}
|
|
if got := highLevelBody[highLevelTailStart+0x02]; got != byte(highLevel.Level) {
|
|
t.Fatalf("current scene object level = %d, want character level %d", got, highLevel.Level)
|
|
}
|
|
if level, parsedTailStart, ok := currentSceneObjectLevelForLog(highLevelBody); !ok || parsedTailStart != highLevelTailStart || level != byte(highLevel.Level) {
|
|
t.Fatalf("current scene object level log parse level=%d tailStart=%d ok=%v, want level=%d tailStart=%d",
|
|
level, parsedTailStart, ok, highLevel.Level, highLevelTailStart)
|
|
}
|
|
if got := highLevelBody[highLevelTailStart:]; !bytes.Equal(got, wantHighLevelTail) {
|
|
t.Fatalf("current scene object high-level tail = %x, want current structured tail %x", got, wantHighLevelTail)
|
|
}
|
|
equipped := dnfrepo.CharacterRecord{
|
|
Name: "hero",
|
|
Level: 1,
|
|
Roster: dnfrepo.CharacterRoster{
|
|
Entry: dnfrepo.CharacterRosterEntry{
|
|
EquipSummary: []dnfrepo.CharacterRosterEquipSummary{
|
|
{Slot: 11, ItemIDOrIcon: 900001},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
equippedBody := buildCurrentSceneObjectListBody(currentSceneBootstrapObjectKey, equipped, true, "")
|
|
equippedTailStart := currentSceneObjectTailStartForTest("hero")
|
|
wantEquippedTail := buildCurrentSceneObjectEntryTail(equipped, true)
|
|
wantEquippedRaw, ok := buildCurrentSceneObjectRawState(equipped, true, "hero", "")
|
|
if !ok {
|
|
t.Fatalf("missing equipped scene object raw state")
|
|
}
|
|
if got := equippedBody[5:0x4c]; !bytes.Equal(got, wantEquippedRaw) {
|
|
t.Fatalf("current scene object equipped raw = %x, want current raw %x", got, wantEquippedRaw)
|
|
}
|
|
if got := equippedBody[equippedTailStart:]; !bytes.Equal(got, wantEquippedTail) {
|
|
t.Fatalf("current scene object equipped tail = %x, want current structured mode0 tail %x", got, wantEquippedTail)
|
|
}
|
|
if rows, _, ok := currentSceneObjectTailSummaryForLog(equippedBody); !ok || rows != 1 {
|
|
t.Fatalf("current scene object equipped mode0 summary rows=%d ok=%v, want 1 true", rows, ok)
|
|
}
|
|
if bytes.Contains(body, []byte{0xa1, 0x01}) {
|
|
t.Fatalf("current scene object template still contains DOVE fixture key 0x01a1")
|
|
}
|
|
|
|
objectIdx := -1
|
|
actionTableIdx := -1
|
|
insertOverseerIdx := -1
|
|
requestOverseerBeforeInsert := 0
|
|
for idx, packet := range longHengSceneBootstrapBeforeHudPackets {
|
|
if packet.msgID == uint16(dnfenum.CmdPacketRequestSeriaBuff) {
|
|
t.Fatalf("scene bootstrap must not actively send op432; current EXE crashes in sub_217DBB0 before object init")
|
|
}
|
|
if packet.msgID == uint16(dnfenum.CmdPacketPVPMissionHpPercent) {
|
|
if packet.kind != csharpCurrentActionTableStateKind || !bytes.Equal(packet.body, buildCurrentActionTableStateBody()) {
|
|
t.Fatalf("scene bootstrap op376 action-table init mismatch: kind=%q body=%x", packet.kind, packet.body)
|
|
}
|
|
actionTableIdx = idx
|
|
}
|
|
if packet.msgID == uint16(dnfenum.CmdPacketRequestOverseer) && insertOverseerIdx < 0 {
|
|
requestOverseerBeforeInsert++
|
|
}
|
|
if packet.kind == csharpCurrentSceneObjectListKind {
|
|
objectIdx = idx
|
|
}
|
|
if packet.msgID == uint16(dnfenum.CmdPacketInsertOverseer) {
|
|
insertOverseerIdx = idx
|
|
break
|
|
}
|
|
}
|
|
if insertOverseerIdx <= 0 {
|
|
t.Fatalf("op359 insert-overseer packet index = %d", insertOverseerIdx)
|
|
}
|
|
if objectIdx < 0 || objectIdx >= insertOverseerIdx {
|
|
t.Fatalf("scene bootstrap current object index=%d op359=%d, want object before op359", objectIdx, insertOverseerIdx)
|
|
}
|
|
if actionTableIdx < 0 || actionTableIdx+1 != objectIdx {
|
|
t.Fatalf("scene bootstrap action-table init index=%d object=%d, want op376 immediately before object", actionTableIdx, objectIdx)
|
|
}
|
|
if requestOverseerBeforeInsert != 5 {
|
|
t.Fatalf("request-overseer packets before op359 = %d, want 5", requestOverseerBeforeInsert)
|
|
}
|
|
}
|
|
|
|
func TestCurrentSceneModelLayerCandidateWindowIsAfterObjectFinalizer(t *testing.T) {
|
|
objectIdx := -1
|
|
insertOverseerIdx := -1
|
|
sceneReadyIdx := -1
|
|
op9Idx := -1
|
|
for idx, packet := range longHengSceneBootstrapBeforeHudPackets {
|
|
switch {
|
|
case packet.kind == csharpCurrentSceneObjectListKind:
|
|
objectIdx = idx
|
|
case packet.msgID == uint16(dnfenum.CmdPacketInsertOverseer):
|
|
insertOverseerIdx = idx
|
|
case packet.msgID == uint16(dnfenum.CmdPacketReportClientSpec):
|
|
sceneReadyIdx = idx
|
|
case packet.kind == csharpCurrentSceneOp9ActorDisplayKind:
|
|
op9Idx = idx
|
|
}
|
|
}
|
|
if insertOverseerIdx < 0 || sceneReadyIdx < 0 || op9Idx < 0 {
|
|
t.Fatalf("scene indexes object=%d op359=%d op124=%d op9=%d", objectIdx, insertOverseerIdx, sceneReadyIdx, op9Idx)
|
|
}
|
|
if objectIdx < 0 || !(objectIdx < insertOverseerIdx && insertOverseerIdx < sceneReadyIdx && sceneReadyIdx < op9Idx) {
|
|
t.Fatalf("scene order object=%d op359=%d op124=%d op9=%d", objectIdx, insertOverseerIdx, sceneReadyIdx, op9Idx)
|
|
}
|
|
}
|
|
|
|
func TestInsertOverseerFinalizerDefersUntilCurrentObjectStream(t *testing.T) {
|
|
service := &Service{}
|
|
packet := csharpSelectInitPacket{
|
|
msgID: uint16(dnfenum.CmdPacketInsertOverseer),
|
|
body: buildCurrentInsertOverseerBody(),
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, connID: "game-test", channel: channelcatalog.Channel{ID: 19}, selectedCharacterID: 19}
|
|
|
|
if err := service.sendCSharpSelectInitPacket(session, packet, packet.body); err != nil {
|
|
t.Fatalf("defer op359 before current object stream: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("op359 before current object stream wrote %d bytes", conn.write.Len())
|
|
}
|
|
|
|
session.currentSceneObjectListSent = true
|
|
if err := service.sendCSharpSelectInitPacket(session, packet, packet.body); err != nil {
|
|
t.Fatalf("send op359 after current object stream: %v", err)
|
|
}
|
|
if conn.write.Len() == 0 {
|
|
t.Fatalf("op359 after current object stream wrote no bytes")
|
|
}
|
|
}
|
|
|
|
func TestCurrentSceneStageTransportUsesNeutralCurrentStruct(t *testing.T) {
|
|
for _, packet := range longHengSceneBootstrapBeforeHudPackets {
|
|
if packet.msgID != longHengCurrentSceneStageMsgID || packet.bodyCodec != "current_op1021_scene_state_transport_zlib" {
|
|
continue
|
|
}
|
|
plain, err := zlibDecompress(packet.body)
|
|
if err != nil {
|
|
t.Fatalf("decompress current scene-stage body: %v", err)
|
|
}
|
|
if !bytes.Equal(plain, []byte{0}) {
|
|
t.Fatalf("current scene-stage plain body = %x, want neutral selector 00", plain)
|
|
}
|
|
return
|
|
}
|
|
t.Fatalf("missing current scene-stage transport")
|
|
}
|
|
|
|
func TestCurrentSceneStageTransportDoesNotCarryOldObjectGraph(t *testing.T) {
|
|
stagePackets := 0
|
|
for _, packet := range longHengSceneBootstrapBeforeHudPackets {
|
|
if packet.msgID != longHengCurrentSceneStageMsgID || packet.bodyCodec != "current_op1021_scene_state_transport_zlib" {
|
|
continue
|
|
}
|
|
stagePackets++
|
|
if len(packet.body) < 2 {
|
|
t.Fatalf("scene-stage packet must stay zlib transport, got len=%d", len(packet.body))
|
|
}
|
|
if packet.body[0] != 0x78 || packet.body[1] != 0x9c {
|
|
t.Fatalf("scene-stage packet must stay zlib transport, got prefix=%x", packet.body[:2])
|
|
}
|
|
plain, err := zlibDecompress(packet.body)
|
|
if err != nil {
|
|
t.Fatalf("decompress scene-stage transport: %v", err)
|
|
}
|
|
if !bytes.Equal(plain, []byte{0}) {
|
|
t.Fatalf("scene-stage expanded body = %x, want neutral selector 00", plain)
|
|
}
|
|
}
|
|
if stagePackets != 2 {
|
|
t.Fatalf("scene-stage transport packet count = %d, want 2", stagePackets)
|
|
}
|
|
}
|
|
|
|
func currentSceneObjectTailStartForTest(name string) int {
|
|
return 5 + 0x47 + 2 + len(rosterDstrName(name))
|
|
}
|
|
|
|
func TestBuildCurrentSceneOp9ActorDisplayBodyUsesSafeMinimalRecord(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{
|
|
CharacterID: "60041",
|
|
Name: "hero",
|
|
Job: "11",
|
|
Level: 86,
|
|
Stats: map[string]int64{"grow_type": 1},
|
|
}
|
|
body := buildCurrentSceneOp9ActorDisplayBody(currentSceneBootstrapObjectKey, character, true, "")
|
|
name := rosterNameBytes("hero")
|
|
wantLen := 34 + len(name)
|
|
if len(body) != wantLen {
|
|
t.Fatalf("current op9 body len = %d, want %d body=%x", len(body), wantLen, body)
|
|
}
|
|
if binary.LittleEndian.Uint16(body[0:2]) != 1 ||
|
|
binary.LittleEndian.Uint16(body[2:4]) != currentSceneOp9StableSceneValue ||
|
|
binary.LittleEndian.Uint16(body[4:6]) != currentSceneBootstrapObjectKey ||
|
|
body[9] != currentSceneOp9ActorDisplayKind {
|
|
t.Fatalf("current op9 header/record mismatch: %x", body[:10])
|
|
}
|
|
if body[10] != currentSceneObjectRoute || body[11] != 0 {
|
|
t.Fatalf("current op9 route/name mode = %x", body[10:12])
|
|
}
|
|
if gotLen := binary.LittleEndian.Uint32(body[12:16]); gotLen != uint32(len(name)) {
|
|
t.Fatalf("current op9 name len = %d, want %d", gotLen, len(name))
|
|
}
|
|
if !bytes.Equal(body[16:16+len(name)], name) {
|
|
t.Fatalf("current op9 name bytes = %x, want %x", body[16:16+len(name)], name)
|
|
}
|
|
tail := body[16+len(name):]
|
|
if tail[0] != 11 || tail[1] != 86 || tail[6] != 1 {
|
|
t.Fatalf("current op9 job/level/grow tail = %x", tail[:7])
|
|
}
|
|
if binary.LittleEndian.Uint32(tail[2:6]) != 0 ||
|
|
binary.LittleEndian.Uint16(tail[7:9]) != 0 ||
|
|
binary.LittleEndian.Uint16(tail[11:13]) != 0 {
|
|
t.Fatalf("current op9 unknown fields must stay zero: %x", tail)
|
|
}
|
|
if tail[13] != 0 {
|
|
t.Fatalf("current op9 slot_count = %d, want 0 to avoid raw_len/read_raw branch", tail[13])
|
|
}
|
|
if !bytes.Equal(tail[14:17], []byte{0, 0, 0}) || tail[17] != 0 {
|
|
t.Fatalf("current op9 post-slot/follow fields = %x, want zeros", tail[14:])
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentSceneOp9NoopBodyUsesZeroRecordShape(t *testing.T) {
|
|
body := buildCurrentSceneOp9NoopBody()
|
|
if len(body) != 4 {
|
|
t.Fatalf("current op9 noop body len = %d, want 4 body=%x", len(body), body)
|
|
}
|
|
if binary.LittleEndian.Uint16(body[0:2]) != 0 ||
|
|
binary.LittleEndian.Uint16(body[2:4]) != currentSceneOp9StableSceneValue {
|
|
t.Fatalf("current op9 noop body = %x, want record_count=0 scene_value=%d", body, currentSceneOp9StableSceneValue)
|
|
}
|
|
}
|
|
|
|
func TestSelectedCharacterForEnterAttachesEquipmentSummary(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
if err := repos.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
|
CharacterID: "19",
|
|
AccountID: "acc",
|
|
Slot: 4,
|
|
Name: "hero",
|
|
Job: "11",
|
|
Level: 1,
|
|
}); err != nil {
|
|
t.Fatalf("save character: %v", err)
|
|
}
|
|
if err := repos.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
|
|
CharacterID: "19",
|
|
Entries: map[string]dnfrepo.EquipmentEntry{
|
|
"11": {SlotIndex: 11, ItemID: 900001},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save equipment: %v", err)
|
|
}
|
|
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true }}
|
|
session := &gameSession{selectedCharacterID: 19}
|
|
|
|
_, _, character, ok := service.selectedCharacterForEnter(context.Background(), session)
|
|
if !ok {
|
|
t.Fatalf("selected character not found")
|
|
}
|
|
rows := characterEquipSummary(character)
|
|
if len(rows) != 1 || rows[0].Slot != 11 || rows[0].ItemIDOrIcon != 900001 {
|
|
t.Fatalf("selected character equip summary = %+v", rows)
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneSafeTailAfterHudEmitsOnlyVerifiedCurrentPassiveState(t *testing.T) {
|
|
want := []uint16{
|
|
currentSceneLocalStateMsgID,
|
|
uint16(dnfenum.CmdPacketHardcoreCharacList),
|
|
}
|
|
packets := longHengSceneSafeTailAfterHudPackets
|
|
if len(packets) != len(want) {
|
|
t.Fatalf("safe tail after HUD count = %d, want %d", len(packets), len(want))
|
|
}
|
|
for idx, packet := range packets {
|
|
if packet.class != 0 || packet.msgID != want[idx] {
|
|
t.Fatalf("safe tail after HUD[%d] = class %d msg %d, want class 0 msg %d", idx, packet.class, packet.msgID, want[idx])
|
|
}
|
|
assertNoUnsafeProactiveReplayPacket(t, "safe tail after HUD", idx, packet.class, packet.msgID)
|
|
}
|
|
if got := packets[0].body; !bytes.Equal(got, []byte{0, 0}) {
|
|
t.Fatalf("safe tail current op491 local state body = %x, want mode=0 state=0", got)
|
|
}
|
|
if packets[0].bodyEncoded || packets[0].marker != 0 || packets[0].bodyCodec != "" {
|
|
t.Fatalf("safe tail current op491 must be a plain current body, got encoded=%v marker=%d codec=%q",
|
|
packets[0].bodyEncoded, packets[0].marker, packets[0].bodyCodec)
|
|
}
|
|
if got := packets[1].body; !bytes.Equal(got, []byte{0}) {
|
|
t.Fatalf("safe tail op647 body = %x, want current empty hardcore list count", got)
|
|
}
|
|
for idx, packet := range packets {
|
|
switch packet.msgID {
|
|
case uint16(dnfenum.CmdPacketMercenaryInfo),
|
|
uint16(dnfenum.CmdPacketMercenaryCompetition),
|
|
uint16(dnfenum.UpperMsgLoadExtendCharacs),
|
|
uint16(dnfenum.UpperMsgCharacSlotExtendEffect):
|
|
t.Fatalf("safe tail after HUD[%d] restored popup/passive packet %d", idx, packet.msgID)
|
|
}
|
|
}
|
|
for _, excluded := range []uint16{
|
|
uint16(dnfenum.CmdPacketSeriaRidableInHiddenTruthDungeon),
|
|
uint16(dnfenum.CmdPacketLevelupSupport3rdEventGetItem),
|
|
uint16(dnfenum.CmdPacketEventDnftrendGetReward),
|
|
uint16(dnfenum.CmdPacketToBeZombie),
|
|
uint16(dnfenum.CmdPacketModuleExist),
|
|
} {
|
|
for _, packet := range packets {
|
|
if packet.msgID == excluded {
|
|
t.Fatalf("safe tail reintroduced deferred opcode %d", excluded)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLongHengSceneTailLedgerCoversFullLongHengTail(t *testing.T) {
|
|
ledger := longHengSceneTailAfterHudLedger()
|
|
if len(ledger) != 56 {
|
|
t.Fatalf("tail ledger count = %d, want DOVE idx92-147 count 56", len(ledger))
|
|
}
|
|
implemented := 0
|
|
statusCounts := make(map[longHengScenePacketStatus]int)
|
|
seen := make(map[int]longHengScenePacketLedgerEntry, len(ledger))
|
|
for offset, entry := range ledger {
|
|
wantIdx := 92 + offset
|
|
if entry.idx != wantIdx {
|
|
t.Fatalf("tail ledger[%d] idx = %d, want %d", offset, entry.idx, wantIdx)
|
|
}
|
|
if entry.phase != "07_scene_bootstrap_tail_social_guild" || entry.status == "" || entry.reason == "" {
|
|
t.Fatalf("tail ledger[%d] incomplete entry: %+v", offset, entry)
|
|
}
|
|
if _, ok := seen[entry.idx]; ok {
|
|
t.Fatalf("tail ledger duplicated idx %d", entry.idx)
|
|
}
|
|
seen[entry.idx] = entry
|
|
statusCounts[entry.status]++
|
|
if entry.implemented {
|
|
implemented++
|
|
if entry.status != longHengPacketImplementedCurrentBody {
|
|
t.Fatalf("tail ledger[%d] implemented with status %q", offset, entry.status)
|
|
}
|
|
}
|
|
}
|
|
if implemented != len(longHengSceneSafeTailAfterHudPackets) {
|
|
t.Fatalf("tail ledger implemented count = %d, want current safe tail count %d", implemented, len(longHengSceneSafeTailAfterHudPackets))
|
|
}
|
|
for _, idx := range []int{131, 133, 145, 147} {
|
|
if _, ok := seen[idx]; !ok {
|
|
t.Fatalf("tail ledger missing DOVE idx %d that is not represented in current Go tail spec", idx)
|
|
}
|
|
}
|
|
for _, idx := range []int{103, 104, 105, 106} {
|
|
entry, ok := seen[idx]
|
|
if !ok {
|
|
t.Fatalf("tail ledger missing op1023 idx %d", idx)
|
|
}
|
|
if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op1023_collectbox_result_no_body_reader_old_body8_incompatible" {
|
|
t.Fatalf("tail ledger idx %d op1023 verdict = %s/%q", idx, entry.status, entry.reason)
|
|
}
|
|
}
|
|
for idx := 107; idx <= 120; idx++ {
|
|
entry, ok := seen[idx]
|
|
if !ok {
|
|
t.Fatalf("tail ledger missing op1199 idx %d", idx)
|
|
}
|
|
if entry.status != longHengPacketNotUsedCurrentClient ||
|
|
entry.reason != "current_op1199_has_no_registered_handler_old_body16_incompatible" {
|
|
t.Fatalf("tail ledger idx %d op1199 verdict = %s/%q", idx, entry.status, entry.reason)
|
|
}
|
|
}
|
|
if entry, ok := seen[121]; !ok {
|
|
t.Fatalf("tail ledger missing op1089 idx 121")
|
|
} else if entry.status != longHengPacketNotUsedCurrentClient ||
|
|
entry.reason != "current_op1089_registered_to_DoNothing_old_body24_incompatible" {
|
|
t.Fatalf("tail ledger idx 121 op1089 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[122]; !ok {
|
|
t.Fatalf("tail ledger missing op1145 idx 122")
|
|
} else if entry.status != longHengPacketNotUsedCurrentClient ||
|
|
entry.reason != "current_op1145_has_no_registered_handler_old_body8_incompatible" {
|
|
t.Fatalf("tail ledger idx 122 op1145 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[123]; !ok {
|
|
t.Fatalf("tail ledger missing op825 idx 123")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op825_territory_alliance_request_driven_old_body32_incompatible" {
|
|
t.Fatalf("tail ledger idx 123 op825 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[124]; !ok {
|
|
t.Fatalf("tail ledger missing op647 idx 124")
|
|
} else if entry.status != longHengPacketImplementedCurrentBody ||
|
|
entry.reason != "current_op647_empty_hardcore_list_body_sent" {
|
|
t.Fatalf("tail ledger idx 124 op647 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[125]; !ok {
|
|
t.Fatalf("tail ledger missing op562 idx 125")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op562_fatigue_acceleration_state_request_driven_old_body32_incompatible" {
|
|
t.Fatalf("tail ledger idx 125 op562 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[126]; !ok {
|
|
t.Fatalf("tail ledger missing op761 idx 126")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op761_buy_guild_contents_result_no_body_reader_request_driven_old_body8_incompatible" {
|
|
t.Fatalf("tail ledger idx 126 op761 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[127]; !ok {
|
|
t.Fatalf("tail ledger missing op808 idx 127")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op808_warroom_reward_result_request_driven_old_body8_incompatible" {
|
|
t.Fatalf("tail ledger idx 127 op808 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[128]; !ok {
|
|
t.Fatalf("tail ledger missing op633 idx 128")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op633_avatar_spec_event_request_driven_old_body16_incompatible" {
|
|
t.Fatalf("tail ledger idx 128 op633 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[129]; !ok {
|
|
t.Fatalf("tail ledger missing op428 idx 129")
|
|
} else if entry.status != longHengPacketNotUsedCurrentClient ||
|
|
entry.reason != "current_op428_has_no_registered_handler_old_body16_incompatible" {
|
|
t.Fatalf("tail ledger idx 129 op428 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[130]; !ok {
|
|
t.Fatalf("tail ledger missing op707 idx 130")
|
|
} else if entry.status != longHengPacketNotUsedCurrentClient ||
|
|
entry.reason != "current_op707_has_no_registered_handler_old_body8_incompatible" {
|
|
t.Fatalf("tail ledger idx 130 op707 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[131]; !ok {
|
|
t.Fatalf("tail ledger missing op760 idx 131")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op760_guild_alliance_state_request_driven_old_body16_incompatible" {
|
|
t.Fatalf("tail ledger idx 131 op760 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[132]; !ok {
|
|
t.Fatalf("tail ledger missing op1264 idx 132")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op1264_lucky_balloon_ui_result_request_driven_old_body16_incompatible" {
|
|
t.Fatalf("tail ledger idx 132 op1264 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[133]; !ok {
|
|
t.Fatalf("tail ledger missing op1301 idx 133")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op1301_guild_hongbao_point_list_request_driven_old_body8_incompatible" {
|
|
t.Fatalf("tail ledger idx 133 op1301 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[134]; !ok {
|
|
t.Fatalf("tail ledger missing op1316 idx 134")
|
|
} else if entry.status != longHengPacketNotUsedCurrentClient ||
|
|
entry.reason != "current_op1316_has_no_registered_handler_old_body32_incompatible" {
|
|
t.Fatalf("tail ledger idx 134 op1316 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[135]; !ok {
|
|
t.Fatalf("tail ledger missing op1237 idx 135")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op1237_card_game_result_request_driven_old_body24_incompatible" {
|
|
t.Fatalf("tail ledger idx 135 op1237 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[136]; !ok {
|
|
t.Fatalf("tail ledger missing op413 idx 136")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op413_title_book_get_request_driven_old_body8_incompatible" {
|
|
t.Fatalf("tail ledger idx 136 op413 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
for idx := 137; idx <= 143; idx++ {
|
|
entry, ok := seen[idx]
|
|
if !ok {
|
|
t.Fatalf("tail ledger missing op12 idx %d", idx)
|
|
}
|
|
if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op12_reads_u8_u16_u8_wstr_party_ui_state_old_dove_body_incompatible" {
|
|
t.Fatalf("tail ledger idx %d op12 verdict = %s/%q", idx, entry.status, entry.reason)
|
|
}
|
|
}
|
|
if entry, ok := seen[144]; !ok {
|
|
t.Fatalf("tail ledger missing op53 idx 144")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op53_pvp_ready_state_request_driven_old_body16_incompatible" {
|
|
t.Fatalf("tail ledger idx 144 op53 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[145]; !ok {
|
|
t.Fatalf("tail ledger missing class1 op442 idx 145")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_class1_op442_mercenary_competition_no_body_reader_request_driven_old_body48_incompatible" {
|
|
t.Fatalf("tail ledger idx 145 class1 op442 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[146]; !ok {
|
|
t.Fatalf("tail ledger missing op1292 idx 146")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_op1292_joust_betting_request_driven_old_body16_incompatible" {
|
|
t.Fatalf("tail ledger idx 146 op1292 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if entry, ok := seen[147]; !ok {
|
|
t.Fatalf("tail ledger missing class1 op140 idx 147")
|
|
} else if entry.status != longHengPacketRequestDriven ||
|
|
entry.reason != "current_class1_op140_large_guild_member_list_request_driven_old_body15962_incompatible" {
|
|
t.Fatalf("tail ledger idx 147 class1 op140 verdict = %s/%q", entry.status, entry.reason)
|
|
}
|
|
if statusCounts[longHengPacketPendingCurrentStruct] != 0 || statusCounts[longHengPacketRequestDriven] == 0 {
|
|
t.Fatalf("tail ledger should have no pending markers and keep request-driven debt visible, counts=%v", statusCounts)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperSelectCharacterSendsCSharpInitStream(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
repos.Character.(*fakeCharacterStore).records = map[string]dnfrepo.CharacterRecord{
|
|
"60041": {CharacterID: "60041", AccountID: defaultAccountPrefix + "1", Slot: 12, Name: "hero", Job: "0", Level: 1, Stats: map[string]int64{"town_id": 1, "area_id": 0, currentDungeonTutorialCompletedKey: 0}},
|
|
}
|
|
if err := repos.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
|
|
CharacterID: "60041",
|
|
Entries: map[string]dnfrepo.EquipmentEntry{
|
|
"11": {
|
|
SlotIndex: 11,
|
|
ItemID: 101010912,
|
|
Extra: map[string]string{
|
|
"model_layer_count": "1",
|
|
"model_layer_0_key": "2150",
|
|
"model_layer_0_name": "at_katanaa",
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("save equipment: %v", err)
|
|
}
|
|
service := &Service{
|
|
options: options{accountPrefix: defaultAccountPrefix},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 16, Type: 1, Name: "ch.16", Port: 10016},
|
|
residentChannel: channelcatalog.Channel{ID: 16, Type: 1, Name: "ch.16", Port: 10016},
|
|
rosterRequested: true,
|
|
}
|
|
request := make([]byte, 2)
|
|
binary.LittleEndian.PutUint16(request, 60041)
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgSelectCharacter), request, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build upper select request: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle upper select: %v", err)
|
|
}
|
|
template := expandedCSharpUpperSelectInitTemplate()
|
|
expected := make([]csharpSelectInitPacket, 0, len(template))
|
|
for _, packet := range template {
|
|
if packet.kind == "userinfo" || packet.kind == "select_deferred" {
|
|
continue
|
|
}
|
|
if !selectInitPacketAllowedBeforeScene(packet) {
|
|
continue
|
|
}
|
|
expected = append(expected, packet)
|
|
}
|
|
rest := conn.write.Bytes()
|
|
longHengBootstrapPackets := 0
|
|
longHengSceneStagePackets := 0
|
|
longHengLargeSceneStagePacket := 0
|
|
longHengSceneManagerInitPackets := 0
|
|
longHengSceneReadyTriggers := 0
|
|
longHengRequestOverseerPackets := 0
|
|
longHengInsertOverseerPackets := 0
|
|
currentSceneObjectListPackets := 0
|
|
currentActionTableStatePackets := 0
|
|
currentSceneMode1AfterObjectPackets := 0
|
|
currentSceneOp9ActorDisplayPackets := 0
|
|
selectSceneUserInfoPackets := 0
|
|
selectSceneMainHudPackets := 0
|
|
longHengTailAfterHudPackets := 0
|
|
var firstMainHudBody []byte
|
|
sceneObjectKey := currentSceneActorObjectKey(60041)
|
|
firstWant := expected[0]
|
|
firstPacket, rest := splitGameServerUpperPacket(t, rest)
|
|
if firstPacket.Header.Classification != firstWant.class || firstPacket.Header.MsgID != firstWant.msgID {
|
|
t.Fatalf("select init ack packet = class %d msg %d", firstPacket.Header.Classification, firstPacket.Header.MsgID)
|
|
}
|
|
assertSelectCharacterAckBody(t, firstPacket.Body, 60041)
|
|
if got, ok := currentSelectAckTownIDFromBody(firstPacket.Body); !ok || got != newCharacterInitialTownID {
|
|
t.Fatalf("select init ack town id = %d ok=%v, want %d true", got, ok, newCharacterInitialTownID)
|
|
}
|
|
// op1378 恒定下发角色当前等级:新建角色等级 1,因此这里是 1 而不是 0。
|
|
// 客户端「由 last_level 选出的小节」与「由当前等级选出的小节」因此永远相同,
|
|
// 不会自动播放章节过场(章节过场只由城镇区域开放任务触发)。
|
|
rest = requireCurrentStoryDigestLastLevelPacket(t, rest, 1)
|
|
enterPacket, rest := splitGameServerUpperPacket(t, rest)
|
|
if enterPacket.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
enterPacket.Header.MsgID != uint16(dnfenum.CmdPacketEnterSelectDungeon) {
|
|
t.Fatalf("select init tutorial-enter packet = class %d msg %d", enterPacket.Header.Classification, enterPacket.Header.MsgID)
|
|
}
|
|
if wantBody := upperSuccessBody(buildEnterSelectDungeonAckBody()); !bytes.Equal(enterPacket.Body, wantBody) {
|
|
t.Fatalf("select init tutorial-enter body = %x, want %x", enterPacket.Body, wantBody)
|
|
}
|
|
areaPacket, rest := splitGameServerUpperPacket(t, rest)
|
|
if areaPacket.Header.Classification != 0 ||
|
|
areaPacket.Header.MsgID != currentFatigueMsgID ||
|
|
!bytes.Equal(areaPacket.Body, buildCurrentFatigueBody(dnfrepo.CharacterRecord{}, false)) {
|
|
t.Fatalf("select init fatigue packet = class %d msg %d body=%x", areaPacket.Header.Classification, areaPacket.Header.MsgID, areaPacket.Body)
|
|
}
|
|
for page := 0; page < currentSceneOverseerPageCount; page++ {
|
|
previewPage, next := splitGameServerUpperPacket(t, rest)
|
|
if previewPage.Header.Classification != 0 || previewPage.Header.MsgID != uint16(dnfenum.CmdPacketRequestOverseer) {
|
|
t.Fatalf("select preview page[%d] = class %d msg %d body=%x", page, previewPage.Header.Classification, previewPage.Header.MsgID, previewPage.Body)
|
|
}
|
|
rest = next
|
|
}
|
|
previewAction, rest := splitGameServerUpperPacket(t, rest)
|
|
if previewAction.Header.Classification != 0 || previewAction.Header.MsgID != uint16(dnfenum.CmdPacketPVPMissionHpPercent) ||
|
|
!bytes.Equal(previewAction.Body, buildCurrentActionTableStateBody()) {
|
|
t.Fatalf("select preview action table = class %d msg %d body=%x", previewAction.Header.Classification, previewAction.Header.MsgID, previewAction.Body)
|
|
}
|
|
previewObject, rest := splitGameServerUpperPacket(t, rest)
|
|
previewObjectKey := currentSceneActorObjectKey(60041)
|
|
previewOwner := byte(currentSceneObjectContext)
|
|
if previewObject.Header.Classification != 0 || previewObject.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
|
|
len(previewObject.Body) < 0x4e || previewObject.Body[0] != 0 ||
|
|
previewObject.Body[3] != currentSceneObjectRoute ||
|
|
previewObject.Body[4] != previewOwner ||
|
|
binary.LittleEndian.Uint16(previewObject.Body[0x4c:0x4e]) != previewObjectKey {
|
|
t.Fatalf("select preview mode0 = class %d msg %d key=%#x body=%x", previewObject.Header.Classification, previewObject.Header.MsgID, previewObjectKey, previewObject.Body)
|
|
}
|
|
previewBinding, rest := splitGameServerUpperPacket(t, rest)
|
|
if previewBinding.Header.Classification != 0 || previewBinding.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
|
|
len(previewBinding.Body) != currentMode1BaseWireSize || previewBinding.Body[0] != 1 ||
|
|
previewBinding.Body[3] != currentSceneObjectRoute || previewBinding.Body[4] != previewOwner ||
|
|
binary.LittleEndian.Uint16(previewBinding.Body[0x15:0x17]) != previewObjectKey ||
|
|
previewBinding.Body[currentMode1CreateCountOffset] != 0 ||
|
|
previewBinding.Body[currentMode1CreateRowsOffset+6] != 0 {
|
|
t.Fatalf("select preview binding mode1 = class %d msg %d key=%#x body=%x", previewBinding.Header.Classification, previewBinding.Header.MsgID, previewObjectKey, previewBinding.Body)
|
|
}
|
|
previewFinalizer, rest := splitGameServerUpperPacket(t, rest)
|
|
if previewFinalizer.Header.Classification != 0 || previewFinalizer.Header.MsgID != uint16(dnfenum.CmdPacketInsertOverseer) {
|
|
t.Fatalf("select preview finalizer = class %d msg %d body=%x", previewFinalizer.Header.Classification, previewFinalizer.Header.MsgID, previewFinalizer.Body)
|
|
}
|
|
previewObjectState, rest := splitLongHengGameServerUpperPacket(t, rest)
|
|
if previewObjectState.Header.Classification != 0 || previewObjectState.Header.MsgID != currentClearQuestListMsgID {
|
|
t.Fatalf("select preview object state = class %d msg %d body=%x", previewObjectState.Header.Classification, previewObjectState.Header.MsgID, previewObjectState.Body)
|
|
}
|
|
previewObjectStatePlain, err := zlibDecompress(previewObjectState.Body)
|
|
if err != nil || len(previewObjectStatePlain) != 30004 ||
|
|
binary.LittleEndian.Uint32(previewObjectStatePlain[:4]) != 30000 {
|
|
t.Fatalf("select preview op356 body_len=%d plain_len=%d err=%v", len(previewObjectState.Body), len(previewObjectStatePlain), err)
|
|
}
|
|
if int(previewObjectKey) < 30000 && previewObjectStatePlain[4+int(previewObjectKey)] != 0 {
|
|
t.Fatalf("select preview op356 wrote actor key %#x into clear-quest list", previewObjectKey)
|
|
}
|
|
previewCommit, rest := splitGameServerUpperPacket(t, rest)
|
|
if previewCommit.Header.Classification != 0 || previewCommit.Header.MsgID != uint16(dnfenum.CmdPacketReportClientSpec) || len(previewCommit.Body) != 0 {
|
|
t.Fatalf("select preview op124 = class %d msg %d body=%x", previewCommit.Header.Classification, previewCommit.Header.MsgID, previewCommit.Body)
|
|
}
|
|
if !session.selectPreviewObjectStateSent || session.selectPreviewActorRemoved || session.selectedUserInfoRefreshSent {
|
|
t.Fatalf("select preview flags sent=%v removed=%v mode1=%v", session.selectPreviewObjectStateSent, session.selectPreviewActorRemoved, session.selectedUserInfoRefreshSent)
|
|
}
|
|
for idx, want := range expected[1:] {
|
|
logicalIdx := idx + 1
|
|
var packet dnfproto.ChannelPacket
|
|
packet, rest = splitCSharpSelectInitPacket(t, rest, want)
|
|
if packet.Header.Classification != want.class || packet.Header.MsgID != want.msgID {
|
|
t.Fatalf("select init[%d] packet = class %d msg %d", logicalIdx, packet.Header.Classification, packet.Header.MsgID)
|
|
}
|
|
if want.kind == "select_scene_userinfo" {
|
|
if currentSceneObjectListPackets != 0 {
|
|
t.Fatalf("select init[%d] sent selected userinfo occurrence %d after current scene object list", logicalIdx, want.occurrence)
|
|
}
|
|
wantBody := service.buildCSharpSelectedUserInfoBody(context.Background(), session, repos.LegacyUserInfo, want.occurrence, dnfrepo.CharacterRecord{
|
|
CharacterID: "60041",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 12,
|
|
Name: "hero",
|
|
Job: "0",
|
|
Level: 1,
|
|
}, true, 60041, "hero")
|
|
if packet.Header.Classification != 0 ||
|
|
packet.Header.MsgID != uint16(dnfenum.UpperMsgCharacterRoster) ||
|
|
!bytes.Equal(packet.Body, wantBody) {
|
|
t.Fatalf("select init[%d] selected userinfo occurrence %d mismatch: class=%d msg=%d len=%d body=%x want=%x",
|
|
logicalIdx, want.occurrence, packet.Header.Classification, packet.Header.MsgID, len(packet.Body), packet.Body, wantBody)
|
|
}
|
|
selectSceneUserInfoPackets++
|
|
}
|
|
if want.kind == csharpLongHengSceneBootstrapKind {
|
|
longHengBootstrapPackets++
|
|
wantBody := want.body
|
|
dynamicClearQuestList := want.msgID == currentClearQuestListMsgID && want.bodyCodec == currentClearQuestListTransportCodec
|
|
if dynamicClearQuestList {
|
|
wantBody = buildCurrentPassGateObjectTransportBody()
|
|
}
|
|
if !bytes.Equal(packet.Body, wantBody) {
|
|
t.Fatalf("select init[%d] DOVE scene body mismatch: msg=%d len=%d want_len=%d", logicalIdx, packet.Header.MsgID, len(packet.Body), len(wantBody))
|
|
}
|
|
if packet.Header.MsgID == longHengCurrentSceneStageMsgID {
|
|
longHengSceneStagePackets++
|
|
if !want.bodyEncoded ||
|
|
want.marker != 1 ||
|
|
want.bodyCodec != "current_op1021_scene_state_transport_zlib" ||
|
|
len(packet.Body) < 2 ||
|
|
packet.Body[0] != 0x78 ||
|
|
packet.Body[1] != 0x9c {
|
|
t.Fatalf("select init[%d] scene-stage transport = encoded=%v marker=%d codec=%q len=%d body=%x", logicalIdx, want.bodyEncoded, want.marker, want.bodyCodec, len(packet.Body), packet.Body)
|
|
}
|
|
plain, err := zlibDecompress(packet.Body)
|
|
if err != nil || !bytes.Equal(plain, []byte{0}) {
|
|
t.Fatalf("select init[%d] current scene-stage transport plain=%x err=%v", logicalIdx, plain, err)
|
|
}
|
|
}
|
|
}
|
|
if want.kind == csharpCurrentActionTableStateKind {
|
|
if packet.Header.Classification != 0 ||
|
|
packet.Header.MsgID != uint16(dnfenum.CmdPacketPVPMissionHpPercent) ||
|
|
!bytes.Equal(packet.Body, buildCurrentActionTableStateBody()) {
|
|
t.Fatalf("select init[%d] current action-table state packet mismatch: class=%d msg=%d body=%x",
|
|
logicalIdx, packet.Header.Classification, packet.Header.MsgID, packet.Body)
|
|
}
|
|
currentActionTableStatePackets++
|
|
}
|
|
if want.kind == csharpCurrentSceneObjectListKind {
|
|
if packet.Header.Classification != 0 ||
|
|
packet.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
|
|
len(packet.Body) < 0x50 ||
|
|
packet.Body[0] != 0 ||
|
|
binary.LittleEndian.Uint16(packet.Body[1:3]) != 1 ||
|
|
packet.Body[3] != currentSceneObjectRoute ||
|
|
packet.Body[4] != currentSceneObjectContext ||
|
|
binary.LittleEndian.Uint16(packet.Body[0x4c:0x4e]) != sceneObjectKey {
|
|
t.Fatalf("select init[%d] current scene object list body mismatch: class=%d msg=%d len=%d body=%x", logicalIdx, packet.Header.Classification, packet.Header.MsgID, len(packet.Body), packet.Body)
|
|
}
|
|
currentSceneObjectListPackets++
|
|
rest = assertSelectedSceneUserInfoRefresh(t, service, session, rest)
|
|
currentSceneMode1AfterObjectPackets++
|
|
}
|
|
if want.kind == csharpCurrentSceneOp9ActorDisplayKind {
|
|
wantBody := buildCurrentSceneOp9ActorDisplayBody(sceneObjectKey, dnfrepo.CharacterRecord{
|
|
CharacterID: "60041",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 12,
|
|
Name: "hero",
|
|
Job: "0",
|
|
Level: 1,
|
|
}, true, "hero")
|
|
if packet.Header.Classification != 0 ||
|
|
packet.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
|
|
!bytes.Equal(packet.Body, wantBody) {
|
|
t.Fatalf("select init[%d] current op9 actor/display body mismatch: class=%d msg=%d len=%d body=%x want=%x", logicalIdx, packet.Header.Classification, packet.Header.MsgID, len(packet.Body), packet.Body, wantBody)
|
|
}
|
|
if got := packet.Body[33+len(rosterNameBytes("hero"))]; got != 0 {
|
|
t.Fatalf("select init[%d] current op9 follow_mode = %d, want 0", logicalIdx, got)
|
|
}
|
|
currentSceneOp9ActorDisplayPackets++
|
|
}
|
|
if want.kind == "select_scene_main_hud" {
|
|
selectSceneMainHudPackets++
|
|
if firstMainHudBody == nil {
|
|
firstMainHudBody = append([]byte(nil), packet.Body...)
|
|
}
|
|
if packet.Header.MsgID != longHengCurrentSceneMainHudInfoMsgID || len(packet.Body) != 16 || !bytes.Equal(packet.Body, want.body) {
|
|
t.Fatalf("select init[%d] main hud marker = msg %d body %x", logicalIdx, packet.Header.MsgID, packet.Body)
|
|
}
|
|
}
|
|
if want.kind == csharpLongHengSceneTailAfterHudKind {
|
|
longHengTailAfterHudPackets++
|
|
if !bytes.Equal(packet.Body, want.body) {
|
|
t.Fatalf("select init[%d] DOVE tail body mismatch: msg=%d len=%d want_len=%d", logicalIdx, packet.Header.MsgID, len(packet.Body), len(want.body))
|
|
}
|
|
}
|
|
if want.kind != csharpLongHengSceneBootstrapKind {
|
|
assertNoUnsafeProactiveReplayPacket(t, "select init", logicalIdx, packet.Header.Classification, packet.Header.MsgID)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketLeaveParty) && want.kind == csharpLongHengSceneBootstrapKind && len(packet.Body) == 40 {
|
|
t.Fatalf("select init[%d] replayed an old op13 item-list transport", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketWalkoutPartyMember) && want.kind == csharpLongHengSceneBootstrapKind {
|
|
t.Fatalf("select init[%d] replayed an old op14 item-update transport", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketReport4Hack) && want.kind == csharpLongHengSceneBootstrapKind {
|
|
t.Fatalf("select init[%d] replayed an old op108 report transport", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketSetPartyInfo) {
|
|
t.Fatalf("select init[%d] sent party/chat panel packet", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketLevelupSupport3rdEventGetItem) && want.kind == csharpLongHengSceneTailAfterHudKind {
|
|
if !want.bodyEncoded || want.marker != 1 || want.bodyCodec != "current_op1004_levelup_support_transport_zlib" ||
|
|
len(packet.Body) != 92 || packet.Body[0] != 0x78 || packet.Body[1] != 0x9c {
|
|
t.Fatalf("select init[%d] op1004 transport = encoded=%v marker=%d codec=%q len=%d", logicalIdx, want.bodyEncoded, want.marker, want.bodyCodec, len(packet.Body))
|
|
}
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketEventDnftrendGetReward) && want.kind == csharpLongHengSceneTailAfterHudKind {
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
len(packet.Body) != 88 || packet.Body[0] != 1 || packet.Body[1] != 1 || packet.Body[86] != 0xcc || packet.Body[87] != 0xcc {
|
|
t.Fatalf("select init[%d] class1 op901 body len=%d class=%d", logicalIdx, len(packet.Body), packet.Header.Classification)
|
|
}
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketToBeZombie) {
|
|
if want.kind != csharpLongHengSceneTailAfterHudKind || len(packet.Body) != 4 || binary.LittleEndian.Uint32(packet.Body) != 0 {
|
|
t.Fatalf("select init[%d] op542 body len=%d kind=%q body=%x", logicalIdx, len(packet.Body), want.kind, packet.Body)
|
|
}
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketModuleExist) {
|
|
if want.kind != csharpLongHengSceneTailAfterHudKind || len(packet.Body) != 24 || !bytes.Equal(packet.Body[20:], []byte{0xcc, 0xcc, 0xcc, 0xcc}) {
|
|
t.Fatalf("select init[%d] op581 body len=%d kind=%q", logicalIdx, len(packet.Body), want.kind)
|
|
}
|
|
}
|
|
if packet.Header.MsgID == 0x0069 && want.kind != csharpLongHengSceneBootstrapKind {
|
|
t.Fatalf("select init[%d] sent deferred pre-scene state packet", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketDecreaseDurability) {
|
|
t.Fatalf("select init[%d] sent pre-scene durability packet", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketBuyItem) {
|
|
t.Fatalf("select init[%d] sent pre-scene buy-item packet", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketCallGuildCreateRight) {
|
|
t.Fatalf("select init[%d] sent pre-scene guild-create-right packet", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketRequestAvagachaCoupon) && want.kind == csharpLongHengSceneBootstrapKind {
|
|
if !bytes.Equal(packet.Body, []byte{0}) {
|
|
t.Fatalf("select init[%d] op104 body = %x, want current empty-list count=0", logicalIdx, packet.Body)
|
|
}
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketCancelIntegratedMatching) && want.kind == csharpLongHengSceneBootstrapKind {
|
|
if !bytes.Equal(packet.Body, buildCurrentInfiniteDifficultyCharacInfoBody()) {
|
|
t.Fatalf("select init[%d] msg521 body = %x, want current state=0", logicalIdx, packet.Body)
|
|
}
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketPurifyItem) && want.kind == csharpLongHengSceneBootstrapKind {
|
|
if !bytes.Equal(packet.Body, buildCurrentJoinPowerBody()) {
|
|
t.Fatalf("select init[%d] msg204 body = %x, want current join_state=0", logicalIdx, packet.Body)
|
|
}
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketAlldieMonster) {
|
|
t.Fatalf("select init[%d] sent disproved decimal op138 quest packet", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == currentAcceptableQuestListMsgID && want.kind == currentAcceptableQuestListKind {
|
|
if len(packet.Body) < 4 || int(binary.LittleEndian.Uint32(packet.Body[:4])) != len(packet.Body)-4 {
|
|
t.Fatalf("select init[%d] op0x15 protobuf length mismatch body=%x", logicalIdx, packet.Body)
|
|
}
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketHtIs) && want.kind == csharpLongHengSceneBootstrapKind {
|
|
if !bytes.Equal(packet.Body, buildCurrentDecreaseDurabilityBody()) {
|
|
t.Fatalf("select init[%d] msg281 body = %x, want current neutral durability structure", logicalIdx, packet.Body)
|
|
}
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketJoinPower) {
|
|
t.Fatalf("select init[%d] sent pre-scene join-power packet", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketRequestInfiniteDifficultyCharacInfo) {
|
|
t.Fatalf("select init[%d] sent pre-scene infinite-difficulty packet", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketCancelCargoPad) {
|
|
t.Fatalf("select init[%d] replayed old op391 cargo transport", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketApproveJoinGuild) {
|
|
t.Fatalf("select init[%d] replayed old op350 guild config transport", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketCancelJoinGuild) {
|
|
t.Fatalf("select init[%d] replayed old op349 guild state", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketHatchCreatureEgg) && want.kind == csharpLongHengSceneBootstrapKind {
|
|
t.Fatalf("select init[%d] sent scene bootstrap old391 as current op173; op173 triggers current NoPack config refresh before scene state is ready", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == currentClearQuestListMsgID {
|
|
if want.kind != csharpLongHengSceneBootstrapKind ||
|
|
!want.bodyEncoded ||
|
|
want.marker != 1 ||
|
|
want.bodyCodec != currentClearQuestListTransportCodec ||
|
|
len(packet.Body) < 2 ||
|
|
packet.Body[0] != 0x78 ||
|
|
packet.Body[1] != 0x9c {
|
|
t.Fatalf("select init[%d] op356 clear-quest list must use current zlib transport, got kind=%q encoded=%v marker=%d codec=%q len=%d", logicalIdx, want.kind, want.bodyEncoded, want.marker, want.bodyCodec, len(packet.Body))
|
|
}
|
|
plain, err := zlibDecompress(packet.Body)
|
|
if err != nil {
|
|
t.Fatalf("select init[%d] op356 clear-quest list zlib: %v", logicalIdx, err)
|
|
}
|
|
if len(plain) != 4+30000 {
|
|
t.Fatalf("select init[%d] op356 clear-quest list expanded len=%d", logicalIdx, len(plain))
|
|
}
|
|
rawLen := binary.LittleEndian.Uint32(plain[:4])
|
|
if rawLen != 30000 {
|
|
t.Fatalf("select init[%d] op356 clear-quest list rawLen=%d, want 30000", logicalIdx, rawLen)
|
|
}
|
|
if int(sceneObjectKey) < int(rawLen) && plain[4+int(sceneObjectKey)] != 0 {
|
|
t.Fatalf("select init[%d] op356 wrote actor key %#x into clear-quest list", logicalIdx, sceneObjectKey)
|
|
}
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketAdvanceAltarStageClearInfo) {
|
|
t.Fatalf("select init[%d] sent old op9 op947 placeholder after current op9 builder was added", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketDiePVPCharacter) {
|
|
t.Fatalf("select init[%d] sent active shared-effect packet op55", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketAuctionRegistItem) {
|
|
t.Fatalf("select init[%d] sent active DOVE shared-effect sample op183", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketReportClientSpec) {
|
|
if want.kind != csharpLongHengSceneBootstrapKind || len(packet.Body) != 0 {
|
|
t.Fatalf("select init[%d] report-client-spec outside DOVE scene-ready: kind=%q len=%d", logicalIdx, want.kind, len(packet.Body))
|
|
}
|
|
longHengSceneReadyTriggers++
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketRequestOverseer) {
|
|
if want.kind != csharpLongHengSceneBootstrapKind ||
|
|
len(packet.Body) != 11 ||
|
|
packet.Body[0] != 0 ||
|
|
binary.LittleEndian.Uint16(packet.Body[1:3]) != 0 ||
|
|
binary.LittleEndian.Uint32(packet.Body[7:11]) != 0 {
|
|
t.Fatalf("select init[%d] op358 current empty body mismatch: kind=%q len=%d body=%x", logicalIdx, want.kind, len(packet.Body), packet.Body)
|
|
}
|
|
listIndex := binary.LittleEndian.Uint32(packet.Body[3:7])
|
|
if listIndex != uint32(longHengRequestOverseerPackets) {
|
|
t.Fatalf("select init[%d] op358 list index = %d, want %d", logicalIdx, listIndex, longHengRequestOverseerPackets)
|
|
}
|
|
longHengRequestOverseerPackets++
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketInsertOverseer) {
|
|
expectedInsertOverseerBody := buildCurrentInsertOverseerBody()
|
|
if want.kind != csharpLongHengSceneBootstrapKind || !bytes.Equal(packet.Body, expectedInsertOverseerBody) {
|
|
t.Fatalf("select init[%d] op359 current body mismatch: kind=%q len=%d body=%x", logicalIdx, want.kind, len(packet.Body), packet.Body)
|
|
}
|
|
longHengInsertOverseerPackets++
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.UpperMsgCharacSlotExtendEffect) {
|
|
t.Fatalf("select init[%d] sent slot extend effect packet", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.UpperMsgLoadExtendCharacs) {
|
|
t.Fatalf("select init[%d] sent character slot unlock query packet", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketMercenaryInfo) || packet.Header.MsgID == uint16(dnfenum.CmdPacketMercenaryCompetition) {
|
|
t.Fatalf("select init[%d] sent mercenary modal packet %d", logicalIdx, packet.Header.MsgID)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketGuildAllMemberList) {
|
|
t.Fatalf("select init[%d] sent passive guild member list response without client request", logicalIdx)
|
|
}
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketGetGuildHongbaoPointList) {
|
|
t.Fatalf("select init[%d] sent guild hongbao panel packet", logicalIdx)
|
|
}
|
|
if logicalIdx == 1 {
|
|
if want.kind != csharpLongHengSceneBootstrapKind ||
|
|
packet.Header.MsgID != uint16(dnfenum.CmdPacketCreateCharacter) ||
|
|
len(packet.Body) != 8 ||
|
|
!bytes.Equal(packet.Body, longHengSceneBootstrapBeforeHudPackets[0].body) {
|
|
t.Fatalf("select init op5 body = msg %d %x", packet.Header.MsgID, packet.Body)
|
|
}
|
|
}
|
|
}
|
|
rest = assertNoActiveFinishLoadingPacket(t, rest, "finish loading gate")
|
|
for len(rest) != 0 {
|
|
var packet dnfproto.ChannelPacket
|
|
packet, rest = splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) {
|
|
t.Fatalf("unexpected current msg2 after scene bootstrap body_len=%d body=%x", len(packet.Body), packet.Body)
|
|
}
|
|
switch packet.Header.MsgID {
|
|
case uint16(dnfenum.CmdPacketLeaveParty):
|
|
case uint16(dnfenum.CmdPacketWalkoutPartyMember):
|
|
default:
|
|
t.Fatalf("unexpected trailing packet class=%d msg=%d body_len=%d", packet.Header.Classification, packet.Header.MsgID, len(packet.Body))
|
|
}
|
|
}
|
|
allowedLongHengBootstrapPackets := 0
|
|
for _, packet := range longHengSceneBootstrapBeforeHudPackets {
|
|
if packet.kind == csharpLongHengSceneBootstrapKind && selectInitPacketAllowedBeforeScene(packet) {
|
|
allowedLongHengBootstrapPackets++
|
|
}
|
|
}
|
|
if longHengBootstrapPackets != allowedLongHengBootstrapPackets {
|
|
t.Fatalf("initial DOVE scene bootstrap packet count = %d, want %d", longHengBootstrapPackets, allowedLongHengBootstrapPackets)
|
|
}
|
|
if longHengSceneStagePackets != 0 || longHengLargeSceneStagePacket != 0 || longHengSceneManagerInitPackets != 0 ||
|
|
longHengSceneReadyTriggers != 0 || longHengRequestOverseerPackets != 0 || currentActionTableStatePackets != 0 ||
|
|
longHengInsertOverseerPackets != 0 || currentSceneMode1AfterObjectPackets != 0 || currentSceneObjectListPackets != 0 ||
|
|
currentSceneOp9ActorDisplayPackets != 0 || selectSceneUserInfoPackets != 0 ||
|
|
selectSceneMainHudPackets != 0 || longHengTailAfterHudPackets != 0 || firstMainHudBody != nil {
|
|
t.Fatalf("select ACK leaked scene state before op29: stage=%d large=%d managers=%d ready=%d pages=%d action=%d insert=%d mode1=%d mode0=%d op9=%d userinfo=%d hud=%d tail=%d",
|
|
longHengSceneStagePackets, longHengLargeSceneStagePacket, longHengSceneManagerInitPackets, longHengSceneReadyTriggers,
|
|
longHengRequestOverseerPackets, currentActionTableStatePackets, longHengInsertOverseerPackets,
|
|
currentSceneMode1AfterObjectPackets, currentSceneObjectListPackets, currentSceneOp9ActorDisplayPackets,
|
|
selectSceneUserInfoPackets, selectSceneMainHudPackets, longHengTailAfterHudPackets)
|
|
}
|
|
if !session.sceneBootstrapTailDeferred || session.sceneBootstrapTailSent {
|
|
t.Fatalf("scene tail flags after head: deferred=%v sent=%v", session.sceneBootstrapTailDeferred, session.sceneBootstrapTailSent)
|
|
}
|
|
|
|
conn.write.Reset()
|
|
ceraFrame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgFollowUpStatus), nil, 1, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build cera probe: %v", err)
|
|
}
|
|
if err := service.handleGameUpper(session, ceraFrame); err != nil {
|
|
t.Fatalf("handle cera probe: %v", err)
|
|
}
|
|
rest = conn.write.Bytes()
|
|
if len(rest) != 0 {
|
|
ceraAck, next := splitGameServerUpperPacket(t, rest)
|
|
if ceraAck.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
ceraAck.Header.MsgID != uint16(dnfenum.UpperMsgFollowUpStatus) ||
|
|
!bytes.Equal(ceraAck.Body, []byte{1}) {
|
|
t.Fatalf("cera ack = class %d msg %d body %x", ceraAck.Header.Classification, ceraAck.Header.MsgID, ceraAck.Body)
|
|
}
|
|
if len(next) != 0 {
|
|
t.Fatalf("cera probe sent unexpected scene tail bytes: %d", len(next))
|
|
}
|
|
}
|
|
if !session.sceneBootstrapTailDeferred || session.sceneBootstrapTailSent {
|
|
t.Fatalf("scene tail flags after cera probe: deferred=%v sent=%v", session.sceneBootstrapTailDeferred, session.sceneBootstrapTailSent)
|
|
}
|
|
|
|
conn.write.Reset()
|
|
blacklistFrame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgFollowUpReady), nil, 1, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build blacklist probe: %v", err)
|
|
}
|
|
if err := service.handleGameUpper(session, blacklistFrame); err != nil {
|
|
t.Fatalf("handle blacklist probe: %v", err)
|
|
}
|
|
rest = conn.write.Bytes()
|
|
if len(rest) != 0 {
|
|
blacklistAck, next := splitGameServerUpperPacket(t, rest)
|
|
if blacklistAck.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
blacklistAck.Header.MsgID != uint16(dnfenum.UpperMsgFollowUpReady) ||
|
|
!bytes.Equal(blacklistAck.Body, currentRequestBlacklistResponseBody) {
|
|
t.Fatalf("blacklist ack = class %d msg %d body %x", blacklistAck.Header.Classification, blacklistAck.Header.MsgID, blacklistAck.Body)
|
|
}
|
|
if len(next) != 0 {
|
|
t.Fatalf("blacklist probe sent runtime seed before op29 = %d", len(next))
|
|
}
|
|
if !session.sceneBootstrapTailDeferred || session.sceneBootstrapTailSent || session.runtimeAfterBlacklistSent {
|
|
t.Fatalf("scene tail flags after blacklist probe: deferred=%v sent=%v", session.sceneBootstrapTailDeferred, session.sceneBootstrapTailSent)
|
|
}
|
|
}
|
|
|
|
conn.write.Reset()
|
|
gateFrame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCheckUserConnection), nil, 1, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build check-user-connection gate: %v", err)
|
|
}
|
|
if err := service.handleGameUpper(session, gateFrame); err != nil {
|
|
t.Fatalf("handle check-user-connection gate: %v", err)
|
|
}
|
|
rest = conn.write.Bytes()
|
|
if len(rest) != 0 {
|
|
gateAck, next := splitGameServerUpperPacket(t, rest)
|
|
if gateAck.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
gateAck.Header.MsgID != uint16(dnfenum.UpperMsgCheckUserConnection) ||
|
|
!bytes.Equal(gateAck.Body, upperSuccessBody(buildCurrentCheckUserConnectionSuccessPayload())) {
|
|
t.Fatalf("scene gate ack = class %d msg %d body %x", gateAck.Header.Classification, gateAck.Header.MsgID, gateAck.Body)
|
|
}
|
|
if len(next) != 0 {
|
|
t.Fatalf("scene gate sent unexpected deferred bytes: %d", len(next))
|
|
}
|
|
}
|
|
if !session.sceneBootstrapTailDeferred || session.sceneBootstrapTailSent {
|
|
t.Fatalf("scene tail flags after gate: deferred=%v sent=%v", session.sceneBootstrapTailDeferred, session.sceneBootstrapTailSent)
|
|
}
|
|
}
|
|
|
|
func TestUpperGetUserinfoBoot(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
request := make([]byte, 8)
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgGetUserInfo), request, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build get-userinfo upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle get-userinfo upper: %v", err)
|
|
}
|
|
roster, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if roster.Header.Classification != 0 || roster.Header.MsgID != uint16(dnfenum.UpperMsgCharacterRoster) ||
|
|
!bytes.Equal(roster.Body, buildCSharpEmptyRosterBody()) {
|
|
t.Fatalf("get-userinfo roster header=%+v body=%x", roster.Header, roster.Body)
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("get-userinfo must only send roster; unexpected trailing bytes: %d", len(rest))
|
|
}
|
|
if session.pendingCharacterRosterBootstrap {
|
|
t.Fatal("get-userinfo must not mark the roster pending")
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperMercenaryInfoRejectsMalformedBeforeSelect(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgMercenaryInfo), make([]byte, 8), 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build mercenary info upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle mercenary info upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.MsgID != uint16(dnfenum.UpperMsgMercenaryInfo) ||
|
|
!bytes.Equal(packet.Body, []byte{0, currentAdventureGenericFailureCode}) ||
|
|
len(rest) != 0 {
|
|
t.Fatalf("pre-select mercenary rejection header=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperMercenaryInfoRejectsMalformedAfterSelect(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038},
|
|
selectedCharacterID: 12,
|
|
}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgMercenaryInfo), make([]byte, 8), 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build mercenary info upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle mercenary info upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.MsgID != uint16(dnfenum.UpperMsgMercenaryInfo) ||
|
|
!bytes.Equal(packet.Body, []byte{0, currentAdventureGenericFailureCode}) ||
|
|
len(rest) != 0 {
|
|
t.Fatalf("post-select mercenary rejection header=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperMercenaryCompetitionRejectsMalformedState(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgMercenaryCompetition), nil, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build mercenary competition upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle mercenary competition upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.MsgID != uint16(dnfenum.UpperMsgMercenaryCompetition) ||
|
|
!bytes.Equal(packet.Body, []byte{0, currentAdventureGenericFailureCode}) ||
|
|
len(rest) != 0 {
|
|
t.Fatalf("mercenary competition rejection header=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperDprotoCallbackRepliesEmptySuccess(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgDprotoCallback), make([]byte, 112), 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build dproto callback upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle dproto callback upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("dproto callback emitted trailing bytes: %x", rest)
|
|
}
|
|
wantBody := []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.UpperMsgDprotoCallback) ||
|
|
!bytes.Equal(packet.Body, wantBody) {
|
|
t.Fatalf("dproto callback response = class=%d msg=%d body=%x, want body=%x", packet.Header.Classification, packet.Header.MsgID, packet.Body, wantBody)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperDprotoCallbackNativeAllowsOpaqueIdx6Control(t *testing.T) {
|
|
service := &Service{options: options{gameUpperClientBodyCodec: gameUpperClientBodyCodecNative}}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
body := bytes.Repeat([]byte{0xA5, 0x6C, 0x33, 0x90, 0x12, 0xFE, 0x44, 0x08}, 14)
|
|
msgID := uint16(dnfenum.UpperMsgDprotoCallback)
|
|
if msgID%14 != 6 {
|
|
t.Fatalf("test fixture msg id %d moved away from idx6", msgID)
|
|
}
|
|
frame, err := dnfproto.BuildChannelPacket(msgID, body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build native opaque dproto callback upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle native opaque dproto callback upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("native opaque dproto callback emitted trailing bytes: %x", rest)
|
|
}
|
|
wantBody := []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != msgID ||
|
|
!bytes.Equal(packet.Body, wantBody) {
|
|
t.Fatalf("native opaque dproto callback response = class=%d msg=%d body=%x, want body=%x", packet.Header.Classification, packet.Header.MsgID, packet.Body, wantBody)
|
|
}
|
|
}
|
|
|
|
func TestShouldAllowOpaqueUpperClientBodyControlIsNarrow(t *testing.T) {
|
|
if !shouldAllowOpaqueUpperClientBodyControl(uint16(dnfenum.UpperMsgDprotoCallback), dnfproto.DefaultChannelClassification, false, nil) {
|
|
t.Fatalf("op1518 idx6 unsupported control should be allowed as opaque")
|
|
}
|
|
if shouldAllowOpaqueUpperClientBodyControl(uint16(dnfenum.UpperMsgDprotoCallback), dnfproto.DefaultChannelClassification, true, nil) {
|
|
t.Fatalf("supported codec should not use opaque fallback")
|
|
}
|
|
if shouldAllowOpaqueUpperClientBodyControl(uint16(dnfenum.UpperMsgDprotoCallback), 0, false, nil) {
|
|
t.Fatalf("non-default classification should not use opaque fallback")
|
|
}
|
|
if shouldAllowOpaqueUpperClientBodyControl(6, dnfproto.DefaultChannelClassification, false, nil) {
|
|
t.Fatalf("arbitrary idx6 opcode should not use opaque fallback")
|
|
}
|
|
if shouldAllowOpaqueUpperClientBodyControl(uint16(dnfenum.UpperMsgDprotoCallback), dnfproto.DefaultChannelClassification, false, errUpperNativeCodecBody) {
|
|
t.Fatalf("decode errors should not use opaque fallback")
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperAntibotDefersClientReport(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgAntibot), make([]byte, 32), 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build antibot upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle antibot upper: %v", err)
|
|
}
|
|
if got := conn.write.Len(); got != 0 {
|
|
t.Fatalf("antibot client report must not send unverified 1516 response, wrote %d bytes", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCharacViewHiddenInfoRepliesRebirthHardcoreSuccess(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCharacViewHiddenInfo), nil, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build hidden charac info upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle hidden charac info upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("hidden charac info emitted trailing bytes: %x", rest)
|
|
}
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.UpperMsgRebirthHardcoreCharac) {
|
|
t.Fatalf("hidden charac info response = class=%d msg=%d body=%x", packet.Header.Classification, packet.Header.MsgID, packet.Body)
|
|
}
|
|
if !bytes.Equal(packet.Body, []byte{1}) {
|
|
t.Fatalf("hidden charac info success body = %x, want 01", packet.Body)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCheckUserConnectionDefersUnverifiedAck(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCheckUserConnection), nil, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build check-user-connection upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle check-user-connection upper: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("check-user-connection emitted unverified ack bytes: %x", conn.write.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCheckUserConnectionAcksAfterSelect(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019},
|
|
selectedCharacterID: 60041,
|
|
}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCheckUserConnection), nil, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build check-user-connection upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle check-user-connection upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("check-user-connection emitted trailing bytes: %x", rest)
|
|
}
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.UpperMsgCheckUserConnection) ||
|
|
!bytes.Equal(packet.Body, upperSuccessBody(buildCurrentCheckUserConnectionSuccessPayload())) {
|
|
t.Fatalf("check-user-connection response = class=%d msg=%d body=%x", packet.Header.Classification, packet.Header.MsgID, packet.Body)
|
|
}
|
|
}
|
|
|
|
func TestInitialNoneNotice(t *testing.T) {
|
|
service := &Service{options: options{gameInitialMode: gameInitialModeNone}}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}}
|
|
|
|
mode, bodyLen, sent, err := service.sendGameInitial(session)
|
|
if err != nil {
|
|
t.Fatalf("send initial: %v", err)
|
|
}
|
|
if mode != gameInitialModeUpper || !sent || bodyLen == 0 {
|
|
t.Fatalf("mode=%q sent=%v bodyLen=%d", mode, sent, bodyLen)
|
|
}
|
|
assertUpperInitialWire(t, conn.write.Bytes(), service.gameUpperHeaderSize(), upperSuccessBody(service.buildLoginSuccessForSession(session)), true)
|
|
}
|
|
|
|
func TestLegacyGameEndpointACKIsWriteSilent(t *testing.T) {
|
|
service := &Service{options: options{serverIP: "203.0.113.10"}}
|
|
channel := channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channel}
|
|
|
|
if _, _, _, err := service.sendGameInitial(session); err != nil {
|
|
t.Fatalf("send endpoint bootstrap: %v", err)
|
|
}
|
|
bootstrapLen := conn.write.Len()
|
|
if err := service.handleGameCommand(
|
|
session,
|
|
byte(dnfenum.GameCmdCommand),
|
|
uint16(dnfenum.GameTypeLogin),
|
|
nil,
|
|
); err != nil {
|
|
t.Fatalf("handle legacy endpoint ACK: %v", err)
|
|
}
|
|
if conn.write.Len() != bootstrapLen {
|
|
t.Fatalf("legacy endpoint ACK added %d bytes", conn.write.Len()-bootstrapLen)
|
|
}
|
|
}
|
|
|
|
func TestHandleGameGetUserInfoSendsCurrentCharacterSelectRoster(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
if err := repos.Account.Save(context.Background(), dnfrepo.AccountRecord{
|
|
AccountID: "dnf:1",
|
|
State: "active",
|
|
RepresentAccountName: "group",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repos.Character.(*fakeCharacterStore).records["1"] = dnfrepo.CharacterRecord{
|
|
CharacterID: "1",
|
|
AccountID: "dnf:1",
|
|
Slot: 0,
|
|
Name: "hero",
|
|
Job: "1",
|
|
Level: 1,
|
|
}
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
characterStats: testCharacterStatTable(t),
|
|
adventureGroupTable: loadAdventureGroupTestTables(t),
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 6, Type: 3, Name: "ch.6", Port: 10006}}
|
|
frame, err := dnfproto.BuildLatestGameTCP(
|
|
byte(dnfenum.GameCmdCommand),
|
|
uint16(dnfenum.GameTypeGetUserInfo),
|
|
nil,
|
|
dnfproto.TransportOptions{},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("build get userinfo frame: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameFrame(session, frame); err != nil {
|
|
t.Fatalf("handle get userinfo: %v", err)
|
|
}
|
|
roster, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if roster.Header.Classification != 0 || roster.Header.MsgID != uint16(dnfenum.UpperMsgCharacterRoster) {
|
|
t.Fatalf("legacy GET_USERINFO first packet header=%+v, want roster", roster.Header)
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected trailing bytes after legacy GET_USERINFO roster: %x", rest)
|
|
}
|
|
if session.pendingCharacterRosterBootstrap {
|
|
t.Fatal("legacy GET_USERINFO should not leave pending character roster bootstrap")
|
|
}
|
|
assertCSharpRosterBody(t, roster.Body, 1)
|
|
conn.write.Reset()
|
|
|
|
hiddenFrame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCharacViewHiddenInfo), nil, 7, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build hidden charac info frame: %v", err)
|
|
}
|
|
if err := service.handleGameUpper(session, hiddenFrame); err != nil {
|
|
t.Fatalf("handle hidden charac info after get userinfo: %v", err)
|
|
}
|
|
rebirth, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if rebirth.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
rebirth.Header.MsgID != uint16(dnfenum.UpperMsgRebirthHardcoreCharac) ||
|
|
!bytes.Equal(rebirth.Body, []byte{1}) {
|
|
t.Fatalf("hidden charac info ack = header=%+v body=%x", rebirth.Header, rebirth.Body)
|
|
}
|
|
selectorAdventure, rest := splitGameServerUpperPacket(t, rest)
|
|
if selectorAdventure.Header.Classification != 0 || selectorAdventure.Header.MsgID != currentAdventureInfoPushMsgID {
|
|
t.Fatalf("selector adventure packet = header=%+v, want op1340", selectorAdventure.Header)
|
|
}
|
|
if len(selectorAdventure.Body) < 18+currentAdventureInfoRawLength {
|
|
t.Fatalf("selector adventure body length=%d", len(selectorAdventure.Body))
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected trailing bytes after selector adventure packet: %x", rest)
|
|
}
|
|
if session.pendingCharacterRosterBootstrap {
|
|
t.Fatal("hidden-charac probe should not leave pending character roster bootstrap")
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCreateCharacterWritesRepositoryAndRefreshesList(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
characterStats: testCharacterStatTable(t),
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
prepareTestCharacterInitialization(service, 15)
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 6, Type: 3, Name: "ch.6", Port: 10006}}
|
|
|
|
if err := service.handleUpperCreateCharacter(session, buildCreateRequest(15, "hero")); err != nil {
|
|
t.Fatalf("handle upper create: %v", err)
|
|
}
|
|
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
wantAckBody := []byte{0x01, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 'h', 'e', 'r', 'o'}
|
|
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
ack.Header.MsgID != uint16(dnfenum.UpperMsgCreateCharacter) ||
|
|
!bytes.Equal(ack.Body, wantAckBody) {
|
|
t.Fatalf("upper create ack = %+v body=%x want %x", ack.Header, ack.Body, wantAckBody)
|
|
}
|
|
roster, rest := splitGameServerUpperPacket(t, rest)
|
|
if roster.Header.Classification != 0 ||
|
|
roster.Header.MsgID != uint16(dnfenum.UpperMsgCharacterRoster) {
|
|
t.Fatalf("upper create roster = %+v body=%x", roster.Header, roster.Body)
|
|
}
|
|
assertCSharpRosterBody(t, roster.Body, 1)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected upper create trailing bytes: %x", rest)
|
|
}
|
|
record, ok := repos.Character.(*fakeCharacterStore).records["1"]
|
|
if !ok {
|
|
t.Fatalf("repository missing created character")
|
|
}
|
|
if record.Name != "hero" || record.Job != "15" || record.Level != 1 || record.Slot != 0 {
|
|
t.Fatalf("created character base fields = %+v", record)
|
|
}
|
|
if record.Stats == nil {
|
|
t.Fatalf("created character should write SQL mirror stats")
|
|
}
|
|
for key, want := range map[string]int64{
|
|
"grow_type": 0,
|
|
"delete_flag": 0,
|
|
"pc_room_id": 0x00010001,
|
|
"user_state_bits": 3,
|
|
"return_user_flag": 1,
|
|
"channel_id": 2,
|
|
"stat_hp_max": 11000,
|
|
"stat_mp_max": 11800,
|
|
"stat_block_marker": 83,
|
|
"roster_card_flag": 0,
|
|
"roster_display_flags": 0,
|
|
"create_option_len": 0,
|
|
"create_option_byte_00": 0,
|
|
} {
|
|
if got := record.Stats[key]; got != want {
|
|
t.Fatalf("created stat %s = %d, want %d; stats=%+v", key, got, want, record.Stats)
|
|
}
|
|
}
|
|
if record.Location != (dnfrepo.CharacterLocation{}) {
|
|
t.Fatalf("created character should not write location_json: %+v", record.Location)
|
|
}
|
|
if !reflect.DeepEqual(record.Roster, dnfrepo.CharacterRoster{}) {
|
|
t.Fatalf("created character should not write roster_json: %+v", record.Roster)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCreateCharacterAllowsSeventeenthCharacterWithThirtyTwoSlots(t *testing.T) {
|
|
if defaultCharacterSlots != 32 {
|
|
t.Fatalf("default character slots = %d, want 32", defaultCharacterSlots)
|
|
}
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
for slot := 0; slot < 16; slot++ {
|
|
id := strconv.Itoa(slot + 1)
|
|
store.records[id] = dnfrepo.CharacterRecord{
|
|
CharacterID: id,
|
|
AccountID: "dnf:1",
|
|
Slot: slot,
|
|
Name: "existing-" + id,
|
|
Job: "0",
|
|
Level: 1,
|
|
}
|
|
}
|
|
store.nextID = 17
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
prepareTestCharacterInitialization(service, 15)
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
|
|
if err := service.handleUpperCreateCharacter(session, buildCreateRequest(15, "slot-seventeen")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if ack.Header.MsgID != uint16(dnfenum.UpperMsgCreateCharacter) || len(ack.Body) < 7 ||
|
|
ack.Body[0] != 1 || binary.LittleEndian.Uint16(ack.Body[1:3]) != 17 ||
|
|
binary.LittleEndian.Uint32(ack.Body[3:7]) != 14 {
|
|
t.Fatalf("seventeenth create ack = %+v body=%x", ack.Header, ack.Body)
|
|
}
|
|
roster, trailing := splitGameServerUpperPacket(t, rest)
|
|
if len(trailing) != 0 {
|
|
t.Fatalf("seventeenth create trailing bytes = %x", trailing)
|
|
}
|
|
assertCSharpRosterBody(t, roster.Body, 17)
|
|
if got := binary.LittleEndian.Uint16(roster.Body[3:5]); got != noPackRosterWireSlotLimit {
|
|
t.Fatalf("roster wire slot capacity = %d, want %d", got, noPackRosterWireSlotLimit)
|
|
}
|
|
created, ok := store.records["17"]
|
|
if !ok || created.Slot != 16 || created.Name != "slot-seventeen" {
|
|
t.Fatalf("seventeenth character = %+v found=%t", created, ok)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCreateCharacterUsesSlot31ThenRejectsThirtyThirdCharacter(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
characters := make([]dnfrepo.CharacterRecord, 0, 31)
|
|
for slot := 0; slot < 31; slot++ {
|
|
id := strconv.Itoa(slot + 1)
|
|
record := dnfrepo.CharacterRecord{
|
|
CharacterID: id,
|
|
AccountID: "dnf:1",
|
|
Slot: slot,
|
|
Name: "existing-" + id,
|
|
Job: "0",
|
|
Level: 1,
|
|
}
|
|
store.records[id] = record
|
|
characters = append(characters, record)
|
|
}
|
|
store.nextID = 32
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
prepareTestCharacterInitialization(service, 15)
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
|
|
if err := service.handleUpperCreateCharacter(session, buildCreateRequest(15, "slot-thirty-two")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
roster, trailing := splitGameServerUpperPacket(t, rest)
|
|
if len(trailing) != 0 {
|
|
t.Fatalf("thirty-second create trailing bytes = %x", trailing)
|
|
}
|
|
assertCSharpRosterBody(t, roster.Body, noPackRosterWireSlotLimit)
|
|
created, ok := store.records["32"]
|
|
if !ok || created.Slot != 31 {
|
|
t.Fatalf("thirty-second character = %+v found=%t, want slot 31", created, ok)
|
|
}
|
|
|
|
conn.write.Reset()
|
|
if err := service.handleUpperCreateCharacter(session, buildCreateRequest(15, "slot-thirty-three")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
full, fullTrailing := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if full.Header.MsgID != uint16(dnfenum.UpperMsgCreateCharacter) ||
|
|
!bytes.Equal(full.Body, []byte{0, createCodeSlotFull}) || len(fullTrailing) != 0 {
|
|
t.Fatalf("thirty-third create response=%+v body=%x trailing=%x", full.Header, full.Body, fullTrailing)
|
|
}
|
|
if _, exists := store.records["33"]; exists {
|
|
t.Fatal("slot-full create wrote character 33")
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCreateCharacterUsesExpandedAccountSlotCapacity(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
account := dnfrepo.AccountRecord{AccountID: "dnf:1"}
|
|
if err := dnfrepo.SetAccountCharacterSlotLimit(&account, 33); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repos.Account.Save(context.Background(), account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
for slot := 0; slot < 32; slot++ {
|
|
id := strconv.Itoa(slot + 1)
|
|
store.records[id] = dnfrepo.CharacterRecord{
|
|
CharacterID: id,
|
|
AccountID: "dnf:1",
|
|
Slot: slot,
|
|
Name: "expanded-existing-" + id,
|
|
Job: "0",
|
|
Level: 1,
|
|
}
|
|
}
|
|
store.nextID = 33
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
prepareTestCharacterInitialization(service, 15)
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
|
|
if err := service.handleUpperCreateCharacter(session, buildCreateRequest(15, "slot-thirty-three")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if ack.Header.MsgID != uint16(dnfenum.UpperMsgCreateCharacter) || len(ack.Body) < 7 || ack.Body[0] != 1 {
|
|
t.Fatalf("expanded create ack=%+v body=%x", ack.Header, ack.Body)
|
|
}
|
|
roster, trailing := splitGameServerUpperPacket(t, rest)
|
|
if len(trailing) != 0 {
|
|
t.Fatalf("expanded create trailing bytes=%x", trailing)
|
|
}
|
|
assertCSharpRosterBodyForLimit(t, roster.Body, 33, 33)
|
|
created, ok := store.records["33"]
|
|
if !ok || created.Slot != 32 || created.Name != "slot-thirty-three" {
|
|
t.Fatalf("expanded character=%+v found=%t, want slot 32", created, ok)
|
|
}
|
|
|
|
conn.write.Reset()
|
|
if err := service.handleUpperCreateCharacter(session, buildCreateRequest(15, "slot-thirty-four")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
full, fullTrailing := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if full.Header.MsgID != uint16(dnfenum.UpperMsgCreateCharacter) ||
|
|
!bytes.Equal(full.Body, []byte{0, createCodeSlotFull}) || len(fullTrailing) != 0 {
|
|
t.Fatalf("expanded slot-full response=%+v body=%x trailing=%x", full.Header, full.Body, fullTrailing)
|
|
}
|
|
if _, exists := store.records["34"]; exists {
|
|
t.Fatal("33-slot account wrote character 34")
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCreateCharacterErrorUsesCSharpCreateAck(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 6, Type: 3, Name: "ch.6", Port: 10006}}
|
|
|
|
if err := service.handleUpperCreateCharacter(session, []byte{0x01}); err != nil {
|
|
t.Fatalf("handle invalid upper create: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected invalid create trailing bytes: %x", rest)
|
|
}
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.UpperMsgCreateCharacter) ||
|
|
!bytes.Equal(packet.Body, []byte{0x00, 0x04}) {
|
|
t.Fatalf("invalid upper create ack = %+v body=%x", packet.Header, packet.Body)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCreateCharacterIgnoresSoftDeletedNameAndSlot(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
store.records["1"] = dnfrepo.CharacterRecord{
|
|
CharacterID: "1",
|
|
AccountID: "dnf:1",
|
|
Slot: 0,
|
|
Name: "hero",
|
|
Job: "0",
|
|
Level: 86,
|
|
}
|
|
store.records["2"] = dnfrepo.CharacterRecord{
|
|
CharacterID: "2",
|
|
AccountID: "dnf:1",
|
|
Slot: 1,
|
|
Name: "gone",
|
|
Job: "14",
|
|
Level: 1,
|
|
Stats: map[string]int64{"delete_flag": 1},
|
|
}
|
|
store.nextID = 3
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
prepareTestCharacterInitialization(service, 14)
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 6, Type: 3, Name: "ch.6", Port: 10006}}
|
|
|
|
if err := service.handleUpperCreateCharacter(session, buildCreateRequest(14, "gone")); err != nil {
|
|
t.Fatalf("handle upper create with soft-deleted name: %v", err)
|
|
}
|
|
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
wantAckBody := []byte{0x01, 0x03, 0x00, 0x04, 0x00, 0x00, 0x00, 'g', 'o', 'n', 'e'}
|
|
if ack.Header.MsgID != uint16(dnfenum.UpperMsgCreateCharacter) || !bytes.Equal(ack.Body, wantAckBody) {
|
|
t.Fatalf("upper create ack = %+v body=%x, want success", ack.Header, ack.Body)
|
|
}
|
|
roster, rest := splitGameServerUpperPacket(t, rest)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected upper create trailing bytes: %x", rest)
|
|
}
|
|
assertCSharpRosterBody(t, roster.Body, 2)
|
|
record, ok := store.records["3"]
|
|
if !ok {
|
|
t.Fatalf("repository missing created character")
|
|
}
|
|
if record.Name != "gone" || record.Slot != 1 {
|
|
t.Fatalf("created character = %+v, want name gone slot 1", record)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCreateCharacterDecodesGB18030Name(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
prepareTestCharacterInitialization(service, 15)
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
body := append([]byte{15, 7, 0, 0, 0}, []byte{0xc7, 0xbf, 0xd6, 0xc6, '9', '4', '8'}...)
|
|
|
|
if err := service.handleUpperCreateCharacter(session, body); err != nil {
|
|
t.Fatalf("handle upper create gb18030 name: %v", err)
|
|
}
|
|
record, ok := repos.Character.(*fakeCharacterStore).records["1"]
|
|
if !ok {
|
|
t.Fatalf("repository missing created character")
|
|
}
|
|
if record.Name != "强制948" {
|
|
t.Fatalf("character name = %q, want 强制948", record.Name)
|
|
}
|
|
}
|
|
|
|
func TestDecodeCharacterNamePrioritizesCurrentClientGB18030WhenBytesAreValidUTF8(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw []byte
|
|
want string
|
|
}{
|
|
{
|
|
name: "three_hanzi_cyrillic_utf8_shape",
|
|
raw: []byte{0xd2, 0xa1, 0xd2, 0xbb, 0xd2, 0xa1},
|
|
want: "\u6447\u4e00\u6447",
|
|
},
|
|
{
|
|
name: "two_hanzi_latin_utf8_shape",
|
|
raw: []byte{0xc2, 0xa5, 0xc5, 0xb6},
|
|
want: "\u697c\u54e6",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got, err := decodeCharacterName(test.raw)
|
|
if err != nil {
|
|
t.Fatalf("decode current-client name: %v", err)
|
|
}
|
|
if got != test.want {
|
|
t.Fatalf("decoded name = %q, want %q", got, test.want)
|
|
}
|
|
if encoded := rosterNameBytes(got); !bytes.Equal(encoded, test.raw) {
|
|
t.Fatalf("roster name bytes = %x, want original %x", encoded, test.raw)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCreateCharacterDecodesNoPackProtectedBody(t *testing.T) {
|
|
body := []byte{
|
|
0x3b, 0x3a, 0xd1, 0x48, 0xd1, 0xbf, 0x63, 0x70,
|
|
0xc9, 0xb1, 0x34, 0xfd, 0x51, 0xbc, 0x93, 0x82,
|
|
0x6b, 0xf5, 0x88, 0x9f, 0x80, 0xf0, 0xdb, 0xcc,
|
|
}
|
|
plain, err := decodeUpperKey5(body)
|
|
if err != nil {
|
|
t.Fatalf("decode protected upper create: %v", err)
|
|
}
|
|
wantPlain := []byte{
|
|
0x00, 0x04, 0x00, 0x00, 0x00, 0xce, 0xd2, 0xd2,
|
|
0xb2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff,
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
}
|
|
if !bytes.Equal(plain, wantPlain) {
|
|
t.Fatalf("decoded body = %x, want %x", plain, wantPlain)
|
|
}
|
|
|
|
repos := testRepositoryGroup()
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
prepareTestCharacterInitialization(service, 0)
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
|
|
if err := service.handleUpperCreateCharacter(session, body); err != nil {
|
|
t.Fatalf("handle protected upper create: %v", err)
|
|
}
|
|
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
wantAckBody := []byte{0x01, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0xce, 0xd2, 0xd2, 0xb2}
|
|
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
ack.Header.MsgID != uint16(dnfenum.UpperMsgCreateCharacter) ||
|
|
!bytes.Equal(ack.Body, wantAckBody) {
|
|
t.Fatalf("protected create ack = %+v body=%x want %x", ack.Header, ack.Body, wantAckBody)
|
|
}
|
|
roster, rest := splitGameServerUpperPacket(t, rest)
|
|
if roster.Header.Classification != 0 ||
|
|
roster.Header.MsgID != uint16(dnfenum.UpperMsgCharacterRoster) {
|
|
t.Fatalf("protected create roster = %+v body=%x", roster.Header, roster.Body)
|
|
}
|
|
assertCSharpRosterBody(t, roster.Body, 1)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected protected create trailing bytes: %x", rest)
|
|
}
|
|
if got := len(repos.Character.(*fakeCharacterStore).records); got != 1 {
|
|
t.Fatalf("protected create wrote %d records", got)
|
|
}
|
|
}
|
|
|
|
func TestParseNoPackProtectedSelectCharacterBody(t *testing.T) {
|
|
body := []byte{
|
|
0xa7, 0xc1, 0x2b, 0x5a, 0xc5, 0xf5, 0xe2, 0xf0,
|
|
0xce, 0x15, 0xe9, 0xe5, 0xd7, 0x53, 0xa3, 0xe1,
|
|
}
|
|
plain, err := decodeUpperKey4(body)
|
|
if err != nil {
|
|
t.Fatalf("decode protected upper select: %v", err)
|
|
}
|
|
wantPlain := []byte{
|
|
0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
}
|
|
if !bytes.Equal(plain, wantPlain) {
|
|
t.Fatalf("decoded select body = %x, want %x", plain, wantPlain)
|
|
}
|
|
parsed := parseSelectCharacterRequest(body)
|
|
if parsed.slot != 4 || !parsed.clear || parsed.source != "nopack_key4_slot" {
|
|
t.Fatalf("parsed protected select = %+v, want slot 4 key4", parsed)
|
|
}
|
|
}
|
|
|
|
func TestResolveSelectedCharacterUsesRequestedRosterSlot(t *testing.T) {
|
|
body := []byte{
|
|
0xa7, 0xc1, 0x2b, 0x5a, 0xc5, 0xf5, 0xe2, 0xf0,
|
|
0xce, 0x15, 0xe9, 0xe5, 0xd7, 0x53, 0xa3, 0xe1,
|
|
}
|
|
repos := testRepositoryGroup()
|
|
repos.Character.(*fakeCharacterStore).records = map[string]dnfrepo.CharacterRecord{
|
|
"1": {CharacterID: "1", AccountID: defaultAccountPrefix + "1", Slot: 0, Name: "hero", Job: "0", Level: 70},
|
|
"8": {CharacterID: "8", AccountID: defaultAccountPrefix + "1", Slot: 1, Name: "second", Job: "1", Level: 2},
|
|
"10": {CharacterID: "10", AccountID: defaultAccountPrefix + "1", Slot: 2, Name: "third", Job: "4", Level: 3},
|
|
"12": {CharacterID: "12", AccountID: defaultAccountPrefix + "1", Slot: 4, Name: "female_sword", Job: "15", Level: 1},
|
|
}
|
|
service := &Service{
|
|
options: options{accountPrefix: defaultAccountPrefix},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
|
|
}
|
|
|
|
charID, charName, selected, hasCharacter, slot := service.resolveSelectedCharacter(context.Background(), &gameSession{}, body)
|
|
if !hasCharacter {
|
|
t.Fatalf("resolve protected select did not load a character")
|
|
}
|
|
if charID != 12 || charName != "female_sword" || selected.Slot != 4 || selected.Job != "15" || selected.Level != 1 || slot != 4 {
|
|
t.Fatalf("resolved character id=%d name=%q slot=%d job=%q level=%d return_slot=%d", charID, charName, selected.Slot, selected.Job, selected.Level, slot)
|
|
}
|
|
}
|
|
|
|
func TestResolveNoPackPlainSelectDoesNotUseFallbackCharID(t *testing.T) {
|
|
body := []byte{
|
|
0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
}
|
|
repos := testRepositoryGroup()
|
|
repos.Character.(*fakeCharacterStore).records = map[string]dnfrepo.CharacterRecord{
|
|
"1": {CharacterID: "1", AccountID: defaultAccountPrefix + "1", Slot: 0, Name: "slot0", Job: "0", Level: 70},
|
|
"8": {CharacterID: "8", AccountID: defaultAccountPrefix + "1", Slot: 1, Name: "slot1", Job: "1", Level: 2},
|
|
"10": {CharacterID: "10", AccountID: defaultAccountPrefix + "1", Slot: 2, Name: "slot2", Job: "4", Level: 3},
|
|
"11": {CharacterID: "11", AccountID: defaultAccountPrefix + "1", Slot: 3, Name: "slot3", Job: "5", Level: 4},
|
|
"12": {CharacterID: "12", AccountID: defaultAccountPrefix + "1", Slot: 4, Name: "female_sword", Job: "15", Level: 1},
|
|
}
|
|
service := &Service{
|
|
options: options{accountPrefix: defaultAccountPrefix},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
|
|
}
|
|
|
|
charID, charName, selected, hasCharacter, slot := service.resolveSelectedCharacter(context.Background(), &gameSession{}, body)
|
|
if !hasCharacter {
|
|
t.Fatalf("resolve plain select did not load a character")
|
|
}
|
|
if charID != 12 || charName != "female_sword" || selected.Slot != 4 || selected.Job != "15" || slot != 4 {
|
|
t.Fatalf("plain select resolved id=%d name=%q slot=%d job=%q return_slot=%d", charID, charName, selected.Slot, selected.Job, slot)
|
|
}
|
|
}
|
|
|
|
func assertSelectCharacterAckBody(t *testing.T, body []byte, wantCharacterID uint16) {
|
|
t.Helper()
|
|
if len(body) < currentSelectAckMinimumBodyLen {
|
|
headLen := len(body)
|
|
if headLen > 32 {
|
|
headLen = 32
|
|
}
|
|
t.Fatalf("select init ack body len=%d, want at least %d head=%x", len(body), currentSelectAckMinimumBodyLen, body[:headLen])
|
|
}
|
|
if body[currentSelectAckResultOffset] != 1 {
|
|
t.Fatalf("select init ack success = %x, want 01", body[0])
|
|
}
|
|
if got, ok := currentSelectAckCharacterID(body); !ok || got != wantCharacterID {
|
|
t.Fatalf("select init ack character id = %d ok=%v, want %d true", got, ok, wantCharacterID)
|
|
}
|
|
if body[currentSelectAckPremiumCountOffset] != 0 {
|
|
t.Fatalf("select init ack premium count = %#x, want typed empty list", body[currentSelectAckPremiumCountOffset])
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentSelectCharacterAckUsesCurrentTypedBody(t *testing.T) {
|
|
createdAt := time.Unix(1783660048, 0).UTC()
|
|
character := dnfrepo.CharacterRecord{
|
|
CreatedAt: createdAt,
|
|
Stats: map[string]int64{
|
|
"town_id": 1,
|
|
"fatigue": 156,
|
|
"fatigue_limit": 156,
|
|
"fatigue_update": 3,
|
|
"fatigue_display_update": 4,
|
|
},
|
|
}
|
|
body := buildCurrentSelectCharacterAckBody(character, true, dnfrepo.QuestRecord{}, false, 28, 13, 0, []byte{0})
|
|
assertSelectCharacterAckBody(t, body, 28)
|
|
wantLen := currentSelectAckMinimumBodyLen
|
|
if len(body) != wantLen {
|
|
t.Fatalf("select init ack body len = %d, want current no-premium body %d", len(body), wantLen)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[currentSelectAckCreatedTimeOffset : currentSelectAckCreatedTimeOffset+4]); got != uint32(createdAt.Unix()) {
|
|
t.Fatalf("select init ack created time = %d, want %d", got, createdAt.Unix())
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[currentSelectAckFatigueUsedOffset : currentSelectAckFatigueUsedOffset+2]); got != 0 {
|
|
t.Fatalf("select init ack fatigue used = %d, want 0", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[currentSelectAckFatigueLimitOffset : currentSelectAckFatigueLimitOffset+2]); got != 156 {
|
|
t.Fatalf("select init ack fatigue limit = %d, want 156", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[currentSelectAckFatigueAuxOffset : currentSelectAckFatigueAuxOffset+2]); got != 3 {
|
|
t.Fatalf("select init ack fatigue aux = %d, want 3", got)
|
|
}
|
|
questOffset, ok := currentSelectAckQuestLayout(body)
|
|
if !ok {
|
|
t.Fatalf("select init ack quest layout not readable")
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[questOffset : questOffset+2]); got != 0xffff {
|
|
t.Fatalf("select init ack first pair id = %#x, want 0xffff", got)
|
|
}
|
|
state, stateOffset, ok := currentSelectAckIntermediateState(body)
|
|
if !ok || stateOffset != 206 {
|
|
t.Fatalf("select init ack sub_1A0C3E0 state offset = %d ok=%v, want 206 true", stateOffset, ok)
|
|
}
|
|
if state != [currentSelectAckStateU32Count]uint32{} {
|
|
t.Fatalf("select init ack sub_1A0C3E0 state = %#v, want four initialized zeros", state)
|
|
}
|
|
if got := body[stateOffset+currentSelectAckStateByteSize]; got != 13 {
|
|
t.Fatalf("select init ack town id at offset 222 = %#x, want 0x0d", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[len(body)-6 : len(body)-4]); got != 0 {
|
|
t.Fatalf("select init ack blessing counter = %d, want 0 without battery", got)
|
|
}
|
|
if got, ok := currentSelectAckTownIDFromBody(body); !ok || got != 13 {
|
|
t.Fatalf("select init ack town id = %d ok=%v, want 13 true", got, ok)
|
|
}
|
|
if flag, count, ok := currentSelectAckTutorialState(body); !ok || flag != 0 || count != 0 {
|
|
t.Fatalf("select init ack tutorial state = flag=%d count=%d ok=%v, want 0 0 true", flag, count, ok)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentSelectAckBodyWritesPersistedActiveQuestRows(t *testing.T) {
|
|
quests := dnfrepo.QuestRecord{
|
|
States: map[int64]dnfrepo.QuestState{
|
|
300: {Status: "active", ProgressValue: 1},
|
|
100: {Status: " ACTIVE ", ProgressValue: 3},
|
|
200: {Status: "complete", ProgressValue: 7},
|
|
0: {Status: "active", ProgressValue: 1},
|
|
0xffff: {Status: "active", ProgressValue: 1},
|
|
70000: {Status: "active", ProgressValue: 1},
|
|
},
|
|
Progress: map[int64]dnfrepo.QuestState{
|
|
300: {Status: "active", ProgressValue: 9},
|
|
400: {Status: "active", ProgressValue: int64(^uint32(0)) + 9},
|
|
500: {Status: "completed", ProgressValue: 1},
|
|
},
|
|
}
|
|
body := buildCurrentSelectCharacterAckBody(dnfrepo.CharacterRecord{}, true, quests, true, 28, 13, 0, []byte{0})
|
|
assertSelectCharacterAckBody(t, body, 28)
|
|
questOffset, ok := currentSelectAckQuestLayout(body)
|
|
if !ok {
|
|
t.Fatalf("select init ack quest layout not readable")
|
|
}
|
|
for idx, want := range []struct {
|
|
id uint16
|
|
trigger uint32
|
|
}{{id: 100, trigger: 3}, {id: 300, trigger: 1}, {id: 400, trigger: ^uint32(0)}} {
|
|
off := questOffset + idx*currentSelectAckQuestRowSize
|
|
if got := binary.LittleEndian.Uint16(body[off : off+2]); got != want.id {
|
|
t.Fatalf("select init ack quest[%d] id = %#x, want %#x", idx, got, want.id)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[off+2 : off+6]); got != want.trigger {
|
|
t.Fatalf("select init ack quest[%d] trigger = %d, want %d", idx, got, want.trigger)
|
|
}
|
|
}
|
|
fourth := questOffset + 3*currentSelectAckQuestRowSize
|
|
if got := binary.LittleEndian.Uint16(body[fourth : fourth+2]); got != 0xffff {
|
|
t.Fatalf("select init ack first empty quest id = %#x, want 0xffff", got)
|
|
}
|
|
if fixed, overflow, ok := currentSelectAckQuestRowCounts(body); !ok || fixed != 3 || overflow != 0 {
|
|
t.Fatalf("select init ack quest counts fixed=%d overflow=%d ok=%v, want 3 0 true", fixed, overflow, ok)
|
|
}
|
|
}
|
|
|
|
// TestCurrentSelectAckTownIDMatchesTheInitialTownRoute pins the town the select
|
|
// ACK makes the client build its gate scene for. It must be the same town the
|
|
// initial-town transition sends later: when the two disagree the client loads
|
|
// one town for the ACK and the real one again on the transition.
|
|
func TestCurrentSelectAckTownIDMatchesTheInitialTownRoute(t *testing.T) {
|
|
ordinary := &gameSession{channel: channelcatalog.Channel{ID: 19, Type: 11}}
|
|
if got, err := currentSelectAckTownID(ordinary, dnfrepo.CharacterRecord{}); err != nil || got != newCharacterInitialTownID {
|
|
t.Fatalf("ordinary channel ACK town = %d err=%v, want %d", got, err, newCharacterInitialTownID)
|
|
}
|
|
if got, err := currentSelectAckTownID(nil, dnfrepo.CharacterRecord{}); err != nil || got != newCharacterInitialTownID {
|
|
t.Fatalf("nil session ACK town = %d err=%v, want %d", got, err, newCharacterInitialTownID)
|
|
}
|
|
|
|
pvp := &gameSession{channel: channelcatalog.Channel{ID: 20, Group: "pvp"}}
|
|
if got, err := currentSelectAckTownID(pvp, dnfrepo.CharacterRecord{}); err != nil || got != byte(currentPVPTownID) {
|
|
t.Fatalf("pvp channel ACK town = %d err=%v, want %d", got, err, currentPVPTownID)
|
|
}
|
|
|
|
anton := &gameSession{channel: channelcatalog.Channel{ID: 23, Type: dnfenum.RaidChannelType}}
|
|
if got, err := currentSelectAckTownID(anton, dnfrepo.CharacterRecord{}); err != nil || got != byte(currentAntonRaidTownID) {
|
|
t.Fatalf("anton channel ACK town = %d err=%v, want %d", got, err, currentAntonRaidTownID)
|
|
}
|
|
|
|
luke := &gameSession{channel: channelcatalog.Channel{ID: 24, Type: dnfenum.HiddenRaidType}}
|
|
if got, err := currentSelectAckTownID(luke, dnfrepo.CharacterRecord{}); err != nil || got != byte(currentLukeRaidTownID) {
|
|
t.Fatalf("luke channel ACK town = %d err=%v, want %d", got, err, currentLukeRaidTownID)
|
|
}
|
|
|
|
persisted := dnfrepo.CharacterRecord{Stats: map[string]int64{"town_id": 7}}
|
|
|
|
resumed := &gameSession{channelReconnect: true}
|
|
resumed.channel = channelcatalog.Channel{ID: 19, Type: 11}
|
|
if got, err := currentSelectAckTownID(resumed, persisted); err != nil || got != 7 {
|
|
t.Fatalf("same-character reconnect ACK town = %d err=%v, want 7", got, err)
|
|
}
|
|
|
|
switched := &gameSession{channelReconnect: true, channelReconnectNewCharacter: true}
|
|
switched.channel = channelcatalog.Channel{ID: 19, Type: 11}
|
|
if got, err := currentSelectAckTownID(switched, persisted); err != nil || got != newCharacterInitialTownID {
|
|
t.Fatalf("new-character reconnect ACK town = %d err=%v, want %d", got, err, newCharacterInitialTownID)
|
|
}
|
|
|
|
if _, err := currentSelectAckTownID(resumed, dnfrepo.CharacterRecord{}); err == nil {
|
|
t.Fatalf("reconnect without a persisted town must report an error")
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentSelectAckQuestRowsUsesCurrentOverflowList(t *testing.T) {
|
|
quests := make(map[int64]dnfrepo.QuestState, 32)
|
|
for idx := 0; idx < 32; idx++ {
|
|
quests[int64(100+idx)] = dnfrepo.QuestState{Status: "active", ProgressValue: int64(idx + 1)}
|
|
}
|
|
body := buildCurrentSelectCharacterAckBody(dnfrepo.CharacterRecord{}, false, dnfrepo.QuestRecord{States: quests}, true, 28, 13, 0, []byte{0})
|
|
wantLen := currentSelectAckMinimumBodyLen + 2*currentSelectAckQuestRowSize
|
|
if got := len(body); got != wantLen {
|
|
t.Fatalf("select ack overflow body len = %d, want %d", got, wantLen)
|
|
}
|
|
questOffset, ok := currentSelectAckQuestLayout(body)
|
|
if !ok {
|
|
t.Fatalf("select ack overflow quest layout not readable")
|
|
}
|
|
overflowOffset := questOffset + currentSelectAckFixedQuestCount*currentSelectAckQuestRowSize
|
|
if got := binary.LittleEndian.Uint32(body[overflowOffset : overflowOffset+4]); got != 2 {
|
|
t.Fatalf("select ack overflow count = %d, want 2", got)
|
|
}
|
|
firstOverflow := overflowOffset + 4
|
|
if got := binary.LittleEndian.Uint16(body[firstOverflow : firstOverflow+2]); got != 130 {
|
|
t.Fatalf("select ack first overflow quest id = %d, want 130", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[firstOverflow+2 : firstOverflow+6]); got != 31 {
|
|
t.Fatalf("select ack first overflow quest trigger = %d, want 31", got)
|
|
}
|
|
state, stateOffset, ok := currentSelectAckIntermediateState(body)
|
|
if !ok || stateOffset != firstOverflow+2*currentSelectAckQuestRowSize {
|
|
t.Fatalf("select ack overflow sub_1A0C3E0 state offset=%d ok=%v", stateOffset, ok)
|
|
}
|
|
if state != [currentSelectAckStateU32Count]uint32{} {
|
|
t.Fatalf("select ack overflow sub_1A0C3E0 state = %#v, want zero", state)
|
|
}
|
|
if parsedFixed, parsedOverflow, ok := currentSelectAckQuestRowCounts(body); !ok || parsedFixed != 30 || parsedOverflow != 2 {
|
|
t.Fatalf("select ack parsed counts fixed=%d overflow=%d ok=%v, want 30 2 true", parsedFixed, parsedOverflow, ok)
|
|
}
|
|
if got, ok := currentSelectAckTownIDFromBody(body); !ok || got != 13 {
|
|
t.Fatalf("select ack overflow town id = %d ok=%v, want 13 true", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentFatigueBodyUsesPersistedCharacterState(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{Stats: map[string]int64{
|
|
"fatigue": 120,
|
|
"fatigue_limit": 156,
|
|
"fatigue_update": 5,
|
|
"fatigue_display_update": 36,
|
|
"ack_fatigue_grownup_buff": 7,
|
|
}}
|
|
body := buildCurrentFatigueBody(character, true)
|
|
want := []byte{36, 0, 156, 0, 5, 0, 0, 0, 7, 0}
|
|
if !bytes.Equal(body, want) {
|
|
t.Fatalf("fatigue body = %x, want %x", body, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentFatigueBodyUsesNativeBatteryCounter(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{Stats: map[string]int64{
|
|
"fatigue": 187, "fatigue_limit": 188, "fatigue_display_update": 1, "fatigue_battery": 234,
|
|
}}
|
|
body := buildCurrentFatigueBody(character, true)
|
|
if got := binary.LittleEndian.Uint16(body[6:8]); got != 235 {
|
|
t.Fatalf("fatigue battery counter=%d, want 235; body=%x", got, body)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentSelectAckSeparatesRawChargeAndBlessing(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{Stats: map[string]int64{
|
|
"fatigue": 187, "fatigue_limit": 188, "fatigue_display_update": 1, "fatigue_battery": 234, "fatigue_battery_charge": 150,
|
|
}}
|
|
body := buildCurrentSelectCharacterAckBody(character, true, dnfrepo.QuestRecord{}, false, 28, 13, 0, []byte{0})
|
|
if got := binary.LittleEndian.Uint16(body[len(body)-8 : len(body)-6]); got != 150 {
|
|
t.Fatalf("select ack raw battery field=%d, want 150; body=%x", got, body)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[len(body)-6 : len(body)-4]); got != 235 {
|
|
t.Fatalf("select ack blessing counter=%d, want 235; body=%x", got, body)
|
|
}
|
|
}
|
|
|
|
func TestCurrentSelectAckExpUpMatchesTownRefresh(t *testing.T) {
|
|
for _, test := range []struct {
|
|
level int
|
|
battery, used int64
|
|
want uint16
|
|
}{
|
|
{40, 31, 0, 32}, {40, 0, 50, 0}, {40, -1, 50, 0}, {90, 234, 0, 0},
|
|
} {
|
|
character := dnfrepo.CharacterRecord{Level: test.level, Stats: map[string]int64{
|
|
"fatigue": 156 - test.used, "fatigue_limit": 156,
|
|
"fatigue_battery": test.battery, "fatigue_display_update": test.used,
|
|
}}
|
|
ack := buildCurrentSelectCharacterAckBody(character, true, dnfrepo.QuestRecord{}, false, 28, 13, 0, []byte{0})
|
|
refresh := buildCurrentFatigueBody(character, true)
|
|
if binary.LittleEndian.Uint16(ack[len(ack)-6:]) != test.want || binary.LittleEndian.Uint16(refresh[6:8]) != test.want {
|
|
t.Fatalf("level=%d battery=%d used=%d ack=%x refresh=%x want=%d", test.level, test.battery, test.used, ack, refresh, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCurrentSelectAckAndFatigueDoNotUseChannelOrAreaDungeonID(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{Stats: map[string]int64{"fatigue": 156, "fatigue_limit": 156}}
|
|
first := buildCurrentSelectCharacterAckBody(character, true, dnfrepo.QuestRecord{}, false, 28, 13, 0, []byte{0})
|
|
second := buildCurrentSelectCharacterAckBody(character, true, dnfrepo.QuestRecord{}, false, 28, 13, 0, []byte{0})
|
|
if !bytes.Equal(first, second) {
|
|
t.Fatalf("select ack changed without a character-state change")
|
|
}
|
|
body := buildCurrentFatigueBody(character, true)
|
|
want := []byte{0, 0, 156, 0, 0, 0, 0, 0, 0, 0}
|
|
if !bytes.Equal(body, want) {
|
|
t.Fatalf("fatigue body = %x, want %x", body, want)
|
|
}
|
|
}
|
|
|
|
func TestDefaultCreatedCharacterStatsSeedPersistedFatigue(t *testing.T) {
|
|
stats := defaultCreatedCharacterStats(0)
|
|
if got := stats["tutorial_completed"]; got != 0 {
|
|
t.Fatalf("initial tutorial_completed = %d, want 0", got)
|
|
}
|
|
if got := stats["fatigue"]; got != newCharacterInitialFatigue {
|
|
t.Fatalf("initial fatigue = %d, want %d", got, newCharacterInitialFatigue)
|
|
}
|
|
if got := stats["fatigue_limit"]; got != newCharacterFatigueLimit {
|
|
t.Fatalf("initial fatigue limit = %d, want %d", got, newCharacterFatigueLimit)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCreateCharacterProtectedDecodeFailureIsNonFatal(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
body := []byte{
|
|
0xf1, 0x65, 0x58, 0x40, 0x40, 0x50, 0xf4, 0xfb,
|
|
0xec, 0x23, 0x26, 0x23, 0x1a, 0x1f, 0x17, 0xdc,
|
|
0x36, 0x37, 0x0b, 0xdc, 0xf0, 0xaf, 0x16, 0xbc,
|
|
}
|
|
|
|
if err := service.handleUpperCreateCharacter(session, body); err != nil {
|
|
t.Fatalf("handle protected undecodable upper create: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected protected undecodable trailing bytes: %x", rest)
|
|
}
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.UpperMsgCreateCharacter) ||
|
|
!bytes.Equal(packet.Body, []byte{0x00, createCodeDuplicated}) {
|
|
t.Fatalf("protected undecodable create ack = %+v body=%x", packet.Header, packet.Body)
|
|
}
|
|
}
|
|
|
|
func TestHandleGameCreateCharacterSendsAckThenList(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
prepareTestCharacterInitialization(service, 15)
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 6, Type: 3, Name: "ch.6", Port: 10006}}
|
|
|
|
if err := service.handleGameCreateCharacter(session, buildCreateRequest(15, "hero")); err != nil {
|
|
t.Fatalf("handle game create: %v", err)
|
|
}
|
|
records, err := splitWrittenGameRecords(conn.write.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("split records: %v", err)
|
|
}
|
|
if len(records) != 2 {
|
|
t.Fatalf("records = %d, want 2", len(records))
|
|
}
|
|
if records[0].GameHeader.Cmd != byte(dnfenum.GameCmdCommand) ||
|
|
records[0].GameHeader.Type != uint16(dnfenum.GameTypeCreateCharacter) ||
|
|
!bytes.Equal(records[0].Body, []byte{0x01}) {
|
|
t.Fatalf("create ack = %+v body=%x", records[0].GameHeader, records[0].Body)
|
|
}
|
|
if records[1].GameHeader.Cmd != byte(dnfenum.GameCmdNotice) ||
|
|
records[1].GameHeader.Type != uint16(dnfenum.GameTypeCharacterList) {
|
|
t.Fatalf("list refresh = %+v", records[1].GameHeader)
|
|
}
|
|
assertLatestCharacterState(t, records[1], 1)
|
|
}
|
|
|
|
func TestHandleGameCheckNameUsesVisibleCurrentExeDuplicateResult(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 6, Type: 3, Name: "ch.6", Port: 10006}}
|
|
|
|
if err := service.handleGameCheckName(session, buildCheckNameRequest("hero")); err != nil {
|
|
t.Fatalf("check available name: %v", err)
|
|
}
|
|
repos.Character.(*fakeCharacterStore).records["1"] = dnfrepo.CharacterRecord{
|
|
CharacterID: "1",
|
|
AccountID: "dnf:1",
|
|
Name: "hero",
|
|
}
|
|
if err := service.handleGameCheckName(session, buildCheckNameRequest("hero")); err != nil {
|
|
t.Fatalf("check duplicated name: %v", err)
|
|
}
|
|
first, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
second, rest := splitGameServerUpperPacket(t, rest)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected check-name trailing bytes: %x", rest)
|
|
}
|
|
for idx, packet := range []dnfproto.ChannelPacket{first, second} {
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.UpperMsgCheckDoubleCharName) {
|
|
t.Fatalf("check-name ack[%d] header = %+v", idx, packet.Header)
|
|
}
|
|
}
|
|
if !bytes.Equal(first.Body, []byte{1}) {
|
|
t.Fatalf("available ack body = %x, want 01", first.Body)
|
|
}
|
|
if checkNameCodeDuplicated == 0 {
|
|
t.Fatal("duplicate-name failure status must be visible to the current client")
|
|
}
|
|
if !bytes.Equal(second.Body, []byte{0, checkNameCodeDuplicated}) {
|
|
t.Fatalf("duplicated ack body = %x, want failure plus duplicate code", second.Body)
|
|
}
|
|
}
|
|
|
|
func TestHandleGameCheckNameFailureIncludesCurrentExeErrorByte(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19}}
|
|
|
|
if err := service.handleGameCheckName(session, buildCheckNameRequest("hero")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 || packet.Header.MsgID != uint16(dnfenum.UpperMsgCheckDoubleCharName) ||
|
|
!bytes.Equal(packet.Body, []byte{0, createCodeServerError}) {
|
|
t.Fatalf("repository failure header=%+v body=%x trailing=%d", packet.Header, packet.Body, len(rest))
|
|
}
|
|
}
|
|
|
|
func TestParseCheckNameRejectsTrailingBytes(t *testing.T) {
|
|
body := append(buildCheckNameRequest("hero"), 0)
|
|
if name, code, ok := parseCheckName(body); ok || name != "" || code != createCodeServerError {
|
|
t.Fatalf("trailing-byte request parsed as name=%q code=%d ok=%v", name, code, ok)
|
|
}
|
|
}
|
|
|
|
func TestHandleGameCheckNameAcceptsNoPackProtectedBody(t *testing.T) {
|
|
for _, body := range [][]byte{
|
|
{0xfd, 0x07, 0x49, 0xc0, 0x14, 0x00, 0xb6, 0xa1},
|
|
{0x2a, 0xc3, 0x73, 0x6d, 0xb2, 0x10, 0x34, 0xfb, 0x1d, 0xa4, 0x2a, 0x98, 0xc6, 0x5f, 0x44, 0xf7},
|
|
} {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 19, Type: 1, Name: "ch.19", Port: 10019}}
|
|
|
|
if err := service.handleGameCheckName(session, body); err != nil {
|
|
t.Fatalf("handle protected check-name len=%d: %v", len(body), err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected protected check-name trailing bytes len=%d: %x", len(body), rest)
|
|
}
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.UpperMsgCheckDoubleCharName) {
|
|
t.Fatalf("protected check-name ack header len=%d = %+v", len(body), packet.Header)
|
|
}
|
|
if !bytes.Equal(packet.Body, []byte{1}) {
|
|
t.Fatalf("protected check-name ack body len=%d = %x, want 01", len(body), packet.Body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperChangeCharacterSlotSwapsRealSlotsAndRefreshesRoster(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
store.records["10"] = dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "dnf:1", Slot: 2, Name: "left", Job: "0", Level: 1}
|
|
store.records["20"] = dnfrepo.CharacterRecord{CharacterID: "20", AccountID: "dnf:1", Slot: 7, Name: "right", Job: "1", Level: 2}
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}}
|
|
body := make([]byte, 8)
|
|
binary.LittleEndian.PutUint32(body[:4], 2)
|
|
binary.LittleEndian.PutUint32(body[4:8], 7)
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.CmdPacketChangeCharacSlot), body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build change-slot packet: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle change-slot: %v", err)
|
|
}
|
|
if store.records["10"].Slot != 7 || store.records["20"].Slot != 2 {
|
|
t.Fatalf("stored slots = %d/%d, want 7/2", store.records["10"].Slot, store.records["20"].Slot)
|
|
}
|
|
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
ack.Header.MsgID != uint16(dnfenum.CmdPacketChangeCharacSlot) ||
|
|
!bytes.Equal(ack.Body, []byte{0x01}) {
|
|
t.Fatalf("change-slot ack = class %d msg %d body %x", ack.Header.Classification, ack.Header.MsgID, ack.Body)
|
|
}
|
|
roster, _ := splitGameServerUpperPacket(t, rest)
|
|
if roster.Header.Classification != 0 || roster.Header.MsgID != uint16(dnfenum.UpperMsgCharacterRoster) {
|
|
t.Fatalf("change-slot roster header = %+v", roster.Header)
|
|
}
|
|
assertCSharpRosterBody(t, roster.Body, 2)
|
|
entry0 := 15
|
|
if got := binary.LittleEndian.Uint16(roster.Body[entry0 : entry0+2]); got != 2 {
|
|
t.Fatalf("first roster entry key=%d, want slot 2", got)
|
|
}
|
|
name0 := rosterDstrName(store.records["20"].Name)
|
|
if got := roster.Body[entry0+2 : entry0+2+len(name0)]; !bytes.Equal(got, name0) {
|
|
t.Fatalf("first roster entry name bytes=%x, want right %x", got, name0)
|
|
}
|
|
entry1 := 15 + noPackRosterEntryLen(store.records["20"])
|
|
if got := binary.LittleEndian.Uint16(roster.Body[entry1 : entry1+2]); got != 7 {
|
|
t.Fatalf("second roster entry key=%d, want slot 7", got)
|
|
}
|
|
name1 := rosterDstrName(store.records["10"].Name)
|
|
if got := roster.Body[entry1+2 : entry1+2+len(name1)]; !bytes.Equal(got, name1) {
|
|
t.Fatalf("second roster entry name bytes=%x, want left %x", got, name1)
|
|
}
|
|
}
|
|
|
|
func TestHandleDeleteCharacterSoftDeletesAndCompactsRoster(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
store.records["10"] = dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "dnf:1", Slot: 0, Name: "first"}
|
|
store.records["20"] = dnfrepo.CharacterRecord{CharacterID: "20", AccountID: "dnf:1", Slot: 1, Name: "delete-me"}
|
|
store.records["30"] = dnfrepo.CharacterRecord{CharacterID: "30", AccountID: "dnf:1", Slot: 2, Name: "last"}
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
|
|
}
|
|
connection := &bufferConn{}
|
|
session := &gameSession{conn: connection}
|
|
name := []byte("delete-me")
|
|
body := make([]byte, 6+len(name))
|
|
binary.LittleEndian.PutUint16(body[:2], 1)
|
|
binary.LittleEndian.PutUint32(body[2:6], uint32(len(name)))
|
|
copy(body[6:], name)
|
|
|
|
frame, err := dnfproto.BuildChannelPacket(
|
|
uint16(dnfenum.CmdPacketDeleteCharacter),
|
|
body,
|
|
0,
|
|
dnfproto.DefaultChannelClassification,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("build delete packet: %v", err)
|
|
}
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.CmdPacketDeleteCharacter) ||
|
|
!bytes.Equal(packet.Body, []byte{1, 0, 1, 0}) || len(rest) != 0 {
|
|
t.Fatalf("delete response header=%+v body=%x trailing=%x", packet.Header, packet.Body, rest)
|
|
}
|
|
deleted := store.records["20"]
|
|
if fakeCharacterDeleteFlag(deleted) != 1 || deleted.Slot != -1 {
|
|
t.Fatalf("deleted record=%+v", deleted)
|
|
}
|
|
if store.records["10"].Slot != 0 || store.records["30"].Slot != 1 {
|
|
t.Fatalf("compacted slots first=%d last=%d", store.records["10"].Slot, store.records["30"].Slot)
|
|
}
|
|
if !session.selectorAdventureInfoPending || session.selectorAdventureInfoSlot != 0 {
|
|
t.Fatalf("selector adventure pending=%t slot=%d", session.selectorAdventureInfoPending, session.selectorAdventureInfoSlot)
|
|
}
|
|
}
|
|
|
|
func TestHandleDeleteCharacterCanDeleteConsecutiveCompactedSlots(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
store.records["10"] = dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "dnf:1", Slot: 0, Name: "first"}
|
|
store.records["20"] = dnfrepo.CharacterRecord{CharacterID: "20", AccountID: "dnf:1", Slot: 1, Name: "second"}
|
|
store.records["30"] = dnfrepo.CharacterRecord{CharacterID: "30", AccountID: "dnf:1", Slot: 2, Name: "third"}
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
|
|
}
|
|
connection := &bufferConn{}
|
|
session := &gameSession{conn: connection}
|
|
delete := func(slot uint16, name string) []byte {
|
|
nameBytes := []byte(name)
|
|
body := make([]byte, 6+len(nameBytes))
|
|
binary.LittleEndian.PutUint16(body[:2], slot)
|
|
binary.LittleEndian.PutUint32(body[2:6], uint32(len(nameBytes)))
|
|
copy(body[6:], nameBytes)
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.CmdPacketDeleteCharacter), body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("delete slot=%d name=%q wrote trailing packets=%x", slot, name, rest)
|
|
}
|
|
connection.write.Reset()
|
|
return packet.Body
|
|
}
|
|
delete(0, "first")
|
|
secondACK := delete(1, "second")
|
|
if !bytes.Equal(secondACK, []byte{1, 0, 1, 0}) {
|
|
t.Fatalf("second stale-slot ACK=%x want request slot 1", secondACK)
|
|
}
|
|
if fakeCharacterDeleteFlag(store.records["10"]) != 1 || fakeCharacterDeleteFlag(store.records["20"]) != 1 {
|
|
t.Fatalf("consecutive deletes did not commit: first=%+v second=%+v", store.records["10"], store.records["20"])
|
|
}
|
|
if store.records["30"].Slot != 0 {
|
|
t.Fatalf("remaining character slot=%d, want 0", store.records["30"].Slot)
|
|
}
|
|
}
|
|
|
|
func TestHandleLegacyDeleteCharacterAcceptsCurrentNineByteRequest(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
store.records["10"] = dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "dnf:1", Slot: 0, Name: "keep"}
|
|
store.records["20"] = dnfrepo.CharacterRecord{CharacterID: "20", AccountID: "dnf:1", Slot: 1, Name: "delete"}
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
|
|
}
|
|
connection := &bufferConn{}
|
|
session := &gameSession{conn: connection}
|
|
name := []byte("delete")
|
|
body := make([]byte, 5+len(name))
|
|
body[0] = 1
|
|
binary.LittleEndian.PutUint32(body[1:5], uint32(len(name)))
|
|
copy(body[5:], name)
|
|
frame := buildLegacyGamePacketForBridgeTest(
|
|
byte(dnfenum.GameCmdCommand),
|
|
uint16(dnfenum.CmdPacketDeleteCharacter),
|
|
0,
|
|
body,
|
|
)
|
|
|
|
if err := service.handleLegacyGamePacket(session, frame); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
|
|
if packet.Header.MsgID != uint16(dnfenum.CmdPacketDeleteCharacter) ||
|
|
!bytes.Equal(packet.Body, []byte{1, 0, 1, 0}) || len(rest) != 0 {
|
|
t.Fatalf("legacy delete response header=%+v body=%x trailing=%x", packet.Header, packet.Body, rest)
|
|
}
|
|
deleted := store.records["20"]
|
|
if fakeCharacterDeleteFlag(deleted) != 1 || deleted.Slot != -1 {
|
|
t.Fatalf("legacy deleted record=%+v", deleted)
|
|
}
|
|
}
|
|
|
|
func TestHandleDeleteCharacterDecodesGB18030Name(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
store.records["10"] = dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "dnf:1", Slot: 0, Name: "请问"}
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
|
|
}
|
|
connection := &bufferConn{}
|
|
session := &gameSession{conn: connection}
|
|
name := rosterNameBytes("请问")
|
|
body := make([]byte, 6+len(name))
|
|
binary.LittleEndian.PutUint16(body[:2], 0)
|
|
binary.LittleEndian.PutUint32(body[2:6], uint32(len(name)))
|
|
copy(body[6:], name)
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.CmdPacketDeleteCharacter), body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
|
|
if !bytes.Equal(packet.Body, []byte{1, 0, 0, 0}) || len(rest) != 0 {
|
|
t.Fatalf("GB18030 delete ACK=%x trailing=%x", packet.Body, rest)
|
|
}
|
|
if fakeCharacterDeleteFlag(store.records["10"]) != 1 {
|
|
t.Fatalf("GB18030 character was not deleted: %+v", store.records["10"])
|
|
}
|
|
}
|
|
|
|
func TestHandleDeleteCharacterRejectsUnknownNameWithoutMutation(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
store.records["10"] = dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "dnf:1", Slot: 0, Name: "first"}
|
|
store.records["20"] = dnfrepo.CharacterRecord{CharacterID: "20", AccountID: "dnf:1", Slot: 1, Name: "second"}
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
|
|
}
|
|
connection := &bufferConn{}
|
|
session := &gameSession{conn: connection}
|
|
name := []byte("missing")
|
|
body := make([]byte, 6+len(name))
|
|
binary.LittleEndian.PutUint16(body[:2], 1)
|
|
binary.LittleEndian.PutUint32(body[2:6], uint32(len(name)))
|
|
copy(body[6:], name)
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.CmdPacketDeleteCharacter), body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
|
|
if !bytes.Equal(packet.Body, []byte{0, currentDeleteCharacterMissingError}) || len(rest) != 0 {
|
|
t.Fatalf("unknown-name delete response=%x trailing=%x", packet.Body, rest)
|
|
}
|
|
if fakeCharacterDeleteFlag(store.records["10"]) != 0 || fakeCharacterDeleteFlag(store.records["20"]) != 0 ||
|
|
store.records["10"].Slot != 0 || store.records["20"].Slot != 1 {
|
|
t.Fatalf("unknown-name delete mutated roster: first=%+v second=%+v", store.records["10"], store.records["20"])
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperChangeCharacterSlotDoesNotMoveIntoEmptySlot(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
store.records["10"] = dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "dnf:1", Slot: 2, Name: "left", Job: "0", Level: 1}
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 1, Name: "ch.38", Port: 10038}}
|
|
body := make([]byte, 8)
|
|
binary.LittleEndian.PutUint32(body[:4], 2)
|
|
binary.LittleEndian.PutUint32(body[4:8], 7)
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.CmdPacketChangeCharacSlot), body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build change-slot packet: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle change-slot with empty slot: %v", err)
|
|
}
|
|
if store.records["10"].Slot != 2 {
|
|
t.Fatalf("empty-slot request moved character to slot %d, want 2", store.records["10"].Slot)
|
|
}
|
|
ack, _ := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if ack.Header.MsgID != uint16(dnfenum.CmdPacketChangeCharacSlot) || !bytes.Equal(ack.Body, []byte{0x01}) {
|
|
t.Fatalf("empty-slot change ack = %+v body=%x", ack.Header, ack.Body)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperChangeCharacterSlotRejectsTrailingBytes(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
store.records["10"] = dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "dnf:1", Slot: 2, Name: "left"}
|
|
store.records["20"] = dnfrepo.CharacterRecord{CharacterID: "20", AccountID: "dnf:1", Slot: 7, Name: "right"}
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38}}
|
|
body := make([]byte, 9)
|
|
binary.LittleEndian.PutUint32(body[:4], 2)
|
|
binary.LittleEndian.PutUint32(body[4:8], 7)
|
|
|
|
if err := service.handleChangeCharacterSlot(session, body, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if store.records["10"].Slot != 2 || store.records["20"].Slot != 7 {
|
|
t.Fatalf("malformed request changed slots to %d/%d", store.records["10"].Slot, store.records["20"].Slot)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketChangeCharacSlot) ||
|
|
!bytes.Equal(packet.Body, []byte{0, createCodeServerError}) {
|
|
t.Fatalf("malformed response header=%+v body=%x trailing=%d", packet.Header, packet.Body, len(rest))
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperLoadExtendCharacsRejectsMalformedRequest(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgLoadExtendCharacs), make([]byte, 8), 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build load-extend-characs upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle load-extend-characs upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 ||
|
|
packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.UpperMsgLoadExtendCharacs) ||
|
|
!bytes.Equal(packet.Body, []byte{0, 0}) {
|
|
t.Fatalf("malformed load-extend response header=%+v body=%x trailing=%x", packet.Header, packet.Body, rest)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperLoadExtendCharacsCompletesEmptyRosterProbe(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038},
|
|
}
|
|
if err := service.sendUpperRoster(session, nil); err != nil {
|
|
t.Fatalf("send empty roster: %v", err)
|
|
}
|
|
if !session.emptyRosterSlotProbePending {
|
|
t.Fatal("empty mode-2 roster did not arm the slot probe completion")
|
|
}
|
|
conn.write.Reset()
|
|
|
|
frame, err := dnfproto.BuildChannelPacket(
|
|
uint16(dnfenum.UpperMsgLoadExtendCharacs),
|
|
[]byte{0xff, 0xff},
|
|
0,
|
|
dnfproto.DefaultChannelClassification,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("build empty-roster load-extend-characs upper: %v", err)
|
|
}
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle empty-roster load-extend-characs upper: %v", err)
|
|
}
|
|
if session.emptyRosterSlotProbePending {
|
|
t.Fatal("empty roster slot probe remained pending after completion")
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("empty-roster slot probe response has %d trailing bytes: %x", len(rest), rest)
|
|
}
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.UpperMsgLoadExtendCharacs) ||
|
|
!bytes.Equal(packet.Body, []byte{0, 0}) {
|
|
t.Fatalf("empty-roster slot probe response header=%+v body=%x, want class1/op679 failure 0000", packet.Header, packet.Body)
|
|
}
|
|
}
|
|
|
|
func TestNonEmptyRosterClearsEmptyRosterSlotProbe(t *testing.T) {
|
|
session := &gameSession{emptyRosterSlotProbePending: true}
|
|
noteEmptyRosterSlotProbe(session, buildCSharpRosterBody([]dnfrepo.CharacterRecord{{
|
|
CharacterID: "1",
|
|
Slot: 0,
|
|
Name: "Existing",
|
|
}}))
|
|
if session.emptyRosterSlotProbePending {
|
|
t.Fatal("non-empty mode-2 roster retained an empty-roster slot probe")
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCharacSlotExtendEffectDefersWithoutPendingUnlock(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 11, Type: 1, Name: "ch.11", Port: 10011}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCharacSlotExtendEffect), nil, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build slot-extend-effect upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle slot-extend-effect upper: %v", err)
|
|
}
|
|
if got := conn.write.Len(); got != 0 {
|
|
t.Fatalf("slot-extend-effect wrote %d bytes, want none: %x", got, conn.write.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperStaticsRuntimeTingDefersDuplicateLoginEndpoint(t *testing.T) {
|
|
service := &Service{options: options{gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain}}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgStaticsRuntimeTing), make([]byte, 16), 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build statics-runtime upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle statics-runtime upper: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("statics-runtime emitted duplicate login endpoint bytes: %x", conn.write.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestHandleGameStaticsRuntimeTingDoesNotSendLegacyClientCheck(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.GameTypeStaticsRuntimeTing), make([]byte, 16)); err != nil {
|
|
t.Fatalf("handle game statics-runtime: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("statics-runtime game command emitted legacy client-check bytes: %x", conn.write.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperMultiplexedStateEchoesCurrentEXEDiscriminator(t *testing.T) {
|
|
tests := []struct {
|
|
discriminator uint32
|
|
bodyLength int
|
|
}{
|
|
// 注:222 与 2208 从这张表里**移出**了。它们现在是活动(升级预约 / 周签到)
|
|
// 借 op689 带货的分支,必须在"通用 echo"之前分流;下面的
|
|
// TestHandleUpperMultiplexedStateRoutesActivityDiscriminators 专门钉住它们。
|
|
{discriminator: 349, bodyLength: 12},
|
|
{discriminator: 2099, bodyLength: 16},
|
|
{discriminator: 2384, bodyLength: 16},
|
|
{discriminator: 2427, bodyLength: 12},
|
|
{discriminator: 2428, bodyLength: 8},
|
|
{discriminator: 2443, bodyLength: 4},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(strconv.FormatUint(uint64(test.discriminator), 10), func(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
body := make([]byte, test.bodyLength)
|
|
binary.LittleEndian.PutUint32(body[:4], test.discriminator)
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCreatePostState), body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build multiplexed-state upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle multiplexed-state upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected trailing bytes: %d", len(rest))
|
|
}
|
|
if packet.Header.MsgID != uint16(dnfenum.UpperMsgCreatePostState) {
|
|
t.Fatalf("msg id = %d", packet.Header.MsgID)
|
|
}
|
|
wantBody := make([]byte, 5)
|
|
wantBody[0] = 1
|
|
binary.LittleEndian.PutUint32(wantBody[1:], test.discriminator)
|
|
if !bytes.Equal(packet.Body, wantBody) {
|
|
t.Fatalf("multiplexed-state body = %x, want %x", packet.Body, wantBody)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperMultiplexedStateRejectsUnprovedShapes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body []byte
|
|
}{
|
|
{name: "missing discriminator", body: make([]byte, 3)},
|
|
// 349 是已知判别号但长度错(应为 12):必须静默丢弃,不能 echo。
|
|
{name: "known discriminator wrong length", body: func() []byte {
|
|
body := make([]byte, 16)
|
|
binary.LittleEndian.PutUint32(body[:4], 349)
|
|
return body
|
|
}()},
|
|
{name: "unknown discriminator", body: func() []byte {
|
|
body := make([]byte, 4)
|
|
binary.LittleEndian.PutUint32(body, 9999)
|
|
return body
|
|
}()},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCreatePostState), test.body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build multiplexed-state upper: %v", err)
|
|
}
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle multiplexed-state upper: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("unproved request emitted bytes: %x", conn.write.Bytes())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCheckCharacterGateEchoesCandidate(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
body := make([]byte, 8)
|
|
binary.LittleEndian.PutUint32(body[0:4], 9)
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCheckCharacterGate), body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build gate upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle gate upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected trailing bytes: %d", len(rest))
|
|
}
|
|
if packet.Header.MsgID != uint16(dnfenum.UpperMsgCheckCharacterGate) {
|
|
t.Fatalf("msg id = %d", packet.Header.MsgID)
|
|
}
|
|
if !bytes.Equal(packet.Body, []byte{1, 9, 0, 0, 0}) {
|
|
t.Fatalf("gate body = %x", packet.Body)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCheckCharacterGateKeepsZeroFirstValue(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
body := make([]byte, 8)
|
|
binary.LittleEndian.PutUint32(body[4:8], 9)
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCheckCharacterGate), body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build gate upper: %v", err)
|
|
}
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle gate upper: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected trailing bytes: %d", len(rest))
|
|
}
|
|
if !bytes.Equal(packet.Body, []byte{1, 0, 0, 0, 0}) {
|
|
t.Fatalf("gate body = %x", packet.Body)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperCheckCharacterGateRejectsUnprovedShapes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body []byte
|
|
}{
|
|
{name: "short", body: make([]byte, 4)},
|
|
{name: "long", body: make([]byte, 12)},
|
|
{name: "candidate sentinel", body: []byte{0xff, 0xff, 0xff, 0xff, 1, 0, 0, 0}},
|
|
{name: "pair sentinel", body: []byte{1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgCheckCharacterGate), test.body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build gate upper: %v", err)
|
|
}
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle gate upper: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("unproved gate emitted bytes: %x", conn.write.Bytes())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleGameSelectCharacterEchoesLatestStateTuple(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
channel := channelcatalog.Channel{ServerID: 1, ID: 16, Type: 1, Name: "ch.16", Port: 10016}
|
|
session := &gameSession{conn: conn, channel: channel, residentChannel: channel}
|
|
body := []byte{1, 0, 0, 0, 2, 3, 4, 0, 0, 0, 5}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.GameTypeSelectCharacter), body); err != nil {
|
|
t.Fatalf("handle select: %v", err)
|
|
}
|
|
records, err := splitWrittenGameRecords(conn.write.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("split select record: %v", err)
|
|
}
|
|
if len(records) != 1 ||
|
|
records[0].GameHeader.Cmd != byte(dnfenum.GameCmdCommand) ||
|
|
records[0].GameHeader.Type != uint16(dnfenum.GameTypeSelectCharacter) ||
|
|
!bytes.Equal(records[0].Body, body) {
|
|
t.Fatalf("select response = %+v body=%x", records, records[0].Body)
|
|
}
|
|
}
|
|
|
|
func TestHandleLegacyGameSelectCharacterStripsCodecPrefix(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
channel := channelcatalog.Channel{ServerID: 1, ID: 16, Type: 1, Name: "ch.16", Port: 10016}
|
|
session := &gameSession{conn: conn, channel: channel, residentChannel: channel}
|
|
payload := []byte{1, 0, 0, 0, 2, 3, 4, 0, 0, 0, 5}
|
|
raw := buildLegacyGamePacketForBridgeTest(
|
|
byte(dnfenum.GameCmdCommand),
|
|
uint16(dnfenum.GameTypeSelectCharacter),
|
|
0,
|
|
append([]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee}, payload...),
|
|
)
|
|
|
|
if err := service.handleLegacyGamePacket(session, raw); err != nil {
|
|
t.Fatalf("handle legacy select: %v", err)
|
|
}
|
|
records, err := splitWrittenGameRecords(conn.write.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("split select record: %v", err)
|
|
}
|
|
if len(records) != 1 || !bytes.Equal(records[0].Body, payload) {
|
|
t.Fatalf("select response body = %x, want %x", records[0].Body, payload)
|
|
}
|
|
}
|
|
|
|
func TestHandleGameFinishLoadingReturnsUpperStatusSuccess(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
body := make([]byte, 8)
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.GameTypeFinishLoading), body); err != nil {
|
|
t.Fatalf("handle loading: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.CmdPacketFinishLoading) ||
|
|
!bytes.Equal(packet.Body, []byte{1}) {
|
|
t.Fatalf("finish-loading status = class %d msg %d body=%x", packet.Header.Classification, packet.Header.MsgID, packet.Body)
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("finish-loading status trailing bytes=%x", rest)
|
|
}
|
|
}
|
|
|
|
func TestHandleGameFinishLoadingRejectsUnexpectedProtectedBodyLengths(t *testing.T) {
|
|
for _, bodyLen := range []int{0, 15, 17, 32} {
|
|
t.Run(fmt.Sprintf("body_len_%d", bodyLen), func(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038},
|
|
selectedCharacterID: 77,
|
|
postStartMapPlayerStateSent: true,
|
|
currentFinishLoadingStateSent: false,
|
|
currentFinishLoadingCompletionSent: false,
|
|
postFinishLoadingPlayerStateSent: false,
|
|
}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.GameTypeFinishLoading), make([]byte, bodyLen)); err != nil {
|
|
t.Fatalf("handle malformed finish loading: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("malformed finish loading wrote %d bytes", conn.write.Len())
|
|
}
|
|
if session.currentFinishLoadingStateSent || session.currentFinishLoadingCompletionSent || session.postFinishLoadingPlayerStateSent {
|
|
t.Fatalf("malformed finish loading advanced gates state=%t completion=%t post=%t",
|
|
session.currentFinishLoadingStateSent,
|
|
session.currentFinishLoadingCompletionSent,
|
|
session.postFinishLoadingPlayerStateSent)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleGameContentsPlayInfoDefersSidePanelAck(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.GameTypeContentsPlayInfo), make([]byte, 16)); err != nil {
|
|
t.Fatalf("handle contents play info: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("contents play info wrote %d bytes, want deferred", conn.write.Len())
|
|
}
|
|
}
|
|
|
|
func TestHandleGameRequestBlacklistReturnsCurrentSceneOp120Body(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038},
|
|
selectedCharacterID: 12,
|
|
// The client asks while the select-character tail is pending too, but the
|
|
// answer is held until it commits; the request that produces the body is the
|
|
// one after that point.
|
|
sceneBootstrapTailSent: true,
|
|
}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRequestBlacklist), make([]byte, 16)); err != nil {
|
|
t.Fatalf("handle request blacklist: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestBlacklist) ||
|
|
!bytes.Equal(packet.Body, currentRequestBlacklistResponseBody) {
|
|
t.Fatalf("request blacklist response = class %d msg %d body=%x want=%x", packet.Header.Classification, packet.Header.MsgID, packet.Body, currentRequestBlacklistResponseBody)
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("request blacklist sent runtime seed before op29 = %d", len(rest))
|
|
}
|
|
if session.runtimeAfterBlacklistSent || session.runtimeFinishLoadingGateSent {
|
|
t.Fatalf("runtime flags after request blacklist: after_blacklist=%v finish_gate=%v", session.runtimeAfterBlacklistSent, session.runtimeFinishLoadingGateSent)
|
|
}
|
|
|
|
conn.write.Reset()
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRequestBlacklist), make([]byte, 16)); err != nil {
|
|
t.Fatalf("handle repeated request blacklist: %v", err)
|
|
}
|
|
packet, rest = splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.MsgID != uint16(dnfenum.CmdPacketRequestBlacklist) ||
|
|
!bytes.Equal(packet.Body, currentRequestBlacklistResponseBody) {
|
|
t.Fatalf("repeated request blacklist response = msg %d body=%x", packet.Header.MsgID, packet.Body)
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("repeated request blacklist resent finish gate bytes = %d", len(rest))
|
|
}
|
|
}
|
|
|
|
func TestCurrentRequestBlacklistBodyMatchesUpperBlacklistEmptyResult(t *testing.T) {
|
|
if !bytes.Equal(currentRequestBlacklistResponseBody, []byte{0, 0}) {
|
|
t.Fatalf("current upper blacklist/op120 body = %x, want 0000", currentRequestBlacklistResponseBody)
|
|
}
|
|
if len(currentRequestBlacklistResponseBody) != 2 {
|
|
t.Fatalf("current upper blacklist/op120 body len = %d, want 2", len(currentRequestBlacklistResponseBody))
|
|
}
|
|
}
|
|
|
|
func TestCurrentSceneActorPlacementBodyMatchesMainOp120Reader(t *testing.T) {
|
|
if body := buildCurrentSceneActorPlacementBody(); !bytes.Equal(body, []byte{0, 0}) {
|
|
t.Fatalf("current main scene op120 body = %x, want actor_slot=0 placement_seed=0", body)
|
|
}
|
|
}
|
|
|
|
func TestHandleGameRequestBlacklistAfterSceneTailSendsSafeRuntimeSeed(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038},
|
|
selectedCharacterID: 12,
|
|
sceneBootstrapTailSent: true,
|
|
postStartMapPlayerStateSent: true,
|
|
}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRequestBlacklist), make([]byte, 16)); err != nil {
|
|
t.Fatalf("handle request blacklist after scene tail: %v", err)
|
|
}
|
|
rest := conn.write.Bytes()
|
|
blacklist, rest := splitGameServerUpperPacket(t, rest)
|
|
if blacklist.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
blacklist.Header.MsgID != uint16(dnfenum.CmdPacketRequestBlacklist) ||
|
|
!bytes.Equal(blacklist.Body, currentRequestBlacklistResponseBody) {
|
|
t.Fatalf("request blacklist response = class %d msg %d body=%x", blacklist.Header.Classification, blacklist.Header.MsgID, blacklist.Body)
|
|
}
|
|
rest = assertRuntimeAfterBlacklistSafePrefix(t, service, session, rest)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("request blacklist trailing bytes after runtime seed = %d", len(rest))
|
|
}
|
|
if !session.runtimeAfterBlacklistSent || session.runtimeFinishLoadingGateSent {
|
|
t.Fatalf("runtime flags after scene tail: after_blacklist=%v finish_gate=%v", session.runtimeAfterBlacklistSent, session.runtimeFinishLoadingGateSent)
|
|
}
|
|
|
|
conn.write.Reset()
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketRequestBlacklist), make([]byte, 16)); err != nil {
|
|
t.Fatalf("handle repeated request blacklist after scene tail: %v", err)
|
|
}
|
|
_, rest = splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("repeated request blacklist resent runtime seed bytes = %d", len(rest))
|
|
}
|
|
}
|
|
|
|
func TestHandleGameFpsDevideCollectDefersFinishLoadingGate(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038},
|
|
selectedCharacterID: 12,
|
|
}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketFpsDevideCollect), make([]byte, 976)); err != nil {
|
|
t.Fatalf("handle fps devide collect: %v", err)
|
|
}
|
|
if got := conn.write.Len(); got != 0 {
|
|
t.Fatalf("fps gate wrote %d bytes after class0/op37 defer", got)
|
|
}
|
|
if !session.fpsFinishLoadingGateSent {
|
|
t.Fatalf("fpsFinishLoadingGateSent = false")
|
|
}
|
|
|
|
conn.write.Reset()
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketFpsDevideCollect), make([]byte, 976)); err != nil {
|
|
t.Fatalf("handle repeated fps devide collect: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("repeated fps collect wrote %d bytes", conn.write.Len())
|
|
}
|
|
}
|
|
|
|
func TestHandleGameFpsDevideCollectSkipsAfterSceneBootstrapTail(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038},
|
|
selectedCharacterID: 12,
|
|
sceneBootstrapTailSent: true,
|
|
}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketFpsDevideCollect), make([]byte, 976)); err != nil {
|
|
t.Fatalf("handle fps devide collect after scene tail: %v", err)
|
|
}
|
|
if conn.write.Len() != 0 {
|
|
t.Fatalf("fps collect after scene tail wrote %d bytes", conn.write.Len())
|
|
}
|
|
if session.fpsFinishLoadingGateSent {
|
|
t.Fatalf("fpsFinishLoadingGateSent after scene tail = true")
|
|
}
|
|
}
|
|
|
|
func TestHandleGameGuildAllMemberListFailsClosedWithoutRepository(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038},
|
|
selectedCharacterID: 12,
|
|
}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketGuildAllMemberList), make([]byte, 16)); err != nil {
|
|
t.Fatalf("handle guild all member list: %v", err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.MsgID != uint16(dnfenum.CmdPacketGuildAllMemberList) || !bytes.Equal(packet.Body, []byte{0, currentGuildGenericError}) || len(rest) != 0 {
|
|
t.Fatalf("guild all member list response msg=%d body=%x trailing=%x", packet.Header.MsgID, packet.Body, rest)
|
|
}
|
|
}
|
|
|
|
func TestHandleGameEnterSelectDungeonUsesRuntimeSceneEnums(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.GameTypeEnterSelectDungeon), nil); err != nil {
|
|
t.Fatalf("handle enter select dungeon: %v", err)
|
|
}
|
|
records, err := splitWrittenGameRecords(conn.write.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("split enter select dungeon records: %v", err)
|
|
}
|
|
if len(records) != 2 {
|
|
t.Fatalf("enter select dungeon records = %d, want 2", len(records))
|
|
}
|
|
if records[0].GameHeader.Type != uint16(dnfenum.CmdPacketEnterSelectDungeon) {
|
|
t.Fatalf("record[0] type = %d, want %d", records[0].GameHeader.Type, dnfenum.CmdPacketEnterSelectDungeon)
|
|
}
|
|
if wantBody := []byte{3, byte(currentSceneBootstrapObjectKey & 0xff), byte(currentSceneBootstrapObjectKey >> 8)}; !bytes.Equal(records[0].Body, wantBody) {
|
|
t.Fatalf("record[0] body = %x, want %x", records[0].Body, wantBody)
|
|
}
|
|
if records[1].GameHeader.Type != currentFatigueMsgID {
|
|
t.Fatalf("record[1] type = %d, want %d", records[1].GameHeader.Type, currentFatigueMsgID)
|
|
}
|
|
if wantBody := buildCurrentFatigueBody(dnfrepo.CharacterRecord{}, false); !bytes.Equal(records[1].Body, wantBody) {
|
|
t.Fatalf("record[1] body = %x, want %x", records[1].Body, wantBody)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperEnterSelectDungeonUsesRuntimeSceneEnums(t *testing.T) {
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038},
|
|
selectedCharacterID: 12,
|
|
enterSelectDungeonSent: true,
|
|
enterSelectDungeonAckSent: false,
|
|
}
|
|
frame, err := dnfproto.BuildChannelPacket(uint16(dnfenum.UpperMsgSelectStart), nil, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build enter select upper: %v", err)
|
|
}
|
|
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle enter select upper: %v", err)
|
|
}
|
|
first, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if first.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
first.Header.MsgID != uint16(dnfenum.CmdPacketEnterSelectDungeon) {
|
|
t.Fatalf("first upper = class %d msg %d", first.Header.Classification, first.Header.MsgID)
|
|
}
|
|
if wantBody := upperSuccessBody(buildEnterSelectDungeonAckBody()); !bytes.Equal(first.Body, wantBody) {
|
|
t.Fatalf("first upper body = %x, want %x", first.Body, wantBody)
|
|
}
|
|
second, rest := splitGameServerUpperPacket(t, rest)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected trailing bytes: %d", len(rest))
|
|
}
|
|
if second.Header.Classification != 0 ||
|
|
second.Header.MsgID != currentFatigueMsgID ||
|
|
!bytes.Equal(second.Body, buildCurrentFatigueBody(dnfrepo.CharacterRecord{}, false)) {
|
|
t.Fatalf("second upper = class %d msg %d body=%x", second.Header.Classification, second.Header.MsgID, second.Body)
|
|
}
|
|
if !session.enterSelectDungeonAckSent {
|
|
t.Fatalf("request-driven enter-select ACK was not marked sent")
|
|
}
|
|
|
|
conn.write.Reset()
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatalf("handle duplicate enter select upper: %v", err)
|
|
}
|
|
first, rest = splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if first.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
first.Header.MsgID != uint16(dnfenum.CmdPacketEnterSelectDungeon) ||
|
|
!bytes.Equal(first.Body, upperSuccessBody(buildEnterSelectDungeonAckBody())) {
|
|
t.Fatalf("duplicate first upper = header=%+v body=%x", first.Header, first.Body)
|
|
}
|
|
second, rest = splitGameServerUpperPacket(t, rest)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("duplicate enter-select trailing bytes: %d", len(rest))
|
|
}
|
|
if second.Header.Classification != 0 ||
|
|
second.Header.MsgID != currentFatigueMsgID ||
|
|
!bytes.Equal(second.Body, buildCurrentFatigueBody(dnfrepo.CharacterRecord{}, false)) {
|
|
t.Fatalf("duplicate second upper = class %d msg %d body=%x", second.Header.Classification, second.Header.MsgID, second.Body)
|
|
}
|
|
}
|
|
|
|
func TestGameListenPortsUsesOnlyExactCatalogPorts(t *testing.T) {
|
|
const fixture = `
|
|
[dungeon]
|
|
` + "`[trade]` `交易频道` 1" + `
|
|
[/dungeon]
|
|
[server]
|
|
1 1 ` + "`频道1`" + ` 1 ` + "`[trade]`" + ` 0 0
|
|
[/server]
|
|
`
|
|
index, err := channelinfo.Parse([]byte(fixture))
|
|
if err != nil {
|
|
t.Fatalf("parse channel info: %v", err)
|
|
}
|
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: 1})
|
|
if err != nil {
|
|
t.Fatalf("build catalog: %v", err)
|
|
}
|
|
service := &Service{}
|
|
|
|
ports := service.gameListenPorts(catalog)
|
|
if intSliceContains(ports, dnfenum.GamePortBase+dnfenum.DefaultGameChannelID) {
|
|
t.Fatalf("ports = %+v, unexpected fabricated default port %d", ports, dnfenum.GamePortBase+dnfenum.DefaultGameChannelID)
|
|
}
|
|
if !intSliceContains(ports, dnfenum.GamePortBase+1) {
|
|
t.Fatalf("ports = %+v, want catalog port %d", ports, dnfenum.GamePortBase+1)
|
|
}
|
|
for i := 1; i < len(ports); i++ {
|
|
if ports[i-1] > ports[i] {
|
|
t.Fatalf("ports not sorted: %+v", ports)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChannelForPortRejectsSyntheticPortMissingFrom90CNCatalog(t *testing.T) {
|
|
catalog := testCatalogWithoutAutoChannel(t)
|
|
service := &Service{
|
|
options: options{channelServerID: 1, channelAdvertiseID: 0},
|
|
catalog: catalog,
|
|
}
|
|
|
|
channel, found := service.channelForPort(dnfenum.GamePortBase + 99)
|
|
if found || channel.ID != 0 || channel.Port != 0 {
|
|
t.Fatalf("synthetic port unexpectedly resolved found=%t channel=%+v", found, channel)
|
|
}
|
|
}
|
|
|
|
func TestDofLoginPrefaceKeepsNextChannelPacket(t *testing.T) {
|
|
preface := make([]byte, dnfproto.DofLoginPrefaceSize)
|
|
copy(preface, dnfproto.BuildLegacyClientPacket(dnfenum.LegacyMsgDofLoginPreface, nil))
|
|
copy(preface[dnfproto.LegacyChannelHeaderSize:], bytes.Repeat([]byte{1}, dnfproto.DofLoginTokenSize))
|
|
channelPacket, err := dnfproto.BuildChannelPacket(uint16(dnfenum.MsgCSConnect), nil, 1, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatalf("build channel packet: %v", err)
|
|
}
|
|
reader := bufio.NewReader(bytes.NewReader(append(preface, channelPacket...)))
|
|
service := &Service{}
|
|
conn := &bufferConn{}
|
|
session := &channelSession{conn: conn, connID: "channel-test"}
|
|
|
|
if err := service.acceptDofLoginPreface(reader, session); err != nil {
|
|
t.Fatalf("accept preface: %v", err)
|
|
}
|
|
ack, rest := splitLegacyPacket(t, conn.write.Bytes(), legacyMsgID(dnfenum.LegacyMsgDofLoginAck))
|
|
if len(ack) != 47 || len(rest) != 0 {
|
|
t.Fatalf("unexpected preface ack len=%d rest=%d", len(ack), len(rest))
|
|
}
|
|
packet, err := service.readChannelPacket(reader, session)
|
|
if err != nil {
|
|
t.Fatalf("read channel after preface: %v", err)
|
|
}
|
|
if packet.Header.MsgID != uint16(dnfenum.MsgCSConnect) {
|
|
t.Fatalf("msg id = %d", packet.Header.MsgID)
|
|
}
|
|
}
|
|
|
|
func TestReadChannelPacketAcceptsMixedLegacyConnect(t *testing.T) {
|
|
raw := []byte{0x00, 0x09, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x11}
|
|
service := &Service{}
|
|
session := &channelSession{conn: &bufferConn{}, connID: "channel-test"}
|
|
|
|
packet, err := service.readChannelPacket(bytes.NewReader(raw), session)
|
|
if err != nil {
|
|
t.Fatalf("read mixed legacy connect: %v", err)
|
|
}
|
|
if packet.Header.MsgID != uint16(dnfenum.MsgCSConnect) {
|
|
t.Fatalf("msg id = %d, want %d", packet.Header.MsgID, dnfenum.MsgCSConnect)
|
|
}
|
|
if packet.Header.Length != uint32(len(raw)) {
|
|
t.Fatalf("length = %d, want %d", packet.Header.Length, len(raw))
|
|
}
|
|
if !bytes.Equal(packet.Body, raw[4:]) {
|
|
t.Fatalf("body = %x, want %x", packet.Body, raw[4:])
|
|
}
|
|
}
|
|
|
|
func TestHandleLegacyChannelRefreshDoesNotResendScript(t *testing.T) {
|
|
catalog := testChannelCatalog(t)
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
channelServerID: 1,
|
|
channelAdvertiseID: 0,
|
|
channelInfoFile: tempChannelInfoFile(t),
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
request := make([]byte, dnfproto.DofAskChannelSize)
|
|
copy(request, dnfproto.BuildLegacyClientPacket(dnfenum.LegacyMsgAskChannelInfo, nil))
|
|
copy(request[dnfproto.LegacyChannelHeaderSize:], []byte("cain"))
|
|
reader := bufio.NewReader(bytes.NewReader(request))
|
|
conn := &bufferConn{}
|
|
session := &channelSession{conn: conn}
|
|
|
|
handled, err := service.handleLegacyChannelRequest(reader, session)
|
|
if err != nil {
|
|
t.Fatalf("handle legacy ask: %v", err)
|
|
}
|
|
if !handled {
|
|
t.Fatal("expected legacy request handled")
|
|
}
|
|
if session.scriptSent {
|
|
t.Fatal("refresh without prior 0x09 must not resend the online script")
|
|
}
|
|
if session.closeAfterBootstrapDirectory {
|
|
t.Fatal("normal refresh must keep the channel connection open")
|
|
}
|
|
info, rest := splitLegacyPacket(t, conn.write.Bytes(), legacyMsgID(dnfenum.LegacyMsgChannelInfo))
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected trailing bytes: %d", len(rest))
|
|
}
|
|
raw, err := decryptCompressedChannelData(info[dnfproto.LegacyChannelHeaderSize:], service.aesKey())
|
|
if err != nil {
|
|
t.Fatalf("decrypt legacy channel body: %v", err)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(raw[4:8]); got != 0 {
|
|
t.Fatalf("server index = %d, want online server 0", got)
|
|
}
|
|
assertChannelInfoHasEntries(t, raw, 2,
|
|
channelEntryWant{name: "#ch.11", port: dnfenum.GamePortBase + 11},
|
|
channelEntryWant{name: "#ch.38", port: dnfenum.GamePortBase + 38},
|
|
)
|
|
}
|
|
|
|
func TestQuietChannelReadErrorTreatsTimeoutAsQuiet(t *testing.T) {
|
|
if !isQuietChannelReadError(timeoutNetError{}) {
|
|
t.Fatal("channel idle timeout should be treated as quiet close")
|
|
}
|
|
}
|
|
|
|
func TestHandleLatestChannelAskSendsScriptBeforeChannelInfo(t *testing.T) {
|
|
catalog := testChannelCatalog(t)
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
channelServerID: 1,
|
|
channelAdvertiseID: 0,
|
|
channelInfoFile: tempChannelInfoFile(t),
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &channelSession{conn: conn}
|
|
|
|
err := service.handleChannelPacket(session, dnfproto.ChannelPacket{
|
|
Header: dnfproto.ChannelHeader{
|
|
Classification: dnfproto.DefaultChannelClassification,
|
|
MsgID: uint16(dnfenum.MsgCSAskChannelInfoNew),
|
|
},
|
|
Body: []byte("cain"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("handle latest ask: %v", err)
|
|
}
|
|
if !session.scriptSent {
|
|
t.Fatal("latest ask without prior 0x09 must send fallback script")
|
|
}
|
|
script, rest := splitChannelPacket(t, conn.write.Bytes())
|
|
if script.Header.MsgID != uint16(dnfenum.MsgSCGetScript) {
|
|
t.Fatalf("script msg id = %d, want %d", script.Header.MsgID, dnfenum.MsgSCGetScript)
|
|
}
|
|
info, rest := splitChannelPacket(t, rest)
|
|
if info.Header.MsgID != uint16(dnfenum.MsgSCAskChannelInfoNew) {
|
|
t.Fatalf("msg id = %d, want %d", info.Header.MsgID, dnfenum.MsgSCAskChannelInfoNew)
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected trailing bytes: %d", len(rest))
|
|
}
|
|
raw, err := decryptCompressedChannelData(info.Body, service.aesKey())
|
|
if err != nil {
|
|
t.Fatalf("decrypt latest channel body: %v", err)
|
|
}
|
|
assertChannelInfoHasEntries(t, raw, 2,
|
|
channelEntryWant{name: "#ch.11", port: dnfenum.GamePortBase + 11},
|
|
channelEntryWant{name: "#ch.38", port: dnfenum.GamePortBase + 38},
|
|
)
|
|
}
|
|
|
|
func TestHandleLatestGetScriptReturnsScript(t *testing.T) {
|
|
service := &Service{
|
|
options: options{
|
|
channelInfoFile: tempChannelInfoFile(t),
|
|
},
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &channelSession{conn: conn}
|
|
|
|
err := service.handleChannelPacket(session, dnfproto.ChannelPacket{
|
|
Header: dnfproto.ChannelHeader{
|
|
Classification: dnfproto.DefaultChannelClassification,
|
|
MsgID: uint16(dnfenum.MsgCSGetScript),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("handle latest get script: %v", err)
|
|
}
|
|
if !session.scriptSent {
|
|
t.Fatal("expected scriptSent after latest get script")
|
|
}
|
|
script, rest := splitChannelPacket(t, conn.write.Bytes())
|
|
if script.Header.MsgID != uint16(dnfenum.MsgSCGetScript) {
|
|
t.Fatalf("msg id = %d, want %d", script.Header.MsgID, dnfenum.MsgSCGetScript)
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected trailing bytes: %d", len(rest))
|
|
}
|
|
if len(script.Body) == 0 {
|
|
t.Fatal("script body is empty")
|
|
}
|
|
}
|
|
|
|
func TestHandleLegacyChannelProbeKeepsFollowingAskPacket(t *testing.T) {
|
|
catalog := testChannelCatalog(t)
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
channelServerID: 1,
|
|
channelAdvertiseID: 0,
|
|
channelInfoFile: tempChannelInfoFile(t),
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
probe := dnfproto.BuildLegacyClientPacket(dnfenum.LegacyMsgDofChannelProbe, nil)
|
|
ask := make([]byte, dnfproto.DofAskChannelSize)
|
|
copy(ask, dnfproto.BuildLegacyClientPacket(dnfenum.LegacyMsgAskChannelInfo, nil))
|
|
copy(ask[dnfproto.LegacyChannelHeaderSize:], []byte("cain"))
|
|
reader := bufio.NewReader(bytes.NewReader(append(probe, ask...)))
|
|
conn := &bufferConn{}
|
|
session := &channelSession{conn: conn}
|
|
|
|
handled, err := service.handleLegacyChannelRequest(reader, session)
|
|
if err != nil {
|
|
t.Fatalf("handle probe: %v", err)
|
|
}
|
|
if !handled {
|
|
t.Fatal("expected probe handled")
|
|
}
|
|
script, rest := splitLegacyPacket(t, conn.write.Bytes(), legacyMsgID(dnfenum.LegacyMsgDofScript))
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected bytes after script: %d", len(rest))
|
|
}
|
|
if len(script) <= dnfproto.LegacyChannelHeaderSize {
|
|
t.Fatalf("script response too short: %d", len(script))
|
|
}
|
|
if !session.scriptSent {
|
|
t.Fatal("expected scriptSent after get script")
|
|
}
|
|
handled, err = service.handleLegacyChannelRequest(reader, session)
|
|
if err != nil {
|
|
t.Fatalf("handle ask after probe: %v", err)
|
|
}
|
|
if !handled {
|
|
t.Fatal("expected ask handled after probe")
|
|
}
|
|
if !session.closeAfterBootstrapDirectory {
|
|
t.Fatal("bootstrap directory must close the legacy connection so the client commits it")
|
|
}
|
|
_, rest = splitLegacyPacket(t, conn.write.Bytes(), legacyMsgID(dnfenum.LegacyMsgDofScript))
|
|
info, rest := splitLegacyPacket(t, rest, legacyMsgID(dnfenum.LegacyMsgChannelInfo))
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected trailing bytes: %d", len(rest))
|
|
}
|
|
if len(info) <= dnfproto.LegacyChannelHeaderSize {
|
|
t.Fatalf("response too short: %d", len(info))
|
|
}
|
|
}
|
|
|
|
func TestHandleChannelUpdateReturnsLoginChannelEndpoint(t *testing.T) {
|
|
catalog := testChannelCatalog(t)
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
conn := &bufferConn{}
|
|
|
|
err := service.handleChannelPacket(&channelSession{conn: conn}, dnfproto.ChannelPacket{
|
|
Header: dnfproto.ChannelHeader{
|
|
Classification: dnfproto.DefaultChannelClassification,
|
|
MsgID: uint16(dnfenum.MsgCSUpdateChannelInfo),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("handle channel update: %v", err)
|
|
}
|
|
packet, err := dnfproto.ParseChannelPacket(conn.write.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("parse update response: %v", err)
|
|
}
|
|
if packet.Header.MsgID != uint16(dnfenum.MsgSCAskChannelInfo) {
|
|
t.Fatalf("msg id = %d, want %d", packet.Header.MsgID, dnfenum.MsgSCAskChannelInfo)
|
|
}
|
|
if len(packet.Body) != 32 {
|
|
t.Fatalf("body len = %d, want 32", len(packet.Body))
|
|
}
|
|
if got := binary.LittleEndian.Uint32(packet.Body[20:24]); got != dnfenum.GamePortBase+11 {
|
|
t.Fatalf("port = %d, want %d", got, dnfenum.GamePortBase+11)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(packet.Body[24:28]); got != dnfenum.DefaultChannelMaxUsers {
|
|
t.Fatalf("max users = %d, want %d", got, dnfenum.DefaultChannelMaxUsers)
|
|
}
|
|
}
|
|
|
|
func TestHandleChannelUpdateUsesResidentChannel11Endpoint(t *testing.T) {
|
|
catalog := testCatalogWithoutAutoChannel(t)
|
|
service := &Service{
|
|
options: options{
|
|
serverIP: "203.0.113.10",
|
|
channelServerID: 1,
|
|
channelAdvertiseID: 0,
|
|
},
|
|
catalog: catalog,
|
|
}
|
|
conn := &bufferConn{}
|
|
|
|
err := service.handleChannelPacket(&channelSession{conn: conn}, dnfproto.ChannelPacket{
|
|
Header: dnfproto.ChannelHeader{
|
|
Classification: dnfproto.DefaultChannelClassification,
|
|
MsgID: uint16(dnfenum.MsgCSUpdateChannelInfo),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("handle channel update: %v", err)
|
|
}
|
|
packet, err := dnfproto.ParseChannelPacket(conn.write.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("parse update response: %v", err)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(packet.Body[20:24]); got != dnfenum.GamePortBase+dnfenum.DefaultGameChannelID {
|
|
t.Fatalf("port = %d, want %d", got, dnfenum.GamePortBase+dnfenum.DefaultGameChannelID)
|
|
}
|
|
}
|
|
|
|
func TestHandleChannelUpdateFallsBackToOneClickChannel19(t *testing.T) {
|
|
index, err := channelinfo.Parse([]byte("[server] 1\n19 `forest` 11 `[granfloris]` 5 0 0 ``\n[/server]\n"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: 1})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &Service{
|
|
options: options{serverIP: "203.0.113.10"},
|
|
catalog: catalog,
|
|
}
|
|
conn := &bufferConn{}
|
|
|
|
err = service.handleChannelPacket(&channelSession{conn: conn}, dnfproto.ChannelPacket{
|
|
Header: dnfproto.ChannelHeader{
|
|
Classification: dnfproto.DefaultChannelClassification,
|
|
MsgID: uint16(dnfenum.MsgCSUpdateChannelInfo),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("handle compact channel update: %v", err)
|
|
}
|
|
packet, err := dnfproto.ParseChannelPacket(conn.write.Bytes())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(packet.Body[20:24]); got != dnfenum.GamePortBase+dnfenum.BootstrapChannelID {
|
|
t.Fatalf("compact login port = %d, want %d", got, dnfenum.GamePortBase+dnfenum.BootstrapChannelID)
|
|
}
|
|
}
|
|
|
|
func decryptCompressedChannelData(data []byte, key string) ([]byte, error) {
|
|
encrypted, err := zlibDecompress(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keyBytes := make([]byte, aes.BlockSize)
|
|
copy(keyBytes, []byte(key))
|
|
block, err := aes.NewCipher(keyBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]byte, len(encrypted))
|
|
for offset := 0; offset < len(encrypted); offset += aes.BlockSize {
|
|
block.Decrypt(out[offset:offset+aes.BlockSize], encrypted[offset:offset+aes.BlockSize])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type channelEntryWant struct {
|
|
name string
|
|
port int
|
|
}
|
|
|
|
func assertChannelInfoHasEntries(t *testing.T, raw []byte, count int, wants ...channelEntryWant) {
|
|
t.Helper()
|
|
got := binary.LittleEndian.Uint32(raw[8:12])
|
|
if got != uint32(count) {
|
|
t.Fatalf("channel count = %d, want %d", got, count)
|
|
}
|
|
for _, want := range wants {
|
|
if !hasChannelEntryFrom(raw, 12, int(got), want.name, want.port) {
|
|
t.Fatalf("channel body missing %s/%d: %x", want.name, want.port, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func hasChannelEntryFrom(raw []byte, offset int, count int, name string, port int) bool {
|
|
for i := 0; i < count; i++ {
|
|
if offset+48 > len(raw) {
|
|
return false
|
|
}
|
|
entryName, entryPort := channelEntryNamePort(raw, offset)
|
|
if entryName == name && entryPort == port {
|
|
return true
|
|
}
|
|
offset += 48
|
|
}
|
|
return false
|
|
}
|
|
|
|
func channelEntryNamePort(raw []byte, offset int) (string, int) {
|
|
if offset+48 > len(raw) {
|
|
return "", 0
|
|
}
|
|
return trimFixedString(raw[offset : offset+20]), int(binary.LittleEndian.Uint32(raw[offset+44 : offset+48]))
|
|
}
|
|
|
|
func trimFixedString(data []byte) string {
|
|
if end := bytes.IndexByte(data, 0); end >= 0 {
|
|
data = data[:end]
|
|
}
|
|
return string(bytes.TrimSpace(data))
|
|
}
|
|
|
|
func splitLegacyPacket(t *testing.T, data []byte, msgID byte) ([]byte, []byte) {
|
|
t.Helper()
|
|
if len(data) < dnfproto.LegacyChannelHeaderSize {
|
|
t.Fatalf("legacy packet too short: %d", len(data))
|
|
}
|
|
if data[0] != dnfenum.ChannelPacketClass || data[1] != msgID {
|
|
t.Fatalf("legacy header = %x, want class=%02x msg=%02x", data[:dnfproto.LegacyChannelHeaderSize], dnfenum.ChannelPacketClass, msgID)
|
|
}
|
|
total := int(binary.LittleEndian.Uint32(data[2:6]))
|
|
if total < dnfproto.LegacyChannelHeaderSize || total > len(data) {
|
|
t.Fatalf("legacy length = %d, data len = %d", total, len(data))
|
|
}
|
|
if data[10] != 1 {
|
|
t.Fatalf("legacy seq = %d, want 1", data[10])
|
|
}
|
|
return data[:total], data[total:]
|
|
}
|
|
|
|
func splitChannelPacket(t *testing.T, data []byte) (dnfproto.ChannelPacket, []byte) {
|
|
t.Helper()
|
|
if len(data) < dnfproto.ChannelHeaderSize {
|
|
t.Fatalf("channel packet too short: %d", len(data))
|
|
}
|
|
total := int(binary.LittleEndian.Uint32(data[3:7]))
|
|
if total < dnfproto.ChannelHeaderSize || total > len(data) {
|
|
t.Fatalf("channel length = %d, data len = %d", total, len(data))
|
|
}
|
|
packet, err := dnfproto.ParseChannelPacket(data[:total])
|
|
if err != nil {
|
|
t.Fatalf("parse channel packet: %v", err)
|
|
}
|
|
return packet, data[total:]
|
|
}
|
|
|
|
func splitGameServerUpperPacket(t *testing.T, data []byte) (dnfproto.ChannelPacket, []byte) {
|
|
t.Helper()
|
|
return splitGameServerUpperPacketWithHeader(t, data, dnfproto.GameServerUpperHeaderSize)
|
|
}
|
|
|
|
func splitLongHengGameServerUpperPacket(t *testing.T, data []byte) (dnfproto.ChannelPacket, []byte) {
|
|
t.Helper()
|
|
return splitGameServerUpperPacketWithHeader(t, data, dnfproto.GameServerUpperHeaderSize16)
|
|
}
|
|
|
|
func splitFixed15CharacterRosterPacket(t *testing.T, data []byte) ([]byte, []byte) {
|
|
t.Helper()
|
|
const headerSize = 15
|
|
if len(data) < headerSize {
|
|
t.Fatalf("fixed15 character roster too short: %d", len(data))
|
|
}
|
|
total := int(binary.LittleEndian.Uint32(data[3:7]))
|
|
if total < headerSize || total > len(data) {
|
|
t.Fatalf("fixed15 character roster length=%d data=%d", total, len(data))
|
|
}
|
|
if data[0] != byte(dnfenum.GameCmdNotice) ||
|
|
binary.LittleEndian.Uint16(data[1:3]) != uint16(dnfenum.GameTypeCharacterList) ||
|
|
data[7] != latestCharacterStateRoute ||
|
|
!bytes.Equal(data[8:15], make([]byte, 7)) {
|
|
t.Fatalf("fixed15 character roster header=%x", data[:headerSize])
|
|
}
|
|
return append([]byte(nil), data[headerSize:total]...), data[total:]
|
|
}
|
|
|
|
func splitCurrentSceneItemListPacket(t *testing.T, data []byte) (dnfproto.ChannelPacket, []byte) {
|
|
t.Helper()
|
|
packet, rest := splitLongHengGameServerUpperPacket(t, data)
|
|
if packet.Header.Classification != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) {
|
|
t.Fatalf("current scene item list packet = class %d msg %d", packet.Header.Classification, packet.Header.MsgID)
|
|
}
|
|
if !bytes.Equal(data[11:16], make([]byte, 5)) {
|
|
t.Fatalf("current scene item list fixed header tail=%x want zero", data[11:16])
|
|
}
|
|
return packet, rest
|
|
}
|
|
|
|
// splitCurrentGameServerUpperPacketAuto is only for streams that contain the
|
|
// current scene item-list/update packets among ordinary upper packets. These
|
|
// are recognizable by their class0 message id plus the required five zero
|
|
// bytes at header offsets 11..15; all other packets retain their parser.
|
|
func splitCurrentGameServerUpperPacketAuto(t *testing.T, data []byte) (dnfproto.ChannelPacket, []byte) {
|
|
t.Helper()
|
|
if len(data) >= dnfproto.GameServerUpperHeaderSize16 && data[0] == 0 && bytes.Equal(data[11:16], make([]byte, 5)) {
|
|
switch binary.LittleEndian.Uint16(data[1:3]) {
|
|
case uint16(dnfenum.CmdPacketLeaveParty), uint16(dnfenum.CmdPacketWalkoutPartyMember), currentTownUserLeaveMsgID, currentTownActorSceneSnapshotMsgID, currentTownUserPositionNotificationMsgID, currentTownUserAreaNotificationMsgID, currentPVPRoomInfoMsgID, currentPVPSeatStateMsgID, currentPVPRoomStateMsgID:
|
|
return splitGameServerUpperPacketWithHeader(t, data, dnfproto.GameServerUpperHeaderSize16)
|
|
}
|
|
}
|
|
return splitGameServerUpperPacket(t, data)
|
|
}
|
|
|
|
func splitCSharpSelectInitPacket(t *testing.T, data []byte, want csharpSelectInitPacket) (dnfproto.ChannelPacket, []byte) {
|
|
t.Helper()
|
|
if want.bodyEncoded || want.marker != 0 || want.bodyCodec != "" {
|
|
if len(data) < dnfproto.GameServerUpperHeaderSize16 {
|
|
t.Fatalf("DOVE select packet too short for protect flag: %d", len(data))
|
|
}
|
|
if got := data[15]; got != byte(want.marker) {
|
|
t.Fatalf("DOVE select packet protect flag = %d, want %d for msg %d", got, want.marker, want.msgID)
|
|
}
|
|
packet, rest := splitLongHengGameServerUpperPacket(t, data)
|
|
if packet.Header.Checksum != want.marker {
|
|
t.Fatalf("DOVE select packet marker = %d, want %d for msg %d", packet.Header.Checksum, want.marker, want.msgID)
|
|
}
|
|
return packet, rest
|
|
}
|
|
return splitGameServerUpperPacket(t, data)
|
|
}
|
|
|
|
func assertLongHengSceneStageTransportObjectKey(t *testing.T, body []byte, want uint16) {
|
|
t.Helper()
|
|
plain, err := zlibDecompress(body)
|
|
if err != nil {
|
|
t.Fatalf("scene-stage transport zlib decode: %v", err)
|
|
}
|
|
wantKey := make([]byte, 2)
|
|
fixtureKey := make([]byte, 2)
|
|
binary.LittleEndian.PutUint16(wantKey, want)
|
|
binary.LittleEndian.PutUint16(fixtureKey, longHengSceneStageFixtureObjectKey)
|
|
if got := bytes.Count(plain, wantKey); got == 0 {
|
|
t.Fatalf("scene-stage transport missing object key %#x in expanded body len=%d", want, len(plain))
|
|
}
|
|
if got := bytes.Count(plain, fixtureKey); got != 0 {
|
|
t.Fatalf("scene-stage transport still contains fixture object key %#x count=%d", longHengSceneStageFixtureObjectKey, got)
|
|
}
|
|
}
|
|
|
|
func splitGameServerUpperPacketWithHeader(t *testing.T, data []byte, headerSize int) (dnfproto.ChannelPacket, []byte) {
|
|
t.Helper()
|
|
if len(data) < headerSize {
|
|
t.Fatalf("game upper packet too short: %d", len(data))
|
|
}
|
|
total := int(binary.LittleEndian.Uint32(data[3:7]))
|
|
if total < headerSize || total > len(data) {
|
|
t.Fatalf("game upper length = %d, data len = %d", total, len(data))
|
|
}
|
|
if headerSize == dnfproto.GameServerUpperHeaderSize16 && (data[13] != 0 || data[14] != 0) {
|
|
t.Fatalf("game upper reserved bytes = %x", data[13:15])
|
|
}
|
|
packet := dnfproto.ChannelPacket{
|
|
Header: dnfproto.ChannelHeader{
|
|
Classification: data[0],
|
|
MsgID: binary.LittleEndian.Uint16(data[1:3]),
|
|
Length: binary.LittleEndian.Uint32(data[3:7]),
|
|
Checksum: binary.LittleEndian.Uint32(data[7:11]),
|
|
Seq: binary.LittleEndian.Uint16(data[11:13]),
|
|
},
|
|
Body: append([]byte(nil), data[headerSize:total]...),
|
|
}
|
|
return packet, data[total:]
|
|
}
|
|
|
|
func splitWrittenGameRecords(data []byte) ([]dnfproto.LatestGameTransportRecord, error) {
|
|
records := make([]dnfproto.LatestGameTransportRecord, 0)
|
|
for len(data) > 0 {
|
|
frames, remaining, skipped := dnfproto.SplitLatestGameTCPFrames(data)
|
|
if skipped != 0 || len(frames) == 0 {
|
|
return nil, dnfproto.ErrPacketLength
|
|
}
|
|
for _, frame := range frames {
|
|
frameRecords, err := dnfproto.ParseLatestGameTCPRecords(frame)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
records = append(records, frameRecords...)
|
|
}
|
|
data = remaining
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
func assertRosterSlots(t *testing.T, body []byte, count int) {
|
|
t.Helper()
|
|
if len(body) != 25 {
|
|
t.Fatalf("roster slot body len = %d, want 25", len(body))
|
|
}
|
|
if body[0] != 2 ||
|
|
body[1] != byte(latestRosterRouteNormal) ||
|
|
body[2] != byte(latestRosterContextNormal) ||
|
|
binary.LittleEndian.Uint16(body[3:5]) != uint16(defaultCharacterSlots) ||
|
|
binary.LittleEndian.Uint16(body[5:7]) != 0 ||
|
|
binary.LittleEndian.Uint16(body[7:9]) != 0 ||
|
|
binary.LittleEndian.Uint32(body[9:13]) != 0 ||
|
|
binary.LittleEndian.Uint16(body[13:15]) != 0 {
|
|
t.Fatalf("roster slot body mismatch: %x", body)
|
|
}
|
|
}
|
|
|
|
func assertRosterChars(t *testing.T, body []byte, characters []dnfrepo.CharacterRecord) {
|
|
t.Helper()
|
|
count := rosterCount(characters)
|
|
if len(body) != 3+count*112 {
|
|
t.Fatalf("roster char body len = %d, want %d", len(body), 3+count*112)
|
|
}
|
|
if body[0] != 3 || binary.LittleEndian.Uint16(body[1:3]) != uint16(count) {
|
|
t.Fatalf("roster char header mismatch: %x", body[:3])
|
|
}
|
|
for idx := 0; idx < count; idx++ {
|
|
character := characters[idx]
|
|
base := 3 + idx*112
|
|
charID := uint32(numericCharacterID(character))
|
|
if body[base] != byte(latestRosterRouteNormal) || body[base+1] != byte(latestRosterContextNormal) {
|
|
t.Fatalf("roster char[%d] context = %x", idx, body[base:base+2])
|
|
}
|
|
if binary.LittleEndian.Uint16(body[base+10:base+12]) != uint16(charID) {
|
|
t.Fatalf("roster char[%d] object id = %x", idx, body[base+10:base+12])
|
|
}
|
|
if body[base+12] != latestCharacterStateActive || body[base+13] != 0 {
|
|
t.Fatalf("roster char[%d] state = %x", idx, body[base+12:base+14])
|
|
}
|
|
if binary.LittleEndian.Uint32(body[base+14:base+18]) != charID {
|
|
t.Fatalf("roster char[%d] stat id mismatch", idx)
|
|
}
|
|
if binary.LittleEndian.Uint16(body[base+35:base+37]) != uint16(character.Slot) ||
|
|
binary.LittleEndian.Uint32(body[base+37:base+41]) != charID {
|
|
t.Fatalf("roster char[%d] slot/id section mismatch", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRosterBodyDefaultsToNormalCharacter(t *testing.T) {
|
|
characters := []dnfrepo.CharacterRecord{{
|
|
CharacterID: "1",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 0,
|
|
Name: "hero",
|
|
Job: "0",
|
|
Level: 1,
|
|
}}
|
|
slots := buildRosterSlots(characters)
|
|
chars := buildRosterChars(characters)
|
|
|
|
assertRosterSlots(t, slots, 1)
|
|
assertRosterChars(t, chars, characters)
|
|
if got := chars[3+30]; got != 0 {
|
|
t.Fatalf("roster char appearance flag count = %d, want 0", got)
|
|
}
|
|
if got := chars[3+111]; got != 0 {
|
|
t.Fatalf("roster char extra object count = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestRosterBodyEncodesNameAsUTF16(t *testing.T) {
|
|
characters := []dnfrepo.CharacterRecord{{
|
|
CharacterID: "1",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 0,
|
|
Name: "对对对",
|
|
Job: "0",
|
|
Level: 1,
|
|
}}
|
|
body := buildRosterChars(characters)
|
|
|
|
assertRosterChars(t, body, characters)
|
|
nameStart := 3 + 41
|
|
want := []byte{0xf9, 0x5b, 0xf9, 0x5b, 0xf9, 0x5b}
|
|
if got := body[nameStart : nameStart+len(want)]; !bytes.Equal(got, want) {
|
|
t.Fatalf("roster utf16 name bytes = %x, want %x", got, want)
|
|
}
|
|
if got := body[nameStart+len(want) : nameStart+64]; !bytes.Equal(got, make([]byte, 64-len(want))) {
|
|
t.Fatalf("roster utf16 name padding is not zero: %x", got)
|
|
}
|
|
}
|
|
|
|
func assertCSharpRosterBody(t *testing.T, body []byte, count int) {
|
|
assertCSharpRosterBodyForLimit(t, body, count, noPackRosterWireSlotLimit)
|
|
}
|
|
|
|
func assertCSharpRosterBodyForLimit(t *testing.T, body []byte, count int, slotLimit int) {
|
|
assertCSharpRosterBodyForLimitAndEntries(t, body, count, count, slotLimit)
|
|
}
|
|
|
|
func assertCSharpRosterBodyForLimitAndEntries(t *testing.T, body []byte, totalCount int, entryCount int, slotLimit int) {
|
|
t.Helper()
|
|
if len(body) < 15 {
|
|
t.Fatalf("csharp roster body too short: %d", len(body))
|
|
}
|
|
if body[0] != 2 ||
|
|
body[1] != byte(totalCount) ||
|
|
body[2] != 5 ||
|
|
binary.LittleEndian.Uint16(body[3:5]) != uint16(slotLimit) ||
|
|
binary.LittleEndian.Uint16(body[5:7]) != uint16(slotLimit) ||
|
|
binary.LittleEndian.Uint16(body[7:9]) != uint16(totalCount) ||
|
|
binary.LittleEndian.Uint32(body[9:13]) != 0 ||
|
|
binary.LittleEndian.Uint16(body[13:15]) != uint16(entryCount) {
|
|
t.Fatalf("csharp roster header mismatch: %x", body[:15])
|
|
}
|
|
}
|
|
|
|
func noPackRosterEntryLen(character dnfrepo.CharacterRecord) int {
|
|
return 2 + 4 + len(rosterRawNameBytes(character)) + noPackRosterEntryTailBytes + noPackRosterEquipRowsLen(character)
|
|
}
|
|
|
|
func noPackRosterJobStart(entryStart int, character dnfrepo.CharacterRecord) int {
|
|
return entryStart + 2 + 4 + len(rosterRawNameBytes(character)) + noPackRosterPreJobBytes
|
|
}
|
|
|
|
func noPackRosterGrowStart(entryStart int, character dnfrepo.CharacterRecord) int {
|
|
return noPackRosterJobStart(entryStart, character) + 1
|
|
}
|
|
|
|
func noPackRosterLevelStart(entryStart int, character dnfrepo.CharacterRecord) int {
|
|
return noPackRosterGrowStart(entryStart, character) + 1
|
|
}
|
|
|
|
func noPackRosterAppearanceStart(entryStart int, character dnfrepo.CharacterRecord) int {
|
|
return noPackRosterLevelStart(entryStart, character) + 1 + 10
|
|
}
|
|
|
|
func noPackRosterPostEquipStart(entryStart int, character dnfrepo.CharacterRecord) int {
|
|
return noPackRosterAppearanceStart(entryStart, character) + 1 + noPackRosterEquipRowsLen(character)
|
|
}
|
|
|
|
func noPackRosterEquipRowsLen(character dnfrepo.CharacterRecord) int {
|
|
return len(currentRosterEquipSummaryRows(characterEquipSummary(character))) * noPackRosterEquipRowBytes
|
|
}
|
|
|
|
func rosterDstrName(value string) []byte {
|
|
var writer packetWriter
|
|
writer.writeRawDstr(rosterNameBytes(value))
|
|
return writer.bytes()
|
|
}
|
|
|
|
func normalNoPackRosterPostEquipBytes() []byte {
|
|
var writer packetWriter
|
|
writeNoPackRosterNormalPostEquip(&writer, false)
|
|
return writer.bytes()
|
|
}
|
|
|
|
func TestCSharpRosterBodyEmptyEchoesSlotLimitNoPushTail(t *testing.T) {
|
|
body := buildCSharpRosterBody(nil)
|
|
|
|
assertCSharpRosterBody(t, body, 0)
|
|
if got := body[:15]; !bytes.Equal(got, []byte{2, 0, 5, byte(noPackRosterWireSlotLimit), 0, byte(noPackRosterWireSlotLimit), 0, 0, 0, 0, 0, 0, 0, 0, 0}) {
|
|
t.Fatalf("empty csharp roster header = %x", got)
|
|
}
|
|
if got := body[15:25]; !bytes.Equal(got, []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}) {
|
|
t.Fatalf("empty csharp roster tail = %x, want page/no-push tail", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyEchoesSlotLimitAndActualEntryCount(t *testing.T) {
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{{
|
|
CharacterID: "1",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 0,
|
|
Name: "hero",
|
|
Job: "0",
|
|
Level: 1,
|
|
Roster: dnfrepo.CharacterRoster{
|
|
Header: dnfrepo.CharacterRosterHeader{
|
|
UnkA: 8,
|
|
UnkB: 8,
|
|
TotalOrSlotLimit: 8,
|
|
UsedOrRemain: 7,
|
|
SelectedOrPage: 7,
|
|
RosterState: 6,
|
|
PageCount: 9,
|
|
RosterFlag: 9,
|
|
RosterValueA: 0x11111111,
|
|
RosterValueB: 0x22222222,
|
|
},
|
|
},
|
|
}})
|
|
|
|
assertCSharpRosterBody(t, body, 1)
|
|
if got := body[1]; got != 1 {
|
|
t.Fatalf("roster display count = %d, want 1", got)
|
|
}
|
|
if got := body[2]; got != 5 {
|
|
t.Fatalf("roster fixed flag = %d, want 5", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[3:5]); got != noPackRosterWireSlotLimit {
|
|
t.Fatalf("stale roster TotalOrSlotLimit leaked into header: got %d", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[5:7]); got != noPackRosterWireSlotLimit {
|
|
t.Fatalf("roster echoed slot limit = %d, want %d", got, noPackRosterWireSlotLimit)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[13:15]); got != 1 {
|
|
t.Fatalf("roster entry count = %d, want 1", got)
|
|
}
|
|
if got := binary.LittleEndian.Uint32(body[9:13]); got != 0 {
|
|
t.Fatalf("stale roster state leaked into header: got %d", got)
|
|
}
|
|
tailStart := 15 + noPackRosterEntryLen(dnfrepo.CharacterRecord{Name: "hero", Level: 1})
|
|
if got := body[tailStart : tailStart+10]; !bytes.Equal(got, []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}) {
|
|
t.Fatalf("stale roster tail leaked into header: got %x", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyIgnoresStaleSelectedPage(t *testing.T) {
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{{
|
|
CharacterID: "1",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 0,
|
|
Name: "hero",
|
|
Job: "0",
|
|
Level: 1,
|
|
Roster: dnfrepo.CharacterRoster{
|
|
Header: dnfrepo.CharacterRosterHeader{
|
|
SelectedOrPage: 7,
|
|
},
|
|
},
|
|
}})
|
|
|
|
assertCSharpRosterBody(t, body, 1)
|
|
if got := binary.LittleEndian.Uint16(body[7:9]); got != 1 {
|
|
t.Fatalf("roster shown character count = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyUsesSQLSlotAsEntryKey(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{
|
|
CharacterID: "3",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 5,
|
|
Name: "hero",
|
|
Job: "0",
|
|
Level: 1,
|
|
Roster: dnfrepo.CharacterRoster{
|
|
Entry: dnfrepo.CharacterRosterEntry{
|
|
ObjectID: 99,
|
|
},
|
|
},
|
|
}
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{character})
|
|
|
|
if got := binary.LittleEndian.Uint16(body[15:17]); got != 5 {
|
|
t.Fatalf("roster entry key = %d, want slot 5", got)
|
|
}
|
|
postEquipStart := noPackRosterPostEquipStart(15, character)
|
|
if got, want := body[postEquipStart:postEquipStart+noPackRosterPostEquipBytes], normalNoPackRosterPostEquipBytes(); !bytes.Equal(got, want) {
|
|
t.Fatalf("roster stale object/link state leaked after equip summary: %x", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyPreservesEquipmentSummaryOnCharacterSelect(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{
|
|
CharacterID: "3",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 5,
|
|
Name: "hero",
|
|
Job: "11",
|
|
Level: 1,
|
|
Roster: dnfrepo.CharacterRoster{
|
|
Entry: dnfrepo.CharacterRosterEntry{
|
|
EquipSummary: []dnfrepo.CharacterRosterEquipSummary{
|
|
{Slot: 0, ItemIDOrIcon: 1001, PackedFlags: 1, OptionalIDOrExpire: 2, AuxValue: 3, AuxFlag: 4},
|
|
{Slot: 24, ItemIDOrIcon: 2002, PackedFlags: 5, OptionalIDOrExpire: 6, AuxValue: 7, AuxFlag: 8},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{character})
|
|
|
|
assertCSharpRosterBody(t, body, 1)
|
|
entryStart := 15
|
|
tailStart := entryStart + noPackRosterEntryLen(character)
|
|
if got, want := len(body), tailStart+10; got != want {
|
|
t.Fatalf("roster body len = %d, want %d with equipment summary", got, want)
|
|
}
|
|
equipSummaryStart := noPackRosterAppearanceStart(entryStart, character)
|
|
if got := body[equipSummaryStart]; got != 2 {
|
|
t.Fatalf("roster equip summary count = %d, want 2", got)
|
|
}
|
|
postEquipStart := noPackRosterPostEquipStart(entryStart, character)
|
|
if got, want := body[postEquipStart:tailStart], normalNoPackRosterPostEquipBytes(); !bytes.Equal(got, want) {
|
|
t.Fatalf("roster post-equipment tail = %x, want %x", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCurrentRosterEquipSummaryRowsRejectInvalidRows(t *testing.T) {
|
|
rows := currentRosterEquipSummaryRows([]dnfrepo.CharacterRosterEquipSummary{
|
|
{Slot: 8, ItemIDOrIcon: 1001},
|
|
{Slot: 9, ItemIDOrIcon: 1002, OptionalIDOrExpire: 77, RawEntry: []byte{1, 2, 3}},
|
|
{Slot: 33, ItemIDOrIcon: 1003},
|
|
{Slot: 10, ItemIDOrIcon: -1},
|
|
{Slot: 12, ItemIDOrIcon: 1004, PackedFlags: 3, AuxValue: 4, AuxFlag: 5},
|
|
})
|
|
if len(rows) != 3 {
|
|
t.Fatalf("safe roster equipment row count = %d, want 3: %+v", len(rows), rows)
|
|
}
|
|
if rows[0].Slot != 8 || rows[0].ItemIDOrIcon != 1001 || rows[1].Slot != 9 || rows[1].ItemIDOrIcon != 1002 || rows[2].Slot != 12 || rows[2].ItemIDOrIcon != 1004 {
|
|
t.Fatalf("safe roster equipment rows = %+v", rows)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyKeepsNextEntryAlignedAfterThreeEquipmentRows(t *testing.T) {
|
|
characters := []dnfrepo.CharacterRecord{
|
|
{
|
|
CharacterID: "28",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 13,
|
|
Name: "first",
|
|
Job: "15",
|
|
Level: 1,
|
|
Roster: dnfrepo.CharacterRoster{Entry: dnfrepo.CharacterRosterEntry{
|
|
EquipSummary: []dnfrepo.CharacterRosterEquipSummary{
|
|
{Slot: 15, ItemIDOrIcon: 12400},
|
|
{Slot: 11, ItemIDOrIcon: 116000011},
|
|
{Slot: 13, ItemIDOrIcon: 10400},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
CharacterID: "29",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 14,
|
|
Name: "second",
|
|
Job: "0",
|
|
Level: 1,
|
|
},
|
|
}
|
|
body := buildCSharpRosterBody(characters)
|
|
|
|
assertCSharpRosterBody(t, body, 2)
|
|
firstEquipStart := noPackRosterAppearanceStart(15, characters[0])
|
|
if got := body[firstEquipStart]; got != 3 {
|
|
t.Fatalf("first roster equipment count = %d, want 3", got)
|
|
}
|
|
secondStart := 15 + noPackRosterEntryLen(characters[0])
|
|
if got := binary.LittleEndian.Uint16(body[secondStart : secondStart+2]); got != 14 {
|
|
t.Fatalf("second roster key = %d, want slot 14", got)
|
|
}
|
|
secondName := rosterDstrName(characters[1].Name)
|
|
if got := body[secondStart+2 : secondStart+2+len(secondName)]; !bytes.Equal(got, secondName) {
|
|
t.Fatalf("second roster name = %x, want %x", got, secondName)
|
|
}
|
|
if got := body[noPackRosterJobStart(secondStart, characters[1])]; got != 0 {
|
|
t.Fatalf("second roster job = %d, want 0", got)
|
|
}
|
|
if got := body[noPackRosterGrowStart(secondStart, characters[1])]; got != 0 {
|
|
t.Fatalf("second roster grow = %d, want 0", got)
|
|
}
|
|
if got := body[noPackRosterLevelStart(secondStart, characters[1])]; got != 1 {
|
|
t.Fatalf("second roster level = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyUsesSlotsAndActualEntryCountForSparseSQLSlots(t *testing.T) {
|
|
characters := []dnfrepo.CharacterRecord{
|
|
{CharacterID: "1", AccountID: defaultAccountPrefix + "1", Slot: 0, Name: "a", Job: "0", Level: 1},
|
|
{CharacterID: "2", AccountID: defaultAccountPrefix + "1", Slot: 7, Name: "b", Job: "1", Level: 1},
|
|
}
|
|
body := buildCSharpRosterBody(characters)
|
|
|
|
assertCSharpRosterBody(t, body, 2)
|
|
if got := binary.LittleEndian.Uint16(body[3:5]); got != noPackRosterWireSlotLimit {
|
|
t.Fatalf("roster total slots = %d, want %d", got, noPackRosterWireSlotLimit)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[5:7]); got != noPackRosterWireSlotLimit {
|
|
t.Fatalf("roster echoed slot limit = %d, want %d", got, noPackRosterWireSlotLimit)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[13:15]); got != 2 {
|
|
t.Fatalf("roster entry count = %d, want 2", got)
|
|
}
|
|
secondEntry := 15 + noPackRosterEntryLen(characters[0])
|
|
if got := binary.LittleEndian.Uint16(body[secondEntry : secondEntry+2]); got != 7 {
|
|
t.Fatalf("second roster entry key = %d, want slot 7", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyFiveCharactersStillEchoesConfiguredSlots(t *testing.T) {
|
|
characters := []dnfrepo.CharacterRecord{
|
|
{CharacterID: "1", AccountID: defaultAccountPrefix + "1", Slot: 0, Name: "a", Job: "0", Level: 86},
|
|
{CharacterID: "2", AccountID: defaultAccountPrefix + "1", Slot: 1, Name: "b", Job: "1", Level: 1},
|
|
{CharacterID: "3", AccountID: defaultAccountPrefix + "1", Slot: 2, Name: "c", Job: "2", Level: 1},
|
|
{CharacterID: "4", AccountID: defaultAccountPrefix + "1", Slot: 3, Name: "d", Job: "3", Level: 1},
|
|
{CharacterID: "5", AccountID: defaultAccountPrefix + "1", Slot: 4, Name: "e", Job: "4", Level: 1},
|
|
}
|
|
body := buildCSharpRosterBody(characters)
|
|
|
|
assertCSharpRosterBody(t, body, len(characters))
|
|
if got := binary.LittleEndian.Uint16(body[3:5]); got != noPackRosterWireSlotLimit {
|
|
t.Fatalf("roster total slots = %d, want %d", got, noPackRosterWireSlotLimit)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[5:7]); got != noPackRosterWireSlotLimit {
|
|
t.Fatalf("roster echoed slot limit = %d, want %d", got, noPackRosterWireSlotLimit)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[7:9]); got != uint16(len(characters)) {
|
|
t.Fatalf("roster shown character count = %d, want %d", got, len(characters))
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyThirtyTwoCharactersUsesFirstWirePage(t *testing.T) {
|
|
characters := make([]dnfrepo.CharacterRecord, 0, defaultCharacterSlots)
|
|
for slot := 0; slot < defaultCharacterSlots; slot++ {
|
|
id := strconv.Itoa(slot + 1)
|
|
characters = append(characters, dnfrepo.CharacterRecord{
|
|
CharacterID: id,
|
|
AccountID: "dnf:1",
|
|
Slot: slot,
|
|
Name: "character-" + id,
|
|
Job: "0",
|
|
Level: 1,
|
|
})
|
|
}
|
|
body := buildCSharpRosterBody(characters)
|
|
assertCSharpRosterBody(t, body, noPackRosterWireSlotLimit)
|
|
lastEntry := 15
|
|
for _, character := range characters[:noPackRosterWireSlotLimit-1] {
|
|
lastEntry += noPackRosterEntryLen(character)
|
|
}
|
|
if got := binary.LittleEndian.Uint16(body[lastEntry : lastEntry+2]); got != noPackRosterWireSlotLimit-1 {
|
|
t.Fatalf("last roster entry key = %d, want slot %d", got, noPackRosterWireSlotLimit-1)
|
|
}
|
|
lastEnd := lastEntry + noPackRosterEntryLen(characters[noPackRosterWireSlotLimit-1])
|
|
if len(body) != lastEnd+10 {
|
|
t.Fatalf("wire-limited roster body len = %d, want %d", len(body), lastEnd+10)
|
|
}
|
|
|
|
request31 := make([]byte, 16)
|
|
binary.LittleEndian.PutUint32(request31[:4], 31)
|
|
if parsed, ok := parseNoPackPlainSelectCharacterRequest(request31); !ok || parsed.slot != 31 {
|
|
t.Fatalf("slot31 select parsed=%+v ok=%t", parsed, ok)
|
|
}
|
|
request32 := make([]byte, 16)
|
|
binary.LittleEndian.PutUint32(request32[:4], 32)
|
|
if parsed, ok := parseNoPackPlainSelectCharacterRequest(request32); !ok || parsed.slot != 32 {
|
|
t.Fatalf("expanded slot32 select parsed=%+v ok=%t", parsed, ok)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyExpandedSlotsPublishSecondSelectorPage(t *testing.T) {
|
|
characters := make([]dnfrepo.CharacterRecord, 0, 37)
|
|
for slot := 0; slot < 36; slot++ {
|
|
id := strconv.Itoa(slot + 1)
|
|
characters = append(characters, dnfrepo.CharacterRecord{
|
|
CharacterID: id,
|
|
AccountID: "dnf:1",
|
|
Slot: slot,
|
|
Name: "expanded-" + id,
|
|
Job: "0",
|
|
Level: 1,
|
|
})
|
|
}
|
|
|
|
body := buildCSharpRosterBodyForLimit(characters, 37)
|
|
assertCSharpRosterBodyForLimit(t, body, 36, 37)
|
|
tailStart := 15
|
|
for _, character := range characters {
|
|
tailStart += noPackRosterEntryLen(character)
|
|
}
|
|
if got := body[tailStart]; got != 1 {
|
|
t.Fatalf("36-row initial roster last occupied transport page = %d, want 1", got)
|
|
}
|
|
|
|
sparse := []dnfrepo.CharacterRecord{{
|
|
CharacterID: "33",
|
|
AccountID: "dnf:1",
|
|
Slot: 32,
|
|
Name: "sparse-expanded",
|
|
Job: "0",
|
|
Level: 1,
|
|
}}
|
|
sparseBody := buildCSharpRosterBodyForLimit(sparse, 37)
|
|
sparseTail := 15 + noPackRosterEntryLen(sparse[0])
|
|
if got := sparseBody[sparseTail]; got != 1 {
|
|
t.Fatalf("sparse slot-32 roster last occupied transport page = %d, want 1", got)
|
|
}
|
|
|
|
characters = append(characters, dnfrepo.CharacterRecord{
|
|
CharacterID: "37",
|
|
AccountID: "dnf:1",
|
|
Slot: 36,
|
|
Name: "expanded-37",
|
|
Job: "0",
|
|
Level: 1,
|
|
})
|
|
body = buildCSharpRosterBodyForLimit(characters, 37)
|
|
assertCSharpRosterBodyForLimitAndEntries(t, body, 37, 36, 37)
|
|
tailStart = 15
|
|
for _, character := range characters[:36] {
|
|
tailStart += noPackRosterEntryLen(character)
|
|
}
|
|
if got := body[tailStart]; got != 2 {
|
|
t.Fatalf("37-character roster last occupied transport page = %d, want 2", got)
|
|
}
|
|
if len(body) != tailStart+10 {
|
|
t.Fatalf("mode-2 roster unexpectedly embedded extension row: len=%d want=%d", len(body), tailStart+10)
|
|
}
|
|
extension, ok := buildNoPackRosterExtensionBodyForLimit(characters, 37, 36)
|
|
if !ok {
|
|
t.Fatal("slot-36 extension body was not built")
|
|
}
|
|
if len(extension) != 5+noPackRosterEntryLen(characters[36]) ||
|
|
extension[0] != 4 ||
|
|
binary.LittleEndian.Uint16(extension[1:3]) != 37 ||
|
|
binary.LittleEndian.Uint16(extension[3:5]) != 1 ||
|
|
binary.LittleEndian.Uint16(extension[5:7]) != 36 {
|
|
t.Fatalf("mode-4 extension body mismatch: %x", extension)
|
|
}
|
|
|
|
request59 := make([]byte, 16)
|
|
binary.LittleEndian.PutUint32(request59[:4], 59)
|
|
if parsed, ok := parseNoPackPlainSelectCharacterRequest(request59); !ok || parsed.slot != 59 {
|
|
t.Fatalf("expanded slot59 select parsed=%+v ok=%t", parsed, ok)
|
|
}
|
|
request60 := make([]byte, 16)
|
|
binary.LittleEndian.PutUint32(request60[:4], 60)
|
|
if parsed, ok := parseNoPackPlainSelectCharacterRequest(request60); ok {
|
|
t.Fatalf("out-of-range slot60 select unexpectedly accepted: %+v", parsed)
|
|
}
|
|
|
|
selected, selectedBy, ok := selectCharacterRecord(sparse, selectCharacterRequest{
|
|
slot: 32,
|
|
clear: true,
|
|
source: "nopack_plain_slot",
|
|
})
|
|
if !ok || selectedBy != "slot" || selected.Slot != 32 {
|
|
t.Fatalf("expanded slot selection selected=%+v selected_by=%q ok=%t", selected, selectedBy, ok)
|
|
}
|
|
}
|
|
|
|
func TestHandleUpperLoadExtendCharacsSendsAckThenMode4Rows(t *testing.T) {
|
|
repos := testRepositoryGroup()
|
|
account := dnfrepo.AccountRecord{AccountID: "dnf:1"}
|
|
if err := dnfrepo.SetAccountCharacterSlotLimit(&account, 37); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repos.Account.Save(context.Background(), account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := repos.Character.(*fakeCharacterStore)
|
|
characters := make([]dnfrepo.CharacterRecord, 0, 37)
|
|
for slot := 0; slot < 37; slot++ {
|
|
id := strconv.Itoa(slot + 1)
|
|
record := dnfrepo.CharacterRecord{
|
|
CharacterID: id,
|
|
AccountID: "dnf:1",
|
|
Slot: slot,
|
|
Name: "extension-" + id,
|
|
Job: "0",
|
|
Level: 1,
|
|
}
|
|
store.records[id] = record
|
|
characters = append(characters, record)
|
|
}
|
|
|
|
initial := buildNoPackRosterBodyForLimit(characters, 37)
|
|
assertCSharpRosterBodyForLimitAndEntries(t, initial, 37, 36, 37)
|
|
service := &Service{
|
|
options: options{accountPrefix: "dnf:"},
|
|
repositoryProvider: func() (dnfrepo.Group, bool) {
|
|
return repos, true
|
|
},
|
|
}
|
|
conn := &bufferConn{}
|
|
session := &gameSession{conn: conn, channel: channelcatalog.Channel{ID: 38, Type: 3, Name: "ch.38", Port: 10038}}
|
|
frame, err := dnfproto.BuildChannelPacket(
|
|
uint16(dnfenum.UpperMsgLoadExtendCharacs),
|
|
[]byte{36, 0},
|
|
0,
|
|
dnfproto.DefaultChannelClassification,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.handleGameUpper(session, frame); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ack, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
|
|
ack.Header.MsgID != uint16(dnfenum.UpperMsgLoadExtendCharacs) ||
|
|
!bytes.Equal(ack.Body, []byte{1}) {
|
|
t.Fatalf("extension ACK header=%+v body=%x", ack.Header, ack.Body)
|
|
}
|
|
mode4, trailing := splitGameServerUpperPacket(t, rest)
|
|
if len(trailing) != 0 || mode4.Header.Classification != 0 || mode4.Header.MsgID != 2 {
|
|
t.Fatalf("mode-4 packet header=%+v trailing=%x", mode4.Header, trailing)
|
|
}
|
|
if len(mode4.Body) != 5+noPackRosterEntryLen(characters[36]) ||
|
|
mode4.Body[0] != 4 ||
|
|
binary.LittleEndian.Uint16(mode4.Body[1:3]) != 37 ||
|
|
binary.LittleEndian.Uint16(mode4.Body[3:5]) != 1 ||
|
|
binary.LittleEndian.Uint16(mode4.Body[5:7]) != 36 {
|
|
t.Fatalf("mode-4 response body mismatch: %x", mode4.Body)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyScrubsMySQLRosterStateColumns(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{
|
|
CharacterID: "9",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 1,
|
|
Name: "hero",
|
|
Job: "4",
|
|
Level: 7,
|
|
Stats: map[string]int64{
|
|
"grow_type": 2,
|
|
"roster_state0": 2,
|
|
"roster_time_a": 0x01020304,
|
|
"roster_time_b": 0x05060708,
|
|
"roster_value0": 0x11121314,
|
|
"roster_value1": 0x21,
|
|
"roster_value2": 0x22,
|
|
"roster_reserved_a": 0x23,
|
|
"roster_reserved_b": 0x24,
|
|
"roster_linked_id_00": 1,
|
|
"roster_linked_id_01": 2,
|
|
"roster_linked_id_02": 3,
|
|
"roster_linked_id_03": 4,
|
|
"roster_linked_id_04": 5,
|
|
"roster_linked_id_05": 6,
|
|
"roster_linked_id_06": 7,
|
|
"roster_linked_id_07": 8,
|
|
"roster_value3": 0x31,
|
|
"roster_object_id": 0x1234,
|
|
"roster_flag0_eq1": 1,
|
|
"roster_card_flag": 2,
|
|
"roster_value5": 3,
|
|
"roster_display_flags": 4,
|
|
"roster_tail_00": 5,
|
|
"roster_tail_11": 16,
|
|
"roster_flag6_eq1": 17,
|
|
},
|
|
}
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{character})
|
|
|
|
assertCSharpRosterBody(t, body, 1)
|
|
entryStart := 15
|
|
jobStart := noPackRosterJobStart(entryStart, character)
|
|
growStart := noPackRosterGrowStart(entryStart, character)
|
|
levelStart := noPackRosterLevelStart(entryStart, character)
|
|
appearanceStart := noPackRosterAppearanceStart(entryStart, character)
|
|
if got := body[jobStart]; got != 4 {
|
|
t.Fatalf("roster job byte = %d, want 4", got)
|
|
}
|
|
if got := body[growStart]; got != 2 {
|
|
t.Fatalf("roster grow byte = %d, want 2", got)
|
|
}
|
|
if got := body[levelStart]; got != 7 {
|
|
t.Fatalf("roster level byte = %d, want 7", got)
|
|
}
|
|
if got := body[levelStart+1 : appearanceStart]; !bytes.Equal(got, make([]byte, appearanceStart-levelStart-1)) {
|
|
t.Fatalf("roster reserved bytes after level = %x, want zero", got)
|
|
}
|
|
if got := body[appearanceStart]; got != 0 {
|
|
t.Fatalf("roster equip summary count = %d, want 0", got)
|
|
}
|
|
value0Start := appearanceStart + 1
|
|
if got := binary.LittleEndian.Uint32(body[value0Start : value0Start+4]); got != 0 {
|
|
t.Fatalf("roster value0 = %#x", got)
|
|
}
|
|
if got := body[value0Start+4 : value0Start+8]; !bytes.Equal(got, []byte{0, 0, 0, 0}) {
|
|
t.Fatalf("roster byte values = %x", got)
|
|
}
|
|
if got, want := body[value0Start:value0Start+noPackRosterPostEquipBytes], normalNoPackRosterPostEquipBytes(); !bytes.Equal(got, want) {
|
|
t.Fatalf("roster post-equip bytes = %x, want %x", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyDefaultsToNormalCharacter(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{
|
|
CharacterID: "1",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 0,
|
|
Name: "hero",
|
|
Job: "0",
|
|
Level: 1,
|
|
}
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{character})
|
|
|
|
assertCSharpRosterBody(t, body, 1)
|
|
entryStart := 15
|
|
tailStart := entryStart + noPackRosterEntryLen(character)
|
|
jobStart := noPackRosterJobStart(entryStart, character)
|
|
growStart := noPackRosterGrowStart(entryStart, character)
|
|
levelStart := noPackRosterLevelStart(entryStart, character)
|
|
if got, want := len(body), tailStart+10; got != want {
|
|
t.Fatalf("roster body len = %d, want fixed entry len %d", got, want)
|
|
}
|
|
nameStart := entryStart + 2
|
|
wantName := rosterDstrName("hero")
|
|
if got := body[nameStart : nameStart+len(wantName)]; !bytes.Equal(got, wantName) {
|
|
t.Fatalf("roster dstr name = %x, want %x", got, wantName)
|
|
}
|
|
preJobStart := nameStart + len(wantName)
|
|
if got := body[preJobStart:jobStart]; !bytes.Equal(got, make([]byte, noPackRosterPreJobBytes)) {
|
|
t.Fatalf("roster pre-job reserved bytes = %x, want zero", got)
|
|
}
|
|
if got := body[jobStart]; got != 0 {
|
|
t.Fatalf("roster job byte = %d, want 0", got)
|
|
}
|
|
if got := body[growStart]; got != 0 {
|
|
t.Fatalf("roster grow byte = %d, want 0", got)
|
|
}
|
|
if got := body[levelStart]; got != 1 {
|
|
t.Fatalf("roster level byte = %d, want 1", got)
|
|
}
|
|
appearanceStart := noPackRosterAppearanceStart(entryStart, character)
|
|
if got := body[levelStart+1 : appearanceStart]; !bytes.Equal(got, make([]byte, appearanceStart-levelStart-1)) {
|
|
t.Fatalf("roster post-level reserved bytes = %x, want zero", got)
|
|
}
|
|
if got := body[appearanceStart]; got != 0 {
|
|
t.Fatalf("roster equip summary count = %d, want 0", got)
|
|
}
|
|
postEquipStart := noPackRosterPostEquipStart(entryStart, character)
|
|
if got, want := body[postEquipStart:tailStart], normalNoPackRosterPostEquipBytes(); !bytes.Equal(got, want) {
|
|
t.Fatalf("roster post-equip normal-state bytes = %x, want %x", got, want)
|
|
}
|
|
if got := body[tailStart : tailStart+10]; !bytes.Equal(got, []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}) {
|
|
t.Fatalf("post-entry roster tail = %x, want page/no-push tail", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyScrubsStaleSpecialState(t *testing.T) {
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{{
|
|
CharacterID: "21",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 4,
|
|
Name: "hero",
|
|
Job: "11",
|
|
Level: 9,
|
|
Roster: dnfrepo.CharacterRoster{
|
|
Entry: dnfrepo.CharacterRosterEntry{
|
|
PackedJobGrow: 0xff,
|
|
ByteC: 7,
|
|
Field2CC: 8,
|
|
State0: 1,
|
|
TimeA: 123,
|
|
TimeB: 456,
|
|
Value0: 9,
|
|
Value1: 10,
|
|
Value2: 11,
|
|
ReservedA: 12,
|
|
ReservedB: 13,
|
|
LinkedIDBlock: []int64{1, 2, 3, 4, 5, 6, 7, 8},
|
|
Value3: 14,
|
|
ObjectID: 77,
|
|
Flag0Eq1: 1,
|
|
SpecialStatusFlag: 1,
|
|
Value5: 1,
|
|
DisplayFlags: 0xff,
|
|
ReservedC: 1,
|
|
ReservedD: 1,
|
|
Value6: 1,
|
|
Flag1Nonzero: 1,
|
|
BoolAEq1: 1,
|
|
BoolBEq1: 1,
|
|
BoolCEq1: 1,
|
|
Flag2Nonzero: 1,
|
|
Flag3Nonzero: 1,
|
|
Flag4Nonzero: 1,
|
|
Flag5Nonzero: 1,
|
|
Value7: 1,
|
|
Flag6Eq1: 1,
|
|
},
|
|
},
|
|
}})
|
|
|
|
assertCSharpRosterBody(t, body, 1)
|
|
character := dnfrepo.CharacterRecord{
|
|
CharacterID: "21",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 4,
|
|
Name: "hero",
|
|
Job: "11",
|
|
Level: 9,
|
|
}
|
|
jobStart := noPackRosterJobStart(15, character)
|
|
growStart := noPackRosterGrowStart(15, character)
|
|
levelStart := noPackRosterLevelStart(15, character)
|
|
if got := binary.LittleEndian.Uint16(body[15:17]); got != 4 {
|
|
t.Fatalf("roster entry key = %d, want slot 4", got)
|
|
}
|
|
if got := body[levelStart]; got != 9 {
|
|
t.Fatalf("roster level byte = %d, want 9", got)
|
|
}
|
|
if got := body[jobStart]; got != 11 {
|
|
t.Fatalf("roster job byte = %d, want 11", got)
|
|
}
|
|
if got := body[growStart]; got != 0 {
|
|
t.Fatalf("roster grow byte = %d, want 0", got)
|
|
}
|
|
appearanceStart := noPackRosterAppearanceStart(15, character)
|
|
if got := body[levelStart+1 : appearanceStart]; !bytes.Equal(got, make([]byte, appearanceStart-levelStart-1)) {
|
|
t.Fatalf("roster stale reserved bytes leaked: %x", got)
|
|
}
|
|
postEquipStart := noPackRosterPostEquipStart(15, character)
|
|
if got, want := body[postEquipStart:postEquipStart+noPackRosterPostEquipBytes], normalNoPackRosterPostEquipBytes(); !bytes.Equal(got, want) {
|
|
t.Fatalf("roster stale special/pvp state leaked after equip summary: %x", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyEncodesJobGrowAndLevel(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{
|
|
CharacterID: "17",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 2,
|
|
Name: "hero",
|
|
Job: "4",
|
|
Level: 56,
|
|
Stats: map[string]int64{"grow_type": 2},
|
|
}
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{character})
|
|
|
|
assertCSharpRosterBody(t, body, 1)
|
|
jobStart := noPackRosterJobStart(15, character)
|
|
growStart := noPackRosterGrowStart(15, character)
|
|
levelStart := noPackRosterLevelStart(15, character)
|
|
if got := binary.LittleEndian.Uint16(body[15:17]); got != 2 {
|
|
t.Fatalf("roster entry key = %d, want slot 2", got)
|
|
}
|
|
if got := body[jobStart]; got != 4 {
|
|
t.Fatalf("roster job byte = %d, want 4", got)
|
|
}
|
|
if got := body[growStart]; got != 2 {
|
|
t.Fatalf("roster grow byte = %d, want 2", got)
|
|
}
|
|
if got := body[levelStart]; got != 56 {
|
|
t.Fatalf("roster level byte = %d, want 56", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyEncodesNameAsDSTR(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{
|
|
CharacterID: "1",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 0,
|
|
Name: "\u5bf9\u5bf9\u5bf9",
|
|
Job: "0",
|
|
Level: 1,
|
|
}
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{character})
|
|
|
|
assertCSharpRosterBody(t, body, 1)
|
|
entryStart := 15
|
|
nameStart := entryStart + 2
|
|
want := rosterDstrName(character.Name)
|
|
if got := body[nameStart : nameStart+len(want)]; !bytes.Equal(got, want) {
|
|
t.Fatalf("roster dstr name bytes = %x, want %x", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyUsesMySQLNameOverStaleJSONName(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{
|
|
CharacterID: "1",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 0,
|
|
Name: "mysql-name",
|
|
Job: "0",
|
|
Level: 1,
|
|
Stats: map[string]int64{
|
|
"create_name_len": 3,
|
|
"create_name_byte_00": int64('b'),
|
|
"create_name_byte_01": int64('a'),
|
|
"create_name_byte_02": int64('d'),
|
|
},
|
|
}
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{character})
|
|
|
|
assertCSharpRosterBody(t, body, 1)
|
|
entryStart := 15
|
|
nameStart := entryStart + 2
|
|
want := rosterDstrName("mysql-name")
|
|
if got := body[nameStart : nameStart+len(want)]; !bytes.Equal(got, want) {
|
|
t.Fatalf("roster name bytes = %x, want MySQL name %x", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyDoesNotReadGunbladerJobAsLevel(t *testing.T) {
|
|
character := dnfrepo.CharacterRecord{
|
|
CharacterID: "13",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 1,
|
|
Name: "hero",
|
|
Job: "15",
|
|
Level: 1,
|
|
}
|
|
body := buildCSharpRosterBody([]dnfrepo.CharacterRecord{character})
|
|
|
|
assertCSharpRosterBody(t, body, 1)
|
|
jobStart := noPackRosterJobStart(15, character)
|
|
growStart := noPackRosterGrowStart(15, character)
|
|
levelStart := noPackRosterLevelStart(15, character)
|
|
if got := body[jobStart]; got != 15 {
|
|
t.Fatalf("roster gunblader job byte = %d, want 15", got)
|
|
}
|
|
if got := body[growStart]; got != 0 {
|
|
t.Fatalf("roster gunblader grow byte = %d, want 0", got)
|
|
}
|
|
if got := body[levelStart]; got != 1 {
|
|
t.Fatalf("roster level byte = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyUsesExactEntryStride(t *testing.T) {
|
|
characters := []dnfrepo.CharacterRecord{
|
|
{
|
|
CharacterID: "1",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 0,
|
|
Name: "刚刚发给",
|
|
Job: "0",
|
|
Level: 1,
|
|
},
|
|
{
|
|
CharacterID: "2",
|
|
AccountID: defaultAccountPrefix + "1",
|
|
Slot: 7,
|
|
Name: "测试",
|
|
Job: "15",
|
|
Level: 1,
|
|
},
|
|
}
|
|
body := buildCSharpRosterBody(characters)
|
|
|
|
assertCSharpRosterBody(t, body, 2)
|
|
secondStart := 15 + noPackRosterEntryLen(characters[0])
|
|
if got := binary.LittleEndian.Uint16(body[secondStart : secondStart+2]); got != 7 {
|
|
t.Fatalf("second roster entry key = %d, want slot 7", got)
|
|
}
|
|
secondNameStart := secondStart + 2
|
|
secondName := rosterDstrName(characters[1].Name)
|
|
if got := body[secondNameStart : secondNameStart+len(secondName)]; !bytes.Equal(got, secondName) {
|
|
t.Fatalf("second roster dstr name bytes = %x, want %x", got, secondName)
|
|
}
|
|
jobStart := noPackRosterJobStart(secondStart, characters[1])
|
|
growStart := noPackRosterGrowStart(secondStart, characters[1])
|
|
levelStart := noPackRosterLevelStart(secondStart, characters[1])
|
|
if got := body[jobStart]; got != 15 {
|
|
t.Fatalf("second roster job byte = %d, want 15", got)
|
|
}
|
|
if got := body[growStart]; got != 0 {
|
|
t.Fatalf("second roster grow byte = %d, want 0", got)
|
|
}
|
|
if got := body[levelStart]; got != 1 {
|
|
t.Fatalf("second roster level byte = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestCSharpRosterBodyMatchesSub200B250ReadCursor(t *testing.T) {
|
|
characters := []dnfrepo.CharacterRecord{
|
|
{CharacterID: "1", AccountID: defaultAccountPrefix + "1", Slot: 0, Name: "first", Job: "0", Level: 1},
|
|
{CharacterID: "2", AccountID: defaultAccountPrefix + "1", Slot: 7, Name: "鍒氬垰", Job: "15", Level: 56, Stats: map[string]int64{"grow_type": 2}},
|
|
}
|
|
body := buildCSharpRosterBody(characters)
|
|
|
|
assertCSharpRosterBody(t, body, len(characters))
|
|
cursor := 15
|
|
for idx, character := range characters {
|
|
entryStart := cursor
|
|
wantKey := uint16(rosterSlotValue(character.Slot, idx))
|
|
if got := binary.LittleEndian.Uint16(body[cursor : cursor+2]); got != wantKey {
|
|
t.Fatalf("entry[%d] key = %d, want %d", idx, got, wantKey)
|
|
}
|
|
cursor += 2
|
|
nameLen := int(binary.LittleEndian.Uint32(body[cursor : cursor+4]))
|
|
cursor += 4
|
|
name := rosterRawNameBytes(character)
|
|
if nameLen != len(name) {
|
|
t.Fatalf("entry[%d] name len = %d, want %d", idx, nameLen, len(name))
|
|
}
|
|
if got := body[cursor : cursor+nameLen]; !bytes.Equal(got, name) {
|
|
t.Fatalf("entry[%d] name = %x, want %x", idx, got, name)
|
|
}
|
|
cursor += nameLen
|
|
if got := body[cursor : cursor+noPackRosterPreJobBytes]; !bytes.Equal(got, make([]byte, noPackRosterPreJobBytes)) {
|
|
t.Fatalf("entry[%d] pre-job bytes = %x", idx, got)
|
|
}
|
|
cursor += noPackRosterPreJobBytes
|
|
wantJob := rosterByteValue(int64(numericCharacterStat(character.Job)), 0)
|
|
if got := body[cursor]; got != wantJob {
|
|
t.Fatalf("entry[%d] job = %d, want %d", idx, got, wantJob)
|
|
}
|
|
cursor++
|
|
wantGrow := rosterByteValue(numericCharacterStatValue(character, "grow_type"), 0)
|
|
if got := body[cursor]; got != wantGrow {
|
|
t.Fatalf("entry[%d] grow = %d, want %d", idx, got, wantGrow)
|
|
}
|
|
cursor++
|
|
if got := body[cursor]; got != rosterLevel(character) {
|
|
t.Fatalf("entry[%d] level = %d, want %d", idx, got, rosterLevel(character))
|
|
}
|
|
cursor++
|
|
if got := body[cursor : cursor+10]; !bytes.Equal(got, make([]byte, 10)) {
|
|
t.Fatalf("entry[%d] pre-appearance zero block = %x, want 10 zero bytes", idx, got)
|
|
}
|
|
cursor += 10
|
|
rows := currentRosterEquipSummaryRows(characterEquipSummary(character))
|
|
if got := body[cursor]; got != byte(len(rows)) {
|
|
t.Fatalf("entry[%d] equip count = %d, want %d", idx, got, len(rows))
|
|
}
|
|
cursor++
|
|
cursor += len(rows) * noPackRosterEquipRowBytes
|
|
if got, want := body[cursor:cursor+noPackRosterPostEquipBytes], normalNoPackRosterPostEquipBytes(); !bytes.Equal(got, want) {
|
|
t.Fatalf("entry[%d] post-equip bytes = %x, want %x", idx, got, want)
|
|
}
|
|
cursor += noPackRosterPostEquipBytes
|
|
if want := entryStart + noPackRosterEntryLen(character); cursor != want {
|
|
t.Fatalf("entry[%d] cursor = %d, want %d", idx, cursor, want)
|
|
}
|
|
}
|
|
if got := body[cursor : cursor+10]; !bytes.Equal(got, []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0}) {
|
|
t.Fatalf("roster tail = %x, want page/no-push tail", got)
|
|
}
|
|
if got := cursor + 10; got != len(body) {
|
|
t.Fatalf("body end cursor = %d, len = %d", got, len(body))
|
|
}
|
|
}
|
|
|
|
func buildLegacyGamePacketForBridgeTest(cmd byte, typ uint16, seq uint16, body []byte) []byte {
|
|
packet := make([]byte, dnfproto.LegacyGameHeaderSize+len(body))
|
|
packet[0] = cmd
|
|
binary.LittleEndian.PutUint16(packet[1:3], typ)
|
|
binary.LittleEndian.PutUint32(packet[3:7], uint32(len(packet)))
|
|
binary.LittleEndian.PutUint16(packet[11:13], seq)
|
|
copy(packet[dnfproto.LegacyGameHeaderSize:], body)
|
|
return packet
|
|
}
|
|
|
|
func assertLatestCharacterState(t *testing.T, record dnfproto.LatestGameTransportRecord, count byte) {
|
|
t.Helper()
|
|
if record.TransportHeader.Route != latestCharacterStateRoute {
|
|
t.Fatalf("character state route = %d, want %d", record.TransportHeader.Route, latestCharacterStateRoute)
|
|
}
|
|
minLen := 9 + int(count)*6
|
|
if len(record.Body) < minLen {
|
|
t.Fatalf("character state body too short: %d, want at least %d", len(record.Body), minLen)
|
|
}
|
|
if record.Body[0] != count || record.Body[1] != latestCharacterStateActive || record.Body[3] != latestCharacterCreateEnabled {
|
|
t.Fatalf("character state header mismatch: route=%d body=%x", record.TransportHeader.Route, record.Body)
|
|
}
|
|
}
|
|
|
|
func legacyMsgID(msg dnfenum.LegacyChannelMsg) byte {
|
|
return byte(uint16(msg))
|
|
}
|
|
|
|
func tempChannelInfoFile(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "channel_info.etc")
|
|
if err := os.WriteFile(path, []byte("[server]\n1 1 `tower` 2 `[deathtower]` 0 0 19 `crack` 1 `[crack]` 5 0\n[/server]\n"), 0o600); err != nil {
|
|
t.Fatalf("write temp channel info: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func testChannelCatalog(t *testing.T) *channelcatalog.Catalog {
|
|
t.Helper()
|
|
const fixture = `
|
|
[dungeon]
|
|
` + "`[elven_guard]` `艾尔文防线` 1 2" + `
|
|
[/dungeon]
|
|
[server]
|
|
1 11 ` + "`普通频道`" + ` 1 ` + "`[elven_guard]`" + ` 0 0 38 ` + "`推荐频道`" + ` 1 ` + "`[elven_guard]`" + ` 0 0
|
|
[/server]
|
|
`
|
|
index, err := channelinfo.Parse([]byte(fixture))
|
|
if err != nil {
|
|
t.Fatalf("parse channel info: %v", err)
|
|
}
|
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: 1})
|
|
if err != nil {
|
|
t.Fatalf("build catalog: %v", err)
|
|
}
|
|
return catalog
|
|
}
|
|
|
|
func testCatalogWithoutAutoChannel(t *testing.T) *channelcatalog.Catalog {
|
|
t.Helper()
|
|
const fixture = `
|
|
[dungeon]
|
|
` + "`[elven_guard]` `艾尔文防线` 1 2" + `
|
|
[/dungeon]
|
|
[server]
|
|
1 1 ` + "`普通频道`" + ` 1 ` + "`[elven_guard]`" + ` 0 0 11 ` + "`推荐频道`" + ` 1 ` + "`[elven_guard]`" + ` 0 0
|
|
[/server]
|
|
`
|
|
index, err := channelinfo.Parse([]byte(fixture))
|
|
if err != nil {
|
|
t.Fatalf("parse channel info: %v", err)
|
|
}
|
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: 1})
|
|
if err != nil {
|
|
t.Fatalf("build catalog: %v", err)
|
|
}
|
|
return catalog
|
|
}
|
|
|
|
func testTowerCatalog(t *testing.T) *channelcatalog.Catalog {
|
|
t.Helper()
|
|
const fixture = `
|
|
[dungeon]
|
|
` + "`[deathtower]` `亡者峡谷` 1" + `
|
|
[/dungeon]
|
|
[server]
|
|
1 1 ` + "`塔`" + ` 2 ` + "`[deathtower]`" + ` 0 0 6 ` + "`贸易1`" + ` 3 ` + "`[trade]`" + ` 0 0 38 ` + "`贸易2`" + ` 3 ` + "`[trade]`" + ` 0 0 10 ` + "`格兰`" + ` 1 ` + "`[granfloris]`" + ` 0 0 11 ` + "`天空`" + ` 1 ` + "`[sky_catle]`" + ` 0 0
|
|
[/server]
|
|
`
|
|
index, err := channelinfo.Parse([]byte(fixture))
|
|
if err != nil {
|
|
t.Fatalf("parse channel info: %v", err)
|
|
}
|
|
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: 1})
|
|
if err != nil {
|
|
t.Fatalf("build catalog: %v", err)
|
|
}
|
|
return catalog
|
|
}
|
|
|
|
func intSliceContains(values []int, want int) bool {
|
|
for _, value := range values {
|
|
if value == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func buildCreateRequest(job byte, name string) []byte {
|
|
nameRaw := []byte(name)
|
|
body := make([]byte, 5+len(nameRaw))
|
|
body[0] = job
|
|
binary.LittleEndian.PutUint32(body[1:5], uint32(len(nameRaw)))
|
|
copy(body[5:], nameRaw)
|
|
return body
|
|
}
|
|
|
|
func buildCheckNameRequest(name string) []byte {
|
|
nameRaw := []byte(name)
|
|
body := make([]byte, 4+len(nameRaw))
|
|
binary.LittleEndian.PutUint32(body[:4], uint32(len(nameRaw)))
|
|
copy(body[4:], nameRaw)
|
|
return body
|
|
}
|
|
|
|
func testRepositoryGroup() dnfrepo.Group {
|
|
group := dnfrepomemory.NewMemoryGroup()
|
|
group.Character = &fakeCharacterStore{
|
|
records: make(map[string]dnfrepo.CharacterRecord),
|
|
nextID: 1,
|
|
}
|
|
group.CharacterAssets = &fakeCharacterAssetUnitOfWork{
|
|
character: group.Character,
|
|
inventory: group.Inventory,
|
|
equipment: group.Equipment,
|
|
}
|
|
return group
|
|
}
|
|
|
|
func prepareTestCharacterInitialization(service *Service, jobs ...byte) {
|
|
if service.initialEquipmentByJob == nil {
|
|
service.initialEquipmentByJob = make(map[byte][]initialEquipmentEntry)
|
|
}
|
|
if service.initialSkillsByJob == nil {
|
|
service.initialSkillsByJob = make(map[byte][]initialSkillEntry)
|
|
}
|
|
if service.initialSPTable == nil {
|
|
service.initialSPTable = map[int]int{}
|
|
}
|
|
for _, job := range jobs {
|
|
if _, ok := service.initialEquipmentByJob[job]; !ok {
|
|
service.initialEquipmentByJob[job] = []initialEquipmentEntry{}
|
|
}
|
|
if _, ok := service.initialSkillsByJob[job]; !ok {
|
|
service.initialSkillsByJob[job] = []initialSkillEntry{}
|
|
}
|
|
}
|
|
}
|
|
|
|
type fakeCharacterStore struct {
|
|
records map[string]dnfrepo.CharacterRecord
|
|
nextID int
|
|
}
|
|
|
|
type fakeCharacterAssetUnitOfWork struct {
|
|
character dnfrepo.CharacterRepository
|
|
inventory dnfrepo.InventoryRepository
|
|
equipment dnfrepo.EquipmentRepository
|
|
}
|
|
|
|
func (u *fakeCharacterAssetUnitOfWork) WithinCharacterAssets(
|
|
ctx context.Context,
|
|
_ string,
|
|
apply func(dnfrepo.CharacterRepository, dnfrepo.InventoryRepository, dnfrepo.EquipmentRepository) error,
|
|
) error {
|
|
if u == nil || u.character == nil || u.inventory == nil || u.equipment == nil || apply == nil {
|
|
return dnfrepo.ErrCharacterAssetTransactionUnavailable
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
return apply(u.character, u.inventory, u.equipment)
|
|
}
|
|
|
|
type fakeLegacyInventoryStore struct {
|
|
items map[byte][]dnfrepo.LegacyInventoryItem
|
|
}
|
|
|
|
func (s *fakeLegacyInventoryStore) SelectItems(_ context.Context, _ string, listType byte) ([]dnfrepo.LegacyInventoryItem, error) {
|
|
items := s.items[listType]
|
|
out := make([]dnfrepo.LegacyInventoryItem, len(items))
|
|
copy(out, items)
|
|
return out, nil
|
|
}
|
|
|
|
func (s *fakeCharacterStore) Load(_ context.Context, characterID string) (dnfrepo.CharacterRecord, bool, error) {
|
|
record, ok := s.records[characterID]
|
|
return dnfrepo.CloneCharacter(record), ok, nil
|
|
}
|
|
|
|
func (s *fakeCharacterStore) Save(_ context.Context, record dnfrepo.CharacterRecord) error {
|
|
if s.records == nil {
|
|
s.records = make(map[string]dnfrepo.CharacterRecord)
|
|
}
|
|
s.records[record.CharacterID] = dnfrepo.CloneCharacter(record)
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeCharacterStore) CreateCharacter(ctx context.Context, record dnfrepo.CharacterRecord) error {
|
|
if _, ok, err := s.Load(ctx, record.CharacterID); err != nil || ok {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return dnfrepo.ErrCharacterIDExists
|
|
}
|
|
for _, existing := range s.records {
|
|
if existing.AccountID == record.AccountID &&
|
|
existing.Slot == record.Slot &&
|
|
fakeCharacterDeleteFlag(existing) == 0 {
|
|
return dnfrepo.ErrCharacterSlotOccupied
|
|
}
|
|
}
|
|
return s.Save(ctx, record)
|
|
}
|
|
|
|
func (s *fakeCharacterStore) ListByAccount(_ context.Context, accountID string, _ int) ([]dnfrepo.CharacterRecord, error) {
|
|
out := make([]dnfrepo.CharacterRecord, 0)
|
|
for _, record := range s.records {
|
|
if record.AccountID == accountID && fakeCharacterDeleteFlag(record) == 0 {
|
|
out = append(out, dnfrepo.CloneCharacter(record))
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *fakeCharacterStore) FindIDByName(_ context.Context, name string) (string, bool, error) {
|
|
for _, record := range s.records {
|
|
if record.Name == name && fakeCharacterDeleteFlag(record) == 0 {
|
|
return record.CharacterID, true, nil
|
|
}
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
func fakeCharacterDeleteFlag(record dnfrepo.CharacterRecord) int64 {
|
|
if record.Stats == nil {
|
|
return 0
|
|
}
|
|
return record.Stats["delete_flag"]
|
|
}
|
|
|
|
func (s *fakeCharacterStore) NextNumericID(context.Context) (int, error) {
|
|
id := s.nextID
|
|
s.nextID++
|
|
return id, nil
|
|
}
|
|
|
|
type bufferConn struct {
|
|
write bytes.Buffer
|
|
}
|
|
|
|
func (c *bufferConn) Read(_ []byte) (int, error) { return 0, io.EOF }
|
|
func (c *bufferConn) Write(data []byte) (int, error) { return c.write.Write(data) }
|
|
func (c *bufferConn) Close() error { return nil }
|
|
func (c *bufferConn) LocalAddr() net.Addr { return fakeAddr("local") }
|
|
func (c *bufferConn) RemoteAddr() net.Addr { return fakeAddr("remote") }
|
|
func (c *bufferConn) SetDeadline(_ time.Time) error { return nil }
|
|
func (c *bufferConn) SetReadDeadline(_ time.Time) error { return nil }
|
|
func (c *bufferConn) SetWriteDeadline(_ time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
type fakeAddr string
|
|
|
|
func (a fakeAddr) Network() string { return "tcp" }
|
|
func (a fakeAddr) String() string { return string(a) }
|
|
|
|
type timeoutNetError struct{}
|
|
|
|
func (timeoutNetError) Error() string { return "i/o timeout" }
|
|
func (timeoutNetError) Timeout() bool { return true }
|
|
func (timeoutNetError) Temporary() bool { return true }
|