DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,88 @@
|
||||
// 本文件定义副本类命令计划。
|
||||
// 副本命令会影响房间、掉落、经验和进场门闸,当前只记录意图和缺口,不伪造成功响应。
|
||||
package dungeoncmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
)
|
||||
|
||||
// Command 是 dungeoncmd handler 交给后续 dungeon/room owner 的最小命令计划。
|
||||
type Command struct {
|
||||
Operation string
|
||||
AccountID string
|
||||
SelectedCharacterID uint16
|
||||
DungeonID uint32
|
||||
Difficulty byte
|
||||
EntryOption uint16
|
||||
SelectionMode byte
|
||||
DropObjectKey uint32
|
||||
NextX byte
|
||||
NextY byte
|
||||
TutorialProgress uint32
|
||||
TutorialCommit byte
|
||||
RawLen int
|
||||
NeedsOwner string
|
||||
}
|
||||
|
||||
// NewRawCommand 构造尚未闭合包体字段的副本命令计划。
|
||||
func NewRawCommand(req alignedcmd.Request, operation string, needs string) Command {
|
||||
return Command{
|
||||
Operation: operation,
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
RawLen: len(req.Body),
|
||||
NeedsOwner: needs,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSelectCommand 构造选择副本命令计划。
|
||||
func NewSelectCommand(req alignedcmd.Request, parsed SelectDungeonRequest) Command {
|
||||
cmd := NewRawCommand(req, "select_dungeon", "dungeon owner + party/entry validation + scene bootstrap response order")
|
||||
cmd.DungeonID = parsed.DungeonID
|
||||
cmd.Difficulty = parsed.Difficulty
|
||||
cmd.EntryOption = parsed.EntryOption
|
||||
cmd.SelectionMode = parsed.SelectionMode
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewGetItemCommand 构造副本内拾取掉落命令计划。
|
||||
func NewGetItemCommand(req alignedcmd.Request, parsed GetItemRequest) Command {
|
||||
cmd := NewRawCommand(req, "get_item", "room drop owner + inventory owner + mutation id + drop/backpack notify order")
|
||||
cmd.DropObjectKey = parsed.DropObjectKey
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewMoveMapCommand 构造切换房间命令计划。
|
||||
func NewMoveMapCommand(req alignedcmd.Request, parsed MoveMapRequest) Command {
|
||||
cmd := NewRawCommand(req, "move_map", "active DungeonSession + current op45 ACK + proven target-room actor/object packet chain")
|
||||
cmd.NextX = parsed.NextX
|
||||
cmd.NextY = parsed.NextY
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewTutorialCommand 构造教程标记命令计划。
|
||||
func NewTutorialCommand(req alignedcmd.Request, parsed ChangeTutorialFlagRequest) Command {
|
||||
cmd := NewRawCommand(req, "change_tutorial_flag", "tutorial owner + profile persistence + current EXE response evidence")
|
||||
cmd.TutorialProgress = parsed.Progress
|
||||
cmd.TutorialCommit = parsed.CommitFlag
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (c Command) String() string {
|
||||
base := fmt.Sprintf("account=%q char=%d", c.AccountID, c.SelectedCharacterID)
|
||||
switch c.Operation {
|
||||
case "select_dungeon":
|
||||
return fmt.Sprintf("%s dungeon=%d difficulty=%d entryOption=%d selectionMode=%d needs=%s", base, c.DungeonID, c.Difficulty, c.EntryOption, c.SelectionMode, c.NeedsOwner)
|
||||
case "get_item":
|
||||
return fmt.Sprintf("%s dropObject=%d needs=%s", base, c.DropObjectKey, c.NeedsOwner)
|
||||
case "move_map":
|
||||
return fmt.Sprintf("%s next=(%d,%d) rawLen=%d needs=%s", base, c.NextX, c.NextY, c.RawLen, c.NeedsOwner)
|
||||
case "change_tutorial_flag":
|
||||
return fmt.Sprintf("%s progress=%d commit=%d rawLen=%d needs=%s", base, c.TutorialProgress, c.TutorialCommit, c.RawLen, c.NeedsOwner)
|
||||
default:
|
||||
return fmt.Sprintf("%s rawLen=%d needs=%s", base, c.RawLen, c.NeedsOwner)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package dungeoncmd 承接 DNF 地下城进入、房间移动、拾取和结算类 C2S 协议。
|
||||
//
|
||||
// 现有 dnf/dungeon 包只负责 PVF 地下城静态表;本包只做客户端命令入口,
|
||||
// 后续真实校验和奖励写入必须交给 dungeon/battle owner。
|
||||
package dungeoncmd
|
||||
@@ -0,0 +1,149 @@
|
||||
// 本文件负责副本命令的协议分发。
|
||||
// 当前只把已经对齐的 C2S 解析成命令计划;场景 owner、结算和加载门闸闭合前不回成功 ACK。
|
||||
package dungeoncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
)
|
||||
|
||||
type handler struct{}
|
||||
|
||||
// NewHandler 创建副本协议处理器。
|
||||
func NewHandler() alignedcmd.Handler {
|
||||
return handler{}
|
||||
}
|
||||
|
||||
// Domain 返回副本业务域。
|
||||
func (handler) Domain() dnfenum.AlignedDomain {
|
||||
return dnfenum.AlignedDomainDungeon
|
||||
}
|
||||
|
||||
// Handle 解析副本请求,并在场景 owner 和 S2C 顺序闭合前禁止成功 ACK。
|
||||
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
switch dnfenum.CmdPacket(req.Opcode) {
|
||||
case dnfenum.CmdPacketEnterSelectDungeon:
|
||||
cmd := NewRawCommand(req, "enter_select_dungeon", "scene bootstrap owner + channel/party gate + response order")
|
||||
return applyOwner(ctx, req, cmd, nil), nil
|
||||
case dnfenum.CmdPacketSelectDungeon:
|
||||
parsed, err := DecodeSelectDungeonRequest(req.Body)
|
||||
cmd := NewSelectCommand(req, parsed)
|
||||
return applyOwner(ctx, req, cmd, err), nil
|
||||
case dnfenum.CmdPacketGetItem:
|
||||
parsed, err := DecodeGetItemRequest(req.Body)
|
||||
cmd := NewGetItemCommand(req, parsed)
|
||||
return applyOwner(ctx, req, cmd, err), nil
|
||||
case dnfenum.CmdPacketMoveMap:
|
||||
parsed, err := DecodeMoveMapRequest(req.Body)
|
||||
cmd := NewMoveMapCommand(req, parsed)
|
||||
return applyOwner(ctx, req, cmd, err), nil
|
||||
case dnfenum.CmdPacketDieMonster:
|
||||
parsed, err := DecodeDieMonsterRequest(req.Body)
|
||||
cmd := NewRawCommand(req, "die_monster", parsed.String())
|
||||
return applyOwner(ctx, req, cmd, err), nil
|
||||
case dnfenum.CmdPacketDieCharacter:
|
||||
cmd := NewRawCommand(req, "die_character", "room combat owner + death/revive state + current EXE response order")
|
||||
return applyOwner(ctx, req, cmd, nil), nil
|
||||
case dnfenum.CmdPacketUseCoin:
|
||||
cmd := NewRawCommand(req, "use_coin", "account wallet/coin owner + revive state + scene refresh order")
|
||||
return applyOwner(ctx, req, cmd, nil), nil
|
||||
case dnfenum.CmdPacketSetPlayResult:
|
||||
cmd := NewRawCommand(req, "set_play_result", "dungeon settlement owner + reward owner + back-to-town flush order")
|
||||
return applyOwner(ctx, req, cmd, nil), nil
|
||||
case dnfenum.CmdPacketChangeTutorialFlag:
|
||||
parsed, err := DecodeChangeTutorialFlagRequest(req.Body)
|
||||
cmd := NewTutorialCommand(req, parsed)
|
||||
return applyOwner(ctx, req, cmd, err), nil
|
||||
case dnfenum.CmdPacketDungeonEventStoryPause:
|
||||
cmd := NewRawCommand(req, "dungeon_event_story_pause", "dungeon story owner + pause state + current EXE response evidence")
|
||||
return applyOwner(ctx, req, cmd, nil), nil
|
||||
case dnfenum.CmdPacketRequestDisjointItem:
|
||||
cmd := NewRawCommand(req, "request_disjoint_item", "dungeon disjoint machine context + inventory owner + response evidence")
|
||||
return applyOwner(ctx, req, cmd, nil), nil
|
||||
case dnfenum.CmdPacketDropItem:
|
||||
cmd := NewRawCommand(req, "drop_item", "inventory owner + scene drop notification + pickup registration")
|
||||
return applyOwner(ctx, req, cmd, nil), nil
|
||||
default:
|
||||
return alignedcmd.Result{
|
||||
Handled: false,
|
||||
ResponseAllowed: false,
|
||||
Operation: "unregistered",
|
||||
Reason: fmt.Sprintf("dungeon 模块未登记 opcode %d", req.Opcode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func applyOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
|
||||
if parseErr != nil {
|
||||
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
|
||||
}
|
||||
owner, err := NewOwner(req.Repositories)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, err)
|
||||
}
|
||||
result, err := owner.Plan(ctx, cmd)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
return ownerPlannedResult(cmd, result)
|
||||
}
|
||||
|
||||
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
|
||||
if err != nil {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("%s 请求体解析失败:%v;禁止回成功包", operation, err),
|
||||
}
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("已解析 %s:%s;副本场景状态、奖励和加载门闸闭合前禁止回包", operation, summary),
|
||||
}
|
||||
}
|
||||
|
||||
func ownerBlockedResult(cmd Command, err error) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf("已解析 %s:%s;dungeon owner 预检未通过:%v;禁止回成功包", cmd.Operation, cmd.String(), err),
|
||||
}
|
||||
}
|
||||
|
||||
func ownerPlannedResult(cmd Command, result PlanResult) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"已解析 %s:%s;dungeon owner verified account=%s char=%s level=%d fatigue=%d location=(town=%d dungeon=%d room=%q) inventory=%t slots=%d warehouse=%d requestDungeon=%d difficulty=%d dropObject=%d next=(%d,%d) tutorialProgress=%d tutorialCommit=%d rawLen=%d;房间/掉落/结算/加载门闸未闭合,禁止成功 ACK",
|
||||
cmd.Operation,
|
||||
cmd.String(),
|
||||
result.AccountID,
|
||||
result.CharacterID,
|
||||
result.Level,
|
||||
result.Fatigue,
|
||||
result.TownID,
|
||||
result.DungeonID,
|
||||
result.RoomID,
|
||||
result.InventoryKnown,
|
||||
result.InventorySlotCount,
|
||||
result.WarehouseSlotCount,
|
||||
result.RequestedDungeonID,
|
||||
result.Difficulty,
|
||||
result.DropObjectKey,
|
||||
result.NextX,
|
||||
result.NextY,
|
||||
result.TutorialProgress,
|
||||
result.TutorialCommit,
|
||||
result.RawLen,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package dungeoncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestDecodeSelectDungeonRequest(t *testing.T) {
|
||||
body := make([]byte, 23)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 0x78563412)
|
||||
body[4] = 3
|
||||
binary.LittleEndian.PutUint16(body[5:7], 0x0504)
|
||||
body[7] = 6
|
||||
body[8] = 7
|
||||
binary.LittleEndian.PutUint16(body[9:11], 0x0908)
|
||||
binary.LittleEndian.PutUint32(body[11:15], 0x0d0c0b0a)
|
||||
body[15] = 0x0e
|
||||
binary.LittleEndian.PutUint32(body[16:20], 0x1211100f)
|
||||
body[20] = 0x13
|
||||
body[21], body[22] = 0xDE, 0xAD
|
||||
got, err := DecodeSelectDungeonRequest(body)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeSelectDungeonRequest error = %v", err)
|
||||
}
|
||||
if got.DungeonID != 0x78563412 || got.Difficulty != 3 || got.EntryOption != 0x0504 ||
|
||||
got.SelectionMode != 6 || got.RuntimeState != 7 || got.RuntimeToken != 0x0908 ||
|
||||
got.Reserved != 0x0d0c0b0a || got.PartyState != 0x0e ||
|
||||
got.LeaderObjectKey != 0x1211100f || got.SpecialMode != 0x13 ||
|
||||
len(got.OpaqueTail) != 2 || got.OpaqueTail[0] != 0xDE {
|
||||
t.Fatalf("got = %+v", got)
|
||||
}
|
||||
body[21] = 0
|
||||
if got.OpaqueTail[0] != 0xDE {
|
||||
t.Fatal("opaque select tail aliases request body")
|
||||
}
|
||||
if _, err := DecodeSelectDungeonRequest(make([]byte, 20)); err == nil {
|
||||
t.Fatal("20-byte select request accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSelectDungeonRequestRejectsTruncatedFiveByteBody(t *testing.T) {
|
||||
if _, err := DecodeSelectDungeonRequest(make([]byte, 5)); err == nil {
|
||||
t.Fatal("truncated five-byte op16 request accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeGetItemRequestMatchesCurrentEXEOrdinaryTwentyByteWriter(t *testing.T) {
|
||||
body := make([]byte, GetItemRequestSize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 0x44332211)
|
||||
body[4] = 0
|
||||
body[5] = 0x66
|
||||
for index, value := range []uint16{101, 102, 103, 201, 202, 301, 302} {
|
||||
binary.LittleEndian.PutUint16(body[6+index*2:], value)
|
||||
}
|
||||
request, err := DecodeGetItemRequest(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.DropObjectKey != 0x44332211 || request.FixedZero != 0 || request.ObjectContext != 0x66 ||
|
||||
request.PlayerX != 101 || request.PlayerY != 102 || request.Token0 != 103 ||
|
||||
request.DropX != 201 || request.DropY != 202 || request.Token1 != 301 || request.Token2 != 302 {
|
||||
t.Fatalf("request = %+v", request)
|
||||
}
|
||||
for _, size := range []int{2, 18, GetItemRequestSize - 1, GetItemRequestSize + 1} {
|
||||
if _, err := DecodeGetItemRequest(make([]byte, size)); err == nil {
|
||||
t.Fatalf("op43 body size %d accepted", size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeGetItemRequestMatchesLiveOrdinaryPickupCapture(t *testing.T) {
|
||||
// Full C2S body after the legacy game header from a live accepted op43.
|
||||
// Keep the byte at offset 4: an earlier truncated transcription omitted it
|
||||
// and incorrectly shifted ObjectContext into FixedZero.
|
||||
body, err := hex.DecodeString("ae01000000016502f500d8094b02e500876c9a10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, err := DecodeGetItemRequest(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.DropObjectKey != 430 || request.FixedZero != 0 || request.ObjectContext != 1 ||
|
||||
request.PlayerX != 613 || request.PlayerY != 245 || request.Token0 != 2520 ||
|
||||
request.DropX != 587 || request.DropY != 229 || request.Token1 != 27783 || request.Token2 != 4250 {
|
||||
t.Fatalf("request = %+v", request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMoveMapRequestCurrentEXELayoutAndBoundary(t *testing.T) {
|
||||
if _, err := DecodeMoveMapRequest(make([]byte, MoveMapRequestSize-1)); err == nil {
|
||||
t.Fatal("99-byte move-map request accepted")
|
||||
}
|
||||
if _, err := DecodeMoveMapRequest(make([]byte, MoveMapRequestSize+1)); err == nil {
|
||||
t.Fatal("101-byte move-map request accepted")
|
||||
}
|
||||
body := make([]byte, MoveMapRequestSize)
|
||||
body[0], body[1] = 1, 2
|
||||
binary.LittleEndian.PutUint32(body[2:6], 0x11223344)
|
||||
binary.LittleEndian.PutUint32(body[6:10], 0x55667788)
|
||||
body[10] = 4
|
||||
binary.LittleEndian.PutUint16(body[11:13], 0x1234)
|
||||
offset := 13
|
||||
for index := 0; index < 8; index++ {
|
||||
binary.LittleEndian.PutUint16(body[offset:offset+2], uint16(100+index))
|
||||
offset += 2
|
||||
}
|
||||
for index := 0; index < 8; index++ {
|
||||
binary.LittleEndian.PutUint32(body[offset:offset+4], uint32(1000+index))
|
||||
offset += 4
|
||||
}
|
||||
binary.LittleEndian.PutUint16(body[61:63], 0xBEEF)
|
||||
for index := 63; index < 99; index++ {
|
||||
body[index] = byte(index)
|
||||
}
|
||||
body[99] = 9
|
||||
full, err := DecodeMoveMapRequest(body)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeMoveMapRequest full error = %v", err)
|
||||
}
|
||||
if full.NextX != 1 || full.NextY != 2 || full.PositionX != 0x11223344 ||
|
||||
full.PositionY != 0x55667788 || full.MoveKind != 4 || full.TimingToken != 0x1234 ||
|
||||
full.ShortState[7] != 107 || full.IntegerState[7] != 1007 ||
|
||||
full.SequenceToken != 0xBEEF || full.RuntimeState != 9 ||
|
||||
full.RuntimeTail[0] != 63 || full.RuntimeTail[35] != 98 || len(full.OpaqueTail) != 0 {
|
||||
t.Fatalf("full = %+v", full)
|
||||
}
|
||||
body[63] = 0
|
||||
if full.RuntimeTail[0] != 63 {
|
||||
t.Fatal("runtime tail aliases request body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDieMonsterRequestCurrentEXELayoutAndBoundary(t *testing.T) {
|
||||
if _, err := DecodeDieMonsterRequest(make([]byte, 61)); err == nil {
|
||||
t.Fatal("61-byte die-monster request accepted")
|
||||
}
|
||||
body := make([]byte, DieMonsterRequestSize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 0x11223344)
|
||||
binary.LittleEndian.PutUint16(body[4:6], 0x5566)
|
||||
for index := 0; index < 4; index++ {
|
||||
binary.LittleEndian.PutUint32(body[6+index*4:], uint32(100+index))
|
||||
}
|
||||
body[22] = 7
|
||||
binary.LittleEndian.PutUint16(body[23:25], 201)
|
||||
binary.LittleEndian.PutUint16(body[25:27], 202)
|
||||
copy(body[27:31], []byte{8, 9, 10, 11})
|
||||
binary.LittleEndian.PutUint16(body[31:33], 0x7788)
|
||||
body[33] = 1
|
||||
binary.LittleEndian.PutUint32(body[34:38], 0x99AABBCC)
|
||||
binary.LittleEndian.PutUint64(body[38:46], 0x0102030405060708)
|
||||
for index := 0; index < 7; index++ {
|
||||
binary.LittleEndian.PutUint16(body[46+index*2:], uint16(300+index))
|
||||
}
|
||||
body[60], body[61] = 12, 13
|
||||
|
||||
got, err := DecodeDieMonsterRequest(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.RuntimeObjectKey != 0x11223344 || got.OwnerObjectKey != 0x5566 ||
|
||||
got.CombatState[3] != 103 || got.ShortState[1] != 202 || got.ByteState[3] != 11 ||
|
||||
got.RandomState != 0x7788 || got.HasActorState != 1 || got.ActorState != 0x99AABBCC ||
|
||||
got.ActorValue != 0x0102030405060708 || got.ActorShortState[6] != 306 ||
|
||||
got.Layout != DieMonsterRequestLayoutFixed62 || got.TailState != [2]byte{12, 13} || len(got.OpaqueTail) != 0 {
|
||||
t.Fatalf("got = %+v", got)
|
||||
}
|
||||
if _, err := DecodeDieMonsterRequest(append(append([]byte(nil), body...), 0xFA)); err == nil {
|
||||
t.Fatal("unsupported 63-byte die-monster boundary accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDieMonsterRequestCurrentTutorialAPCVariableLayout(t *testing.T) {
|
||||
body, err := hex.DecodeString("9301000094010000000000000000910d0000130000000211019401bf0b00000d001101130094010000060064024d01000000001e3c0001000000430d00000000000064024d0164024d0112002f0003000000300280d6")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecodeDieMonsterRequest(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Layout != DieMonsterRequestLayoutVariableCombat ||
|
||||
got.RuntimeObjectKey != 403 || got.OwnerObjectKey != 404 ||
|
||||
got.CombatEntryCount != 2 || len(got.CombatEntries) != 20 ||
|
||||
len(got.RuntimeTail) != DieMonsterVariableRuntimeTailSize || len(got.OpaqueTail) != 0 {
|
||||
t.Fatalf("got = %+v", got)
|
||||
}
|
||||
body[23] = 0
|
||||
if got.CombatEntries[0] != 0x11 {
|
||||
t.Fatal("variable combat entries alias request body")
|
||||
}
|
||||
withTail := append(append([]byte(nil), body...), 0xFA, 0xCE)
|
||||
got, err = DecodeDieMonsterRequest(withTail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.OpaqueTail) != 2 || got.OpaqueTail[0] != 0xFA || got.OpaqueTail[1] != 0xCE {
|
||||
t.Fatalf("opaque tail = %x", got.OpaqueTail)
|
||||
}
|
||||
truncated := append([]byte(nil), body[:len(body)-1]...)
|
||||
truncated[22] = 2
|
||||
if _, err := DecodeDieMonsterRequest(truncated); err == nil {
|
||||
t.Fatal("truncated variable die-monster body accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBossDieCheckRequestCurrentEXELayoutAndBoundary(t *testing.T) {
|
||||
for _, size := range []int{BossDieCheckRequestSize - 1, BossDieCheckRequestSize + 1} {
|
||||
if _, err := DecodeBossDieCheckRequest(make([]byte, size)); err == nil {
|
||||
t.Fatalf("%d-byte boss-die-check request accepted", size)
|
||||
}
|
||||
}
|
||||
body := make([]byte, BossDieCheckRequestSize)
|
||||
binary.LittleEndian.PutUint16(body[0:2], 0x1122)
|
||||
binary.LittleEndian.PutUint16(body[2:4], 0x3344)
|
||||
binary.LittleEndian.PutUint32(body[4:8], 0)
|
||||
binary.LittleEndian.PutUint32(body[8:12], 0x55667788)
|
||||
body[12] = 0
|
||||
binary.LittleEndian.PutUint32(body[13:17], 0x99aabbcc)
|
||||
binary.LittleEndian.PutUint64(body[17:25], 0x0102030405060708)
|
||||
for index := 0; index < 7; index++ {
|
||||
binary.LittleEndian.PutUint16(body[25+index*2:], uint16(0x1200+index))
|
||||
}
|
||||
|
||||
got, err := DecodeBossDieCheckRequest(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.RelatedActorObjectKey != 0x1122 || got.TargetObjectKey != 0x3344 ||
|
||||
got.ReservedZero != 0 || got.Field08 != 0x55667788 || got.Field12 != 0 ||
|
||||
got.Field13 != 0x99aabbcc || got.Field17 != 0x0102030405060708 ||
|
||||
got.Field25[0] != 0x1200 || got.Field25[6] != 0x1206 {
|
||||
t.Fatalf("got = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerBlocksDungeonAck(t *testing.T) {
|
||||
body := make([]byte, GetItemRequestSize)
|
||||
binary.LittleEndian.PutUint32(body[0:4], 0x11223344)
|
||||
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketGetItem),
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || got.ResponseAllowed {
|
||||
t.Fatalf("result = %+v, want handled without response", got)
|
||||
}
|
||||
if got.Operation != "get_item" {
|
||||
t.Fatalf("operation = %q", got.Operation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerUsesOwnerPreflightAndStillBlocksDungeonAck(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "99",
|
||||
AccountID: "acc",
|
||||
Level: 86,
|
||||
Stats: map[string]int64{"fatigue": 121},
|
||||
Location: dnfrepo.CharacterLocation{TownID: 3, DungeonID: 4001, RoomID: "2:3"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Character.Save error = %v", err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "99",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:9": {ItemID: 1001, Count: 2},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Inventory.Save error = %v", err)
|
||||
}
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketSelectDungeon),
|
||||
SelectedCharacterID: 99,
|
||||
Repositories: repos,
|
||||
Body: func() []byte {
|
||||
body := make([]byte, 21)
|
||||
binary.LittleEndian.PutUint32(body, 4001)
|
||||
body[4] = 3
|
||||
return body
|
||||
}(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || got.ResponseAllowed {
|
||||
t.Fatalf("result = %+v, want handled without response", got)
|
||||
}
|
||||
for _, want := range []string{"dungeon owner verified", "level=86", "fatigue=121", "requestDungeon=4001", "difficulty=3", `room="2:3"`} {
|
||||
if !strings.Contains(got.Reason, want) {
|
||||
t.Fatalf("reason %q missing %q", got.Reason, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMoveMapRequiresLiveRuntimeBeforeResponse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "99", AccountID: "acc"}); err != nil {
|
||||
t.Fatalf("Character.Save error = %v", err)
|
||||
}
|
||||
body := make([]byte, MoveMapRequestSize)
|
||||
body[0], body[1] = 1, 2
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketMoveMap),
|
||||
SelectedCharacterID: 99,
|
||||
Repositories: repos,
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || got.ResponseAllowed || got.Operation != "move_map" {
|
||||
t.Fatalf("result = %+v, want blocked move_map", got)
|
||||
}
|
||||
for _, want := range []string{"active dungeon runtime session required", "target-room actor/object packet chain is not proven"} {
|
||||
if !strings.Contains(got.Reason, want) {
|
||||
t.Fatalf("reason %q missing %q", got.Reason, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeChangeTutorialFlagRequestFromCurrentEXEWriter(t *testing.T) {
|
||||
got, err := DecodeChangeTutorialFlagRequest([]byte{
|
||||
0x00,
|
||||
0x1f, 0x00, 0x00, 0x00,
|
||||
0x01,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeChangeTutorialFlagRequest error = %v", err)
|
||||
}
|
||||
if got.Prefix != 0 || got.Progress != 31 || got.CommitFlag != 1 {
|
||||
t.Fatalf("request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeChangeTutorialFlagRequestRequiresExactBoundary(t *testing.T) {
|
||||
for _, body := range [][]byte{make([]byte, 5), make([]byte, 7), make([]byte, 16)} {
|
||||
if _, err := DecodeChangeTutorialFlagRequest(body); err == nil {
|
||||
t.Fatalf("body len %d unexpectedly decoded", len(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTutorialCommandRecordsMCPOwnerGap(t *testing.T) {
|
||||
cmd := NewTutorialCommand(alignedcmd.Request{
|
||||
AccountID: " acc ",
|
||||
SelectedCharacterID: 99,
|
||||
Body: make([]byte, ChangeTutorialFlagRequestSize),
|
||||
}, ChangeTutorialFlagRequest{
|
||||
Progress: 31,
|
||||
CommitFlag: 1,
|
||||
})
|
||||
summary := cmd.String()
|
||||
for _, want := range []string{`account="acc"`, "char=99", "progress=31", "commit=1", "rawLen=6", "tutorial owner", "EXE"} {
|
||||
if !strings.Contains(summary, want) {
|
||||
t.Fatalf("summary %q missing %q", summary, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// 本文件提供副本命令的只读 owner 预检。
|
||||
// 当前只读取角色和背包快照,房间、掉落、结算和进场门闸闭合前不会写库或开放成功 ACK。
|
||||
package dungeoncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("dungeon owner unavailable")
|
||||
ErrCharacterRequired = errors.New("selected character id required")
|
||||
ErrCharacterNotFound = errors.New("character record not found")
|
||||
ErrInventoryNotFound = errors.New("inventory record not found")
|
||||
ErrRuntimeSessionRequired = errors.New("active dungeon runtime session required")
|
||||
ErrCombatAuthorityNeeded = errors.New("server combat authority required")
|
||||
)
|
||||
|
||||
// Owner 是副本命令进入真实场景 owner 前的只读校验边界。
|
||||
type Owner struct {
|
||||
characters dnfrepo.CharacterRepository
|
||||
inventory dnfrepo.InventoryRepository
|
||||
}
|
||||
|
||||
// PlanResult 描述副本命令当前能从玩家读模型确认到的上下文。
|
||||
type PlanResult struct {
|
||||
AccountID string
|
||||
CharacterID string
|
||||
Operation string
|
||||
Level int
|
||||
Fatigue int64
|
||||
TownID int64
|
||||
DungeonID int64
|
||||
RoomID string
|
||||
InventoryKnown bool
|
||||
InventorySlotCount int
|
||||
WarehouseSlotCount int
|
||||
RequestedDungeonID uint32
|
||||
Difficulty byte
|
||||
DropObjectKey uint32
|
||||
NextX byte
|
||||
NextY byte
|
||||
TutorialProgress uint32
|
||||
TutorialCommit byte
|
||||
RawLen int
|
||||
}
|
||||
|
||||
// NewOwner 创建副本命令 owner;缺少角色或背包仓储时拒绝预检。
|
||||
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
|
||||
if repos.Character == nil || repos.Inventory == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{characters: repos.Character, inventory: repos.Inventory}, nil
|
||||
}
|
||||
|
||||
// Plan 只读取角色、位置和背包快照;真实房间状态、掉落和结算写路径仍等待后续 owner。
|
||||
func (o *Owner) Plan(ctx context.Context, cmd Command) (PlanResult, error) {
|
||||
if o == nil || o.characters == nil || o.inventory == nil {
|
||||
return PlanResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
if cmd.SelectedCharacterID == 0 {
|
||||
return PlanResult{}, ErrCharacterRequired
|
||||
}
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
character, ok, err := o.characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return PlanResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
return PlanResult{}, ErrCharacterNotFound
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
|
||||
// The repository owner cannot prove live room ownership. Keep these paths
|
||||
// blocked before inventory/reward reads or any future mutation is attempted.
|
||||
switch cmd.Operation {
|
||||
case "move_map":
|
||||
return PlanResult{}, fmt.Errorf(
|
||||
"%w: current op45 success reads two coordinates, but the target-room actor/object packet chain is not proven",
|
||||
ErrRuntimeSessionRequired,
|
||||
)
|
||||
case "die_monster":
|
||||
return PlanResult{}, fmt.Errorf(
|
||||
"%w: a client op39 report is not a server-owned defeat or reward decision",
|
||||
ErrCombatAuthorityNeeded,
|
||||
)
|
||||
}
|
||||
|
||||
inventory, inventoryKnown, err := o.inventory.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return PlanResult{}, err
|
||||
}
|
||||
if cmd.Operation == "get_item" && !inventoryKnown {
|
||||
return PlanResult{}, ErrInventoryNotFound
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
|
||||
return PlanResult{
|
||||
AccountID: character.AccountID,
|
||||
CharacterID: characterID,
|
||||
Operation: cmd.Operation,
|
||||
Level: character.Level,
|
||||
Fatigue: statValue(character.Stats, "fatigue", "fp", "疲劳"),
|
||||
TownID: character.Location.TownID,
|
||||
DungeonID: character.Location.DungeonID,
|
||||
RoomID: character.Location.RoomID,
|
||||
InventoryKnown: inventoryKnown,
|
||||
InventorySlotCount: len(inventory.Slots),
|
||||
WarehouseSlotCount: len(inventory.Warehouse),
|
||||
RequestedDungeonID: cmd.DungeonID,
|
||||
Difficulty: cmd.Difficulty,
|
||||
DropObjectKey: cmd.DropObjectKey,
|
||||
NextX: cmd.NextX,
|
||||
NextY: cmd.NextY,
|
||||
TutorialProgress: cmd.TutorialProgress,
|
||||
TutorialCommit: cmd.TutorialCommit,
|
||||
RawLen: cmd.RawLen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func statValue(stats map[string]int64, names ...string) int64 {
|
||||
for _, name := range names {
|
||||
if value, ok := stats[name]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func planError(operation string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", strings.TrimSpace(operation), err)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package dungeoncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerPlansSelectDungeonContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "99",
|
||||
AccountID: "acc",
|
||||
Level: 86,
|
||||
Stats: map[string]int64{"fatigue": 121},
|
||||
Location: dnfrepo.CharacterLocation{TownID: 3, DungeonID: 4001, RoomID: "2:3"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Character.Save error = %v", err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "99",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:9": {ItemID: 1001, Count: 2},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Inventory.Save error = %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner error = %v", err)
|
||||
}
|
||||
got, err := owner.Plan(ctx, Command{
|
||||
Operation: "select_dungeon",
|
||||
SelectedCharacterID: 99,
|
||||
DungeonID: 4001,
|
||||
Difficulty: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Plan error = %v", err)
|
||||
}
|
||||
if got.AccountID != "acc" || got.CharacterID != "99" || got.Level != 86 || got.Fatigue != 121 {
|
||||
t.Fatalf("identity/stat = %+v", got)
|
||||
}
|
||||
if got.TownID != 3 || got.DungeonID != 4001 || got.RoomID != "2:3" {
|
||||
t.Fatalf("location = %+v", got)
|
||||
}
|
||||
if !got.InventoryKnown || got.InventorySlotCount != 1 {
|
||||
t.Fatalf("inventory = known %t slots %d", got.InventoryKnown, got.InventorySlotCount)
|
||||
}
|
||||
if got.RequestedDungeonID != 4001 || got.Difficulty != 3 {
|
||||
t.Fatalf("request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerRejectsGetItemWithoutInventory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "99", AccountID: "acc"}); err != nil {
|
||||
t.Fatalf("Character.Save error = %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner error = %v", err)
|
||||
}
|
||||
_, err = owner.Plan(ctx, Command{
|
||||
Operation: "get_item",
|
||||
SelectedCharacterID: 99,
|
||||
DropObjectKey: 7007,
|
||||
})
|
||||
if !errors.Is(err, ErrInventoryNotFound) {
|
||||
t.Fatalf("Plan error = %v, want ErrInventoryNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerBlocksLiveRoomCommandsWithoutRuntimeAuthority(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "99", AccountID: "acc"}); err != nil {
|
||||
t.Fatalf("Character.Save error = %v", err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
operation string
|
||||
want error
|
||||
}{
|
||||
{operation: "move_map", want: ErrRuntimeSessionRequired},
|
||||
{operation: "die_monster", want: ErrCombatAuthorityNeeded},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.operation, func(t *testing.T) {
|
||||
_, planErr := owner.Plan(ctx, Command{
|
||||
Operation: test.operation,
|
||||
SelectedCharacterID: 99,
|
||||
})
|
||||
if !errors.Is(planErr, test.want) {
|
||||
t.Fatalf("Plan error = %v, want %v", planErr, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// 本文件解析副本类 C2S 请求体。
|
||||
// 只记录当前已经对齐的请求字段,复杂场景状态交给后续 dungeon owner 处理。
|
||||
package dungeoncmd
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
SelectDungeonRequestSize = 21
|
||||
GetItemRequestSize = 20
|
||||
MoveMapRequestSize = 100
|
||||
DieMonsterRequestSize = 62
|
||||
DieMonsterVariableBaseSize = 66
|
||||
DieMonsterVariableCombatEntrySize = 10
|
||||
DieMonsterVariableRuntimeTailSize = 43
|
||||
DieMonsterVariableMaxCombatCount = 254
|
||||
BossDieCheckRequestSize = 39
|
||||
ChangeTutorialFlagRequestSize = 6
|
||||
)
|
||||
|
||||
// SelectDungeonRequest 描述选择副本请求。
|
||||
type SelectDungeonRequest struct {
|
||||
DungeonID uint32
|
||||
Difficulty byte
|
||||
EntryOption uint16
|
||||
SelectionMode byte
|
||||
RuntimeState byte
|
||||
RuntimeToken uint16
|
||||
Reserved uint32
|
||||
PartyState byte
|
||||
// LeaderObjectKey is the legacy name for the u32 at offset 16. Its
|
||||
// semantics are not proved for the current EXE, so owners must retain it
|
||||
// as opaque request state and must not use it as an authority boundary.
|
||||
LeaderObjectKey uint32
|
||||
SpecialMode byte
|
||||
OpaqueTail []byte
|
||||
}
|
||||
|
||||
// GetItemRequest is the exact 20-byte ordinary scene-item pickup body written
|
||||
// by the current EXE sub_2326920. Only DropObjectKey has proved ownership
|
||||
// semantics; the remaining coordinates/tokens are retained by wire position.
|
||||
// The separate 18-byte op43 writer is a script/special-object variant and is
|
||||
// deliberately rejected by DecodeGetItemRequest.
|
||||
type GetItemRequest struct {
|
||||
DropObjectKey uint32
|
||||
FixedZero byte
|
||||
ObjectContext byte
|
||||
PlayerX uint16
|
||||
PlayerY uint16
|
||||
Token0 uint16
|
||||
DropX uint16
|
||||
DropY uint16
|
||||
Token1 uint16
|
||||
Token2 uint16
|
||||
}
|
||||
|
||||
// MoveMapRequest 描述切换房间请求。
|
||||
type MoveMapRequest struct {
|
||||
NextX byte
|
||||
NextY byte
|
||||
PositionX uint32
|
||||
PositionY uint32
|
||||
MoveKind byte
|
||||
TimingToken uint16
|
||||
ShortState [8]uint16
|
||||
IntegerState [8]uint32
|
||||
SequenceToken uint16
|
||||
RuntimeTail [36]byte
|
||||
RuntimeState byte
|
||||
OpaqueTail []byte
|
||||
}
|
||||
|
||||
// DieMonsterRequest is the current EXE op39 body. Unknown combat scalars are
|
||||
// retained by position so the room owner can use the proven runtime object key
|
||||
// without inventing semantics for the remaining fields.
|
||||
type DieMonsterRequest struct {
|
||||
Layout DieMonsterRequestLayout
|
||||
RuntimeObjectKey uint32
|
||||
OwnerObjectKey uint16
|
||||
CombatState [4]uint32
|
||||
StateFlag byte
|
||||
ShortState [2]uint16
|
||||
ByteState [4]byte
|
||||
RandomState uint16
|
||||
HasActorState byte
|
||||
ActorState uint32
|
||||
ActorValue uint64
|
||||
ActorShortState [7]uint16
|
||||
TailState [2]byte
|
||||
CombatEntryCount byte
|
||||
CombatEntries []byte
|
||||
RuntimeTail []byte
|
||||
OpaqueTail []byte
|
||||
}
|
||||
|
||||
// BossDieCheckRequest is the exact 39-byte plaintext body written by the
|
||||
// current EXE sub_24351D0 for class-1/op117. Only the two object keys have
|
||||
// proven ownership semantics. The remaining values are retained by their
|
||||
// native offsets so the bridge does not invent names for unproven fields.
|
||||
type BossDieCheckRequest struct {
|
||||
RelatedActorObjectKey uint16
|
||||
TargetObjectKey uint16
|
||||
ReservedZero uint32
|
||||
Field08 uint32
|
||||
Field12 byte
|
||||
Field13 uint32
|
||||
Field17 uint64
|
||||
Field25 [7]uint16
|
||||
}
|
||||
|
||||
type DieMonsterRequestLayout byte
|
||||
|
||||
const (
|
||||
DieMonsterRequestLayoutFixed62 DieMonsterRequestLayout = iota + 1
|
||||
DieMonsterRequestLayoutVariableCombat
|
||||
)
|
||||
|
||||
// ChangeTutorialFlagRequest is the exact six-byte C2S body written by the
|
||||
// current EXE sub_33BDAF0: u8 prefix, u32 progress, u8 commit flag. The
|
||||
// count/entry layout belongs to the S2C success callback and must not be used
|
||||
// to decode this request direction.
|
||||
type ChangeTutorialFlagRequest struct {
|
||||
Prefix byte
|
||||
Progress uint32
|
||||
CommitFlag byte
|
||||
}
|
||||
|
||||
// DecodeSelectDungeonRequest 解析选择副本请求。
|
||||
func DecodeSelectDungeonRequest(body []byte) (SelectDungeonRequest, error) {
|
||||
if len(body) < SelectDungeonRequestSize {
|
||||
return SelectDungeonRequest{}, fmt.Errorf("body too short: got %d want >= %d", len(body), SelectDungeonRequestSize)
|
||||
}
|
||||
request := SelectDungeonRequest{
|
||||
DungeonID: binary.LittleEndian.Uint32(body[0:4]),
|
||||
Difficulty: body[4],
|
||||
EntryOption: binary.LittleEndian.Uint16(body[5:7]),
|
||||
SelectionMode: body[7],
|
||||
RuntimeState: body[8],
|
||||
RuntimeToken: binary.LittleEndian.Uint16(body[9:11]),
|
||||
Reserved: binary.LittleEndian.Uint32(body[11:15]),
|
||||
PartyState: body[15],
|
||||
LeaderObjectKey: binary.LittleEndian.Uint32(body[16:20]),
|
||||
SpecialMode: body[20],
|
||||
}
|
||||
request.OpaqueTail = append([]byte(nil), body[SelectDungeonRequestSize:]...)
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// DecodeGetItemRequest accepts only the current ordinary-drop writer boundary.
|
||||
func DecodeGetItemRequest(body []byte) (GetItemRequest, error) {
|
||||
if len(body) != GetItemRequestSize {
|
||||
return GetItemRequest{}, fmt.Errorf("invalid body length: got %d want %d", len(body), GetItemRequestSize)
|
||||
}
|
||||
return GetItemRequest{
|
||||
DropObjectKey: binary.LittleEndian.Uint32(body[0:4]),
|
||||
FixedZero: body[4],
|
||||
ObjectContext: body[5],
|
||||
PlayerX: binary.LittleEndian.Uint16(body[6:8]),
|
||||
PlayerY: binary.LittleEndian.Uint16(body[8:10]),
|
||||
Token0: binary.LittleEndian.Uint16(body[10:12]),
|
||||
DropX: binary.LittleEndian.Uint16(body[12:14]),
|
||||
DropY: binary.LittleEndian.Uint16(body[14:16]),
|
||||
Token1: binary.LittleEndian.Uint16(body[16:18]),
|
||||
Token2: binary.LittleEndian.Uint16(body[18:20]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecodeMoveMapRequest parses the fixed 100-byte plaintext body written by the
|
||||
// current EXE sub_17C9D80. Its first 63 bytes are followed by the exact 36-byte
|
||||
// sub_17C8490 runtime block and one final state byte. The native opcode-45
|
||||
// transport normally Twofish-pads this plaintext to 112 wire bytes; the client
|
||||
// compatibility path must bypass that process-local cipher before this decoder.
|
||||
func DecodeMoveMapRequest(body []byte) (MoveMapRequest, error) {
|
||||
if len(body) != MoveMapRequestSize {
|
||||
return MoveMapRequest{}, fmt.Errorf("invalid body length: got %d want %d", len(body), MoveMapRequestSize)
|
||||
}
|
||||
request := MoveMapRequest{
|
||||
NextX: body[0],
|
||||
NextY: body[1],
|
||||
PositionX: binary.LittleEndian.Uint32(body[2:6]),
|
||||
PositionY: binary.LittleEndian.Uint32(body[6:10]),
|
||||
MoveKind: body[10],
|
||||
TimingToken: binary.LittleEndian.Uint16(body[11:13]),
|
||||
RuntimeState: body[99],
|
||||
}
|
||||
offset := 13
|
||||
for index := range request.ShortState {
|
||||
request.ShortState[index] = binary.LittleEndian.Uint16(body[offset : offset+2])
|
||||
offset += 2
|
||||
}
|
||||
for index := range request.IntegerState {
|
||||
request.IntegerState[index] = binary.LittleEndian.Uint32(body[offset : offset+4])
|
||||
offset += 4
|
||||
}
|
||||
request.SequenceToken = binary.LittleEndian.Uint16(body[offset : offset+2])
|
||||
copy(request.RuntimeTail[:], body[63:99])
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// DecodeDieMonsterRequest parses both current EXE death-writer families.
|
||||
// sub_2276500/sub_22767C0 emit the fixed 62-byte form. Tutorial APC writer
|
||||
// sub_2570F20 emits 66+10*count bytes: a 23-byte prefix, count ten-byte combat
|
||||
// entries, and a 43-byte runtime tail which includes sub_226F700 output.
|
||||
// Unknown values are retained by exact boundary; extra bytes remain opaque so
|
||||
// the runtime owner can reject an unproven extension before changing state.
|
||||
func DecodeDieMonsterRequest(body []byte) (DieMonsterRequest, error) {
|
||||
if len(body) < DieMonsterRequestSize {
|
||||
return DieMonsterRequest{}, fmt.Errorf("body too short: got %d want >= %d", len(body), DieMonsterRequestSize)
|
||||
}
|
||||
if len(body) != DieMonsterRequestSize {
|
||||
return decodeVariableDieMonsterRequest(body)
|
||||
}
|
||||
request := DieMonsterRequest{
|
||||
Layout: DieMonsterRequestLayoutFixed62,
|
||||
RuntimeObjectKey: binary.LittleEndian.Uint32(body[0:4]),
|
||||
OwnerObjectKey: binary.LittleEndian.Uint16(body[4:6]),
|
||||
StateFlag: body[22],
|
||||
ShortState: [2]uint16{
|
||||
binary.LittleEndian.Uint16(body[23:25]),
|
||||
binary.LittleEndian.Uint16(body[25:27]),
|
||||
},
|
||||
ByteState: [4]byte{body[27], body[28], body[29], body[30]},
|
||||
RandomState: binary.LittleEndian.Uint16(body[31:33]),
|
||||
HasActorState: body[33],
|
||||
ActorState: binary.LittleEndian.Uint32(body[34:38]),
|
||||
ActorValue: binary.LittleEndian.Uint64(body[38:46]),
|
||||
TailState: [2]byte{body[60], body[61]},
|
||||
OpaqueTail: append([]byte(nil), body[DieMonsterRequestSize:]...),
|
||||
}
|
||||
for index := range request.CombatState {
|
||||
offset := 6 + index*4
|
||||
request.CombatState[index] = binary.LittleEndian.Uint32(body[offset : offset+4])
|
||||
}
|
||||
for index := range request.ActorShortState {
|
||||
offset := 46 + index*2
|
||||
request.ActorShortState[index] = binary.LittleEndian.Uint16(body[offset : offset+2])
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// DecodeBossDieCheckRequest parses only the current EXE's exact plaintext
|
||||
// boundary. The ordinary protected path pads the same body to 40 wire bytes;
|
||||
// that ciphertext must not be accepted by this decoder.
|
||||
func DecodeBossDieCheckRequest(body []byte) (BossDieCheckRequest, error) {
|
||||
if len(body) != BossDieCheckRequestSize {
|
||||
return BossDieCheckRequest{}, fmt.Errorf(
|
||||
"invalid body length: got %d want %d",
|
||||
len(body),
|
||||
BossDieCheckRequestSize,
|
||||
)
|
||||
}
|
||||
request := BossDieCheckRequest{
|
||||
RelatedActorObjectKey: binary.LittleEndian.Uint16(body[0:2]),
|
||||
TargetObjectKey: binary.LittleEndian.Uint16(body[2:4]),
|
||||
ReservedZero: binary.LittleEndian.Uint32(body[4:8]),
|
||||
Field08: binary.LittleEndian.Uint32(body[8:12]),
|
||||
Field12: body[12],
|
||||
Field13: binary.LittleEndian.Uint32(body[13:17]),
|
||||
Field17: binary.LittleEndian.Uint64(body[17:25]),
|
||||
}
|
||||
for index := range request.Field25 {
|
||||
offset := 25 + index*2
|
||||
request.Field25[index] = binary.LittleEndian.Uint16(body[offset : offset+2])
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func decodeVariableDieMonsterRequest(body []byte) (DieMonsterRequest, error) {
|
||||
if len(body) < DieMonsterVariableBaseSize {
|
||||
return DieMonsterRequest{}, fmt.Errorf(
|
||||
"unsupported die-monster boundary: got %d want %d or >= %d",
|
||||
len(body), DieMonsterRequestSize, DieMonsterVariableBaseSize,
|
||||
)
|
||||
}
|
||||
count := int(body[22])
|
||||
if count > DieMonsterVariableMaxCombatCount {
|
||||
return DieMonsterRequest{}, fmt.Errorf(
|
||||
"die-monster combat count exceeds current writer limit: got %d want <= %d",
|
||||
count, DieMonsterVariableMaxCombatCount,
|
||||
)
|
||||
}
|
||||
expected := DieMonsterVariableBaseSize + count*DieMonsterVariableCombatEntrySize
|
||||
if len(body) < expected {
|
||||
return DieMonsterRequest{}, fmt.Errorf(
|
||||
"variable die-monster body too short: got %d want >= %d for count %d",
|
||||
len(body), expected, count,
|
||||
)
|
||||
}
|
||||
entriesStart := 23
|
||||
entriesEnd := entriesStart + count*DieMonsterVariableCombatEntrySize
|
||||
runtimeTailEnd := entriesEnd + DieMonsterVariableRuntimeTailSize
|
||||
request := DieMonsterRequest{
|
||||
Layout: DieMonsterRequestLayoutVariableCombat,
|
||||
RuntimeObjectKey: binary.LittleEndian.Uint32(body[0:4]),
|
||||
OwnerObjectKey: binary.LittleEndian.Uint16(body[4:6]),
|
||||
CombatEntryCount: byte(count),
|
||||
CombatEntries: append([]byte(nil), body[entriesStart:entriesEnd]...),
|
||||
RuntimeTail: append([]byte(nil), body[entriesEnd:runtimeTailEnd]...),
|
||||
OpaqueTail: append([]byte(nil), body[runtimeTailEnd:]...),
|
||||
}
|
||||
for index := range request.CombatState {
|
||||
offset := 6 + index*4
|
||||
request.CombatState[index] = binary.LittleEndian.Uint32(body[offset : offset+4])
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// DecodeChangeTutorialFlagRequest parses the current EXE C2S writer. The
|
||||
// boundary is exact so a protected/padded body cannot be mistaken for the
|
||||
// plaintext tutorial-finish handshake.
|
||||
func DecodeChangeTutorialFlagRequest(body []byte) (ChangeTutorialFlagRequest, error) {
|
||||
if len(body) != ChangeTutorialFlagRequestSize {
|
||||
return ChangeTutorialFlagRequest{}, fmt.Errorf(
|
||||
"body length mismatch: got %d want %d",
|
||||
len(body),
|
||||
ChangeTutorialFlagRequestSize,
|
||||
)
|
||||
}
|
||||
return ChangeTutorialFlagRequest{
|
||||
Prefix: body[0],
|
||||
Progress: binary.LittleEndian.Uint32(body[1:5]),
|
||||
CommitFlag: body[5],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r SelectDungeonRequest) String() string {
|
||||
return fmt.Sprintf(
|
||||
"dungeon=%d difficulty=%d entryOption=%d selectionMode=%d runtimeState=%d runtimeToken=%d reserved=%d partyState=%d leader=%d specialMode=%d tail=%d",
|
||||
r.DungeonID, r.Difficulty, r.EntryOption, r.SelectionMode, r.RuntimeState,
|
||||
r.RuntimeToken, r.Reserved, r.PartyState, r.LeaderObjectKey, r.SpecialMode, len(r.OpaqueTail),
|
||||
)
|
||||
}
|
||||
|
||||
func (r GetItemRequest) String() string {
|
||||
return fmt.Sprintf(
|
||||
"dropObject=%d fixed=%d context=%d player=(%d,%d) token0=%d drop=(%d,%d) token1=%d token2=%d",
|
||||
r.DropObjectKey,
|
||||
r.FixedZero,
|
||||
r.ObjectContext,
|
||||
r.PlayerX,
|
||||
r.PlayerY,
|
||||
r.Token0,
|
||||
r.DropX,
|
||||
r.DropY,
|
||||
r.Token1,
|
||||
r.Token2,
|
||||
)
|
||||
}
|
||||
|
||||
func (r MoveMapRequest) String() string {
|
||||
return fmt.Sprintf(
|
||||
"next=(%d,%d) position=(%d,%d) kind=%d sequence=%d tail=%d",
|
||||
r.NextX, r.NextY, r.PositionX, r.PositionY, r.MoveKind, r.SequenceToken, len(r.OpaqueTail),
|
||||
)
|
||||
}
|
||||
|
||||
func (r DieMonsterRequest) String() string {
|
||||
return fmt.Sprintf(
|
||||
"layout=%d runtimeObject=%d ownerObject=%d actorState=%d combatEntries=%d runtimeTail=%d tail=%d",
|
||||
r.Layout, r.RuntimeObjectKey, r.OwnerObjectKey, r.ActorState,
|
||||
r.CombatEntryCount, len(r.RuntimeTail), len(r.OpaqueTail),
|
||||
)
|
||||
}
|
||||
|
||||
func (r BossDieCheckRequest) String() string {
|
||||
return fmt.Sprintf(
|
||||
"relatedActor=%d target=%d reserved=%d field08=%d field12=%d field13=%d field17=%d",
|
||||
r.RelatedActorObjectKey,
|
||||
r.TargetObjectKey,
|
||||
r.ReservedZero,
|
||||
r.Field08,
|
||||
r.Field12,
|
||||
r.Field13,
|
||||
r.Field17,
|
||||
)
|
||||
}
|
||||
|
||||
func (r ChangeTutorialFlagRequest) String() string {
|
||||
return fmt.Sprintf("prefix=%d progress=%d commit=%d", r.Prefix, r.Progress, r.CommitFlag)
|
||||
}
|
||||
|
||||
func readU16(body []byte, offset int) uint16 {
|
||||
if offset+2 <= len(body) {
|
||||
return binary.LittleEndian.Uint16(body[offset:])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func readU32(body []byte, offset int) uint32 {
|
||||
if offset+4 <= len(body) {
|
||||
return binary.LittleEndian.Uint32(body[offset:])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in new issue
Block a user