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

163 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
}