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,162 @@
package dnfbridge
import (
"encoding/binary"
"errors"
"fmt"
)
// op1119(ENUM_CMDPACKET_BIND_PLUS,梦想/绝对绑定装扮合成器)的线格式。
//
// 请求体(实测 144 字节,两次现场取证逐字段一致):
//
// +0x00 u32 800 客户端结构体直发(窗口/分辨率域),服务端不看
// +0x04 u32 600 同上
// +0x08 u32 随机/哈希,逐次变化
// +0x0C u32 23864 固定
// +0x10 u32 输出装扮 ID ★ 玩家点名的输出,= chn_absolute_bind_cube.etc 的
// [absolute bind plus rare][职业][部位](现场 101560739 = 剑士头发)
// +0x14 u16 ability 结果装扮的 ability 号(现场 4 / 上一次 5)
// +0x16 u16 材料件数 = 8
// +0x18 8 × (u16 槽, u32 道具) ★ 8 件材料装扮(装扮栏槽位 + 道具 ID)
// +0x48 u32 / +0x4C u32 随选择变化,未解
// +0x50 64 字节 客户端结构体直发(内含栈指针),服务端必须忽略
//
// 回包(class-1 op1119,共 55 字节)的形状是逐指令读出来的,不是猜的:
// 客户端处理器 RVA 0x1135C10 先 `cmp byte ptr [ebp+0xC], 0 / je return`(传输层把
// body[0] 当成功标志 a1),然后 `ReadBytes(&buf, 0x36)` 读 54 字节,再调 0x1135680。
// 见 梦想装扮合成器op1119_回包格式定案_20261004.md 第二/三/四节。
const (
currentAvatarBindPlusMaterialOffset = 0x18
currentAvatarBindPlusMaterialSize = 6
currentAvatarBindPlusRequestedOffset = 0x10
currentAvatarBindPlusAbilityOffset = 0x14
currentAvatarBindPlusCountOffset = 0x16
currentAvatarBindPlusMaterialCount = 8
currentAvatarBindPlusResultSize = 0x36
currentAvatarBindPlusReplySize = 1 + currentAvatarBindPlusResultSize
currentAvatarBindPlusResultItemOffset = 0x06
currentAvatarBindPlusResultSlotOffset = 0x0A
currentAvatarBindPlusResultCountOff = 0x0C
currentAvatarBindPlusResultSlotsOff = 0x0E
currentAvatarBindPlusErrorCode = 0x16
)
var (
errCurrentAvatarBindPlusRequestInvalid = errors.New("dnf current avatar bind plus request is invalid")
errCurrentAvatarBindPlusUnavailable = errors.New("dnf current avatar bind plus source or PVF mapping is unavailable")
errCurrentAvatarBindPlusOutputInvalid = errors.New("dnf current avatar bind plus requested output is invalid")
errCurrentAvatarBindPlusMaterial = errors.New("dnf current avatar bind plus material is invalid")
errCurrentAvatarBindPlusCube = errors.New("dnf current avatar bind plus synthesizer is invalid")
)
type currentAvatarBindPlusMaterial struct {
Slot int16
ItemID uint32
}
type currentAvatarBindPlusRequest struct {
RequestedItemID uint32
AbilityNo uint16
Count uint16
Materials []currentAvatarBindPlusMaterial
BodyLength int
}
func parseCurrentAvatarBindPlusRequest(body []byte) (currentAvatarBindPlusRequest, error) {
if len(body) < currentAvatarBindPlusMaterialOffset {
return currentAvatarBindPlusRequest{}, fmt.Errorf("%w: body=%d", errCurrentAvatarBindPlusRequestInvalid, len(body))
}
request := currentAvatarBindPlusRequest{
RequestedItemID: binary.LittleEndian.Uint32(body[currentAvatarBindPlusRequestedOffset : currentAvatarBindPlusRequestedOffset+4]),
AbilityNo: binary.LittleEndian.Uint16(body[currentAvatarBindPlusAbilityOffset : currentAvatarBindPlusAbilityOffset+2]),
Count: binary.LittleEndian.Uint16(body[currentAvatarBindPlusCountOffset : currentAvatarBindPlusCountOffset+2]),
BodyLength: len(body),
}
if request.RequestedItemID == 0 || request.Count == 0 || request.Count > currentAvatarBindPlusMaterialCount {
return currentAvatarBindPlusRequest{}, fmt.Errorf("%w: item=%d count=%d body=%d",
errCurrentAvatarBindPlusRequestInvalid, request.RequestedItemID, request.Count, len(body))
}
need := currentAvatarBindPlusMaterialOffset + int(request.Count)*currentAvatarBindPlusMaterialSize
if len(body) < need {
return currentAvatarBindPlusRequest{}, fmt.Errorf("%w: count=%d need=%d body=%d",
errCurrentAvatarBindPlusRequestInvalid, request.Count, need, len(body))
}
request.Materials = make([]currentAvatarBindPlusMaterial, 0, request.Count)
seen := make(map[int16]struct{}, request.Count)
for index := 0; index < int(request.Count); index++ {
offset := currentAvatarBindPlusMaterialOffset + index*currentAvatarBindPlusMaterialSize
material := currentAvatarBindPlusMaterial{
Slot: int16(binary.LittleEndian.Uint16(body[offset : offset+2])),
ItemID: binary.LittleEndian.Uint32(body[offset+2 : offset+6]),
}
if material.ItemID == 0 || material.Slot < 0 {
return currentAvatarBindPlusRequest{}, fmt.Errorf("%w: index=%d slot=%d item=%d",
errCurrentAvatarBindPlusRequestInvalid, index, material.Slot, material.ItemID)
}
if _, duplicate := seen[material.Slot]; duplicate {
return currentAvatarBindPlusRequest{}, fmt.Errorf("%w: duplicate slot=%d", errCurrentAvatarBindPlusRequestInvalid, material.Slot)
}
seen[material.Slot] = struct{}{}
request.Materials = append(request.Materials, material)
}
return request, nil
}
type currentAvatarBindPlusOutcome struct {
OutputItemID uint32
OutputSlot int16
// AbilityNo 是玩家在窗口里选的属性(请求 +0x14)。它同时进两处:服务端那一行
// 的 avatar_ability_no,以及回包 +0x0A —— 后者是客户端结果描述符的构造参数,
// 现场 2026-10-04 正是因为这里塞的是槽号,客户端把"属性"显示成了那个数字。
AbilityNo uint16
// ConsumedSlots 按客户端 8 个材料格的顺序回显,长度固定 8;不足处用 0 占位
// (客户端只为它自己填过东西的格子取用这个值,空格的 0 不会被读)。
ConsumedSlots []int16
SetIndex int64
Part string
CubeItemID uint32
CubeListType byte
CubeSlot int16
CubeRemain int64
}
// buildCurrentAvatarBindPlusSuccessBody 编 op1119 的成功回包(55 字节)。
//
// body[0] u8 1 成功标志(处理器 0x1135C10 的 cmp [ebp+0xC],0)
// body[1] ── 54 字节结果结构 ──
// +0x00 u32 0 客户端不读
// +0x04 u16 新道具落在哪个槽 ★ 0x1F2B470(container, X, itemObj, 1, 1) 里
// X 是**越界检查过的容器下标**(0x1F2B55D
// `mov [eax + ecx*4], esi`),即"把结果对象写进
// container[槽]"。
// +0x06 u32 输出装扮道具 ID → 0x2359A70(道具ID, 结果描述符, 0, 0)
// +0x0A u16 玩家选的属性 → 结果描述符构造器 0x1D76DB0 第 4 参。
// 现场教训:这一格塞槽号会让客户端把属性显示成
// "35"(服务端数据是对的,所以整理背包后恢复正常)。
// +0x0C u16 1 每个材料格扣减数量 → 0x1F2B850(container, 槽, 扣减数)
// +0x0E i16×8 8 个材料槽 → 客户端逐格按槽扣
// +0x1E 24 字节 0 客户端不读
func buildCurrentAvatarBindPlusSuccessBody(outcome currentAvatarBindPlusOutcome) []byte {
var body packetWriter
body.writeByte(1)
body.writeUint32(0)
body.writeUint16(uint16(outcome.OutputSlot))
body.writeUint32(outcome.OutputItemID)
body.writeUint16(outcome.AbilityNo)
body.writeUint16(1)
for index := 0; index < currentAvatarBindPlusMaterialCount; index++ {
slot := int16(0)
if index < len(outcome.ConsumedSlots) {
slot = outcome.ConsumedSlots[index]
}
body.writeUint16(uint16(slot))
}
body.writeZero(currentAvatarBindPlusResultSize - currentAvatarBindPlusResultSlotsOff - currentAvatarBindPlusMaterialCount*2)
return body.bytes()
}
func currentAvatarBindPlusFailureBody() []byte {
// 与 op99 同款:首字节 0 = 客户端处理器直接返回;第二字节是通用错误码。
return []byte{0, currentAvatarBindPlusErrorCode, 0}
}