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,420 @@
// 本文件负责礼包/随机盒子命令的协议分发。
// 礼包开启会消耗物品并发放称号、时装、宠物或道具,当前只解析请求体,不直接回成功包。
package packageitem
import (
"context"
"encoding/binary"
"fmt"
"strconv"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
type handler struct{}
// NewHandler 创建礼包协议处理器。
func NewHandler() alignedcmd.Handler {
return handler{}
}
// Domain 返回礼包业务域。
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainPackage
}
// Handle 解析礼包请求,并在奖励和物品刷新链路闭合前禁止成功 ACK。
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketUseBoosterItem:
parsed, err := DecodeSelectablePackageRequest(req.Body)
cmd := NewSelectableCommand(req, parsed)
if err != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
result, err := owner.PlanSelectable(ctx, SelectableCommand{
SelectedCharacterID: req.SelectedCharacterID,
SlotIndex: parsed.SlotIndex,
SelectedItemTemplateID: parsed.SelectedItemTemplateID,
})
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
return ownerPlannedResult(cmd.Operation, cmd.String(), result), nil
case dnfenum.CmdPacketUseRandomboxItem:
parsed, err := DecodeMagicBoxSingleRequest(req.Body)
cmd := NewMagicBoxCommand(req, parsed)
if err != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
}
if req.MagicBoxResolver == nil || req.MagicBoxRewardItemResolver == nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("magic box blocked: runtime-PVF resolvers unavailable; refusing to trust request metadata: %v", ErrMagicBoxResolverRequired),
}, nil
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
result, err := owner.ApplyMagicBox(ctx, MagicBoxCommand{
SelectedCharacterID: req.SelectedCharacterID,
AccountID: req.AccountID,
RawListType: parsed.RawListType,
ListType: parsed.ListType,
SlotIndex: parsed.SlotIndex,
MaterialSlotIndex: parsed.MaterialSlotIndex,
ActivateContractUnits: req.MagicBoxContractActivator,
}, req.MagicBoxResolver, req.MagicBoxRewardItemResolver)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
return ownerAppliedMagicBoxResult(req.Opcode, cmd.Operation, result), nil
case dnfenum.CmdPacketUseRandomboxItemExpand:
parsed, err := DecodeMagicBoxExpandRequest(req.Body)
cmd := NewMagicBoxExpandCommand(req, parsed)
if err != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
}
if req.MagicBoxResolver == nil || req.MagicBoxRewardItemResolver == nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("magic box expand blocked: runtime-PVF resolvers unavailable; refusing to trust request metadata: %v", ErrMagicBoxResolverRequired),
}, nil
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
result, err := owner.ApplyMagicBoxExpand(ctx, MagicBoxExpandCommand{
SelectedCharacterID: req.SelectedCharacterID,
AccountID: req.AccountID,
RawListType: parsed.RawListType,
ListType: parsed.ListType,
SlotIndex: parsed.SlotIndex,
BoxItemID: parsed.BoxItemID,
MaterialSlotIndex: parsed.MaterialSlotIndex,
MaterialItemID: parsed.MaterialItemID,
OpenCount: parsed.OpenCount,
ActivateContractUnits: req.MagicBoxContractActivator,
}, req.MagicBoxResolver, req.MagicBoxRewardItemResolver)
if err != nil {
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
}
return ownerAppliedMagicBoxResult(req.Opcode, cmd.Operation, result), nil
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("package 模块未登记 opcode %d", req.Opcode),
}, nil
}
}
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(operation string, summary string, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("已解析 %s:%s;package owner 预检未通过:%v;禁止回成功包", operation, summary, err),
}
}
func ownerPlannedResult(operation string, summary string, result PlanResult) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("已解析 %s:%s;package owner 已验证 char=%s source=(%d,%d) item=%d materialSlot=%d materialItem=%d selected=%d;奖励发放/物品刷新/弹窗顺序未闭合,禁止回成功包", operation, summary, result.CharacterID, result.SourceListType, result.SourceSlotIndex, result.SourceItemID, result.MaterialSlotIndex, result.MaterialItemID, result.SelectedItemTemplateID),
}
}
func ownerAppliedMagicBoxResult(opcode uint16, operation string, result MagicBoxResult) alignedcmd.Result {
if !result.Success {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: operation,
Reason: fmt.Sprintf("magic box owner rejected: %s; returned {0x00} failure in family order", result.Reason),
UpperResponses: []alignedcmd.UpperResponse{
class1Response(opcode, buildMagicBoxFailureAck()),
},
}
}
ackBody := buildMagicBoxSingleAck(result)
responses := []alignedcmd.UpperResponse{
class1Response(opcode, ackBody),
}
if dnfenum.CmdPacket(opcode) == dnfenum.CmdPacketUseRandomboxItemExpand {
if result.BoxItemID == currentSeriaLuckItemID {
// 赛丽亚十连由原生批量回包独占结果展示。额外发送 op208
// 会用单开“三格获得道具”弹窗覆盖双栏十连结果。
responses = []alignedcmd.UpperResponse{
class1Response(opcode, buildMagicBoxSeriaBatchAck(result)),
}
} else {
// 非赛丽亚源(泰迪礼盒等):0x0468 原生回包在客户端 sub_1D074C0
// 末尾固定弹一条收尾公告(当前 2018 字符串表把它渲染成错误文案),
// 而其结果窗口由 208 通道完整承载,故只发 208 展示回包(2026-07-26
// 实测:1128 未实现时客户端也无等待卡死,可安全省略)。
responses = []alignedcmd.UpperResponse{
class1Response(uint16(dnfenum.CmdPacketUseRandomboxItem), buildMagicBoxSingleAck(result)),
}
}
}
out := alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: operation,
Reason: fmt.Sprintf(
"package owner applied: %s box=%d opens=%d rewards=%d material=%d changed=%t; opened exactly one result ACK then incremental changed-slot refreshes",
operation,
result.BoxItemID,
result.OpenCount,
len(result.Rewards),
result.MaterialItemID,
result.Changed,
),
UpperResponses: responses,
ItemSlotRefreshes: magicBoxChangedSlotRefreshes(result),
}
if result.BoxItemID == currentSeriaLuckItemID {
// The account owner has already committed the absolute Seria-luck value.
// Keep it out of the native magic-box result body: dnfbridge sends this
// projection only after the result ACK and changed inventory rows, so the
// current client's opening animation remains authoritative while its HUD
// is ready for another open without a character reselect.
out.SeriaLuck = &alignedcmd.SeriaLuckProjection{
Before: result.SeriaLuckBefore,
After: result.SeriaLuckAfter,
}
}
if result.OverflowMailID != "" {
// The durable system mail is already committed. Send the exact current
// client alarm only after the normal box ACK and item refresh complete.
out.MailboxAlarmRecipientID = uint16(parseMagicBoxCharacterID(result.CharacterID))
}
if len(result.DirectActivations) > 0 {
// Premium contracts never reached the bag, so the bridge must extend the
// account entitlement itself and notify the client with the ordinary
// premium activation packets instead of item rows.
out.ContractActivations = result.DirectActivations
}
return out
}
// magicBoxChangedSlotRefreshes follows the proved 86JP post-open sequence:
// publish only the source box, optional material, and actual reward slots that
// changed. A full list-0/list-1/list-2/list-7/list-38 replay is not part of the
// native magic-box flow. Besides being unnecessary, that replay also made the
// bridge treat a bag-only mutation as worn-equipment state and resend the
// private combat projection, temporarily replacing live collection/buff stats.
func magicBoxChangedSlotRefreshes(result MagicBoxResult) []alignedcmd.ItemSlotRefresh {
if !result.Changed {
return nil
}
seen := make(map[string]struct{}, 2+len(result.Rewards))
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, 2+len(result.Rewards))
appendSlot := func(listType byte, slot int16) {
if slot < 0 {
return
}
key := fmt.Sprintf("%d:%d", listType, slot)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{
ListType: listType,
SlotIndex: slot,
})
}
if len(result.ChangedSlots) > 0 {
for _, changed := range result.ChangedSlots {
appendSlot(changed.ListType, changed.SlotIndex)
}
return refreshes
}
appendSlot(listTypeMain, result.BoxSlotIndex)
appendSlot(listTypeMain, result.MaterialSlotIndex)
for _, reward := range result.Rewards {
appendSlot(listTypeMain, reward.Slot)
}
return refreshes
}
func parseMagicBoxCharacterID(raw string) uint64 {
value, err := strconv.ParseUint(raw, 10, 16)
if err != nil {
return 0
}
return value
}
func class1Response(opcode uint16, body []byte) alignedcmd.UpperResponse {
return alignedcmd.UpperResponse{
MsgID: opcode,
Body: body,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}
}
// buildMagicBoxFailureAck is the magic-box family's uniform failure body:
// one zero byte, no error-code subdivision (86JP family rule).
func buildMagicBoxFailureAck() []byte {
return []byte{0x00}
}
// buildMagicBoxSingleAck follows the current EXE's sub_1D05A60 read order.
// The class1 dispatcher consumes result=1 first; the handler then always
// reads u8 clientType and u8 doubleFlag before switching on clientType.
// Direct Seria opens use case 4, which reads i16 boxSlot and materialSlot at
// 0x01D06815/0x01D06821, then sub_1D057B0 reads u16 rewardCount and complete
// 0x77-byte rows at 0x01D0698A. Omitting doubleFlag shifts both slots and the
// reward count, leaving every result row in the client's "loading" state.
func buildMagicBoxSingleAck(result MagicBoxResult) []byte {
doubleFlag := byte(0)
if result.SeriaLuckDoubleTriggered {
doubleFlag = 1
}
out := []byte{1, result.ClientType, doubleFlag}
out = appendI16(out, result.BoxSlotIndex)
out = appendI16(out, result.MaterialSlotIndex)
out = appendI16(out, int16(len(result.Rewards)))
for _, reward := range result.Rewards {
out = append(out, buildMagicBoxBatchRewardRow(reward)...)
}
return out
}
func appendI16(out []byte, value int16) []byte {
return append(out, byte(value), byte(value>>8))
}
func appendI32(out []byte, value int32) []byte {
return append(out, byte(value), byte(value>>8), byte(value>>16), byte(value>>24))
}
// buildMagicBoxSeriaBatchAck 是赛丽亚源连开(0x0468)的原生批量回包,结构
// 以当前 EXE sub_1D074C0 的实读顺序为准(IDA/x32dbg 证据):u8 success=4,
// u8 variant=4
// (无材料盒子,跳过客户端材料扣减校验,否则对 0xffff 材料槽误报 11031),
// u8 doubleFlag(86JP SeriaLuck 翻倍触发时为 1),u16 openCount,
// i16 boxSlot, i16 materialSlot(无材料=-1),随后第一奖励列表(u16 count +
// count × 0x77 完整物品条目:u16 slot, u32 itemID, u32 count,其余字节为
// 零),u16 0,第二(双倍)奖励列表同构。variant=4 且 doubleFlag!=0 时
// 客户端走 0x36B 赛丽亚抽奖双栏窗口;doubleFlag=0 时走 0x145 单栏结果。
// 行宽 0x77 是当前 EXE 列表读取器 sub_1D057B0 的硬编码。
func buildMagicBoxSeriaBatchAck(result MagicBoxResult) []byte {
doubleFlag := byte(0)
if len(result.DoubleRewards) > 0 {
doubleFlag = 1
}
out := []byte{4, 4, doubleFlag}
out = appendI16(out, int16(result.OpenCount))
out = appendI16(out, result.BoxSlotIndex)
out = appendI16(out, result.MaterialSlotIndex)
// 左栏(普通)= 基础抽取 DisplayRewards,右栏(双倍)= DoubleRewards;
// 两个列表重复会让客户端合并清空左栏(86JP GetPrimaryRewards 契约)。
out = appendI16(out, int16(len(result.DisplayRewards)))
for _, reward := range result.DisplayRewards {
out = append(out, buildMagicBoxBatchRewardRow(reward)...)
}
out = appendI16(out, 0)
out = appendI16(out, int16(len(result.DoubleRewards)))
for _, reward := range result.DoubleRewards {
out = append(out, buildMagicBoxBatchRewardRow(reward)...)
}
return out
}
func buildMagicBoxBatchRewardRow(reward MagicBoxGrantedReward) []byte {
row := make([]byte, currentMagicBoxEntrySize)
binary.LittleEndian.PutUint16(row[0x00:0x02], uint16(reward.Slot))
binary.LittleEndian.PutUint32(row[0x02:0x06], uint32(reward.ItemID))
if reward.Kind == "equipment" {
// 86JP 契约:装备行的 0x06 字段是品质种子而非数量,0x0B 是耐久;
// 写成数量会让客户端结果窗口渲染为"加载中"(2026-07-26 实测)。
seed := reward.QualitySeed
if seed == 0 {
seed = currentMagicBoxTopQualitySeed
}
binary.LittleEndian.PutUint32(row[0x06:0x0A], seed)
binary.LittleEndian.PutUint16(row[0x0B:0x0D], reward.Durability)
} else {
binary.LittleEndian.PutUint32(row[0x06:0x0A], uint32(reward.Count))
}
return row
}
// buildMagicBoxExpandAck 是非赛丽亚源连开(0x0468)的原生回包。结构以当前
// EXE sub_1D074C0 的实读顺序为准:u8 variant=1, u8 clientType=4(走
// 0x1FC 分支),u16 openCount, i16 boxSlot, i16 materialSlot(无材料回填
// 盒子槽,配合随后的 op14 刷新对齐),空第一列表 + u16 0 + 第二奖励列表
// (0x77 完整物品条目:u16 slot, u32 itemID, u32 count,其余字节为零)。
func buildMagicBoxExpandAck(result MagicBoxResult) []byte {
materialSlot := result.MaterialSlotIndex
if result.MaterialItemID == 0 {
materialSlot = result.BoxSlotIndex
}
out := []byte{1, 4}
out = appendI16(out, int16(result.OpenCount))
out = appendI16(out, result.BoxSlotIndex)
out = appendI16(out, materialSlot)
out = appendI16(out, 0)
out = appendI16(out, 0)
out = appendI16(out, int16(len(result.Rewards)))
for _, reward := range result.Rewards {
out = append(out, buildMagicBoxBatchRewardRow(reward)...)
}
return out
}
// buildCurrentBoosterPopupAck 是非赛丽亚源连开的 0x00A0 传统获得物品弹窗。
// 布局以当前 EXE 处理器 sub_1D2BFF0 的实读顺序为准(2026-07-26 IDA):
// i32 1, i16 sourceSlot, i32 0, i32 0, u16 count,随后每行
// (i32 itemID, i32 count)。86JP 的 u8 头布局与该客户端读取器不对齐,
// 会渲染出乱码行,故按 IDA 证据构造。
func buildCurrentBoosterPopupAck(result MagicBoxResult) []byte {
var writer []byte
writer = appendI32(writer, 1)
writer = appendI16(writer, result.BoxSlotIndex)
writer = appendI32(writer, 0)
writer = appendI32(writer, 0)
writer = appendI16(writer, int16(len(result.Rewards)))
for _, reward := range result.Rewards {
writer = appendI32(writer, int32(reward.ItemID))
writer = appendI32(writer, int32(reward.Count))
}
return writer
}