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,965 @@
// 本文件集中解析背包类 C2S 请求体。
// 字节顺序按最新 EXE/C# 对齐证据整理;解析器只产生请求计划,不直接修改资产或生成成功响应。
package inventory
import (
"encoding/binary"
"fmt"
"math"
"google.golang.org/protobuf/encoding/protowire"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
listTypeMain byte = 0
listTypeAvatar byte = 1
listTypePersonalCargo byte = 2
listTypeEquipment byte = 3
listTypePet byte = 7
listTypeActorWornAlt byte = 17
listTypeAccountCargo byte = 12
listTypeGuildMedal byte = 38
maxDeleteEntries = 100
)
// DeleteRequest 描述删除物品请求;Extended=true 时 body 带批量删除 entry。
type DeleteRequest struct {
Extended bool
StateTrailer bool
ListType byte
SlotIndex int16
Count int16
Entries []DeleteEntry
}
// DeleteEntry 是扩展删除格式里的 12 字节 entry。
type DeleteEntry struct {
OpType int16
SlotIndex int16
ItemID int32
DeleteCount int32
}
// DeleteOrSellRequest 描述删除/出售共用的短格式请求。
type DeleteOrSellRequest struct {
HasListType bool
ListType byte
SlotIndex int16
Count int16
}
// MoveItemspaceRequest 描述物品移动/拆分请求。
type MoveItemspaceRequest struct {
SourceListType byte
SourceSlotIndex int16
SourceInstanceValue int32
MoveCount int32
DestinationListType byte
DestinationSlotIndex int16
DestinationInstanceValue int32
DestinationStack int32
ActorIndex int32
TrailingState0 byte
TrailingState1 byte
}
// SortItemRequest 描述整理背包请求。
type SortItemRequest struct {
ListType byte
Category byte
Condition byte
}
// BuyItemRequest 描述 NPC 购买请求。
type BuyItemRequest struct {
ItemTemplateID int32
Count int32
}
// RepairEquipmentRequest 描述装备维修请求。
type RepairEquipmentRequest struct {
InvenType byte
SlotIndex int16
RepairItemSlot int16
AutoRepair bool
QuickRepair bool
}
// DisjointItemRequest 描述物品分解请求。
type DisjointItemRequest struct {
TargetSlotIndex int16
ItemSpace byte
DisjointItemSlotIndex int16
ContextValue int32
}
// UseStackableRequest 描述使用消耗品请求。
type UseStackableRequest struct {
SlotIndex int16
ListType byte
InstanceValue int32
ItemCode int32
Reserved uint32
}
// EventServerLevelUpRequest is the current NoPack op418 request. The client
// sends only the durable inventory location; item identity is resolved from
// the repository and revalidated inside the atomic level-up settlement.
type EventServerLevelUpRequest struct {
SlotIndex int16
ListType byte
}
// IncreaseStatusRequest is the current NoPack op30
// (ENUM_CMDPACKET_INCREASE_STATUS) request the client sends when an SP/TP
// skill book or a character experience book is double-clicked. Its body is a
// bare main-inventory slot: no list owner, no instance value and no item
// template, so identity is resolved from the durable row and revalidated
// inside the atomic settlement, exactly like op418.
type IncreaseStatusRequest struct {
SlotIndex int16
}
// UseSharedEffectItemRequest describes the current class-1/type251 request.
// Current-client evidence shows a compact six-byte body: item template u32,
// source slot byte, then the inventory list byte.
type UseSharedEffectItemRequest struct {
SlotIndex int16
ItemTemplateID int32
ListType byte
}
type UseStackableActionRequest struct {
SourceSlotIndex int16
ListType byte
Reserved0 uint32
ActionIndex uint32
Reserved1 uint32
Reserved2 uint32
}
type SelectDamageFontRequest struct {
FontIndex uint16
}
// ContainerUpgradeRequest is the current-EXE op430/op408 body: one i16 slot.
//
// 现场 2026-10-03(conn game-000124,测试x = char 11)抓到的就是 2 字节:
//
// op430 ENUM_CMDPACKET_UPGRADE_INVENTORY body=`4200`(券在背包槽 66)
// op408 ENUM_CMDPACKET_UPGRADE_CARGO body=`4400`(券在背包槽 68)
//
// 官方服务端(df_game_r(1) 带符号)同名分发器读的也是 `PacketBuf::get_short`,之后
// 用 INVEN_TYPE=1 去取槽位物品,本字段口径与它一致。客户端只发背包(list 0)里的券。
type ContainerUpgradeRequest struct {
SlotIndex int16
ListType byte
}
// UpgradeItemRequest 描述强化/增幅请求。
type UpgradeItemRequest struct {
Mode string
RawMode uint16
// TargetListType is the high byte of the op50 mode word: 0 for a backpack
// row, 3 for a worn equipment slot.
TargetListType byte
TargetSlotIndex int16
TargetItemTemplateID int32
MaterialSlotIndex int16
OptionalTicketSlotIndex int16
TargetItemName string
// HighUpgrade is op50 sub mode 2: the 高级装备强化机, fed with 高级炉岩炭
// instead of the ordinary 无色小晶块. The mode is otherwise a reinforce.
HighUpgrade bool
}
// ChronicleGrowthRequest is the fixed current-EXE class1/op271 body.
// TargetTaggedValue is independent from authoritative raw+0x58 and is kept
// only for protocol completeness.
type ChronicleGrowthRequest struct {
TicketSlotIndex int16
TicketItemCode int32
TargetSlotIndex int16
TargetItemCode int32
TargetTaggedValue byte
MaterialSlotIndex int16
MaterialItemCode int32
}
// SeparateUpgradeRequest is the current NoPack class1/op438 weapon-forging
// request. The item name is a u32 byte-length DSTR, followed by one independent
// trailing byte.
type SeparateUpgradeRequest struct {
TargetListType byte
TargetSlotIndex int16
TargetItemTemplateID int32
MaterialSlotIndex int16
TargetItemName string
TrailingFlag byte
}
// ChronicleRefineRequest is the exact current NoPack class1/op370 body.
type ChronicleRefineRequest struct {
MaterialSlotIndex int16
MaterialItemTemplateID int32
TargetListType byte
TargetSlotIndex int16
TargetItemTemplateID int32
OptionNo byte
}
// EnchantByBeadRequest 描述宝珠附魔请求。
type EnchantByBeadRequest struct {
BeadListType byte
BeadSlotIndex int16
TargetListType byte
TargetSlotIndex int16
}
// MonsterCardUpgradeRequest is the exact current NoPack op603 body observed
// from the Abello card-upgrade window.
type MonsterCardUpgradeRequest struct {
ListType byte
TargetSlot int16
MaterialCount int16
MaterialSlot int16
}
// DecodeDeleteRequest 解析删除物品请求,兼容 C# 里 simple 与 extended 两种格式。
func DecodeDeleteRequest(body []byte) (DeleteRequest, error) {
// Skill-material consumption appends a one-byte state marker (0x01)
// after the declared delete protobuf. Keep this exception local to op18.
if payload, ok := unwrapCurrentDeleteStateTrailer(body); ok {
parsed, err := decodeCurrentDeleteRequest(payload)
if err != nil {
return DeleteRequest{}, err
}
parsed.StateTrailer = true
return parsed, nil
}
if payload, current, err := unwrapCurrentProtobufBody(body); current {
if err != nil {
return DeleteRequest{}, err
}
return decodeCurrentDeleteRequest(payload)
}
if len(body) < 4 {
return DeleteRequest{}, shortBodyError(len(body), 4)
}
if len(body) >= 15 && body[1] >= 1 && body[1] <= maxDeleteEntries {
count := int(body[1])
want := 2 + count*12
if len(body) < want {
return DeleteRequest{}, shortBodyError(len(body), want)
}
entries := make([]DeleteEntry, 0, count)
for i := 0; i < count; i++ {
offset := 2 + i*12
entries = append(entries, DeleteEntry{
OpType: readI16(body, offset),
SlotIndex: readI16(body, offset+2),
ItemID: readI32(body, offset+4),
DeleteCount: readI32(body, offset+8),
})
}
return DeleteRequest{Extended: true, ListType: body[0], Entries: entries}, nil
}
simple, err := DecodeDeleteOrSellRequest(body)
if err != nil {
return DeleteRequest{}, err
}
return DeleteRequest{
ListType: simple.ListType,
SlotIndex: simple.SlotIndex,
Count: simple.Count,
}, nil
}
// DecodeDeleteOrSellRequest 解析删除/出售短格式;首字节是已知 list type 时使用带 list 格式。
func unwrapCurrentDeleteStateTrailer(body []byte) ([]byte, bool) {
if len(body) < 5 {
return nil, false
}
declared := uint64(binary.LittleEndian.Uint32(body[:4]))
available := uint64(len(body) - 4)
if declared+1 != available || body[len(body)-1] != 1 || declared > uint64(len(body)-5) {
return nil, false
}
return body[4 : 4+int(declared)], true
}
func DecodeDeleteOrSellRequest(body []byte) (DeleteOrSellRequest, error) {
if len(body) < 4 {
return DeleteOrSellRequest{}, shortBodyError(len(body), 4)
}
if len(body) >= 5 && isKnownListType(body[0]) {
return DeleteOrSellRequest{
HasListType: true,
ListType: body[0],
SlotIndex: readI16(body, 1),
Count: readI16(body, 3),
}, nil
}
return DeleteOrSellRequest{
ListType: listTypeMain,
SlotIndex: readI16(body, 0),
Count: readI16(body, 2),
}, nil
}
// DecodeMoveItemspaceRequest decodes the 28-byte layout emitted by the current
// NoPack.exe senders sub_2326C90, sub_232EA00, and sub_1B33B40.
func DecodeMoveItemspaceRequest(body []byte) (MoveItemspaceRequest, error) {
const currentMoveItemspaceBodySize = 28
if len(body) != currentMoveItemspaceBodySize {
return MoveItemspaceRequest{}, fmt.Errorf("move itemspace body length %d, want %d", len(body), currentMoveItemspaceBodySize)
}
return MoveItemspaceRequest{
SourceListType: body[0],
SourceSlotIndex: readI16(body, 1),
SourceInstanceValue: readI32(body, 3),
MoveCount: readI32(body, 7),
DestinationListType: body[11],
DestinationSlotIndex: readI16(body, 12),
DestinationInstanceValue: readI32(body, 14),
DestinationStack: readI32(body, 18),
ActorIndex: readI32(body, 22),
TrailingState0: body[26],
TrailingState1: body[27],
}, nil
}
// DecodeSortItemRequest 解析整理背包请求。
func DecodeSortItemRequest(body []byte) (SortItemRequest, error) {
if payload, current, err := unwrapCurrentProtobufBody(body); current {
if err != nil {
return SortItemRequest{}, err
}
return decodeCurrentSortItemRequest(payload)
}
if len(body) < 2 {
return SortItemRequest{}, shortBodyError(len(body), 2)
}
req := SortItemRequest{ListType: body[0], Category: body[1]}
if len(body) > 2 {
req.Condition = body[2]
}
return req, nil
}
// unwrapCurrentProtobufBody recognizes the current u32-length-prefixed body.
// One zero byte after the protobuf is accepted because current delete senders
// append that terminator. Legacy S4A12 bodies are left to the old decoders.
func unwrapCurrentProtobufBody(body []byte) ([]byte, bool, error) {
if len(body) < 4 {
return nil, false, nil
}
declared := uint64(binary.LittleEndian.Uint32(body[:4]))
available := uint64(len(body) - 4)
if declared == available {
return body[4:], true, nil
}
if declared+1 == available {
if body[len(body)-1] != 0 {
return nil, true, fmt.Errorf("protobuf body has non-zero terminator 0x%02X", body[len(body)-1])
}
return body[4 : len(body)-1], true, nil
}
// Current inventory protobufs are small and therefore have a zero upper
// length prefix. Treat a mismatch as truncation instead of unsafe fallback.
if body[1] == 0 && body[2] == 0 && body[3] == 0 {
return nil, true, fmt.Errorf("protobuf body length %d, want %d or %d with zero terminator", available, declared, declared+1)
}
return nil, false, nil
}
func decodeCurrentSortItemRequest(payload []byte) (SortItemRequest, error) {
var req SortItemRequest
var seen uint8
for len(payload) > 0 {
number, wireType, n := protowire.ConsumeTag(payload)
if n < 0 {
return SortItemRequest{}, fmt.Errorf("sort protobuf tag: %w", protowire.ParseError(n))
}
payload = payload[n:]
if wireType != protowire.VarintType || number < 2 || number > 4 {
return SortItemRequest{}, fmt.Errorf("sort protobuf field %d has unsupported wire type %d", number, wireType)
}
value, n := protowire.ConsumeVarint(payload)
if n < 0 {
return SortItemRequest{}, fmt.Errorf("sort protobuf field %d: %w", number, protowire.ParseError(n))
}
payload = payload[n:]
if value > math.MaxUint8 {
return SortItemRequest{}, fmt.Errorf("sort protobuf field %d value %d overflows byte", number, value)
}
bit := uint8(1 << (number - 2))
if seen&bit != 0 {
return SortItemRequest{}, fmt.Errorf("sort protobuf field %d is duplicated", number)
}
seen |= bit
switch number {
case 2:
req.ListType = byte(value)
case 3:
req.Category = byte(value)
case 4:
req.Condition = byte(value)
}
}
if seen != 0x07 {
return SortItemRequest{}, fmt.Errorf("sort protobuf required fields missing: mask=0x%02X", seen)
}
return req, nil
}
func decodeCurrentDeleteRequest(payload []byte) (DeleteRequest, error) {
var req DeleteRequest
req.Extended = true
var seenListType, seenCondition bool
for len(payload) > 0 {
number, wireType, n := protowire.ConsumeTag(payload)
if n < 0 {
return DeleteRequest{}, fmt.Errorf("delete protobuf tag: %w", protowire.ParseError(n))
}
payload = payload[n:]
switch number {
case 2, 4:
if wireType != protowire.VarintType {
return DeleteRequest{}, fmt.Errorf("delete protobuf field %d has wire type %d, want varint", number, wireType)
}
value, consumed := protowire.ConsumeVarint(payload)
if consumed < 0 {
return DeleteRequest{}, fmt.Errorf("delete protobuf field %d: %w", number, protowire.ParseError(consumed))
}
payload = payload[consumed:]
if number == 2 {
if seenListType || value > math.MaxUint8 {
return DeleteRequest{}, fmt.Errorf("delete protobuf list type is duplicated or invalid: %d", value)
}
req.ListType = byte(value)
seenListType = true
} else {
if seenCondition || value != 0 {
return DeleteRequest{}, fmt.Errorf("delete protobuf condition is duplicated or non-zero: %d", value)
}
seenCondition = true
}
case 3:
if wireType != protowire.BytesType {
return DeleteRequest{}, fmt.Errorf("delete protobuf field 3 has wire type %d, want bytes", wireType)
}
if len(req.Entries) >= maxDeleteEntries {
return DeleteRequest{}, fmt.Errorf("delete protobuf has more than %d entries", maxDeleteEntries)
}
nested, consumed := protowire.ConsumeBytes(payload)
if consumed < 0 {
return DeleteRequest{}, fmt.Errorf("delete protobuf entry: %w", protowire.ParseError(consumed))
}
payload = payload[consumed:]
entry, err := decodeCurrentDeleteEntry(nested)
if err != nil {
return DeleteRequest{}, err
}
req.Entries = append(req.Entries, entry)
default:
return DeleteRequest{}, fmt.Errorf("delete protobuf field %d is unsupported", number)
}
}
if !seenListType || !seenCondition || len(req.Entries) == 0 {
return DeleteRequest{}, fmt.Errorf("delete protobuf required fields missing: list=%t entries=%d condition=%t", seenListType, len(req.Entries), seenCondition)
}
return req, nil
}
func decodeCurrentDeleteEntry(payload []byte) (DeleteEntry, error) {
var entry DeleteEntry
var seen uint8
for len(payload) > 0 {
number, wireType, n := protowire.ConsumeTag(payload)
if n < 0 {
return DeleteEntry{}, fmt.Errorf("delete entry protobuf tag: %w", protowire.ParseError(n))
}
payload = payload[n:]
if wireType != protowire.VarintType || number < 1 || number > 4 {
return DeleteEntry{}, fmt.Errorf("delete entry protobuf field %d has unsupported wire type %d", number, wireType)
}
value, consumed := protowire.ConsumeVarint(payload)
if consumed < 0 {
return DeleteEntry{}, fmt.Errorf("delete entry protobuf field %d: %w", number, protowire.ParseError(consumed))
}
payload = payload[consumed:]
bit := uint8(1 << (number - 1))
if seen&bit != 0 {
return DeleteEntry{}, fmt.Errorf("delete entry protobuf field %d is duplicated", number)
}
seen |= bit
switch number {
case 1:
if value > math.MaxInt16 {
return DeleteEntry{}, fmt.Errorf("delete entry op type %d overflows int16", value)
}
entry.OpType = int16(value)
case 2:
if value > math.MaxInt16 {
return DeleteEntry{}, fmt.Errorf("delete entry slot %d overflows int16", value)
}
entry.SlotIndex = int16(value)
case 3:
if value == 0 || value > math.MaxInt32 {
return DeleteEntry{}, fmt.Errorf("delete entry item id %d is invalid", value)
}
entry.ItemID = int32(value)
case 4:
if value == 0 || value > math.MaxInt32 {
return DeleteEntry{}, fmt.Errorf("delete entry count %d is invalid", value)
}
entry.DeleteCount = int32(value)
}
}
if seen != 0x0F {
return DeleteEntry{}, fmt.Errorf("delete entry protobuf required fields missing: mask=0x%02X", seen)
}
return entry, nil
}
// DecodeBuyItemRequest 解析 NPC 购买请求。
func DecodeBuyItemRequest(body []byte) (BuyItemRequest, error) {
if len(body) < 4 {
return BuyItemRequest{}, shortBodyError(len(body), 4)
}
req := BuyItemRequest{ItemTemplateID: readI32(body, 0), Count: 1}
if len(body) >= 8 {
req.Count = readI32(body, 4)
if req.Count <= 0 {
req.Count = 1
}
}
return req, nil
}
// DecodeRepairEquipmentRequest 解析装备维修请求。
func DecodeRepairEquipmentRequest(body []byte) (RepairEquipmentRequest, error) {
if len(body) < 5 {
return RepairEquipmentRequest{}, shortBodyError(len(body), 5)
}
return RepairEquipmentRequest{
InvenType: body[0],
SlotIndex: readI16(body, 1),
RepairItemSlot: readI16(body, 3),
AutoRepair: len(body) >= 6 && body[5] == 1,
QuickRepair: len(body) >= 8 && body[7] == 1,
}, nil
}
// DecodeDisjointItemRequest 解析物品分解请求。
func DecodeDisjointItemRequest(body []byte) (DisjointItemRequest, error) {
if len(body) < 5 {
return DisjointItemRequest{}, shortBodyError(len(body), 5)
}
req := DisjointItemRequest{
TargetSlotIndex: readI16(body, 0),
ItemSpace: body[2],
DisjointItemSlotIndex: readI16(body, 3),
}
if len(body) >= 9 {
req.ContextValue = readI32(body, 5)
}
if req.TargetSlotIndex < 0 {
return DisjointItemRequest{}, fmt.Errorf("target slot %d invalid", req.TargetSlotIndex)
}
return req, nil
}
// DecodeUseStackableRequest 解析使用消耗品请求。
func DecodeUseStackableRequest(body []byte) (UseStackableRequest, error) {
const currentUseStackableBodySize = 15
if len(body) != currentUseStackableBodySize {
return UseStackableRequest{}, fmt.Errorf("use stackable body length %d, want %d", len(body), currentUseStackableBodySize)
}
req := UseStackableRequest{
SlotIndex: readI16(body, 0),
ListType: body[2],
InstanceValue: readI32(body, 3),
ItemCode: readI32(body, 7),
Reserved: readU32(body, 11),
}
if req.SlotIndex < 0 {
return UseStackableRequest{}, fmt.Errorf("use stackable slot %d invalid", req.SlotIndex)
}
if req.InstanceValue == 0 || req.ItemCode <= 0 {
return UseStackableRequest{}, fmt.Errorf("use stackable identity invalid: instance=0x%08X item=%d", uint32(req.InstanceValue), req.ItemCode)
}
if req.Reserved != 0 {
return UseStackableRequest{}, fmt.Errorf("use stackable reserved value 0x%08X, want 0", req.Reserved)
}
return req, nil
}
// DecodeEventServerLevelUpRequest parses the exact current NoPack op418 body:
// u16 slot followed by u8 inventory list.
func DecodeEventServerLevelUpRequest(body []byte) (EventServerLevelUpRequest, error) {
const bodySize = 3
if len(body) != bodySize {
return EventServerLevelUpRequest{}, fmt.Errorf("event server level-up body length %d, want %d", len(body), bodySize)
}
req := EventServerLevelUpRequest{
SlotIndex: readI16(body, 0),
ListType: body[2],
}
if req.SlotIndex < 0 {
return EventServerLevelUpRequest{}, fmt.Errorf("event server level-up slot %d invalid", req.SlotIndex)
}
if req.ListType != listTypeMain {
return EventServerLevelUpRequest{}, fmt.Errorf("event server level-up list %d, want main inventory", req.ListType)
}
return req, nil
}
// DecodeIncreaseStatusRequest parses the exact current NoPack op30 body: one
// u16 main-inventory slot. Two live captures carried the double-clicked books'
// own slots (0x42 and 0x43) and nothing else.
func DecodeIncreaseStatusRequest(body []byte) (IncreaseStatusRequest, error) {
const bodySize = 2
if len(body) != bodySize {
return IncreaseStatusRequest{}, fmt.Errorf("increase status body length %d, want %d", len(body), bodySize)
}
req := IncreaseStatusRequest{SlotIndex: readI16(body, 0)}
if req.SlotIndex < 0 {
return IncreaseStatusRequest{}, fmt.Errorf("increase status slot %d invalid", req.SlotIndex)
}
return req, nil
}
// DecodeUseSharedEffectItemRequest parses the exact current type251 body.
func DecodeUseSharedEffectItemRequest(body []byte) (UseSharedEffectItemRequest, error) {
const bodySize = 6
if len(body) != bodySize {
return UseSharedEffectItemRequest{}, fmt.Errorf("use shared effect item body length %d, want %d", len(body), bodySize)
}
req := UseSharedEffectItemRequest{
ItemTemplateID: readI32(body, 0),
SlotIndex: int16(body[4]),
ListType: body[5],
}
if req.ItemTemplateID <= 0 {
return UseSharedEffectItemRequest{}, fmt.Errorf("use shared effect item template invalid: 0x%08X", uint32(req.ItemTemplateID))
}
if err := checkUseStackableListType(req.ListType); err != nil {
return UseSharedEffectItemRequest{}, err
}
return req, nil
}
func DecodeUseStackableActionRequest(body []byte) (UseStackableActionRequest, error) {
const bodySize = 19
if len(body) != bodySize {
return UseStackableActionRequest{}, fmt.Errorf("use stackable action body length %d, want %d", len(body), bodySize)
}
req := UseStackableActionRequest{
SourceSlotIndex: readI16(body, 0),
ListType: body[2],
Reserved0: readU32(body, 3),
ActionIndex: readU32(body, 7),
Reserved1: readU32(body, 11),
Reserved2: readU32(body, 15),
}
if req.SourceSlotIndex < 0 {
return UseStackableActionRequest{}, fmt.Errorf("use stackable action slot %d invalid", req.SourceSlotIndex)
}
if req.ListType != listTypeMain {
return UseStackableActionRequest{}, fmt.Errorf("use stackable action list %d, want %d", req.ListType, listTypeMain)
}
if req.ActionIndex != dungeonAdmissionTicketActionIndex && req.ActionIndex != damageFontActionIndex &&
req.ActionIndex != questCompletionTicketActionIndex && req.ActionIndex != fatiguePotionActionIndex &&
req.ActionIndex != rentalPointActionIndex && req.ActionIndex != accountCargoUpgradeActionIndex {
return UseStackableActionRequest{}, fmt.Errorf("use stackable action index %d unsupported", req.ActionIndex)
}
if req.Reserved0 != 0 || req.Reserved1 != 0 || req.Reserved2 != 0 {
return UseStackableActionRequest{}, fmt.Errorf("use stackable action reserved values (%d,%d,%d), want all zero", req.Reserved0, req.Reserved1, req.Reserved2)
}
return req, nil
}
func DecodeSelectDamageFontRequest(body []byte) (SelectDamageFontRequest, error) {
if len(body) != 2 {
return SelectDamageFontRequest{}, fmt.Errorf("select damage font body length %d, want 2", len(body))
}
return SelectDamageFontRequest{FontIndex: readU16(body, 0)}, nil
}
// DecodeContainerUpgradeRequest 解析 op430/op408 的扩容券请求:只有一个 i16 槽位。
//
// 严格 2 字节:现场 11 次 op430 + 1 次 op408 全是这个形状,多一个字节就说明这不是
// 我们证过的那条路,宁可拒绝也不猜。槽位必须落在背包主表(list 0)且不是账号共享
// 固定槽(354..365),因为券只可能躺在普通背包格里。
func DecodeContainerUpgradeRequest(body []byte) (ContainerUpgradeRequest, error) {
const bodySize = 2
if len(body) != bodySize {
return ContainerUpgradeRequest{}, fmt.Errorf("container upgrade body length %d, want %d", len(body), bodySize)
}
req := ContainerUpgradeRequest{
SlotIndex: readI16(body, 0),
ListType: listTypeMain,
}
if req.SlotIndex < 0 {
return ContainerUpgradeRequest{}, fmt.Errorf("container upgrade slot %d invalid", req.SlotIndex)
}
if dnfrepo.IsAccountSharedInventorySlot(req.ListType, req.SlotIndex) {
return ContainerUpgradeRequest{}, fmt.Errorf("container upgrade slot %d is an account-shared fixed slot", req.SlotIndex)
}
return req, nil
}
// DecodeUpgradeItemRequest 解析强化/增幅请求。
func DecodeUpgradeItemRequest(body []byte) (UpgradeItemRequest, error) {
if len(body) < 16 {
return UpgradeItemRequest{}, shortBodyError(len(body), 16)
}
// The current client packs the two-byte mode word as target list (high
// byte) + sub mode (low byte): 0x0000 list-0 reinforce, 0x0001 list-0
// amplify, 0x0300 worn list-3 reinforce, 0x0301 worn list-3 amplify.
// Reading it as one flat u16 and refusing anything above 1 rejected every
// upgrade on a worn item at decode with "raw mode 768 invalid".
rawMode := readU16(body, 0)
listType := byte(rawMode >> 8)
subMode := byte(rawMode & 0xFF)
// 公会徽章 (list 38) is a reinforce-only path. Upstream refuses list 38 with
// an amplify sub mode and confines it to the medal page slots 0..48: the
// native flag_upgrade_table is keyed by flag level, so there is no row for
// an amplify and nothing outside the page to address.
targetSlot := readI16(body, 2)
guildMedal := listType == listTypeGuildMedal && subMode == 0 && targetSlot >= 0 && targetSlot <= 48
if listType != listTypeMain && listType != listTypeEquipment && !guildMedal {
return UpgradeItemRequest{}, fmt.Errorf("raw mode 0x%04X invalid: target list %d unsupported", rawMode, listType)
}
// Sub mode 2 is the 高级装备强化机: the client names the 高级炉岩炭 stack in
// the material slot instead of the 无色小晶块 the ordinary machine uses.
// Live capture 2026-09-11 13:35:37, mode 0x0002, material slot 126 holding
// item 2681644 (高级炉岩炭). It was rejected outright until then, so the
// machine had never worked on this server.
if subMode > 2 {
return UpgradeItemRequest{}, fmt.Errorf("raw mode 0x%04X invalid: sub mode %d unsupported", rawMode, subMode)
}
nameLength := readI32(body, 12)
if nameLength < 0 {
return UpgradeItemRequest{}, fmt.Errorf("name length %d invalid", nameLength)
}
end := 16 + int(nameLength)
if len(body) < end {
return UpgradeItemRequest{}, shortBodyError(len(body), end)
}
mode := "reinforce"
if subMode == 1 {
mode = "amplify"
}
return UpgradeItemRequest{
Mode: mode,
HighUpgrade: subMode == 2,
RawMode: rawMode,
TargetListType: listType,
TargetSlotIndex: targetSlot,
TargetItemTemplateID: readI32(body, 4),
MaterialSlotIndex: readI16(body, 8),
OptionalTicketSlotIndex: readI16(body, 10),
TargetItemName: string(body[16:end]),
}, nil
}
// DecodeSeparateUpgradeRequest decodes the exact current-client op438 body:
// u8 list, i16 target slot, i32 item id, i16 material slot, DSTR name, u8 tail.
func DecodeChronicleGrowthRequest(body []byte) (ChronicleGrowthRequest, error) {
if len(body) != 19 {
return ChronicleGrowthRequest{}, fmt.Errorf("chronicle growth body length %d, want 19", len(body))
}
req := ChronicleGrowthRequest{
TicketSlotIndex: readI16(body, 0),
TicketItemCode: readI32(body, 2),
TargetSlotIndex: readI16(body, 6),
TargetItemCode: readI32(body, 8),
TargetTaggedValue: body[12],
MaterialSlotIndex: readI16(body, 13),
MaterialItemCode: readI32(body, 15),
}
if req.TicketSlotIndex < 0 || req.TargetSlotIndex < 0 ||
req.TicketItemCode <= 0 || req.TargetItemCode <= 0 {
return ChronicleGrowthRequest{}, fmt.Errorf("chronicle growth request contains invalid slot/id")
}
// Equipment-emancipation books own a multi-material recipe, so the current
// client cannot name one material stack in this fixed body. It sends the
// native (-1, 0xffff) auto-material sentinel instead. Ordinary Chronicle
// growth still names one concrete material stack and item code.
autoMaterials := req.MaterialSlotIndex == -1 && req.MaterialItemCode == 0xffff
explicitMaterial := req.MaterialSlotIndex >= 0 && req.MaterialItemCode > 0
if !autoMaterials && !explicitMaterial {
return ChronicleGrowthRequest{}, fmt.Errorf("chronicle growth request contains invalid material slot/id")
}
if req.TicketSlotIndex == req.TargetSlotIndex ||
(req.MaterialSlotIndex >= 0 && (req.MaterialSlotIndex == req.TargetSlotIndex || req.MaterialSlotIndex == req.TicketSlotIndex)) {
return ChronicleGrowthRequest{}, fmt.Errorf("chronicle growth slots overlap")
}
return req, nil
}
func DecodeSeparateUpgradeRequest(body []byte) (SeparateUpgradeRequest, error) {
const fixedSizeBeforeName = 13
const minimumBodySize = fixedSizeBeforeName + 1
if len(body) < minimumBodySize {
return SeparateUpgradeRequest{}, shortBodyError(len(body), minimumBodySize)
}
nameLength := binary.LittleEndian.Uint32(body[9:13])
if uint64(nameLength) > uint64(len(body)-minimumBodySize) {
return SeparateUpgradeRequest{}, fmt.Errorf("separate upgrade name length %d exceeds body %d", nameLength, len(body))
}
nameEnd := fixedSizeBeforeName + int(nameLength)
if nameEnd+1 != len(body) {
return SeparateUpgradeRequest{}, fmt.Errorf("separate upgrade body length %d, want %d from DSTR", len(body), nameEnd+1)
}
return SeparateUpgradeRequest{
TargetListType: body[0],
TargetSlotIndex: readI16(body, 1),
TargetItemTemplateID: readI32(body, 3),
MaterialSlotIndex: readI16(body, 7),
TargetItemName: string(body[fixedSizeBeforeName:nameEnd]),
TrailingFlag: body[nameEnd],
}, nil
}
// DecodeChronicleRefineRequest decodes the fixed 14-byte body emitted by
// SendChronicleRefineRequest_Op370 in the current client.
func DecodeChronicleRefineRequest(body []byte) (ChronicleRefineRequest, error) {
const bodySize = 14
if len(body) != bodySize {
return ChronicleRefineRequest{}, fmt.Errorf("chronicle refine body length %d, want %d", len(body), bodySize)
}
return ChronicleRefineRequest{
MaterialSlotIndex: readI16(body, 0),
MaterialItemTemplateID: readI32(body, 2),
TargetListType: body[6],
TargetSlotIndex: readI16(body, 7),
TargetItemTemplateID: readI32(body, 9),
OptionNo: body[13],
}, nil
}
// DecodeEnchantByBeadRequest 解析宝珠附魔请求。
func DecodeEnchantByBeadRequest(body []byte) (EnchantByBeadRequest, error) {
if len(body) < 6 {
return EnchantByBeadRequest{}, shortBodyError(len(body), 6)
}
return EnchantByBeadRequest{
BeadListType: body[0],
BeadSlotIndex: readI16(body, 1),
TargetListType: body[3],
TargetSlotIndex: readI16(body, 4),
}, nil
}
// DecodeMonsterCardUpgradeRequest decodes the fixed seven-byte op603 body:
// u8 inventory list, i16 target slot, i16 material count, i16 material slot.
func DecodeMonsterCardUpgradeRequest(body []byte) (MonsterCardUpgradeRequest, error) {
if len(body) != 7 {
return MonsterCardUpgradeRequest{}, fmt.Errorf("monster card upgrade body=%d want=7", len(body))
}
return MonsterCardUpgradeRequest{
ListType: body[0],
TargetSlot: readI16(body, 1),
MaterialCount: readI16(body, 3),
MaterialSlot: readI16(body, 5),
}, nil
}
func (r DeleteRequest) String() string {
if r.Extended {
return fmt.Sprintf("extended list=%d entries=%d", r.ListType, len(r.Entries))
}
return fmt.Sprintf("list=%d slot=%d count=%d", r.ListType, r.SlotIndex, r.Count)
}
func (r DeleteOrSellRequest) String() string {
return fmt.Sprintf("hasList=%t list=%d slot=%d count=%d", r.HasListType, r.ListType, r.SlotIndex, r.Count)
}
func (r MoveItemspaceRequest) String() string {
return fmt.Sprintf("src=(%d,%d,0x%08X) count=%d dst=(%d,%d,0x%08X) dstStack=%d actor=%d tail=(%d,%d)", r.SourceListType, r.SourceSlotIndex, uint32(r.SourceInstanceValue), r.MoveCount, r.DestinationListType, r.DestinationSlotIndex, uint32(r.DestinationInstanceValue), r.DestinationStack, r.ActorIndex, r.TrailingState0, r.TrailingState1)
}
func (r SortItemRequest) String() string {
return fmt.Sprintf("list=%d category=%d condition=%d", r.ListType, r.Category, r.Condition)
}
func (r BuyItemRequest) String() string {
return fmt.Sprintf("item=%d count=%d", r.ItemTemplateID, r.Count)
}
func (r RepairEquipmentRequest) String() string {
return fmt.Sprintf("inven=%d slot=%d repairSlot=%d auto=%t quick=%t", r.InvenType, r.SlotIndex, r.RepairItemSlot, r.AutoRepair, r.QuickRepair)
}
func (r DisjointItemRequest) String() string {
return fmt.Sprintf("targetSlot=%d itemSpace=%d disjointSlot=%d ctx=0x%08X", r.TargetSlotIndex, r.ItemSpace, r.DisjointItemSlotIndex, uint32(r.ContextValue))
}
func (r UseStackableRequest) String() string {
return fmt.Sprintf("slot=%d list=%d instance=0x%08X item=0x%08X reserved=0x%08X", r.SlotIndex, r.ListType, uint32(r.InstanceValue), uint32(r.ItemCode), r.Reserved)
}
func (r EventServerLevelUpRequest) String() string {
return fmt.Sprintf("slot=%d list=%d", r.SlotIndex, r.ListType)
}
func (r UseSharedEffectItemRequest) String() string {
return fmt.Sprintf("slot=%d list=%d item=0x%08X", r.SlotIndex, r.ListType, uint32(r.ItemTemplateID))
}
func (r UseStackableActionRequest) String() string {
return fmt.Sprintf("slot=%d list=%d action=%d reserved=(%d,%d,%d)", r.SourceSlotIndex, r.ListType, r.ActionIndex, r.Reserved0, r.Reserved1, r.Reserved2)
}
func (r SelectDamageFontRequest) String() string {
return fmt.Sprintf("font=%d", r.FontIndex)
}
func (r UpgradeItemRequest) String() string {
return fmt.Sprintf("mode=%s target=(%d,%d) material=%d ticket=%d name=%q", r.Mode, r.TargetSlotIndex, r.TargetItemTemplateID, r.MaterialSlotIndex, r.OptionalTicketSlotIndex, r.TargetItemName)
}
func (r SeparateUpgradeRequest) String() string {
return fmt.Sprintf("target=(%d,%d,%d) material=%d name=%q tail=%d", r.TargetListType, r.TargetSlotIndex, r.TargetItemTemplateID, r.MaterialSlotIndex, r.TargetItemName, r.TrailingFlag)
}
func (r EnchantByBeadRequest) String() string {
return fmt.Sprintf("bead=(%d,%d) target=(%d,%d)", r.BeadListType, r.BeadSlotIndex, r.TargetListType, r.TargetSlotIndex)
}
func isKnownListType(v byte) bool {
switch v {
case listTypeMain, listTypeAvatar, listTypePersonalCargo, listTypeEquipment, listTypePet, listTypeAccountCargo, listTypeGuildMedal:
return true
default:
return false
}
}
func shortBodyError(got int, want int) error {
return fmt.Errorf("body too short: got %d want >= %d", got, want)
}
func readI16(body []byte, offset int) int16 {
return int16(binary.LittleEndian.Uint16(body[offset:]))
}
func readU16(body []byte, offset int) uint16 {
return binary.LittleEndian.Uint16(body[offset:])
}
func readI32(body []byte, offset int) int32 {
return int32(binary.LittleEndian.Uint32(body[offset:]))
}
func readU32(body []byte, offset int) uint32 {
return binary.LittleEndian.Uint32(body[offset:])
}