DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,128 @@
|
||||
package protocol
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
const (
|
||||
ChannelHeaderSize = 13
|
||||
GameServerUpperHeaderSize = ChannelHeaderSize
|
||||
GameServerUpperHeaderSize16 = 16
|
||||
DefaultChannelClassification = 1
|
||||
)
|
||||
|
||||
// ChannelHeader 是客户端发来的 raw upper 13 字节包头。
|
||||
type ChannelHeader struct {
|
||||
Classification byte
|
||||
MsgID uint16
|
||||
Length uint32
|
||||
Checksum uint32
|
||||
Seq uint16
|
||||
}
|
||||
|
||||
type ChannelPacket struct {
|
||||
Header ChannelHeader
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// BuildChannelPacket 封装客户端到服务端方向使用的 13 字节 raw upper 包。
|
||||
func BuildChannelPacket(msgID uint16, body []byte, seq uint16, classification byte) ([]byte, error) {
|
||||
if classification == 0 {
|
||||
classification = DefaultChannelClassification
|
||||
}
|
||||
total := ChannelHeaderSize + len(body)
|
||||
if total > int(^uint32(0)) {
|
||||
return nil, ErrPacketTooLarge
|
||||
}
|
||||
packet := make([]byte, total)
|
||||
packet[0] = classification
|
||||
binary.LittleEndian.PutUint16(packet[1:3], msgID)
|
||||
binary.LittleEndian.PutUint32(packet[3:7], uint32(total))
|
||||
binary.LittleEndian.PutUint16(packet[11:13], seq)
|
||||
copy(packet[ChannelHeaderSize:], body)
|
||||
sum, err := ChecksumRange(packet, 11, total-11)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binary.LittleEndian.PutUint32(packet[7:11], sum)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
// BuildGameServerUpperPacket 封装 game 端口服务端发给客户端的 raw upper 包。
|
||||
// 最新 C# 重写文件与 7001 upper 一致使用 13 字节头;16 字节头只保留为兼容实验。
|
||||
func BuildGameServerUpperPacket(msgID uint16, body []byte, seq uint16, classification byte) ([]byte, error) {
|
||||
return BuildGameServerUpperPacketWithHeaderSize(msgID, body, seq, classification, GameServerUpperHeaderSize)
|
||||
}
|
||||
|
||||
// BuildGameServerUpperPacketWithHeaderSize 按指定头长封装 game raw upper。
|
||||
//
|
||||
// headerSize 只允许 13 或 16。16 字节模式会在 seq 后补 3 个保留字节,
|
||||
// 用于回退旧实验;主路径必须保持 13 字节,避免客户端按 ChannelPacket 读体时错位。
|
||||
func BuildGameServerUpperPacketWithHeaderSize(msgID uint16, body []byte, seq uint16, classification byte, headerSize int) ([]byte, error) {
|
||||
if headerSize != ChannelHeaderSize && headerSize != GameServerUpperHeaderSize16 {
|
||||
return nil, ErrPacketLength
|
||||
}
|
||||
total := headerSize + len(body)
|
||||
if total > int(^uint32(0)) {
|
||||
return nil, ErrPacketTooLarge
|
||||
}
|
||||
packet := make([]byte, total)
|
||||
packet[0] = classification
|
||||
binary.LittleEndian.PutUint16(packet[1:3], msgID)
|
||||
binary.LittleEndian.PutUint32(packet[3:7], uint32(total))
|
||||
binary.LittleEndian.PutUint16(packet[11:13], seq)
|
||||
copy(packet[headerSize:], body)
|
||||
sum, err := ChecksumRange(packet, 11, total-11)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binary.LittleEndian.PutUint32(packet[7:11], sum)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
func ParseChannelPacket(packet []byte) (ChannelPacket, error) {
|
||||
return parseChannelPacket(packet, true)
|
||||
}
|
||||
|
||||
// ParseChannelPacketUnchecked 只按 13 字节 upper 包头切 body,不校验 checksum。
|
||||
//
|
||||
// 最新 game 端口上行的部分 raw upper 包会在 body 里携带 codec/encrypted payload,
|
||||
// 线上的 0x02B2 样本显示同 msg/length 下 body 每次变化但 header checksum 固定。
|
||||
// 分流阶段必须先保住 msg/length 语义,checksum 只能作为后续诊断信号。
|
||||
func ParseChannelPacketUnchecked(packet []byte) (ChannelPacket, error) {
|
||||
return parseChannelPacket(packet, false)
|
||||
}
|
||||
|
||||
func parseChannelPacket(packet []byte, verifyChecksum bool) (ChannelPacket, error) {
|
||||
if len(packet) < ChannelHeaderSize {
|
||||
return ChannelPacket{}, ErrPacketTooShort
|
||||
}
|
||||
header := ChannelHeader{
|
||||
Classification: packet[0],
|
||||
MsgID: binary.LittleEndian.Uint16(packet[1:3]),
|
||||
Length: binary.LittleEndian.Uint32(packet[3:7]),
|
||||
Checksum: binary.LittleEndian.Uint32(packet[7:11]),
|
||||
Seq: binary.LittleEndian.Uint16(packet[11:13]),
|
||||
}
|
||||
if header.Length < ChannelHeaderSize || int(header.Length) != len(packet) {
|
||||
return ChannelPacket{}, ErrPacketLength
|
||||
}
|
||||
if verifyChecksum {
|
||||
sum, err := ChecksumRange(packet, 11, len(packet)-11)
|
||||
if err != nil {
|
||||
return ChannelPacket{}, err
|
||||
}
|
||||
if sum != header.Checksum {
|
||||
return ChannelPacket{}, ErrChecksumInvalid
|
||||
}
|
||||
}
|
||||
return ChannelPacket{
|
||||
Header: header,
|
||||
Body: cloneBytes(packet[ChannelHeaderSize:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cloneBytes(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]byte(nil), data...)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package protocol
|
||||
|
||||
import "math/bits"
|
||||
|
||||
// EncodeLatestUpperClass0Opcode1Body applies the inverse of the current
|
||||
// NoPack.exe class0/op1 receive transform.
|
||||
//
|
||||
// sub_2261E30 copies the wire body and calls sub_2FA1E90(buffer, length, 0).
|
||||
// That function decodes every byte as ROL8(cipher, 6) XOR 0xB5. Therefore the
|
||||
// server-side inverse is ROL8(plain XOR 0xB5, 2). The transform preserves the
|
||||
// exact body length and has no block padding.
|
||||
func EncodeLatestUpperClass0Opcode1Body(body []byte) []byte {
|
||||
encoded := make([]byte, len(body))
|
||||
for i, value := range body {
|
||||
encoded[i] = bits.RotateLeft8(value^0xb5, 2)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/bits"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodeLatestUpperClass0Opcode1BodyMatchesCurrentEXEInverse(t *testing.T) {
|
||||
plain := []byte{0x37, 0x00, 0x00, 0x00, 0x08, 0xef, 0x0b, 0x10}
|
||||
|
||||
encoded := EncodeLatestUpperClass0Opcode1Body(plain)
|
||||
want := []byte{0x0a, 0xd6, 0xd6, 0xd6, 0xf6, 0x69, 0xfa, 0x96}
|
||||
if !bytes.Equal(encoded, want) {
|
||||
t.Fatalf("encoded body = %x, want %x", encoded, want)
|
||||
}
|
||||
if len(encoded) != len(plain) {
|
||||
t.Fatalf("encoded len = %d, want %d", len(encoded), len(plain))
|
||||
}
|
||||
|
||||
decoded := make([]byte, len(encoded))
|
||||
for i, value := range encoded {
|
||||
decoded[i] = bits.RotateLeft8(value, 6) ^ 0xb5
|
||||
}
|
||||
if !bytes.Equal(decoded, plain) {
|
||||
t.Fatalf("current EXE decode result = %x, want %x", decoded, plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeLatestUpperClass0Opcode1BodyReturnsIndependentCopy(t *testing.T) {
|
||||
plain := []byte{1, 2, 3}
|
||||
encoded := EncodeLatestUpperClass0Opcode1Body(plain)
|
||||
encoded[0] = 0
|
||||
if plain[0] != 1 {
|
||||
t.Fatalf("plain body was mutated: %x", plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildAndParseChannelPacket(t *testing.T) {
|
||||
body := []byte{1, 2, 3}
|
||||
got, err := BuildChannelPacket(1, body, 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build channel packet: %v", err)
|
||||
}
|
||||
|
||||
want := mustHex(t, "0101001000000028a15aee0700010203")
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("channel packet mismatch:\n got %x\nwant %x", got, want)
|
||||
}
|
||||
|
||||
packet, err := ParseChannelPacket(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse channel packet: %v", err)
|
||||
}
|
||||
if packet.Header.Classification != DefaultChannelClassification ||
|
||||
packet.Header.MsgID != 1 ||
|
||||
packet.Header.Length != uint32(len(got)) ||
|
||||
packet.Header.Checksum != 3998916904 ||
|
||||
packet.Header.Seq != 7 {
|
||||
t.Fatalf("unexpected header: %+v", packet.Header)
|
||||
}
|
||||
if !bytes.Equal(packet.Body, body) {
|
||||
t.Fatalf("body mismatch: got %x", packet.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChannelPacketRejectsChecksum(t *testing.T) {
|
||||
packet, err := BuildChannelPacket(1, []byte{1, 2, 3}, 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build channel packet: %v", err)
|
||||
}
|
||||
packet[len(packet)-1] ^= 255
|
||||
|
||||
if _, err := ParseChannelPacket(packet); !errors.Is(err, ErrChecksumInvalid) {
|
||||
t.Fatalf("expected checksum error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChannelPacketUncheckedAcceptsCodecUpper(t *testing.T) {
|
||||
raw := mustHex(t, "01b2021d00000038ab3cdc000015b101e6d58515bd9dbb3490ac32e75a")
|
||||
|
||||
if _, err := ParseChannelPacket(raw); !errors.Is(err, ErrChecksumInvalid) {
|
||||
t.Fatalf("strict parse error = %v, want checksum invalid", err)
|
||||
}
|
||||
packet, err := ParseChannelPacketUnchecked(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unchecked parse: %v", err)
|
||||
}
|
||||
if packet.Header.MsgID != 0x02b2 || packet.Header.Length != uint32(len(raw)) || len(packet.Body) != 16 {
|
||||
t.Fatalf("packet header=%+v body_len=%d", packet.Header, len(packet.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGameServerUpperPacketUsesChannel13Header(t *testing.T) {
|
||||
body := []byte{1, 2, 3}
|
||||
got, err := BuildGameServerUpperPacket(1, body, 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build game server upper packet: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != GameServerUpperHeaderSize+len(body) {
|
||||
t.Fatalf("packet len = %d", len(got))
|
||||
}
|
||||
if got[0] != DefaultChannelClassification {
|
||||
t.Fatalf("classification = %d", got[0])
|
||||
}
|
||||
if got[1] != 1 || got[2] != 0 {
|
||||
t.Fatalf("msg bytes = %x", got[1:3])
|
||||
}
|
||||
if !bytes.Equal(got[GameServerUpperHeaderSize:], body) {
|
||||
t.Fatalf("body mismatch: got %x", got[GameServerUpperHeaderSize:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGameServerUpperPacketAllowsServer16Header(t *testing.T) {
|
||||
body := []byte{1, 2, 3}
|
||||
got, err := BuildGameServerUpperPacketWithHeaderSize(1, body, 7, DefaultChannelClassification, GameServerUpperHeaderSize16)
|
||||
if err != nil {
|
||||
t.Fatalf("build game server upper packet: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != GameServerUpperHeaderSize16+len(body) {
|
||||
t.Fatalf("packet len = %d", len(got))
|
||||
}
|
||||
if got[13] != 0 || got[14] != 0 || got[15] != 0 {
|
||||
t.Fatalf("reserved bytes = %x", got[13:16])
|
||||
}
|
||||
if !bytes.Equal(got[GameServerUpperHeaderSize16:], body) {
|
||||
t.Fatalf("body mismatch: got %x", got[GameServerUpperHeaderSize16:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGameServerUpperPacketAllowsSecondaryClass(t *testing.T) {
|
||||
body := []byte{2, 0x20, 0}
|
||||
got, err := BuildGameServerUpperPacket(2, body, 7, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("build game server secondary upper packet: %v", err)
|
||||
}
|
||||
|
||||
if got[0] != 0 {
|
||||
t.Fatalf("classification = %d, want 0", got[0])
|
||||
}
|
||||
if got[1] != 2 || got[2] != 0 {
|
||||
t.Fatalf("msg bytes = %x", got[1:3])
|
||||
}
|
||||
if !bytes.Equal(got[GameServerUpperHeaderSize:], body) {
|
||||
t.Fatalf("body mismatch: got %x", got[GameServerUpperHeaderSize:])
|
||||
}
|
||||
}
|
||||
|
||||
func mustHex(t *testing.T, value string) []byte {
|
||||
t.Helper()
|
||||
data, err := hex.DecodeString(value)
|
||||
if err != nil {
|
||||
t.Fatalf("decode hex: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package protocol
|
||||
|
||||
import "errors"
|
||||
|
||||
const checksumPolynomial uint32 = 1303941417
|
||||
|
||||
var (
|
||||
errChecksumRange = errors.New("dnf checksum range invalid")
|
||||
checksumTable = buildChecksumTable()
|
||||
)
|
||||
|
||||
// Checksum 计算 DNF 最新包校验值。
|
||||
//
|
||||
// 对应 IDA:NoPack.exe 0x34545B0 使用表计算,0x3457610 使用
|
||||
// 0x0D080109 + 0x40B09020 生成多项式 0x4DB89129。
|
||||
func Checksum(data []byte) uint32 {
|
||||
sum, _ := ChecksumRange(data, 0, len(data))
|
||||
return sum
|
||||
}
|
||||
|
||||
// ChecksumRange 计算指定区间的 DNF 校验值。
|
||||
func ChecksumRange(data []byte, offset int, count int) (uint32, error) {
|
||||
if count < 0 || offset < 0 || offset > len(data)-count {
|
||||
return 0, errChecksumRange
|
||||
}
|
||||
crc := uint32(4294967295)
|
||||
for index := 0; index < count; index++ {
|
||||
crc = checksumTable[(crc^uint32(data[offset+index]))&255] ^ (crc >> 8)
|
||||
}
|
||||
return ^crc, nil
|
||||
}
|
||||
|
||||
func buildChecksumTable() [256]uint32 {
|
||||
var table [256]uint32
|
||||
bitValue := uint32(1)
|
||||
for bit := 128; bit != 0; bit >>= 1 {
|
||||
if bitValue&1 != 0 {
|
||||
bitValue = checksumPolynomial ^ (bitValue >> 1)
|
||||
} else {
|
||||
bitValue >>= 1
|
||||
}
|
||||
for left := 0; left < 256; left += bit << 1 {
|
||||
for right := 0; right < bit; right++ {
|
||||
table[left+bit+right] = table[left+right] ^ bitValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return table
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package protocol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestChecksumMatchesLatestClient(t *testing.T) {
|
||||
if got := Checksum([]byte("123456789")); got != 3740255209 {
|
||||
t.Fatalf("checksum mismatch for text: got %#x", got)
|
||||
}
|
||||
|
||||
data := make([]byte, 16)
|
||||
for index := range data {
|
||||
data[index] = byte(index)
|
||||
}
|
||||
if got := Checksum(data); got != 2264570445 {
|
||||
t.Fatalf("checksum mismatch for bytes: got %#x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksumRangeRejectsBadRange(t *testing.T) {
|
||||
if _, err := ChecksumRange([]byte{1, 2, 3}, 2, 2); err == nil {
|
||||
t.Fatal("expected invalid range error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package protocol 收口 DNF 最新客户端的服务端网络包格式。
|
||||
//
|
||||
// 本包只负责协议外壳:校验、拆包和封包。
|
||||
// 频道目录来自 channelcatalog,玩家可变数据来自 repository,不能在这里读取 PVF 或访问数据库。
|
||||
package protocol
|
||||
@@ -0,0 +1,68 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const (
|
||||
DprotoServerControlOpcode = uint16(1466)
|
||||
DprotoServerEnvelopeOpcode = uint16(1467)
|
||||
DprotoClientEnvelopeOpcode = uint16(1517)
|
||||
DprotoCallbackOpcode = uint16(1518)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDprotoEnvelopeOpcode = errors.New("dnf dproto envelope opcode is invalid")
|
||||
ErrDprotoEnvelopeBody = errors.New("dnf dproto envelope body is invalid")
|
||||
ErrDprotoProtectedSize = errors.New("dnf dproto protected payload length is invalid")
|
||||
)
|
||||
|
||||
// DprotoClientEnvelope is the verified C2S class1/op1517 outer packet. The
|
||||
// protected bytes remain opaque until a real server-role provider consumes
|
||||
// the complete Raw packet and its per-connection session state.
|
||||
type DprotoClientEnvelope struct {
|
||||
Header ChannelHeader
|
||||
Protected []byte
|
||||
Raw []byte
|
||||
}
|
||||
|
||||
// ParseDprotoClientEnvelope verifies the ordinary outer upper checksum and
|
||||
// the u32 protected-length field observed at body offset zero.
|
||||
func ParseDprotoClientEnvelope(raw []byte, maxProtectedBytes int) (DprotoClientEnvelope, error) {
|
||||
packet, err := ParseChannelPacket(raw)
|
||||
if err != nil {
|
||||
return DprotoClientEnvelope{}, err
|
||||
}
|
||||
if packet.Header.Classification != DefaultChannelClassification ||
|
||||
packet.Header.MsgID != DprotoClientEnvelopeOpcode {
|
||||
return DprotoClientEnvelope{}, ErrDprotoEnvelopeOpcode
|
||||
}
|
||||
if len(packet.Body) < 4 {
|
||||
return DprotoClientEnvelope{}, ErrDprotoEnvelopeBody
|
||||
}
|
||||
protectedLength := int(binary.LittleEndian.Uint32(packet.Body[:4]))
|
||||
if protectedLength <= 0 || protectedLength != len(packet.Body)-4 {
|
||||
return DprotoClientEnvelope{}, ErrDprotoProtectedSize
|
||||
}
|
||||
if maxProtectedBytes > 0 && protectedLength > maxProtectedBytes {
|
||||
return DprotoClientEnvelope{}, ErrPacketTooLarge
|
||||
}
|
||||
return DprotoClientEnvelope{
|
||||
Header: packet.Header,
|
||||
Protected: cloneBytes(packet.Body[4:]),
|
||||
Raw: cloneBytes(raw),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildDprotoClientEnvelope exists for provider integration tests and native
|
||||
// transport fixtures. Production C2S envelopes are built by the client.
|
||||
func BuildDprotoClientEnvelope(protected []byte, seq uint16) ([]byte, error) {
|
||||
if len(protected) == 0 {
|
||||
return nil, ErrDprotoProtectedSize
|
||||
}
|
||||
body := make([]byte, 4+len(protected))
|
||||
binary.LittleEndian.PutUint32(body[:4], uint32(len(protected)))
|
||||
copy(body[4:], protected)
|
||||
return BuildChannelPacket(DprotoClientEnvelopeOpcode, body, seq, DefaultChannelClassification)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDprotoClientEnvelopeRoundTrip(t *testing.T) {
|
||||
protected := []byte{0x00, 0xad, 0x00, 0x10, 0x00, 0x21, 0x22}
|
||||
wire, err := BuildDprotoClientEnvelope(protected, 55)
|
||||
if err != nil {
|
||||
t.Fatalf("build dproto envelope: %v", err)
|
||||
}
|
||||
envelope, err := ParseDprotoClientEnvelope(wire, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("parse dproto envelope: %v", err)
|
||||
}
|
||||
if envelope.Header.MsgID != DprotoClientEnvelopeOpcode || envelope.Header.Seq != 55 {
|
||||
t.Fatalf("header=%+v", envelope.Header)
|
||||
}
|
||||
if !bytes.Equal(envelope.Protected, protected) || !bytes.Equal(envelope.Raw, wire) {
|
||||
t.Fatalf("protected=%x raw=%x", envelope.Protected, envelope.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDprotoClientEnvelopeRejectsDeclaredLengthMismatch(t *testing.T) {
|
||||
wire, err := BuildDprotoClientEnvelope([]byte{1, 2, 3}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary.LittleEndian.PutUint32(wire[ChannelHeaderSize:ChannelHeaderSize+4], 2)
|
||||
sum, err := ChecksumRange(wire, 11, len(wire)-11)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary.LittleEndian.PutUint32(wire[7:11], sum)
|
||||
if _, err := ParseDprotoClientEnvelope(wire, 1024); !errors.Is(err, ErrDprotoProtectedSize) {
|
||||
t.Fatalf("error=%v, want %v", err, ErrDprotoProtectedSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDprotoClientEnvelopeRejectsCorruptOuterChecksum(t *testing.T) {
|
||||
wire, err := BuildDprotoClientEnvelope([]byte{1, 2, 3}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wire[len(wire)-1] ^= 0xff
|
||||
if _, err := ParseDprotoClientEnvelope(wire, 1024); !errors.Is(err, ErrChecksumInvalid) {
|
||||
t.Fatalf("error=%v, want %v", err, ErrChecksumInvalid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package protocol
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrPacketTooShort = errors.New("dnf packet too short")
|
||||
ErrPacketLength = errors.New("dnf packet length invalid")
|
||||
ErrPacketTooLarge = errors.New("dnf packet too large")
|
||||
ErrChecksumInvalid = errors.New("dnf packet checksum invalid")
|
||||
ErrInnerKind = errors.New("dnf latest game inner kind invalid")
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
package protocol
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
const LatestGameHeaderSize = 7
|
||||
|
||||
// LatestGameHeader 是最新 game 业务包 7 字节包头。
|
||||
//
|
||||
// 格式:u8 cmd + u16 type + u32 checksum,checksum 只覆盖 body。
|
||||
type LatestGameHeader struct {
|
||||
Cmd byte
|
||||
Type uint16
|
||||
Checksum uint32
|
||||
}
|
||||
|
||||
// LatestGamePacket 是最新 game 业务包视图。
|
||||
type LatestGamePacket struct {
|
||||
Header LatestGameHeader
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// BuildLatestGamePacket 构造最新 game 业务包。
|
||||
func BuildLatestGamePacket(cmd byte, typ uint16, body []byte) ([]byte, error) {
|
||||
total := LatestGameHeaderSize + len(body)
|
||||
if total > int(^uint16(0)) {
|
||||
return nil, ErrPacketTooLarge
|
||||
}
|
||||
packet := make([]byte, total)
|
||||
packet[0] = cmd
|
||||
binary.LittleEndian.PutUint16(packet[1:3], typ)
|
||||
binary.LittleEndian.PutUint32(packet[3:7], Checksum(body))
|
||||
copy(packet[LatestGameHeaderSize:], body)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
// ParseLatestGamePacket 解析最新 game 业务包,并校验 body checksum。
|
||||
func ParseLatestGamePacket(packet []byte) (LatestGamePacket, error) {
|
||||
if len(packet) < LatestGameHeaderSize {
|
||||
return LatestGamePacket{}, ErrPacketTooShort
|
||||
}
|
||||
header := LatestGameHeader{
|
||||
Cmd: packet[0],
|
||||
Type: binary.LittleEndian.Uint16(packet[1:3]),
|
||||
Checksum: binary.LittleEndian.Uint32(packet[3:7]),
|
||||
}
|
||||
body := packet[LatestGameHeaderSize:]
|
||||
if Checksum(body) != header.Checksum {
|
||||
return LatestGamePacket{}, ErrChecksumInvalid
|
||||
}
|
||||
return LatestGamePacket{
|
||||
Header: header,
|
||||
Body: cloneBytes(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyLatestGamePacket 只校验最新 game 业务包 checksum。
|
||||
func VerifyLatestGamePacket(packet []byte) error {
|
||||
_, err := ParseLatestGamePacket(packet)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildAndParseLatestGamePacket(t *testing.T) {
|
||||
body := []byte{1, 2, 3}
|
||||
got, err := BuildLatestGamePacket(1, 3, body)
|
||||
if err != nil {
|
||||
t.Fatalf("build latest game packet: %v", err)
|
||||
}
|
||||
|
||||
want := mustHex(t, "0103001df67fa8010203")
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("latest game packet mismatch:\n got %x\nwant %x", got, want)
|
||||
}
|
||||
|
||||
packet, err := ParseLatestGamePacket(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse latest game packet: %v", err)
|
||||
}
|
||||
if packet.Header.Cmd != 1 || packet.Header.Type != 3 || packet.Header.Checksum != 2826958365 {
|
||||
t.Fatalf("unexpected header: %+v", packet.Header)
|
||||
}
|
||||
if !bytes.Equal(packet.Body, body) {
|
||||
t.Fatalf("body mismatch: got %x", packet.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLatestGamePacketRejectsChecksum(t *testing.T) {
|
||||
packet, err := BuildLatestGamePacket(1, 3, []byte{1, 2, 3})
|
||||
if err != nil {
|
||||
t.Fatalf("build latest game packet: %v", err)
|
||||
}
|
||||
packet[len(packet)-1] ^= 255
|
||||
if _, err := ParseLatestGamePacket(packet); !errors.Is(err, ErrChecksumInvalid) {
|
||||
t.Fatalf("expected checksum error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
)
|
||||
|
||||
const (
|
||||
LegacyChannelHeaderSize = 11
|
||||
DofLoginTokenSize = 32
|
||||
DofLoginPrefaceSize = LegacyChannelHeaderSize + DofLoginTokenSize
|
||||
DofAskChannelSize = 35
|
||||
)
|
||||
|
||||
// IsDofLoginPrefacePrefix 判断连接开头是否为 NoPack/DOF 登录预握手。
|
||||
func IsDofLoginPrefacePrefix(data []byte) bool {
|
||||
return hasLegacyPrefix(data, 0, dnfenum.LegacyMsgDofLoginPreface)
|
||||
}
|
||||
|
||||
// IsDofChannelProbePrefix 判断是否为 NoPack/DOF 预登录中的 0x09 脚本请求。
|
||||
func IsDofChannelProbePrefix(data []byte) bool {
|
||||
return hasLegacyPrefix(data, 0, dnfenum.LegacyMsgDofGetScript)
|
||||
}
|
||||
|
||||
// IsDofAskChannelPrefix 判断是否为 DOF 预登录后的频道目录请求。
|
||||
func IsDofAskChannelPrefix(data []byte) bool {
|
||||
return hasLegacyPrefix(data, 0, dnfenum.LegacyMsgAskChannelInfo)
|
||||
}
|
||||
|
||||
// BuildDofLoginAck 构造 DOF 预登录 ack。
|
||||
//
|
||||
// 真实客户端先只读前 11 字节 ack,后续 4 字节保留位和 32 字节 key
|
||||
// 仍按旧服格式一次写出,避免后续读取阶段缺数据。
|
||||
func BuildDofLoginAck(aesKey string) []byte {
|
||||
body := make([]byte, 36)
|
||||
copy(body[4:], []byte(aesKey))
|
||||
return BuildLegacyChannelPacket(dnfenum.LegacyMsgDofLoginAck, body)
|
||||
}
|
||||
|
||||
// BuildLegacyChannelPacket 构造 DOF 旧式频道包。
|
||||
func BuildLegacyChannelPacket(msg dnfenum.LegacyChannelMsg, body []byte) []byte {
|
||||
return buildLegacyPacket(dnfenum.ChannelPacketClass, msg, body)
|
||||
}
|
||||
|
||||
// BuildLegacyClientPacket 构造测试和兼容入口使用的客户端旧式频道包。
|
||||
func BuildLegacyClientPacket(msg dnfenum.LegacyChannelMsg, body []byte) []byte {
|
||||
total := LegacyChannelHeaderSize + len(body)
|
||||
if fixed := legacyFixedLength(msg); fixed > total {
|
||||
total = fixed
|
||||
}
|
||||
packet := make([]byte, total)
|
||||
writeLegacyHeader(packet[:LegacyChannelHeaderSize], 0, byte(msg), total)
|
||||
copy(packet[LegacyChannelHeaderSize:], body)
|
||||
return packet
|
||||
}
|
||||
|
||||
func buildLegacyPacket(command byte, msg dnfenum.LegacyChannelMsg, body []byte) []byte {
|
||||
packet := make([]byte, LegacyChannelHeaderSize+len(body))
|
||||
writeLegacyHeader(packet[:LegacyChannelHeaderSize], command, byte(msg), len(packet))
|
||||
copy(packet[LegacyChannelHeaderSize:], body)
|
||||
return packet
|
||||
}
|
||||
|
||||
// LegacyPayload 返回旧式频道包的 payload 副本。
|
||||
func LegacyPayload(packet []byte) []byte {
|
||||
if len(packet) <= LegacyChannelHeaderSize {
|
||||
return nil
|
||||
}
|
||||
return cloneBytes(packet[LegacyChannelHeaderSize:])
|
||||
}
|
||||
|
||||
func hasLegacyPrefix(data []byte, command byte, msg dnfenum.LegacyChannelMsg) bool {
|
||||
if len(data) < LegacyChannelHeaderSize {
|
||||
return false
|
||||
}
|
||||
var want [LegacyChannelHeaderSize]byte
|
||||
writeLegacyHeader(want[:], command, byte(msg), legacyFixedLength(msg))
|
||||
return bytes.Equal(data[:LegacyChannelHeaderSize], want[:])
|
||||
}
|
||||
|
||||
func writeLegacyHeader(header []byte, command byte, msgID byte, totalLen int) {
|
||||
header[0] = command
|
||||
header[1] = msgID
|
||||
binary.LittleEndian.PutUint32(header[2:6], uint32(totalLen))
|
||||
header[10] = 1
|
||||
}
|
||||
|
||||
func legacyFixedLength(msg dnfenum.LegacyChannelMsg) int {
|
||||
switch msg {
|
||||
case dnfenum.LegacyMsgDofLoginPreface:
|
||||
return DofLoginPrefaceSize
|
||||
case dnfenum.LegacyMsgDofGetScript:
|
||||
return LegacyChannelHeaderSize
|
||||
case dnfenum.LegacyMsgAskChannelInfo:
|
||||
return DofAskChannelSize
|
||||
default:
|
||||
length := int(uint16(msg) >> 8)
|
||||
if length > 0 {
|
||||
return length
|
||||
}
|
||||
return LegacyChannelHeaderSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
)
|
||||
|
||||
func TestDofLoginAckUsesLegacyPrefixAndKey(t *testing.T) {
|
||||
ack := BuildDofLoginAck("20260629000006")
|
||||
if len(ack) != 47 {
|
||||
t.Fatalf("ack len = %d, want 47", len(ack))
|
||||
}
|
||||
if ack[0] != dnfenum.ChannelPacketClass || ack[1] != legacyMsgID(dnfenum.LegacyMsgDofLoginAck) {
|
||||
t.Fatalf("ack header = %x", ack[:LegacyChannelHeaderSize])
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(ack[2:6]); got != uint32(len(ack)) {
|
||||
t.Fatalf("ack len field = %d, want %d", got, len(ack))
|
||||
}
|
||||
if got := string(bytes.TrimRight(ack[15:], "\x00")); got != "20260629000006" {
|
||||
t.Fatalf("ack key = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDofLegacyPrefixDetection(t *testing.T) {
|
||||
preface := make([]byte, DofLoginPrefaceSize)
|
||||
copy(preface, BuildLegacyClientPacket(dnfenum.LegacyMsgDofLoginPreface, nil))
|
||||
if !IsDofLoginPrefacePrefix(preface) {
|
||||
t.Fatal("expected login preface prefix")
|
||||
}
|
||||
if IsDofAskChannelPrefix(preface) {
|
||||
t.Fatal("login preface must not match ask channel")
|
||||
}
|
||||
|
||||
ask := make([]byte, DofAskChannelSize)
|
||||
copy(ask, BuildLegacyClientPacket(dnfenum.LegacyMsgAskChannelInfo, nil))
|
||||
if !IsDofAskChannelPrefix(ask) {
|
||||
t.Fatal("expected ask channel prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyHeaderUsesActualPacketLength(t *testing.T) {
|
||||
packet := BuildLegacyChannelPacket(dnfenum.LegacyMsgChannelInfo, []byte{1, 2, 3})
|
||||
if packet[1] != legacyMsgID(dnfenum.LegacyMsgChannelInfo) {
|
||||
t.Fatalf("msg id = %d", packet[1])
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(packet[2:6]); got != uint32(len(packet)) {
|
||||
t.Fatalf("len field = %d, want %d", got, len(packet))
|
||||
}
|
||||
}
|
||||
|
||||
func legacyMsgID(msg dnfenum.LegacyChannelMsg) byte {
|
||||
return byte(uint16(msg))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package protocol
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
const LegacyGameHeaderSize = 13
|
||||
|
||||
// LegacyGameHeader 是当前 90 级客户端在 game 端口上行仍会发送的 13 字节业务头。
|
||||
// 这里只做 wire 结构解析;checksum 字段受客户端会话编码影响,暂不作为丢包依据。
|
||||
type LegacyGameHeader struct {
|
||||
Cmd byte
|
||||
Type uint16
|
||||
Length uint32
|
||||
Checksum uint32
|
||||
Sequence uint16
|
||||
}
|
||||
|
||||
// LegacyGamePacket 是 13 字节 game 业务包视图。
|
||||
type LegacyGamePacket struct {
|
||||
Header LegacyGameHeader
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// BuildFixed15GameServerPacket builds the fixed server-side header used by
|
||||
// the GET_USERINFO/NOTI character-select exchange. It is deliberately kept
|
||||
// separate from the 13-byte client legacy header: byte 7 is the server route
|
||||
// discriminator and bytes 8..14 are reserved.
|
||||
func BuildFixed15GameServerPacket(cmd byte, typ uint16, body []byte, route byte) ([]byte, error) {
|
||||
const headerSize = 15
|
||||
total := headerSize + len(body)
|
||||
if uint64(total) > uint64(^uint32(0)) {
|
||||
return nil, ErrPacketTooLarge
|
||||
}
|
||||
packet := make([]byte, total)
|
||||
packet[0] = cmd
|
||||
binary.LittleEndian.PutUint16(packet[1:3], typ)
|
||||
binary.LittleEndian.PutUint32(packet[3:7], uint32(total))
|
||||
packet[7] = route
|
||||
copy(packet[headerSize:], body)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
// ParseLegacyGamePacket 解析客户端上行的 13 字节 game 业务包。
|
||||
// 它不写业务状态,只返回 cmd/type/body 给服务端分发层继续判定。
|
||||
func ParseLegacyGamePacket(packet []byte) (LegacyGamePacket, error) {
|
||||
if len(packet) < LegacyGameHeaderSize {
|
||||
return LegacyGamePacket{}, ErrPacketTooShort
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(packet[3:7])
|
||||
if length < LegacyGameHeaderSize || int(length) != len(packet) {
|
||||
return LegacyGamePacket{}, ErrPacketLength
|
||||
}
|
||||
header := LegacyGameHeader{
|
||||
Cmd: packet[0],
|
||||
Type: binary.LittleEndian.Uint16(packet[1:3]),
|
||||
Length: length,
|
||||
Checksum: binary.LittleEndian.Uint32(packet[7:11]),
|
||||
Sequence: binary.LittleEndian.Uint16(packet[11:13]),
|
||||
}
|
||||
return LegacyGamePacket{
|
||||
Header: header,
|
||||
Body: cloneBytes(packet[LegacyGameHeaderSize:]),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// S2C 回包二次加密:**等长流密码**(只加密 body,长度、头、checksum 布局全不变)。
|
||||
//
|
||||
// 设计见 `服务端回包二次加密_客户端DLL解密_设计文档_20260930.md`。要点:
|
||||
//
|
||||
// - 加密边界是**协议消息**,不是 TCP 写边界:合批(upperWriteBatch)与 Nagle 行为不受影响;
|
||||
// - **等长**:客户端 `ProxyCipherDecodeFunction` 的契约是 `*outLength = bodyLength`,
|
||||
// 带 tag 的 AEAD 会破坏它,所以这里不用 GCM;
|
||||
// - **无跨包状态**:每包从 `(连接 nonce, msgID, seq)` 重新起流,丢包/重发/跳号都不会失步
|
||||
// (有状态密钥流一旦错位就是"全部乱码 + 客户端卡住",是必须避免的故障形状);
|
||||
// - 密钥按连接派生:HKDF-SHA256(ikm = 固化主密钥, salt = LANA 握手里那 16 字节随机 nonce),
|
||||
// 因此**不需要任何额外往返**,也天然做到"每连接一把"。
|
||||
//
|
||||
// 计数器块(16 字节,AES-CTR 的初始计数块):
|
||||
//
|
||||
// [0:8] 连接 nonce 前 8 字节(每连接随机,来自 LANA challenge)
|
||||
// [8:10] msgID(大端)—— 明文可读,用于降低"同 seq 复用密钥流"的概率
|
||||
// [10:12] seq(大端)—— 明文可读
|
||||
// [12:16] 0,包内块计数器从这里自增
|
||||
//
|
||||
// ⚠️ 已知边界:同一个连接里,`(msgID, seq)` 会在 65536 个包之后重复,
|
||||
// 那时两包会用到同一段密钥流(CTR 复用)。这是为了"绝不失步"付出的代价:
|
||||
// 加包序号纪元(epoch)能消除它,但服务端在写失败时也会 `upperSeq++`,
|
||||
// 客户端观测不到那次自增 → 纪元错位 → **全部乱码**,比理论上的密钥流复用危险得多。
|
||||
// 若要消除,必须先有一个两端都能可靠对齐的通道(协议字段或可靠的连接内信令)。
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// S2CMasterKeySize 是固化主密钥长度(HKDF 的 ikm)。
|
||||
S2CMasterKeySize = 32
|
||||
// S2CCipherKeySize 是每连接派生出的 AES 密钥长度。
|
||||
S2CCipherKeySize = 16
|
||||
// S2CCipherCodecName 是回包 body 的 codec 标签,写进 game-upper-send-meta 便于排障。
|
||||
S2CCipherCodecName = "s2c-ctr-v1"
|
||||
// s2cKeyInfo 是 HKDF 的域分隔串。
|
||||
s2cKeyInfo = "dnf:s2c:ctr:v1"
|
||||
|
||||
// DefaultS2CMasterKeyBase64 是**固化主密钥**(32 字节 base64)。
|
||||
//
|
||||
// 同一份常量必须同时存在于服务端与 90CN.dll 的 s2ccrypt.h 里——两边不一致 =
|
||||
// 所有回包变成乱码。轮换步骤见设计文档:换这一行 + 改 DLL 常量 + 重新打包,两侧同时上线。
|
||||
// 服务端也认 DNFBRIDGE_GAME_S2C_KEY_BASE64 覆盖(仅供本地联调/灰度)。
|
||||
DefaultS2CMasterKeyBase64 = "ToXJkeLbeccbEN0a2fK2DjRhct6nx8KTYisGp49Levs="
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrS2CMasterKeyInvalid 表示主密钥长度或编码不合法。
|
||||
ErrS2CMasterKeyInvalid = errors.New("dnf protocol: s2c master key is invalid")
|
||||
// ErrS2CNonceInvalid 表示连接 nonce 长度不合法。
|
||||
ErrS2CNonceInvalid = errors.New("dnf protocol: s2c connection nonce is invalid")
|
||||
)
|
||||
|
||||
// S2CCipher 是一条游戏连接上的 S2C body 加解密器(加密与解密是同一个变换)。
|
||||
//
|
||||
// 它是**无状态**的:Apply 只依赖入参与连接级的 block nonce,可以被并发调用。
|
||||
type S2CCipher struct {
|
||||
block cipher.Block
|
||||
// nonce 是连接 nonce 的前 8 字节,来自 LANA challenge 的随机数。
|
||||
nonce [8]byte
|
||||
}
|
||||
|
||||
// ParseS2CMasterKey 解析 32 字节主密钥(base64 / hex / 原始字节)。
|
||||
func ParseS2CMasterKey(data []byte) ([]byte, error) {
|
||||
if len(data) == S2CMasterKeySize {
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if decoded, err := hex.DecodeString(text); err == nil && len(decoded) == S2CMasterKeySize {
|
||||
return decoded, nil
|
||||
}
|
||||
for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
|
||||
if decoded, err := encoding.DecodeString(text); err == nil && len(decoded) == S2CMasterKeySize {
|
||||
return decoded, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: want %d bytes, got %d input bytes", ErrS2CMasterKeyInvalid, S2CMasterKeySize, len(data))
|
||||
}
|
||||
|
||||
// DefaultS2CMasterKey 返回固化主密钥。
|
||||
func DefaultS2CMasterKey() []byte {
|
||||
key, err := ParseS2CMasterKey([]byte(DefaultS2CMasterKeyBase64))
|
||||
if err != nil {
|
||||
// 常量被改坏时这是构建期就能发现的错误;运行期只可能出现在有人手改常量之后。
|
||||
panic("dnf protocol: baked S2C master key is invalid: " + err.Error())
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// NewS2CCipher 用主密钥与连接 nonce 派生一条连接的 S2C 加解密器。
|
||||
//
|
||||
// connectionNonce 必须**每连接不同**(服务端用 LANA challenge 里那 16 字节随机数),
|
||||
// 否则不同连接会共用密钥流。
|
||||
func NewS2CCipher(masterKey, connectionNonce []byte) (*S2CCipher, error) {
|
||||
parsed, err := ParseS2CMasterKey(masterKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(connectionNonce) != 16 {
|
||||
return nil, fmt.Errorf("%w: want 16 bytes, got %d", ErrS2CNonceInvalid, len(connectionNonce))
|
||||
}
|
||||
salt := append([]byte(nil), connectionNonce...)
|
||||
// HKDF-Extract/Expand;salt 取连接 nonce,info 做域分隔。
|
||||
derived, err := hkdf.Key(sha256.New, parsed, salt, s2cKeyInfo, S2CCipherKeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dnf protocol: derive s2c key: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(derived)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dnf protocol: new s2c cipher: %w", err)
|
||||
}
|
||||
cipher := &S2CCipher{block: block}
|
||||
copy(cipher.nonce[:], connectionNonce[:8])
|
||||
return cipher, nil
|
||||
}
|
||||
|
||||
// Apply 就地变换 body(加密与解密用同一函数)。长度不变。
|
||||
//
|
||||
// msgID 与 seq 必须取**线上 13 字节头里**的值:客户端在两个端点都读得到它们,
|
||||
// 因此不需要任何额外字段来对齐密钥流。
|
||||
func (c *S2CCipher) Apply(body []byte, msgID uint16, seq uint16) {
|
||||
if c == nil || c.block == nil || len(body) == 0 {
|
||||
return
|
||||
}
|
||||
block := c.counterBlock(msgID, seq)
|
||||
stream := cipher.NewCTR(c.block, block[:])
|
||||
stream.XORKeyStream(body, body)
|
||||
}
|
||||
|
||||
// counterBlock 组装该包的 AES-CTR 初始计数块。
|
||||
func (c *S2CCipher) counterBlock(msgID uint16, seq uint16) [16]byte {
|
||||
var block [16]byte
|
||||
copy(block[0:8], c.nonce[:])
|
||||
block[8] = byte(msgID >> 8)
|
||||
block[9] = byte(msgID)
|
||||
block[10] = byte(seq >> 8)
|
||||
block[11] = byte(seq)
|
||||
return block
|
||||
}
|
||||
|
||||
// NoncePrefix 返回连接 nonce 前 8 字节(诊断用;客户端用同一段拼计数器块)。
|
||||
func (c *S2CCipher) NoncePrefix() []byte {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]byte(nil), c.nonce[:]...)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 固定向量:**这就是跨实现契约**。
|
||||
// 同一组 (master key, 连接 nonce, msgID, seq, body) 在 90CN.dll 的 s2ccrypt 自检里
|
||||
// 必须算出完全一样的密文(Go 侧与 C 侧各跑一遍比对,见 s2ccrypt_selftest)。
|
||||
const (
|
||||
testVectorMasterKeyBase64 = "ToXJkeLbeccbEN0a2fK2DjRhct6nx8KTYisGp49Levs="
|
||||
testVectorConnNonceHex = "00112233445566778899aabbccddeeff"
|
||||
testVectorMsgID = uint16(38)
|
||||
testVectorSeq = uint16(4242)
|
||||
testVectorBodyHex = "0b000000ff00cafe0102030405060708090a"
|
||||
testVectorCipherHex = "e32cd1aa9c3ac15f46145c774a701eb6bfe0"
|
||||
)
|
||||
|
||||
func testVectorInputs(t *testing.T) ([]byte, []byte, []byte) {
|
||||
t.Helper()
|
||||
key, err := ParseS2CMasterKey([]byte(testVectorMasterKeyBase64))
|
||||
if err != nil {
|
||||
t.Fatalf("parse master key: %v", err)
|
||||
}
|
||||
nonce, err := hex.DecodeString(testVectorConnNonceHex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode nonce: %v", err)
|
||||
}
|
||||
body, err := hex.DecodeString(testVectorBodyHex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
return key, nonce, body
|
||||
}
|
||||
|
||||
// 钉死密文:任何"顺手改一下 KDF / 计数器布局"的改动都会在这里失败,
|
||||
// 而不是等到玩家客户端收到乱码(现象是"卡住",极难定位)。
|
||||
func TestS2CCipherMatchesPinnedVector(t *testing.T) {
|
||||
key, nonce, body := testVectorInputs(t)
|
||||
s2c, err := NewS2CCipher(key, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
sealed := append([]byte(nil), body...)
|
||||
s2c.Apply(sealed, testVectorMsgID, testVectorSeq)
|
||||
if got := hex.EncodeToString(sealed); got != testVectorCipherHex {
|
||||
t.Fatalf("ciphertext = %s, want %s", got, testVectorCipherHex)
|
||||
}
|
||||
s2c.Apply(sealed, testVectorMsgID, testVectorSeq)
|
||||
if !bytes.Equal(sealed, body) {
|
||||
t.Fatalf("apply twice must return the plaintext: %s", hex.EncodeToString(sealed))
|
||||
}
|
||||
}
|
||||
|
||||
// 计数器块布局是两端唯一的共享约定,这里逐字节钉住它。
|
||||
func TestS2CCounterBlockLayout(t *testing.T) {
|
||||
key, nonce, _ := testVectorInputs(t)
|
||||
s2c, err := NewS2CCipher(key, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
block := s2c.counterBlock(0x1234, 0xabcd)
|
||||
want := append(append([]byte(nil), nonce[:8]...), 0x12, 0x34, 0xab, 0xcd, 0, 0, 0, 0)
|
||||
if !bytes.Equal(block[:], want) {
|
||||
t.Fatalf("counter block = %x, want %x", block[:], want)
|
||||
}
|
||||
if !bytes.Equal(s2c.NoncePrefix(), nonce[:8]) {
|
||||
t.Fatalf("nonce prefix = %x, want %x", s2c.NoncePrefix(), nonce[:8])
|
||||
}
|
||||
}
|
||||
|
||||
// 等长:客户端 ProxyCipherDecodeFunction 的契约是 `*outLength = bodyLength`。
|
||||
func TestS2CCipherIsLengthPreserving(t *testing.T) {
|
||||
key, nonce, _ := testVectorInputs(t)
|
||||
s2c, err := NewS2CCipher(key, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
for _, size := range []int{1, 15, 16, 17, 43, 233, 2048} {
|
||||
body := bytes.Repeat([]byte{0xA5}, size)
|
||||
original := append([]byte(nil), body...)
|
||||
s2c.Apply(body, 38, 7)
|
||||
if len(body) != size {
|
||||
t.Fatalf("size %d became %d", size, len(body))
|
||||
}
|
||||
if bytes.Equal(body, original) {
|
||||
t.Fatalf("size %d was not transformed", size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 每连接一把密钥:不同连接 nonce 必须给出不同密文(否则跨连接会共用密钥流)。
|
||||
func TestS2CCipherIsBoundToConnectionNonce(t *testing.T) {
|
||||
key, nonce, body := testVectorInputs(t)
|
||||
first, err := NewS2CCipher(key, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
otherNonce := append([]byte(nil), nonce...)
|
||||
otherNonce[0] ^= 0x01
|
||||
second, err := NewS2CCipher(key, otherNonce)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
a := append([]byte(nil), body...)
|
||||
b := append([]byte(nil), body...)
|
||||
first.Apply(a, 38, 1)
|
||||
second.Apply(b, 38, 1)
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("two connections produced the same keystream")
|
||||
}
|
||||
}
|
||||
|
||||
// 同一连接内不同 (msgID, seq) 必须走不同密钥流;相同输入必须可复现(无跨包状态)。
|
||||
func TestS2CCipherIsStatelessAcrossPackets(t *testing.T) {
|
||||
key, nonce, body := testVectorInputs(t)
|
||||
s2c, err := NewS2CCipher(key, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
first := append([]byte(nil), body...)
|
||||
s2c.Apply(first, 38, 100)
|
||||
second := append([]byte(nil), body...)
|
||||
s2c.Apply(second, 39, 100) // 只改 msgID
|
||||
if bytes.Equal(first, second) {
|
||||
t.Fatal("msgID did not affect the keystream")
|
||||
}
|
||||
third := append([]byte(nil), body...)
|
||||
s2c.Apply(third, 38, 101) // 只改 seq
|
||||
if bytes.Equal(first, third) {
|
||||
t.Fatal("seq did not affect the keystream")
|
||||
}
|
||||
// 无状态:中间插入别的包不影响同一 (msgID, seq) 的结果(丢包/重发安全)。
|
||||
s2c.Apply(append([]byte(nil), body...), 40, 102)
|
||||
replay := append([]byte(nil), body...)
|
||||
s2c.Apply(replay, 38, 100)
|
||||
if !bytes.Equal(first, replay) {
|
||||
t.Fatal("cipher must be stateless: the same (msgID, seq) must reproduce the same keystream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS2CMasterKeyParsing(t *testing.T) {
|
||||
raw, err := base64.StdEncoding.DecodeString(testVectorMasterKeyBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("decode fixture: %v", err)
|
||||
}
|
||||
if parsed, err := ParseS2CMasterKey(raw); err != nil || !bytes.Equal(parsed, raw) {
|
||||
t.Fatalf("raw: %v %x", err, parsed)
|
||||
}
|
||||
if parsed, err := ParseS2CMasterKey([]byte(testVectorMasterKeyBase64)); err != nil || !bytes.Equal(parsed, raw) {
|
||||
t.Fatalf("base64: %v", err)
|
||||
}
|
||||
if parsed, err := ParseS2CMasterKey([]byte(hex.EncodeToString(raw))); err != nil || !bytes.Equal(parsed, raw) {
|
||||
t.Fatalf("hex: %v", err)
|
||||
}
|
||||
if _, err := ParseS2CMasterKey([]byte("short")); err == nil {
|
||||
t.Fatal("a short key must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewS2CCipherRejectsBadInputs(t *testing.T) {
|
||||
key, nonce, _ := testVectorInputs(t)
|
||||
if _, err := NewS2CCipher(key, nonce[:8]); err == nil {
|
||||
t.Fatal("a truncated connection nonce must be rejected")
|
||||
}
|
||||
if _, err := NewS2CCipher([]byte("nope"), nonce); err == nil {
|
||||
t.Fatal("an invalid master key must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// 固化主密钥必须能解析(改坏常量会在测试阶段就炸,而不是线上全服乱码)。
|
||||
func TestBakedMasterKeyIsUsable(t *testing.T) {
|
||||
key := DefaultS2CMasterKey()
|
||||
if len(key) != S2CMasterKeySize {
|
||||
t.Fatalf("baked master key length = %d", len(key))
|
||||
}
|
||||
if _, err := NewS2CCipher(key, make([]byte, 16)); err != nil {
|
||||
t.Fatalf("baked master key must derive a cipher: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 计数器块必须真的被 AES-CTR 用作初值(否则"每包独立"就是空话)。
|
||||
func TestS2CCipherUsesCounterBlockAsInitialCounter(t *testing.T) {
|
||||
key, nonce, body := testVectorInputs(t)
|
||||
s2c, err := NewS2CCipher(key, nonce)
|
||||
if err != nil {
|
||||
t.Fatalf("new cipher: %v", err)
|
||||
}
|
||||
derived, err := hkdf.Key(sha256.New, key, nonce, s2cKeyInfo, S2CCipherKeySize)
|
||||
if err != nil {
|
||||
t.Fatalf("derive: %v", err)
|
||||
}
|
||||
block, err := aes.NewCipher(derived)
|
||||
if err != nil {
|
||||
t.Fatalf("aes: %v", err)
|
||||
}
|
||||
counter := s2c.counterBlock(38, 4242)
|
||||
stream := cipher.NewCTR(block, counter[:])
|
||||
want := append([]byte(nil), body...)
|
||||
stream.XORKeyStream(want, want)
|
||||
got := append([]byte(nil), body...)
|
||||
s2c.Apply(got, 38, 4242)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("Apply did not use counterBlock as the CTR initial counter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package protocol
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
// LatestGameStreamKind 标记 game 端口里混合出现的最新协议帧类型。
|
||||
type LatestGameStreamKind uint8
|
||||
|
||||
const (
|
||||
LatestGameStreamTransport LatestGameStreamKind = 1
|
||||
LatestGameStreamUpper LatestGameStreamKind = 2
|
||||
LatestGameStreamLegacy LatestGameStreamKind = 3
|
||||
LatestGameStreamDproto LatestGameStreamKind = 4
|
||||
|
||||
currentDungeonMoveMapPlainBodySize = 100
|
||||
currentTownSetUserAreaPlainBodySize = 16
|
||||
currentAcceptQuestPlainBodySize = 4
|
||||
currentSetQuestTriggerPlainBodySize = 6
|
||||
currentDieCharacterPlainBodySize = 4
|
||||
currentUseCoinPlainBodySize = 2
|
||||
currentBossDieCheckPlainBodySize = 39
|
||||
currentTutorialFlagPlainBodySize = 6
|
||||
currentPlayResultBaseBodySize = 42
|
||||
currentPlayResultOptionalBodySize = 46
|
||||
currentPlayResultDynamicRowSize = 7
|
||||
currentPlayResultMaximumDynamicRows = 8
|
||||
currentChannelReconnectProbeSize = 31
|
||||
observed90CNShortEndpointBodySize = 586
|
||||
currentChannelDisplayRebindSize = 590
|
||||
reference90CNChannelDisplayRebindBodySize = 598
|
||||
observed90CNCrackEndpointBodySize = 610
|
||||
observed90CNShortEndpointMaxBodySize = 630
|
||||
current90CNEndpointShortVariantSize = 4
|
||||
current90CNEndpointAdapterRecordSize = 32
|
||||
current90CNEndpointMaxAdapterRecords = 32
|
||||
)
|
||||
|
||||
// LatestGameStreamPacket 是从 game TCP 字节流切出的完整包。
|
||||
type LatestGameStreamPacket struct {
|
||||
Kind LatestGameStreamKind
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// SplitLatestGameStream 从 game TCP 流中同时切出 transport frame 和 raw upper 包。
|
||||
//
|
||||
// 最新客户端在 game 连接上既会发送 12+8+7 的 game transport,也会发送
|
||||
// 13 字节频道包头格式的 raw upper 包;拆流时必须先识别 raw upper,再按
|
||||
// legacy game 和 frame+2 的 u16 totalLen 处理 transport。
|
||||
func SplitLatestGameStream(buffer []byte, maxPacketBytes int) (packets []LatestGameStreamPacket, remaining []byte, skipped int, err error) {
|
||||
if maxPacketBytes <= 0 {
|
||||
maxPacketBytes = int(^uint16(0))
|
||||
}
|
||||
offset := 0
|
||||
for offset <= len(buffer)-4 {
|
||||
if packet, wait, ok := splitDprotoClientPacket(buffer, offset, maxPacketBytes); ok {
|
||||
packets = append(packets, LatestGameStreamPacket{
|
||||
Kind: LatestGameStreamDproto,
|
||||
Data: packet,
|
||||
})
|
||||
offset += len(packet)
|
||||
continue
|
||||
} else if wait {
|
||||
break
|
||||
}
|
||||
if packet, wait, ok := splitUpperPacket(buffer, offset, maxPacketBytes); ok {
|
||||
packets = append(packets, LatestGameStreamPacket{
|
||||
Kind: LatestGameStreamUpper,
|
||||
Data: packet,
|
||||
})
|
||||
offset += len(packet)
|
||||
continue
|
||||
} else if wait {
|
||||
break
|
||||
}
|
||||
if packet, wait, ok := splitLegacyGamePacket(buffer, offset, maxPacketBytes); ok {
|
||||
packets = append(packets, LatestGameStreamPacket{
|
||||
Kind: LatestGameStreamLegacy,
|
||||
Data: packet,
|
||||
})
|
||||
offset += len(packet)
|
||||
continue
|
||||
} else if wait {
|
||||
break
|
||||
}
|
||||
|
||||
frameLength := int(binary.LittleEndian.Uint16(buffer[offset+2 : offset+4]))
|
||||
if frameLength < MinTCPFrameSize {
|
||||
offset++
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if frameLength > maxPacketBytes {
|
||||
return nil, nil, skipped, ErrPacketTooLarge
|
||||
}
|
||||
if len(buffer)-offset < frameLength {
|
||||
break
|
||||
}
|
||||
packets = append(packets, LatestGameStreamPacket{
|
||||
Kind: LatestGameStreamTransport,
|
||||
Data: cloneBytes(buffer[offset : offset+frameLength]),
|
||||
})
|
||||
offset += frameLength
|
||||
}
|
||||
return packets, cloneBytes(buffer[offset:]), skipped, nil
|
||||
}
|
||||
|
||||
func splitDprotoClientPacket(buffer []byte, offset int, maxPacketBytes int) ([]byte, bool, bool) {
|
||||
available := len(buffer) - offset
|
||||
if available < ChannelHeaderSize {
|
||||
return nil, false, false
|
||||
}
|
||||
if buffer[offset] != DefaultChannelClassification ||
|
||||
binary.LittleEndian.Uint16(buffer[offset+1:offset+3]) != DprotoClientEnvelopeOpcode {
|
||||
return nil, false, false
|
||||
}
|
||||
length := int(binary.LittleEndian.Uint32(buffer[offset+3 : offset+7]))
|
||||
if length < ChannelHeaderSize || length > maxPacketBytes {
|
||||
return nil, false, false
|
||||
}
|
||||
if available < length {
|
||||
return nil, true, false
|
||||
}
|
||||
return cloneBytes(buffer[offset : offset+length]), false, true
|
||||
}
|
||||
|
||||
func splitLegacyGamePacket(buffer []byte, offset int, maxPacketBytes int) ([]byte, bool, bool) {
|
||||
available := len(buffer) - offset
|
||||
if available < LegacyGameHeaderSize {
|
||||
return nil, false, false
|
||||
}
|
||||
cmd := buffer[offset]
|
||||
if cmd != 0 && cmd != 1 {
|
||||
return nil, false, false
|
||||
}
|
||||
length := int(binary.LittleEndian.Uint32(buffer[offset+3 : offset+7]))
|
||||
if length < LegacyGameHeaderSize {
|
||||
return nil, false, false
|
||||
}
|
||||
if length > maxPacketBytes {
|
||||
return nil, false, false
|
||||
}
|
||||
if available < length {
|
||||
return nil, true, false
|
||||
}
|
||||
packet := cloneBytes(buffer[offset : offset+length])
|
||||
if _, err := ParseLegacyGamePacket(packet); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
return packet, false, true
|
||||
}
|
||||
|
||||
func splitUpperPacket(buffer []byte, offset int, maxPacketBytes int) ([]byte, bool, bool) {
|
||||
available := len(buffer) - offset
|
||||
if available < ChannelHeaderSize {
|
||||
return nil, false, false
|
||||
}
|
||||
classification := buffer[offset]
|
||||
if classification != 0 && classification != DefaultChannelClassification {
|
||||
return nil, false, false
|
||||
}
|
||||
length := int(binary.LittleEndian.Uint32(buffer[offset+3 : offset+7]))
|
||||
if length < ChannelHeaderSize {
|
||||
return nil, false, false
|
||||
}
|
||||
if length > maxPacketBytes {
|
||||
return nil, false, false
|
||||
}
|
||||
if packet, ok := splitHookedShortCreateUpper(buffer, offset, available, length); ok {
|
||||
return packet, false, true
|
||||
}
|
||||
if packet, ok := splitHookedCurrentSelectUpper(buffer, offset, available, length); ok {
|
||||
return packet, false, true
|
||||
}
|
||||
if available < length {
|
||||
return nil, true, false
|
||||
}
|
||||
packet := cloneBytes(buffer[offset : offset+length])
|
||||
parsed, err := ParseChannelPacketUnchecked(packet)
|
||||
if err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
if !isKnownClientUpperMsg(parsed.Header.MsgID, parsed.Body) {
|
||||
return nil, false, false
|
||||
}
|
||||
return packet, false, true
|
||||
}
|
||||
|
||||
func splitHookedCurrentSelectUpper(buffer []byte, offset int, available int, headerLength int) ([]byte, bool) {
|
||||
const (
|
||||
selectMessageID = 16
|
||||
staleSelectBodyLength = 32
|
||||
currentSelectBodySize = 21
|
||||
)
|
||||
msgID := binary.LittleEndian.Uint16(buffer[offset+1 : offset+3])
|
||||
if msgID != selectMessageID || headerLength != ChannelHeaderSize+staleSelectBodyLength {
|
||||
return nil, false
|
||||
}
|
||||
actualLength := ChannelHeaderSize + currentSelectBodySize
|
||||
if available < actualLength {
|
||||
return nil, false
|
||||
}
|
||||
packet := cloneBytes(buffer[offset : offset+actualLength])
|
||||
// The local hook copies the current 21-byte op16 body but leaves the old
|
||||
// 32-byte body length in the upper header. Correct only that observed shape
|
||||
// so the following packet header is not consumed as op16 data.
|
||||
binary.LittleEndian.PutUint32(packet[3:7], uint32(actualLength))
|
||||
return packet, true
|
||||
}
|
||||
|
||||
func splitHookedShortCreateUpper(buffer []byte, offset int, available int, headerLength int) ([]byte, bool) {
|
||||
msgID := binary.LittleEndian.Uint16(buffer[offset+1 : offset+3])
|
||||
if msgID != 5 || headerLength <= ChannelHeaderSize {
|
||||
return nil, false
|
||||
}
|
||||
bodyOffset := offset + ChannelHeaderSize
|
||||
actualBodyLength, ok := hookedPlainCreateBodyLength(buffer[bodyOffset:])
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
actualLength := ChannelHeaderSize + actualBodyLength
|
||||
if actualLength >= headerLength {
|
||||
return nil, false
|
||||
}
|
||||
if available < actualLength {
|
||||
return nil, false
|
||||
}
|
||||
packet := cloneBytes(buffer[offset : offset+actualLength])
|
||||
// NoPack 本地 msg5 hook 只替换 body,没有同步修正 upper 头里的 length。
|
||||
// 真实创建体长度由 nameLen+固定 8 字节外观选项决定,不能写死,否则会吞掉后续 1276 心跳头。
|
||||
binary.LittleEndian.PutUint32(packet[3:7], uint32(actualLength))
|
||||
// NoPack 本地 msg5 hook 只替换 body,未同步修正 upper header length;
|
||||
// 服务端按实际 21 字节创建体切包,避免吞掉后续 1276 心跳头。
|
||||
binary.LittleEndian.PutUint32(packet[3:7], uint32(actualLength))
|
||||
return packet, true
|
||||
}
|
||||
|
||||
func hookedPlainCreateBodyLength(body []byte) (int, bool) {
|
||||
if len(body) < 5 {
|
||||
return 0, false
|
||||
}
|
||||
if body[0] > 0x3f {
|
||||
return 0, false
|
||||
}
|
||||
nameLen := int(binary.LittleEndian.Uint32(body[1:5]))
|
||||
if nameLen < 2 || nameLen > 30 {
|
||||
return 0, false
|
||||
}
|
||||
actualBodyLength := 5 + nameLen + 8
|
||||
if len(body) < actualBodyLength {
|
||||
return 0, false
|
||||
}
|
||||
return actualBodyLength, true
|
||||
}
|
||||
|
||||
func isKnownClientUpperMsg(msgID uint16, body []byte) bool {
|
||||
bodyLen := len(body)
|
||||
switch msgID {
|
||||
case 1:
|
||||
return isCurrent90CNEndpointBody(body)
|
||||
case 2:
|
||||
// A channel transfer opens the target game port without requesting the
|
||||
// ordinary roster and emits this exact 31-byte native upper probe.
|
||||
// Recognize it before the overlapping legacy header parser.
|
||||
return bodyLen == currentChannelReconnectProbeSize
|
||||
case 4, 7, 0x000f, 0x0010, 0x003f, 0x0078, 0x02a7, 0x02b1, 0x02b2, 0x02b5:
|
||||
return true
|
||||
case 31:
|
||||
// Current EXE sub_1C53100 writes class-1/op31 as the exact
|
||||
// u16 opcode echo followed by a u16 quest id. Requiring both the
|
||||
// four-byte size and echo prevents a same-shaped legacy packet from
|
||||
// being cut as raw upper traffic.
|
||||
return bodyLen == currentAcceptQuestPlainBodySize && binary.LittleEndian.Uint16(body[:2]) == msgID
|
||||
case 33:
|
||||
// Current SET_QUEST_TRIGGER is the u16 opcode echo, u16 quest id,
|
||||
// u8 trigger channel, and u8 increment flag. The selective DPROTO
|
||||
// clone form carries a four-byte trailer and therefore intentionally
|
||||
// falls through to the legacy decoder for exact normalization.
|
||||
return bodyLen == currentSetQuestTriggerPlainBodySize && binary.LittleEndian.Uint16(body[:2]) == msgID
|
||||
case 39:
|
||||
if bodyLen == 62 {
|
||||
return true
|
||||
}
|
||||
if bodyLen < 66 {
|
||||
return false
|
||||
}
|
||||
count := int(body[22])
|
||||
return count <= 254 && bodyLen == 66+count*10
|
||||
case 40:
|
||||
// Current EXE normal-dungeon writers emit exactly two u16 actor
|
||||
// coordinates. They are not an actor id and are not server authority.
|
||||
return bodyLen == currentDieCharacterPlainBodySize
|
||||
case 41:
|
||||
// Current EXE normal revive requests emit one u16 target object key.
|
||||
return bodyLen == currentUseCoinPlainBodySize
|
||||
case 42:
|
||||
return bodyLen == 0
|
||||
case 45:
|
||||
return bodyLen == currentDungeonMoveMapPlainBodySize
|
||||
case 36:
|
||||
return bodyLen == currentTownSetUserAreaPlainBodySize
|
||||
case 46:
|
||||
return isCurrentPlayResultPlainBodySize(bodyLen)
|
||||
case 117:
|
||||
return bodyLen == currentBossDieCheckPlainBodySize
|
||||
case 123:
|
||||
return bodyLen == 24
|
||||
case 132:
|
||||
return bodyLen == 0
|
||||
case 143:
|
||||
return bodyLen == currentTutorialFlagPlainBodySize
|
||||
case 295:
|
||||
// Current EXE character-slot exchange writes exactly two u32 slot
|
||||
// indexes. The 13-byte upper header overlaps the legacy game header,
|
||||
// so this exact boundary must be recognized before legacy parsing.
|
||||
return bodyLen == 8
|
||||
case 560:
|
||||
// Current EXE sub_1DB82D0/sub_1DB88B0 starts class-1/op560 and
|
||||
// immediately flushes it without writing a request body. Recognize the
|
||||
// exact boundary so the 13-byte raw upper packet is not mistaken for a
|
||||
// same-shaped legacy game packet.
|
||||
return bodyLen == 0
|
||||
// 2026-06-30 hook/packet_log 对照证明这些号是 msg1 后的 latest raw upper 上行。
|
||||
// 其中 3/8/593 等号会和旧 cmd/type 重叠,必须用 body 长度避免旧 legacy 包误切。
|
||||
case 3:
|
||||
return bodyLen == 16
|
||||
case 5:
|
||||
if actualBodyLength, ok := hookedPlainCreateBodyLength(body); ok && actualBodyLength == bodyLen {
|
||||
return true
|
||||
}
|
||||
return bodyLen == 24 || bodyLen == 32
|
||||
case 8:
|
||||
return bodyLen == 8
|
||||
case 171:
|
||||
return bodyLen == 48
|
||||
case 194:
|
||||
return bodyLen == 368
|
||||
case 415:
|
||||
return bodyLen == 0
|
||||
case 250:
|
||||
return bodyLen == 64
|
||||
case 279:
|
||||
return bodyLen == 16
|
||||
case 441:
|
||||
return bodyLen == 8
|
||||
case 476:
|
||||
return bodyLen == 16
|
||||
case 477:
|
||||
return bodyLen == 8
|
||||
case 593:
|
||||
return bodyLen == 8 || bodyLen == 24
|
||||
case 645:
|
||||
return bodyLen == 0
|
||||
case 1262:
|
||||
return bodyLen == 32
|
||||
case 1276:
|
||||
return bodyLen == 0
|
||||
case 1286:
|
||||
return bodyLen == 16
|
||||
case 1287:
|
||||
return bodyLen == 8
|
||||
case 1320:
|
||||
return bodyLen == 32
|
||||
case 1516:
|
||||
return bodyLen > 0
|
||||
case 1518:
|
||||
return bodyLen > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isCurrent90CNEndpointBody(body []byte) bool {
|
||||
bodyLen := len(body)
|
||||
shortVariant := bodyLen >= observed90CNShortEndpointBodySize &&
|
||||
bodyLen <= observed90CNShortEndpointMaxBodySize &&
|
||||
(bodyLen-observed90CNShortEndpointBodySize)%current90CNEndpointShortVariantSize == 0
|
||||
extendedVariant := bodyLen >= reference90CNChannelDisplayRebindBodySize+current90CNEndpointAdapterRecordSize &&
|
||||
bodyLen <= reference90CNChannelDisplayRebindBodySize+current90CNEndpointAdapterRecordSize*current90CNEndpointMaxAdapterRecords &&
|
||||
(bodyLen-reference90CNChannelDisplayRebindBodySize)%current90CNEndpointAdapterRecordSize == 0
|
||||
if !shortVariant && !extendedVariant {
|
||||
return false
|
||||
}
|
||||
|
||||
// The current 90CN endpoint request has a fixed client-spec envelope,
|
||||
// a lowercase 32-byte hexadecimal token, and a NUL terminator. These
|
||||
// invariants distinguish the admitted length family from the overlapping
|
||||
// legacy cmd=1/type=1 frame.
|
||||
if binary.LittleEndian.Uint32(body[0:4]) != 1 ||
|
||||
body[4] != 0x30 || body[5] != 0x20 ||
|
||||
body[6] != 0 || body[7] != 0 || body[8] != 0 ||
|
||||
body[41] != 0 || binary.LittleEndian.Uint32(body[42:46]) != 8 ||
|
||||
body[54] != 0 || body[55] != 2 || body[56] != 0 || body[57] != 0 ||
|
||||
body[58] != 1 || body[59] != 1 || body[60] != 0 ||
|
||||
!isObserved90CNClientSpecTail(body[61]) {
|
||||
return false
|
||||
}
|
||||
for _, value := range body[9:41] {
|
||||
if !isLowerHexASCII(value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isObserved90CNClientSpecTail(value byte) bool {
|
||||
return value == 0x16 || value == 0x1c || value == 0x2e
|
||||
}
|
||||
|
||||
func isLowerHexASCII(value byte) bool {
|
||||
return value >= '0' && value <= '9' || value >= 'a' && value <= 'f'
|
||||
}
|
||||
|
||||
func isCurrentPlayResultPlainBodySize(bodyLen int) bool {
|
||||
for rows := 0; rows <= currentPlayResultMaximumDynamicRows; rows++ {
|
||||
if bodyLen == currentPlayResultBaseBodySize+rows*currentPlayResultDynamicRowSize ||
|
||||
bodyLen == currentPlayResultOptionalBodySize+rows*currentPlayResultDynamicRowSize {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitLatestGameStreamAcceptsUpperAndTransport(t *testing.T) {
|
||||
upper, err := BuildChannelPacket(4, []byte{1, 0}, 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build upper packet: %v", err)
|
||||
}
|
||||
transport, err := BuildLatestGameTCP(1, 1, []byte{9, 8, 7}, TransportOptions{Sequence: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("build transport: %v", err)
|
||||
}
|
||||
stream := append(append([]byte(nil), upper...), transport...)
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(stream, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split stream: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 {
|
||||
t.Fatalf("unexpected split state skipped=%d remaining=%d", skipped, len(remaining))
|
||||
}
|
||||
if len(packets) != 2 {
|
||||
t.Fatalf("packet count = %d, want 2", len(packets))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("first packet kind = %d, want upper", packets[0].Kind)
|
||||
}
|
||||
if packets[1].Kind != LatestGameStreamTransport {
|
||||
t.Fatalf("second packet kind = %d, want transport", packets[1].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamClassifiesNativeDprotoBeforeLegacy(t *testing.T) {
|
||||
dproto, err := BuildDprotoClientEnvelope([]byte{0x00, 0xad, 0x00, 0x10, 0x00, 1, 2, 3}, 55)
|
||||
if err != nil {
|
||||
t.Fatalf("build dproto: %v", err)
|
||||
}
|
||||
heartbeat, err := BuildChannelPacket(1276, nil, 56, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build heartbeat: %v", err)
|
||||
}
|
||||
stream := append(append([]byte(nil), dproto...), heartbeat...)
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(stream, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split stream: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 2 {
|
||||
t.Fatalf("packets=%d skipped=%d remaining=%d", len(packets), skipped, len(remaining))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamDproto || packets[1].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("kinds=%d,%d", packets[0].Kind, packets[1].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamClassifies90CNChannelReconnectLifecycleBeforeLegacy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgID uint16
|
||||
bodyLen int
|
||||
}{
|
||||
{name: "target channel probe", msgID: 2, bodyLen: currentChannelReconnectProbeSize},
|
||||
{name: "observed 90cn short bootstrap endpoint", msgID: 1, bodyLen: observed90CNShortEndpointBodySize},
|
||||
{name: "current exe post commit display rebind", msgID: 1, bodyLen: currentChannelDisplayRebindSize},
|
||||
{name: "observed 90cn intermediate endpoint", msgID: 1, bodyLen: currentChannelDisplayRebindSize + current90CNEndpointShortVariantSize},
|
||||
{name: "90cn reference post commit display rebind", msgID: 1, bodyLen: reference90CNChannelDisplayRebindBodySize},
|
||||
{name: "observed 90cn crack endpoint", msgID: 1, bodyLen: observed90CNCrackEndpointBodySize},
|
||||
{name: "observed 90cn short endpoint maximum", msgID: 1, bodyLen: observed90CNShortEndpointMaxBodySize},
|
||||
{name: "current bootstrap endpoint with one additional adapter", msgID: 1, bodyLen: reference90CNChannelDisplayRebindBodySize + current90CNEndpointAdapterRecordSize},
|
||||
{name: "current bootstrap endpoint with two additional adapters", msgID: 1, bodyLen: reference90CNChannelDisplayRebindBodySize + 2*current90CNEndpointAdapterRecordSize},
|
||||
{name: "current bootstrap endpoint at bounded adapter maximum", msgID: 1, bodyLen: reference90CNChannelDisplayRebindBodySize + current90CNEndpointMaxAdapterRecords*current90CNEndpointAdapterRecordSize},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := make([]byte, test.bodyLen)
|
||||
if test.msgID == 1 {
|
||||
body = current90CNEndpointTestBody(test.bodyLen)
|
||||
}
|
||||
lifecycle, err := BuildChannelPacket(
|
||||
test.msgID,
|
||||
body,
|
||||
55,
|
||||
DefaultChannelClassification,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heartbeat, err := BuildChannelPacket(1276, nil, 56, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream := append(append([]byte(nil), lifecycle...), heartbeat...)
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(stream, 4096)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 2 {
|
||||
t.Fatalf(
|
||||
"msg%d split packets=%d skipped=%d remaining=%d",
|
||||
test.msgID,
|
||||
len(packets),
|
||||
skipped,
|
||||
len(remaining),
|
||||
)
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamUpper ||
|
||||
packets[1].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("msg%d kinds=%d/%d want upper/upper", test.msgID, packets[0].Kind, packets[1].Kind)
|
||||
}
|
||||
parsed, err := ParseChannelPacketUnchecked(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Header.MsgID != test.msgID || len(parsed.Body) != test.bodyLen {
|
||||
t.Fatalf("msg%d parsed header=%+v body_len=%d", test.msgID, parsed.Header, len(parsed.Body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnown90CNChannelReconnectLifecycleRejectsAdjacentLengths(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
msgID uint16
|
||||
bodyLen int
|
||||
}{
|
||||
{msgID: 1, bodyLen: observed90CNShortEndpointBodySize - 1},
|
||||
{msgID: 1, bodyLen: observed90CNShortEndpointBodySize + 1},
|
||||
{msgID: 1, bodyLen: currentChannelDisplayRebindSize - 1},
|
||||
{msgID: 1, bodyLen: currentChannelDisplayRebindSize + 1},
|
||||
{msgID: 1, bodyLen: observed90CNCrackEndpointBodySize - 1},
|
||||
{msgID: 1, bodyLen: observed90CNCrackEndpointBodySize + 1},
|
||||
{msgID: 1, bodyLen: observed90CNShortEndpointMaxBodySize - 1},
|
||||
{msgID: 1, bodyLen: observed90CNShortEndpointMaxBodySize + 1},
|
||||
{msgID: 1, bodyLen: reference90CNChannelDisplayRebindBodySize - 1},
|
||||
{msgID: 1, bodyLen: reference90CNChannelDisplayRebindBodySize + 1},
|
||||
{msgID: 1, bodyLen: reference90CNChannelDisplayRebindBodySize + current90CNEndpointAdapterRecordSize - 1},
|
||||
{msgID: 1, bodyLen: reference90CNChannelDisplayRebindBodySize + current90CNEndpointAdapterRecordSize + 1},
|
||||
{msgID: 1, bodyLen: reference90CNChannelDisplayRebindBodySize + current90CNEndpointMaxAdapterRecords*current90CNEndpointAdapterRecordSize + current90CNEndpointAdapterRecordSize},
|
||||
{msgID: 2, bodyLen: currentChannelReconnectProbeSize - 1},
|
||||
{msgID: 2, bodyLen: currentChannelReconnectProbeSize + 1},
|
||||
} {
|
||||
body := make([]byte, test.bodyLen)
|
||||
if test.msgID == 1 && test.bodyLen >= reference90CNChannelDisplayRebindBodySize+current90CNEndpointAdapterRecordSize-1 {
|
||||
body = current90CNEndpointTestBody(test.bodyLen)
|
||||
}
|
||||
if isKnownClientUpperMsg(test.msgID, body) {
|
||||
t.Fatalf("msg%d adjacent body length %d accepted", test.msgID, test.bodyLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnown90CNShortEndpointLengthFamily(t *testing.T) {
|
||||
for bodyLen := observed90CNShortEndpointBodySize - 1; bodyLen <= observed90CNShortEndpointMaxBodySize+1; bodyLen++ {
|
||||
valid := current90CNEndpointTestBody(bodyLen)
|
||||
want := bodyLen >= observed90CNShortEndpointBodySize &&
|
||||
bodyLen <= observed90CNShortEndpointMaxBodySize &&
|
||||
(bodyLen-observed90CNShortEndpointBodySize)%current90CNEndpointShortVariantSize == 0
|
||||
if got := isKnownClientUpperMsg(1, valid); got != want {
|
||||
t.Fatalf("endpoint body length %d accepted=%v, want %v", bodyLen, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnown90CNEndpointRejectsMalformedFixedEnvelope(t *testing.T) {
|
||||
bodyLens := []int{
|
||||
observed90CNShortEndpointBodySize,
|
||||
currentChannelDisplayRebindSize,
|
||||
currentChannelDisplayRebindSize + current90CNEndpointShortVariantSize,
|
||||
observed90CNCrackEndpointBodySize,
|
||||
reference90CNChannelDisplayRebindBodySize,
|
||||
observed90CNShortEndpointMaxBodySize,
|
||||
reference90CNChannelDisplayRebindBodySize + 2*current90CNEndpointAdapterRecordSize,
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
offset int
|
||||
value byte
|
||||
}{
|
||||
{name: "wrong version", offset: 0, value: 2},
|
||||
{name: "wrong fixed prefix", offset: 4, value: 0x31},
|
||||
{name: "uppercase token", offset: 9, value: 'A'},
|
||||
{name: "missing token terminator", offset: 41, value: 1},
|
||||
{name: "wrong token length", offset: 42, value: 7},
|
||||
{name: "wrong client-spec marker", offset: 59, value: 2},
|
||||
}
|
||||
for _, bodyLen := range bodyLens {
|
||||
t.Run(strconv.Itoa(bodyLen), func(t *testing.T) {
|
||||
valid := current90CNEndpointTestBody(bodyLen)
|
||||
if !isKnownClientUpperMsg(1, valid) {
|
||||
t.Fatal("valid endpoint body rejected")
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := append([]byte(nil), valid...)
|
||||
body[test.offset] = test.value
|
||||
if isKnownClientUpperMsg(1, body) {
|
||||
t.Fatalf("malformed endpoint body accepted after byte %d changed", test.offset)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnown90CNEndpointAcceptsObservedClientSpecTailVariants(t *testing.T) {
|
||||
for _, bodyLen := range []int{observed90CNShortEndpointBodySize, reference90CNChannelDisplayRebindBodySize, observed90CNCrackEndpointBodySize, observed90CNShortEndpointMaxBodySize} {
|
||||
for _, value := range []byte{0x16, 0x1c, 0x2e} {
|
||||
body := current90CNEndpointTestBody(bodyLen)
|
||||
body[61] = value
|
||||
if !isKnownClientUpperMsg(1, body) {
|
||||
t.Fatalf("observed endpoint len %d client-spec tail %#x rejected", bodyLen, value)
|
||||
}
|
||||
}
|
||||
body := current90CNEndpointTestBody(bodyLen)
|
||||
body[61] = 0x17
|
||||
if isKnownClientUpperMsg(1, body) {
|
||||
t.Fatalf("unobserved endpoint len %d client-spec tail 0x17 accepted", bodyLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func current90CNEndpointTestBody(bodyLen int) []byte {
|
||||
body := make([]byte, bodyLen)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 1)
|
||||
body[4] = 0x30
|
||||
body[5] = 0x20
|
||||
copy(body[9:41], []byte("de509f65e9ccaae621cb7278fc2b8e6c"))
|
||||
binary.LittleEndian.PutUint32(body[42:46], 8)
|
||||
copy(body[54:62], []byte{0, 2, 0, 0, 1, 1, 0, 0x1c})
|
||||
return body
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamClassifiesExactChangeCharacterSlotBeforeLegacy(t *testing.T) {
|
||||
body := []byte{0, 0, 0, 0, 1, 0, 0, 0}
|
||||
changeSlot, err := BuildChannelPacket(295, body, 55, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heartbeat, err := BuildChannelPacket(1276, nil, 56, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream := append(append([]byte(nil), changeSlot...), heartbeat...)
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(stream, 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 2 {
|
||||
t.Fatalf("packets=%d skipped=%d remaining=%d", len(packets), skipped, len(remaining))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamUpper || packets[1].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("kinds=%d/%d want upper/upper", packets[0].Kind, packets[1].Kind)
|
||||
}
|
||||
parsed, err := ParseChannelPacketUnchecked(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Header.MsgID != 295 || !bytes.Equal(parsed.Body, body) {
|
||||
t.Fatalf("change-slot header=%+v body=%x", parsed.Header, parsed.Body)
|
||||
}
|
||||
for _, adjacentLength := range []int{7, 9} {
|
||||
if isKnownClientUpperMsg(295, make([]byte, adjacentLength)) {
|
||||
t.Fatalf("op295 adjacent body length %d accepted", adjacentLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamAcceptsCurrentDungeonDeathMoveSettlementBossStatisticTutorialMissionAndReturnUpper(t *testing.T) {
|
||||
townMove, err := BuildChannelPacket(36, make([]byte, currentTownSetUserAreaPlainBodySize), 6, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build town-move upper: %v", err)
|
||||
}
|
||||
deathBody := make([]byte, 86)
|
||||
deathBody[22] = 2
|
||||
death, err := BuildChannelPacket(39, deathBody, 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build death upper: %v", err)
|
||||
}
|
||||
characterDeath, err := BuildChannelPacket(40, make([]byte, currentDieCharacterPlainBodySize), 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build character-death upper: %v", err)
|
||||
}
|
||||
useCoin, err := BuildChannelPacket(41, make([]byte, currentUseCoinPlainBodySize), 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build use-coin upper: %v", err)
|
||||
}
|
||||
move, err := BuildChannelPacket(45, make([]byte, currentDungeonMoveMapPlainBodySize), 8, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build move upper: %v", err)
|
||||
}
|
||||
playResult, err := BuildChannelPacket(46, make([]byte, currentPlayResultBaseBodySize+currentPlayResultDynamicRowSize), 9, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build play-result upper: %v", err)
|
||||
}
|
||||
giveupGame, err := BuildChannelPacket(42, nil, 10, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build giveup-game upper: %v", err)
|
||||
}
|
||||
bossDieCheck, err := BuildChannelPacket(117, make([]byte, currentBossDieCheckPlainBodySize), 10, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build boss-die-check upper: %v", err)
|
||||
}
|
||||
characterStatistic, err := BuildChannelPacket(123, make([]byte, 24), 11, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build character-statistic upper: %v", err)
|
||||
}
|
||||
missionCheck, err := BuildChannelPacket(560, nil, 11, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build mission-check upper: %v", err)
|
||||
}
|
||||
backToVillage, err := BuildChannelPacket(132, nil, 12, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build return-to-village upper: %v", err)
|
||||
}
|
||||
tutorialFlag, err := BuildChannelPacket(143, []byte{0, 31, 0, 0, 0, 1}, 13, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build tutorial-flag upper: %v", err)
|
||||
}
|
||||
stream := append(append(append(append(append(append(append(append(append(append(append(append([]byte(nil), townMove...), death...), characterDeath...), useCoin...), move...), playResult...), giveupGame...), bossDieCheck...), characterStatistic...), tutorialFlag...), missionCheck...), backToVillage...)
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(stream, 4096)
|
||||
if err != nil {
|
||||
t.Fatalf("split dungeon stream: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 12 {
|
||||
t.Fatalf("split dungeon stream packets=%d skipped=%d remaining=%d", len(packets), skipped, len(remaining))
|
||||
}
|
||||
for index, packet := range packets {
|
||||
if packet.Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("packet[%d] kind=%d, want upper", index, packet.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownCurrentDungeonUpperRejectsUnprovenBoundaries(t *testing.T) {
|
||||
if !isKnownClientUpperMsg(39, make([]byte, 62)) {
|
||||
t.Fatal("fixed 62-byte op39 rejected")
|
||||
}
|
||||
variable := make([]byte, 86)
|
||||
variable[22] = 2
|
||||
if !isKnownClientUpperMsg(39, variable) {
|
||||
t.Fatal("variable 86-byte op39 rejected")
|
||||
}
|
||||
variable[22] = 3
|
||||
if isKnownClientUpperMsg(39, variable) {
|
||||
t.Fatal("op39 count/body mismatch accepted")
|
||||
}
|
||||
if !isKnownClientUpperMsg(132, nil) {
|
||||
t.Fatal("bodyless op132 rejected")
|
||||
}
|
||||
if !isKnownClientUpperMsg(36, make([]byte, currentTownSetUserAreaPlainBodySize)) {
|
||||
t.Fatal("exact 16-byte town op36 rejected")
|
||||
}
|
||||
if !isKnownClientUpperMsg(31, []byte{0x1f, 0x00, 0x49, 0x0c}) {
|
||||
t.Fatal("exact accept-quest op31 rejected")
|
||||
}
|
||||
if !isKnownClientUpperMsg(42, nil) {
|
||||
t.Fatal("bodyless op42 rejected")
|
||||
}
|
||||
if !isKnownClientUpperMsg(40, make([]byte, currentDieCharacterPlainBodySize)) {
|
||||
t.Fatal("two-u16 op40 rejected")
|
||||
}
|
||||
if !isKnownClientUpperMsg(41, make([]byte, currentUseCoinPlainBodySize)) {
|
||||
t.Fatal("single-u16 op41 rejected")
|
||||
}
|
||||
if !isKnownClientUpperMsg(123, make([]byte, 24)) {
|
||||
t.Fatal("six-u32 op123 rejected")
|
||||
}
|
||||
for rows := 0; rows <= currentPlayResultMaximumDynamicRows; rows++ {
|
||||
for _, bodyLen := range []int{
|
||||
currentPlayResultBaseBodySize + rows*currentPlayResultDynamicRowSize,
|
||||
currentPlayResultOptionalBodySize + rows*currentPlayResultDynamicRowSize,
|
||||
} {
|
||||
if !isKnownClientUpperMsg(46, make([]byte, bodyLen)) {
|
||||
t.Fatalf("plaintext op46 rows=%d body_len=%d rejected", rows, bodyLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isKnownClientUpperMsg(117, make([]byte, currentBossDieCheckPlainBodySize)) {
|
||||
t.Fatal("plaintext 39-byte op117 rejected")
|
||||
}
|
||||
if !isKnownClientUpperMsg(143, make([]byte, currentTutorialFlagPlainBodySize)) {
|
||||
t.Fatal("plaintext six-byte op143 rejected")
|
||||
}
|
||||
if !isKnownClientUpperMsg(560, nil) {
|
||||
t.Fatal("bodyless op560 rejected")
|
||||
}
|
||||
if isKnownClientUpperMsg(39, make([]byte, 63)) ||
|
||||
isKnownClientUpperMsg(31, []byte{0x1f, 0x00, 0x49}) ||
|
||||
isKnownClientUpperMsg(31, []byte{0x20, 0x00, 0x49, 0x0c}) ||
|
||||
isKnownClientUpperMsg(36, make([]byte, currentTownSetUserAreaPlainBodySize-1)) ||
|
||||
isKnownClientUpperMsg(36, make([]byte, currentTownSetUserAreaPlainBodySize+1)) ||
|
||||
isKnownClientUpperMsg(45, make([]byte, currentDungeonMoveMapPlainBodySize-1)) ||
|
||||
isKnownClientUpperMsg(45, make([]byte, currentDungeonMoveMapPlainBodySize+1)) ||
|
||||
isKnownClientUpperMsg(45, make([]byte, 112)) ||
|
||||
isKnownClientUpperMsg(40, make([]byte, currentDieCharacterPlainBodySize-1)) ||
|
||||
isKnownClientUpperMsg(40, make([]byte, currentDieCharacterPlainBodySize+1)) ||
|
||||
isKnownClientUpperMsg(41, make([]byte, currentUseCoinPlainBodySize-1)) ||
|
||||
isKnownClientUpperMsg(41, make([]byte, currentUseCoinPlainBodySize+1)) ||
|
||||
isKnownClientUpperMsg(46, make([]byte, currentPlayResultBaseBodySize-1)) ||
|
||||
isKnownClientUpperMsg(46, make([]byte, currentPlayResultBaseBodySize+1)) ||
|
||||
isKnownClientUpperMsg(46, make([]byte, currentPlayResultOptionalBodySize+currentPlayResultMaximumDynamicRows*currentPlayResultDynamicRowSize+1)) ||
|
||||
isKnownClientUpperMsg(117, make([]byte, currentBossDieCheckPlainBodySize-1)) ||
|
||||
isKnownClientUpperMsg(117, make([]byte, currentBossDieCheckPlainBodySize+1)) ||
|
||||
isKnownClientUpperMsg(143, make([]byte, currentTutorialFlagPlainBodySize-1)) ||
|
||||
isKnownClientUpperMsg(143, make([]byte, 16)) ||
|
||||
isKnownClientUpperMsg(42, []byte{0}) ||
|
||||
isKnownClientUpperMsg(123, make([]byte, 23)) ||
|
||||
isKnownClientUpperMsg(123, make([]byte, 25)) ||
|
||||
isKnownClientUpperMsg(132, []byte{0}) ||
|
||||
isKnownClientUpperMsg(560, []byte{0}) {
|
||||
t.Fatal("unproven dungeon upper boundary accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamClassifiesExactAcceptQuestAsUpper(t *testing.T) {
|
||||
packet, err := BuildChannelPacket(31, []byte{0x1f, 0x00, 0x49, 0x0c}, 34, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(packet, 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 1 {
|
||||
t.Fatalf("op31 split packets=%d skipped=%d remaining=%d", len(packets), skipped, len(remaining))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("op31 kind=%d, want upper", packets[0].Kind)
|
||||
}
|
||||
parsed, err := ParseChannelPacketUnchecked(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Header.MsgID != 31 || !bytes.Equal(parsed.Body, []byte{0x1f, 0x00, 0x49, 0x0c}) {
|
||||
t.Fatalf("op31 packet header=%+v body=%x", parsed.Header, parsed.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamClassifiesExactSetQuestTriggerAsUpper(t *testing.T) {
|
||||
body := []byte{0x21, 0x00, 0x78, 0x12, 0x00, 0x00}
|
||||
packet, err := BuildChannelPacket(33, body, 35, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(packet, 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 1 || packets[0].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("op33 split packets=%d skipped=%d remaining=%d kind=%d", len(packets), skipped, len(remaining), packets[0].Kind)
|
||||
}
|
||||
parsed, err := ParseChannelPacketUnchecked(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Header.MsgID != 33 || !bytes.Equal(parsed.Body, body) {
|
||||
t.Fatalf("op33 packet header=%+v body=%x", parsed.Header, parsed.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamKeepsSetQuestTriggerCloneTrailerLegacy(t *testing.T) {
|
||||
body := []byte{0x21, 0x00, 0x78, 0x12, 0x00, 0x00, 0xaa, 0xbb, 0xcc, 0xdd}
|
||||
packet, err := BuildChannelPacket(33, body, 36, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(packet, 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 1 || packets[0].Kind != LatestGameStreamLegacy {
|
||||
t.Fatalf("op33 clone split packets=%d skipped=%d remaining=%d kind=%d", len(packets), skipped, len(remaining), packets[0].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamClassifiesBodylessMissionCheckAsUpperNotLegacy(t *testing.T) {
|
||||
packet, err := BuildChannelPacket(560, nil, 12, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(packet, 1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 1 {
|
||||
t.Fatalf("op560 split packets=%d skipped=%d remaining=%d", len(packets), skipped, len(remaining))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("op560 kind=%d want upper", packets[0].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamKeepsProtectedFortyByteBossDieCheckLegacy(t *testing.T) {
|
||||
packet := buildLegacyGamePacketForTest(1, 117, 8, make([]byte, 40))
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(packet, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split protected boss-die-check: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 1 {
|
||||
t.Fatalf("split protected boss-die-check packets=%d skipped=%d remaining=%d", len(packets), skipped, len(remaining))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamLegacy {
|
||||
t.Fatalf("protected 40-byte op117 kind=%d want legacy", packets[0].Kind)
|
||||
}
|
||||
parsed, err := ParseLegacyGamePacket(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Header.Type != 117 || len(parsed.Body) != 40 {
|
||||
t.Fatalf("protected op117 type/body=%d/%d", parsed.Header.Type, len(parsed.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamWaitsForPartialUpper(t *testing.T) {
|
||||
upper, err := BuildChannelPacket(4, []byte{1, 0}, 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build upper packet: %v", err)
|
||||
}
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(upper[:len(upper)-1], 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split partial upper: %v", err)
|
||||
}
|
||||
if len(packets) != 0 || skipped != 0 {
|
||||
t.Fatalf("partial upper should wait, packets=%d skipped=%d", len(packets), skipped)
|
||||
}
|
||||
if len(remaining) != len(upper)-1 {
|
||||
t.Fatalf("remaining = %d, want %d", len(remaining), len(upper)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamAcceptsLegacyGamePackets(t *testing.T) {
|
||||
first := buildLegacyGamePacketForTest(1, 0x00ab, 1, []byte{1, 2, 3})
|
||||
second := buildLegacyGamePacketForTest(1, 0x01dc, 2, []byte{4, 5})
|
||||
stream := append(append([]byte(nil), first...), second...)
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(stream, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split stream: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 {
|
||||
t.Fatalf("unexpected split state skipped=%d remaining=%d", skipped, len(remaining))
|
||||
}
|
||||
if len(packets) != 2 {
|
||||
t.Fatalf("packet count = %d, want 2", len(packets))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamLegacy || packets[1].Kind != LatestGameStreamLegacy {
|
||||
t.Fatalf("packet kinds = %d/%d, want legacy/legacy", packets[0].Kind, packets[1].Kind)
|
||||
}
|
||||
parsed, err := ParseLegacyGamePacket(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse legacy packet: %v", err)
|
||||
}
|
||||
if parsed.Header.Type != 0x00ab || len(parsed.Body) != 3 {
|
||||
t.Fatalf("parsed legacy packet type=0x%04x body=%d", parsed.Header.Type, len(parsed.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamAcceptsPostCreateUpperChain(t *testing.T) {
|
||||
for _, msgID := range []uint16{0x02b1, 0x02b2, 0x02b5, 0x000f, 0x0010, 0x0007, 0x02a7} {
|
||||
upper, err := BuildChannelPacket(msgID, []byte{1, 2, 3, 4}, 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build upper packet 0x%04x: %v", msgID, err)
|
||||
}
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(upper, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split upper 0x%04x: %v", msgID, err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 1 {
|
||||
t.Fatalf("split upper 0x%04x state packets=%d skipped=%d remaining=%d", msgID, len(packets), skipped, len(remaining))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("upper 0x%04x kind = %d, want upper", msgID, packets[0].Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamAcceptsObservedInitUpperChain(t *testing.T) {
|
||||
cases := []struct {
|
||||
msgID uint16
|
||||
bodyLen int
|
||||
}{
|
||||
{msgID: 171, bodyLen: 48},
|
||||
{msgID: 476, bodyLen: 16},
|
||||
{msgID: 477, bodyLen: 8},
|
||||
{msgID: 1320, bodyLen: 32},
|
||||
{msgID: 1262, bodyLen: 32},
|
||||
{msgID: 593, bodyLen: 24},
|
||||
{msgID: 8, bodyLen: 8},
|
||||
{msgID: 415, bodyLen: 0},
|
||||
{msgID: 441, bodyLen: 8},
|
||||
{msgID: 645, bodyLen: 0},
|
||||
{msgID: 1276, bodyLen: 0},
|
||||
{msgID: 1518, bodyLen: 96},
|
||||
{msgID: 1518, bodyLen: 112},
|
||||
{msgID: 1518, bodyLen: 152},
|
||||
{msgID: 1516, bodyLen: 32},
|
||||
{msgID: 1516, bodyLen: 48},
|
||||
{msgID: 1516, bodyLen: 64},
|
||||
{msgID: 1516, bodyLen: 80},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
upper, err := BuildChannelPacket(tc.msgID, make([]byte, tc.bodyLen), 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build observed upper %d: %v", tc.msgID, err)
|
||||
}
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(upper, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split observed upper %d: %v", tc.msgID, err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 1 {
|
||||
t.Fatalf("split observed upper %d state packets=%d skipped=%d remaining=%d", tc.msgID, len(packets), skipped, len(remaining))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("observed upper %d kind = %d, want upper", tc.msgID, packets[0].Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamAcceptsObservedPostMsg1UpperChain(t *testing.T) {
|
||||
cases := []struct {
|
||||
msgID uint16
|
||||
bodyLen int
|
||||
}{
|
||||
{msgID: 194, bodyLen: 368},
|
||||
{msgID: 1286, bodyLen: 16},
|
||||
{msgID: 1287, bodyLen: 8},
|
||||
{msgID: 250, bodyLen: 64},
|
||||
{msgID: 279, bodyLen: 16},
|
||||
{msgID: 593, bodyLen: 8},
|
||||
{msgID: 3, bodyLen: 16},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
upper, err := BuildChannelPacket(tc.msgID, make([]byte, tc.bodyLen), 7, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build post-msg1 upper %d: %v", tc.msgID, err)
|
||||
}
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(upper, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split post-msg1 upper %d: %v", tc.msgID, err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 1 {
|
||||
t.Fatalf("split post-msg1 upper %d state packets=%d skipped=%d remaining=%d", tc.msgID, len(packets), skipped, len(remaining))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("post-msg1 upper %d kind = %d, want upper", tc.msgID, packets[0].Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamAcceptsCodecUpperWithoutChecksum(t *testing.T) {
|
||||
raw := mustHex(t, "01b2021d00000038ab3cdc000015b101e6d58515bd9dbb3490ac32e75a")
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(raw, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split codec upper: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 || len(packets) != 1 {
|
||||
t.Fatalf("split state packets=%d skipped=%d remaining=%d", len(packets), skipped, len(remaining))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("packet kind = %d, want upper", packets[0].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamCutsHookedShortCreateBeforeHeartbeat(t *testing.T) {
|
||||
create := mustHex(t, "010500250000009585a5c916000008000000b8d5b8d5b7a2b8f8000000000000ff00")
|
||||
heartbeat, err := BuildChannelPacket(1276, nil, 23, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build heartbeat upper: %v", err)
|
||||
}
|
||||
stream := append(append([]byte(nil), create...), heartbeat...)
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(stream, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split hooked create stream: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 {
|
||||
t.Fatalf("unexpected split state skipped=%d remaining=%x", skipped, remaining)
|
||||
}
|
||||
if len(packets) != 2 {
|
||||
t.Fatalf("packet count = %d, want 2", len(packets))
|
||||
}
|
||||
first, err := ParseChannelPacketUnchecked(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse create upper: %v", err)
|
||||
}
|
||||
second, err := ParseChannelPacketUnchecked(packets[1].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse heartbeat upper: %v", err)
|
||||
}
|
||||
if first.Header.MsgID != 5 || len(first.Body) != 21 {
|
||||
t.Fatalf("first packet msg/body = %d/%d", first.Header.MsgID, len(first.Body))
|
||||
}
|
||||
if second.Header.MsgID != 1276 || len(second.Body) != 0 {
|
||||
t.Fatalf("second packet msg/body = %d/%d", second.Header.MsgID, len(second.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamCutsCurrentHookedShortCreateBeforeHeartbeat(t *testing.T) {
|
||||
create := mustHex(t, "010500250000003514fb8014000106000000b7a2b7a2b7a2000000000000ff00")
|
||||
heartbeat, err := BuildChannelPacket(1276, nil, 21, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build heartbeat upper: %v", err)
|
||||
}
|
||||
stream := append(append([]byte(nil), create...), heartbeat...)
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(stream, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split current hooked create stream: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 {
|
||||
t.Fatalf("unexpected split state skipped=%d remaining=%x", skipped, remaining)
|
||||
}
|
||||
if len(packets) != 2 {
|
||||
t.Fatalf("packet count = %d, want 2", len(packets))
|
||||
}
|
||||
first, err := ParseChannelPacketUnchecked(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse create upper: %v", err)
|
||||
}
|
||||
second, err := ParseChannelPacketUnchecked(packets[1].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse heartbeat upper: %v", err)
|
||||
}
|
||||
if first.Header.MsgID != 5 || len(first.Body) != 19 {
|
||||
t.Fatalf("first packet msg/body = %d/%d", first.Header.MsgID, len(first.Body))
|
||||
}
|
||||
if second.Header.MsgID != 1276 || len(second.Body) != 0 {
|
||||
t.Fatalf("second packet msg/body = %d/%d", second.Header.MsgID, len(second.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamClassifiesExactLengthShortCreateAsUpper(t *testing.T) {
|
||||
body := mustHex(t, "0003000000363636000000000000ff00")
|
||||
create, err := BuildChannelPacket(5, body, 20, DefaultChannelClassification)
|
||||
if err != nil {
|
||||
t.Fatalf("build exact-length create upper: %v", err)
|
||||
}
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(create, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split exact-length create upper: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 {
|
||||
t.Fatalf("unexpected split state skipped=%d remaining=%x", skipped, remaining)
|
||||
}
|
||||
if len(packets) != 1 {
|
||||
t.Fatalf("packet count = %d, want 1", len(packets))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamUpper {
|
||||
t.Fatalf("packet kind = %d, want upper", packets[0].Kind)
|
||||
}
|
||||
parsed, err := ParseChannelPacketUnchecked(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse exact-length create upper: %v", err)
|
||||
}
|
||||
if parsed.Header.MsgID != 5 || !bytes.Equal(parsed.Body, body) {
|
||||
t.Fatalf("create packet msg/body = %d/%x, want 5/%x", parsed.Header.MsgID, parsed.Body, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamCutsCurrentHookedSelectBeforeFollowingLegacyPacket(t *testing.T) {
|
||||
selectDungeon := mustHex(t, "0110002d000000a46efcb81800e91b00000000000000ffff00000000000000000000")
|
||||
changeTutorial := mustHex(t, "018f001d00000064d69b8b19004cfb5fc3405889ac4e528f72fe7249ea")
|
||||
stream := append(append([]byte(nil), selectDungeon...), changeTutorial...)
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(stream, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split hooked select stream: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 {
|
||||
t.Fatalf("unexpected split state skipped=%d remaining=%x", skipped, remaining)
|
||||
}
|
||||
if len(packets) != 2 {
|
||||
t.Fatalf("packet count = %d, want 2", len(packets))
|
||||
}
|
||||
first, err := ParseChannelPacketUnchecked(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse select upper: %v", err)
|
||||
}
|
||||
second, err := ParseLegacyGamePacket(packets[1].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse following legacy packet: %v", err)
|
||||
}
|
||||
if first.Header.MsgID != 16 || len(first.Body) != 21 {
|
||||
t.Fatalf("first packet msg/body = %d/%d", first.Header.MsgID, len(first.Body))
|
||||
}
|
||||
if second.Header.Type != 143 || len(second.Body) != 16 {
|
||||
t.Fatalf("second packet type/body = %d/%d", second.Header.Type, len(second.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamPrefersLegacyForUpperLikeCommand(t *testing.T) {
|
||||
packet := buildLegacyGamePacketForTest(1, 0x0301, 8, nil)
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(packet, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split stream: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 {
|
||||
t.Fatalf("unexpected split state skipped=%d remaining=%d", skipped, len(remaining))
|
||||
}
|
||||
if len(packets) != 1 {
|
||||
t.Fatalf("packet count = %d, want 1", len(packets))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamLegacy {
|
||||
t.Fatalf("packet kind = %d, want legacy", packets[0].Kind)
|
||||
}
|
||||
parsed, err := ParseLegacyGamePacket(packets[0].Data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse legacy packet: %v", err)
|
||||
}
|
||||
if parsed.Header.Type != 0x0301 {
|
||||
t.Fatalf("legacy type = 0x%04x, want 0x0301", parsed.Header.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameStreamKeepsLegacyGetUserInfoWithoutUpperBody(t *testing.T) {
|
||||
packet := buildLegacyGamePacketForTest(1, 8, 8, nil)
|
||||
|
||||
packets, remaining, skipped, err := SplitLatestGameStream(packet, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("split stream: %v", err)
|
||||
}
|
||||
if skipped != 0 || len(remaining) != 0 {
|
||||
t.Fatalf("unexpected split state skipped=%d remaining=%d", skipped, len(remaining))
|
||||
}
|
||||
if len(packets) != 1 {
|
||||
t.Fatalf("packet count = %d, want 1", len(packets))
|
||||
}
|
||||
if packets[0].Kind != LatestGameStreamLegacy {
|
||||
t.Fatalf("packet kind = %d, want legacy", packets[0].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func buildLegacyGamePacketForTest(cmd byte, typ uint16, seq uint16, body []byte) []byte {
|
||||
packet := make([]byte, LegacyGameHeaderSize+len(body))
|
||||
packet[0] = cmd
|
||||
packet[1] = byte(typ)
|
||||
packet[2] = byte(typ >> 8)
|
||||
length := uint32(len(packet))
|
||||
packet[3] = byte(length)
|
||||
packet[4] = byte(length >> 8)
|
||||
packet[5] = byte(length >> 16)
|
||||
packet[6] = byte(length >> 24)
|
||||
packet[11] = byte(seq)
|
||||
packet[12] = byte(seq >> 8)
|
||||
copy(packet[LegacyGameHeaderSize:], body)
|
||||
return packet
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package protocol
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
const (
|
||||
TCPOuterHeaderSize = 12
|
||||
InnerHeaderSize = 9
|
||||
MinTCPFrameSize = TCPOuterHeaderSize + InnerHeaderSize + LatestGameHeaderSize
|
||||
NormalInnerKind = 2
|
||||
)
|
||||
|
||||
// TransportOptions 控制最新 game TCP/UDP 外壳字段。
|
||||
type TransportOptions struct {
|
||||
Sequence uint32
|
||||
Route byte
|
||||
Flag byte
|
||||
OuterKind uint16
|
||||
OuterValue4 uint32
|
||||
OuterValue8 uint32
|
||||
}
|
||||
|
||||
// LatestGameTransportHeader 是最新 game transport 外壳视图。
|
||||
type LatestGameTransportHeader struct {
|
||||
OuterKind uint16
|
||||
TotalLength uint16
|
||||
OuterValue4 uint32
|
||||
OuterValue8 uint32
|
||||
InnerKind byte
|
||||
Sequence uint32
|
||||
BusinessLength uint16
|
||||
Route byte
|
||||
Flag byte
|
||||
}
|
||||
|
||||
// LatestGameTransportRecord 是一个 transport inner record。
|
||||
type LatestGameTransportRecord struct {
|
||||
TransportHeader LatestGameTransportHeader
|
||||
GameHeader LatestGameHeader
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// BuildLatestGameTCP 按最新 game TCP transport 封包。
|
||||
//
|
||||
// 对应 IDA:0x35F27E0 按 frame+2 的 u16 totalLen 拆 TCP 帧;
|
||||
// 0x35EC7A0 跳过 12 字节外层;0x35EDED0 按 u8 kind、u32 seq、+5 长度、+9 payload 解析 kind=2 inner record。
|
||||
func BuildLatestGameTCP(cmd byte, typ uint16, body []byte, options TransportOptions) ([]byte, error) {
|
||||
businessPacket, err := BuildLatestGamePacket(cmd, typ, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BuildLatestGameTCPFromBusinessPacket(businessPacket, options)
|
||||
}
|
||||
|
||||
// BuildLatestGameTCPFromBusinessPacket 把 7 字节业务包包进最新 TCP transport。
|
||||
func BuildLatestGameTCPFromBusinessPacket(businessPacket []byte, options TransportOptions) ([]byte, error) {
|
||||
if len(businessPacket) < LatestGameHeaderSize {
|
||||
return nil, ErrPacketTooShort
|
||||
}
|
||||
if len(businessPacket) > int(^uint16(0)) {
|
||||
return nil, ErrPacketTooLarge
|
||||
}
|
||||
total := TCPOuterHeaderSize + InnerHeaderSize + len(businessPacket)
|
||||
if total > int(^uint16(0)) {
|
||||
return nil, ErrPacketTooLarge
|
||||
}
|
||||
packet := make([]byte, total)
|
||||
binary.LittleEndian.PutUint16(packet[0:2], options.OuterKind)
|
||||
binary.LittleEndian.PutUint16(packet[2:4], uint16(total))
|
||||
binary.LittleEndian.PutUint32(packet[4:8], options.OuterValue4)
|
||||
binary.LittleEndian.PutUint32(packet[8:12], options.OuterValue8)
|
||||
writeInnerRecord(packet[TCPOuterHeaderSize:], businessPacket, options)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
// BuildLatestGameUDP 按最新 game UDP inner record 封包。
|
||||
func BuildLatestGameUDP(cmd byte, typ uint16, body []byte, options TransportOptions) ([]byte, error) {
|
||||
businessPacket, err := BuildLatestGamePacket(cmd, typ, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BuildLatestGameUDPFromBusinessPacket(businessPacket, options)
|
||||
}
|
||||
|
||||
// BuildLatestGameUDPFromBusinessPacket 把 7 字节业务包包进 UDP inner record。
|
||||
func BuildLatestGameUDPFromBusinessPacket(businessPacket []byte, options TransportOptions) ([]byte, error) {
|
||||
if len(businessPacket) < LatestGameHeaderSize {
|
||||
return nil, ErrPacketTooShort
|
||||
}
|
||||
if len(businessPacket) > int(^uint16(0)) {
|
||||
return nil, ErrPacketTooLarge
|
||||
}
|
||||
packet := make([]byte, InnerHeaderSize+len(businessPacket))
|
||||
writeInnerRecord(packet, businessPacket, options)
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
// ParseLatestGameTCPRecords 解析一个完整最新 game TCP frame。
|
||||
func ParseLatestGameTCPRecords(frame []byte) ([]LatestGameTransportRecord, error) {
|
||||
if len(frame) < TCPOuterHeaderSize {
|
||||
return nil, ErrPacketTooShort
|
||||
}
|
||||
total := int(binary.LittleEndian.Uint16(frame[2:4]))
|
||||
if total < MinTCPFrameSize || total > len(frame) {
|
||||
return nil, ErrPacketLength
|
||||
}
|
||||
outerKind := binary.LittleEndian.Uint16(frame[0:2])
|
||||
outerValue4 := binary.LittleEndian.Uint32(frame[4:8])
|
||||
outerValue8 := binary.LittleEndian.Uint32(frame[8:12])
|
||||
records := make([]LatestGameTransportRecord, 0, 1)
|
||||
offset := TCPOuterHeaderSize
|
||||
for offset < total {
|
||||
record, consumed, err := parseInnerRecord(frame, offset, total-offset, outerKind, uint16(total), outerValue4, outerValue8)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
offset += consumed
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, ErrPacketLength
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// ParseLatestGameUDPRecords 解析一个或多个 UDP inner record。
|
||||
func ParseLatestGameUDPRecords(packet []byte) ([]LatestGameTransportRecord, error) {
|
||||
if len(packet) < InnerHeaderSize+LatestGameHeaderSize {
|
||||
return nil, ErrPacketTooShort
|
||||
}
|
||||
records := make([]LatestGameTransportRecord, 0, 1)
|
||||
offset := 0
|
||||
for offset < len(packet) {
|
||||
record, consumed, err := parseInnerRecord(packet, offset, len(packet)-offset, 0, 0, 0, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
offset += consumed
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, ErrPacketLength
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// SplitLatestGameTCPFrames 从流式 buffer 中切出完整最新 game TCP frame。
|
||||
//
|
||||
// 如果 frame+2 的长度小于最小包长,按客户端拆帧逻辑跳 1 字节重同步。
|
||||
func SplitLatestGameTCPFrames(buffer []byte) (frames [][]byte, remaining []byte, skipped int) {
|
||||
offset := 0
|
||||
for offset <= len(buffer)-4 {
|
||||
frameLength := int(binary.LittleEndian.Uint16(buffer[offset+2 : offset+4]))
|
||||
if frameLength < MinTCPFrameSize {
|
||||
offset++
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if len(buffer)-offset < frameLength {
|
||||
break
|
||||
}
|
||||
frames = append(frames, cloneBytes(buffer[offset:offset+frameLength]))
|
||||
offset += frameLength
|
||||
}
|
||||
return frames, cloneBytes(buffer[offset:]), skipped
|
||||
}
|
||||
|
||||
func writeInnerRecord(target []byte, businessPacket []byte, options TransportOptions) {
|
||||
target[0] = NormalInnerKind
|
||||
binary.LittleEndian.PutUint32(target[1:5], options.Sequence)
|
||||
binary.LittleEndian.PutUint16(target[5:7], uint16(len(businessPacket)))
|
||||
target[7] = options.Route
|
||||
target[8] = options.Flag
|
||||
copy(target[InnerHeaderSize:], businessPacket)
|
||||
}
|
||||
|
||||
func parseInnerRecord(data []byte, offset int, available int, outerKind uint16, tcpTotal uint16, outerValue4 uint32, outerValue8 uint32) (LatestGameTransportRecord, int, error) {
|
||||
if available < InnerHeaderSize {
|
||||
return LatestGameTransportRecord{}, 0, ErrPacketTooShort
|
||||
}
|
||||
innerKind := data[offset]
|
||||
if innerKind != NormalInnerKind {
|
||||
return LatestGameTransportRecord{}, 0, ErrInnerKind
|
||||
}
|
||||
sequence := binary.LittleEndian.Uint32(data[offset+1 : offset+5])
|
||||
businessLength := int(binary.LittleEndian.Uint16(data[offset+5 : offset+7]))
|
||||
route := data[offset+7]
|
||||
flag := data[offset+8]
|
||||
recordLength := InnerHeaderSize + businessLength
|
||||
if businessLength < LatestGameHeaderSize || recordLength > available {
|
||||
return LatestGameTransportRecord{}, 0, ErrPacketLength
|
||||
}
|
||||
businessOffset := offset + InnerHeaderSize
|
||||
businessEnd := businessOffset + businessLength
|
||||
packet, err := ParseLatestGamePacket(data[businessOffset:businessEnd])
|
||||
if err != nil {
|
||||
return LatestGameTransportRecord{}, 0, err
|
||||
}
|
||||
record := LatestGameTransportRecord{
|
||||
TransportHeader: LatestGameTransportHeader{
|
||||
OuterKind: outerKind,
|
||||
TotalLength: tcpTotal,
|
||||
OuterValue4: outerValue4,
|
||||
OuterValue8: outerValue8,
|
||||
InnerKind: innerKind,
|
||||
Sequence: sequence,
|
||||
BusinessLength: uint16(businessLength),
|
||||
Route: route,
|
||||
Flag: flag,
|
||||
},
|
||||
GameHeader: packet.Header,
|
||||
Body: packet.Body,
|
||||
}
|
||||
return record, recordLength, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLatestGameTCPRoundTrip(t *testing.T) {
|
||||
body := []byte{1, 2, 3}
|
||||
options := TransportOptions{
|
||||
Sequence: 9,
|
||||
Route: 4,
|
||||
Flag: 5,
|
||||
OuterKind: 6,
|
||||
OuterValue4: 287454020,
|
||||
OuterValue8: 1432778632,
|
||||
}
|
||||
|
||||
frame, err := BuildLatestGameTCP(1, 3, body, options)
|
||||
if err != nil {
|
||||
t.Fatalf("build latest game tcp: %v", err)
|
||||
}
|
||||
if len(frame) != TCPOuterHeaderSize+InnerHeaderSize+LatestGameHeaderSize+len(body) {
|
||||
t.Fatalf("unexpected frame length: %d", len(frame))
|
||||
}
|
||||
if binary.LittleEndian.Uint16(frame[2:4]) != uint16(len(frame)) {
|
||||
t.Fatalf("total length not written")
|
||||
}
|
||||
if got := frame[12]; got != NormalInnerKind {
|
||||
t.Fatalf("inner kind = %#x, want kind 2", got)
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(frame[13:17]); got != options.Sequence {
|
||||
t.Fatalf("sequence = %d, want %d", got, options.Sequence)
|
||||
}
|
||||
if got := binary.LittleEndian.Uint16(frame[17:19]); got != uint16(LatestGameHeaderSize+len(body)) {
|
||||
t.Fatalf("business length = %d", got)
|
||||
}
|
||||
if frame[19] != options.Route || frame[20] != options.Flag {
|
||||
t.Fatalf("route/flag = %d/%d", frame[19], frame[20])
|
||||
}
|
||||
if frame[21] != 1 {
|
||||
t.Fatalf("business packet should start at inner+9, got first byte %#x", frame[21])
|
||||
}
|
||||
|
||||
records, err := ParseLatestGameTCPRecords(frame)
|
||||
if err != nil {
|
||||
t.Fatalf("parse latest game tcp: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("record count mismatch: %d", len(records))
|
||||
}
|
||||
record := records[0]
|
||||
if record.TransportHeader.OuterKind != options.OuterKind ||
|
||||
record.TransportHeader.OuterValue4 != options.OuterValue4 ||
|
||||
record.TransportHeader.OuterValue8 != options.OuterValue8 ||
|
||||
record.TransportHeader.Sequence != options.Sequence ||
|
||||
record.TransportHeader.Route != options.Route ||
|
||||
record.TransportHeader.Flag != options.Flag {
|
||||
t.Fatalf("unexpected transport header: %+v", record.TransportHeader)
|
||||
}
|
||||
if record.GameHeader.Cmd != 1 || record.GameHeader.Type != 3 {
|
||||
t.Fatalf("unexpected game header: %+v", record.GameHeader)
|
||||
}
|
||||
if !bytes.Equal(record.Body, body) {
|
||||
t.Fatalf("body mismatch: got %x", record.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFixed15GameServerPacket(t *testing.T) {
|
||||
body := []byte{0x02, 0x03, 0x04}
|
||||
packet, err := BuildFixed15GameServerPacket(0, 2, body, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("build fixed15 packet: %v", err)
|
||||
}
|
||||
if len(packet) != 15+len(body) || packet[0] != 0 || binary.LittleEndian.Uint16(packet[1:3]) != 2 || binary.LittleEndian.Uint32(packet[3:7]) != uint32(len(packet)) || packet[7] != 1 {
|
||||
t.Fatalf("fixed15 header = %x", packet[:15])
|
||||
}
|
||||
if !bytes.Equal(packet[8:15], make([]byte, 7)) || !bytes.Equal(packet[15:], body) {
|
||||
t.Fatalf("fixed15 packet = %x", packet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestGameUDPRoundTrip(t *testing.T) {
|
||||
body := []byte{9, 8, 7}
|
||||
options := TransportOptions{Sequence: 11, Route: 2, Flag: 1}
|
||||
packet, err := BuildLatestGameUDP(1, 3, body, options)
|
||||
if err != nil {
|
||||
t.Fatalf("build latest game udp: %v", err)
|
||||
}
|
||||
|
||||
records, err := ParseLatestGameUDPRecords(packet)
|
||||
if err != nil {
|
||||
t.Fatalf("parse latest game udp: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("record count mismatch: %d", len(records))
|
||||
}
|
||||
if records[0].TransportHeader.Sequence != options.Sequence ||
|
||||
records[0].TransportHeader.Route != options.Route ||
|
||||
records[0].TransportHeader.Flag != options.Flag ||
|
||||
!bytes.Equal(records[0].Body, body) {
|
||||
t.Fatalf("unexpected udp record: %+v", records[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLatestGameTCPFrames(t *testing.T) {
|
||||
frame, err := BuildLatestGameTCP(1, 3, []byte{1, 2, 3}, TransportOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("build latest game tcp: %v", err)
|
||||
}
|
||||
|
||||
stream := append(cloneBytes(frame), frame[:5]...)
|
||||
frames, remaining, skipped := SplitLatestGameTCPFrames(stream)
|
||||
if skipped != 0 {
|
||||
t.Fatalf("unexpected skipped count: %d", skipped)
|
||||
}
|
||||
if len(frames) != 1 || !bytes.Equal(frames[0], frame) {
|
||||
t.Fatalf("frame split mismatch")
|
||||
}
|
||||
if !bytes.Equal(remaining, frame[:5]) {
|
||||
t.Fatalf("remaining mismatch: got %x", remaining)
|
||||
}
|
||||
|
||||
frames, remaining, skipped = SplitLatestGameTCPFrames([]byte{170, 187, 1, 0})
|
||||
if len(frames) != 0 || skipped != 1 || !bytes.Equal(remaining, []byte{187, 1, 0}) {
|
||||
t.Fatalf("invalid prefix resync mismatch frames=%d skipped=%d remaining=%x", len(frames), skipped, remaining)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// 本文件负责 DNF latest raw upper 服务端包体加密兼容。
|
||||
// 这里只处理 wire codec,不写登录、角色或持久化业务状态。
|
||||
package protocol
|
||||
|
||||
import "golang.org/x/crypto/cast5"
|
||||
|
||||
var latestUpperOpcode1Key = []byte("2D@+(vhxnw8h!xvb")
|
||||
|
||||
// EncodeLatestUpperServerBody 按 NoPack.exe 的 raw upper S2C codec 编码服务端包体。
|
||||
// 当前只确认 opcode 1 使用 CAST5;其他 opcode 必须重新追 key 后再接入。
|
||||
func EncodeLatestUpperServerBody(msgID uint16, body []byte) ([]byte, bool, error) {
|
||||
if msgID != 1 {
|
||||
return append([]byte(nil), body...), false, nil
|
||||
}
|
||||
encoded, err := encodeLatestUpperCAST5(body, latestUpperOpcode1Key)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return encoded, true, nil
|
||||
}
|
||||
|
||||
func encodeLatestUpperCAST5(body []byte, key []byte) ([]byte, error) {
|
||||
block, err := cast5.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blockSize := block.BlockSize()
|
||||
size := len(body)
|
||||
if rem := size % blockSize; rem != 0 {
|
||||
size += blockSize - rem
|
||||
}
|
||||
|
||||
padded := make([]byte, size)
|
||||
copy(padded, body)
|
||||
out := make([]byte, size)
|
||||
for offset := 0; offset < size; offset += blockSize {
|
||||
block.Encrypt(out[offset:offset+blockSize], padded[offset:offset+blockSize])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// 本文件验证 DNF latest raw upper 服务端包体 codec 的固定样本。
|
||||
// 样本来自 2026-06-30 MCP/hook 对 opcode 1 CAST5 key 的动态确认。
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/cast5"
|
||||
)
|
||||
|
||||
func TestEncodeLatestUpperServerBodyMsg1CAST5(t *testing.T) {
|
||||
plain := []byte{1, 1, 1, 0, 3, 0, 0, 0, 0}
|
||||
|
||||
encoded, ok, err := EncodeLatestUpperServerBody(1, plain)
|
||||
if err != nil {
|
||||
t.Fatalf("encode msg1: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("msg1 should be encoded")
|
||||
}
|
||||
if len(encoded) != 16 {
|
||||
t.Fatalf("encoded len = %d, want 16", len(encoded))
|
||||
}
|
||||
wantPrefix := []byte{0xb2, 0xc4, 0x32, 0x83, 0x0f, 0xa9, 0x45, 0x57}
|
||||
if !bytes.Equal(encoded[:8], wantPrefix) {
|
||||
t.Fatalf("encoded first block = %x, want %x", encoded[:8], wantPrefix)
|
||||
}
|
||||
|
||||
decoded := decryptLatestUpperCAST5ForTest(t, encoded)
|
||||
if !bytes.Equal(decoded[:len(plain)], plain) {
|
||||
t.Fatalf("decoded body = %x, want prefix %x", decoded[:len(plain)], plain)
|
||||
}
|
||||
for i, b := range decoded[len(plain):] {
|
||||
if b != 0 {
|
||||
t.Fatalf("padding byte %d = %x, want 00", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeLatestUpperServerBodySkipsUntracedOpcode(t *testing.T) {
|
||||
plain := []byte{1, 2, 3}
|
||||
|
||||
encoded, ok, err := EncodeLatestUpperServerBody(4, plain)
|
||||
if err != nil {
|
||||
t.Fatalf("encode msg4: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("msg4 should stay unencoded until its codec key is traced")
|
||||
}
|
||||
if !bytes.Equal(encoded, plain) {
|
||||
t.Fatalf("encoded body = %x, want %x", encoded, plain)
|
||||
}
|
||||
encoded[0] = 9
|
||||
if plain[0] == 9 {
|
||||
t.Fatal("skip path must return a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func decryptLatestUpperCAST5ForTest(t *testing.T, data []byte) []byte {
|
||||
t.Helper()
|
||||
block, err := cast5.NewCipher(latestUpperOpcode1Key)
|
||||
if err != nil {
|
||||
t.Fatalf("cast5 cipher: %v", err)
|
||||
}
|
||||
if len(data)%block.BlockSize() != 0 {
|
||||
t.Fatalf("ciphertext len = %d, block = %d", len(data), block.BlockSize())
|
||||
}
|
||||
out := make([]byte, len(data))
|
||||
for offset := 0; offset < len(data); offset += block.BlockSize() {
|
||||
block.Decrypt(out[offset:offset+block.BlockSize()], data[offset:offset+block.BlockSize()])
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user