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

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

397 lines
14 KiB
Go

// 本文件解析副本类 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
}