服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
163 lines
7.8 KiB
Go
163 lines
7.8 KiB
Go
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}
|
||
}
|