DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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
}