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

896 lines
34 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.
// 本文件负责背包类命令的旧客户端回包 body 编码。
// 这里只写 wire 字节顺序,不做背包、金币、宠物或装备业务判定。
package inventory
import (
"encoding/binary"
"encoding/hex"
"sort"
"strconv"
"strings"
"time"
"google.golang.org/protobuf/encoding/protowire"
"longheng.io/server/internal/modules/dnf/itemquality"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
msgItemListRefresh uint16 = 0x000D
msgItemListUpdate uint16 = 0x000E
msgUpgradeNotice uint16 = 0x0056
// premiumActivatedNotifyMsgID is the NOTI 0x0042 premium-activated
// notification emitted after a contract item activation (86JP
// PremiumService.ActivateAndNotify).
premiumActivatedNotifyMsgID uint16 = 0x0042
currentItemListEntrySize = 0x77
currentPetRemainSecondsOffset = 0x16
currentItemListExpireTimeOffset = 0x38
legacyWrongItemExpireTimeOffset = 0x2B
)
type packetWriter struct {
data []byte
}
func (w *packetWriter) writeByte(value byte) {
w.data = append(w.data, value)
}
func (w *packetWriter) writeUint16(value uint16) {
var buf [2]byte
binary.LittleEndian.PutUint16(buf[:], value)
w.data = append(w.data, buf[:]...)
}
func (w *packetWriter) writeInt16(value int16) {
w.writeUint16(uint16(value))
}
func (w *packetWriter) writeUint32(value uint32) {
var buf [4]byte
binary.LittleEndian.PutUint32(buf[:], value)
w.data = append(w.data, buf[:]...)
}
func (w *packetWriter) writeInt32(value int32) {
w.writeUint32(uint32(value))
}
func (w *packetWriter) writeBytes(value []byte) {
w.data = append(w.data, value...)
}
func (w *packetWriter) bytes() []byte {
return append([]byte(nil), w.data...)
}
func buildDeleteItemAck(items []DeletedItem) []byte {
if len(items) == 0 {
return nil
}
protobuf := protowire.AppendTag(nil, 1, protowire.VarintType)
protobuf = protowire.AppendVarint(protobuf, uint64(items[0].ListType))
for _, item := range items {
nested := protowire.AppendTag(nil, 1, protowire.VarintType)
nested = protowire.AppendVarint(nested, uint64(item.SlotIndex))
nested = protowire.AppendTag(nested, 2, protowire.VarintType)
nested = protowire.AppendVarint(nested, uint64(clampInt32(item.AppliedCount)))
nested = protowire.AppendTag(nested, 3, protowire.VarintType)
nested = protowire.AppendVarint(nested, 0)
protobuf = protowire.AppendTag(protobuf, 2, protowire.BytesType)
protobuf = protowire.AppendBytes(protobuf, nested)
}
protobuf = protowire.AppendTag(protobuf, 3, protowire.VarintType)
protobuf = protowire.AppendVarint(protobuf, 0)
return buildCurrentProtobufAck(protobuf)
}
func buildDungeonSkillMaterialDeleteAck(success bool) []byte {
// Current NoPack sub_1D21830 consumes the result byte, then a u32 protobuf
// length and PB_ENUM_CMDPACKET_DELETE_ITEM.RETURN. Skill casting already
// owns the local removal presentation, so both outcomes use a valid empty
// RETURN. On success the authoritative final quantity follows through op14.
result := byte(0)
if success {
result = 1
}
return []byte{result, 4, 0, 0, 0, 0x08, 0, 0x18, 0}
}
// buildDungeonSkillMaterialDeleteAckWithCode 是同一个失败帧,只把**失败原因码**那一格
// 换成具体码。已确认的线上帧形状是 9 字节 `00 04 00000008 001800`:
//
// [0] result 字节 (0 = 失败)
// [1] 失败原因码 (sub_1D21830 的 [ebp+0x10],查客户端 op18 表;0 = 失败但无原因)
// [2..5] 小端 u32 = 8,其后是 RETURN 消息的字节
//
// 所以整帧是 10 字节,末尾 8 字节是 RETURN。
func buildDungeonSkillMaterialDeleteAckWithCode(code byte) []byte {
return []byte{0, code, 8, 0, 0, 0, 0x08, 0, 0x18, 0}
}
func buildMoveItemspaceSuccessAck(cmd Command, moveValue int32) []byte {
// Current class1/op19 dispatch registers sub_1D2DE80. The class1 result
// marker is consumed before the handler reads these remaining ten bytes.
var writer packetWriter
writer.writeByte(1)
writer.writeByte(cmd.SourceListType)
writer.writeInt16(cmd.SourceSlotIndex)
writer.writeInt32(moveValue)
writer.writeByte(cmd.DestinationListType)
writer.writeInt16(cmd.DestinationSlotIndex)
return writer.bytes()
}
func buildMoveItemspaceFailureAck(cmd Command, errorCode byte) []byte {
// Current class1/op19 handler sub_1D2DE80 receives the leading result and
// error bytes through its dispatcher arguments. Its failure branch then
// reads exactly two u8 list owners from the remaining packet before releasing
// the pending move objects. Supplying both owners avoids the null/stale drag
// path while deliberately omitting every success-only slot/count field.
return []byte{0, errorCode, cmd.SourceListType, cmd.DestinationListType}
}
func buildUseStackableSuccessAck(result UseStackableResult) []byte {
// Current class1/op44 handler sub_1D14380 consumes the result byte before
// reading u16 slot, u8 list, u32 instance value, and u32 item id.
var writer packetWriter
writer.writeByte(1)
writer.writeInt16(result.SlotIndex)
writer.writeByte(result.ListType)
writer.writeInt32(result.InstanceValue)
writer.writeInt32(int32(result.ItemID))
return writer.bytes()
}
func buildUseStackableFailureAck(cmd Command, errorCode byte) []byte {
// Current class1/op44 handler sub_1D14380 consumes the result and error
// bytes before reading u8 list, u32 instance value, and u32 item id. Its
// error-17 branch is intentionally silent, but still releases the client's
// pending item-use state so a stale quick-slot request cannot wedge retries.
// Failure must not echo the request's opaque key/item pair: the native reader
// treats two positive values as an additional item-side operation before it
// releases the list owner.
var writer packetWriter
writer.writeByte(0)
writer.writeByte(errorCode)
writer.writeByte(cmd.SourceListType)
writer.writeInt32(0)
writer.writeInt32(0)
return writer.bytes()
}
func buildEventServerLevelUpSuccessAck(result UseStackableResult) []byte {
// Current NoPack class1/op418 handler sub_1D197C0 consumes the generic
// success byte, then reads u16 slot, u8 list, u32 gained EXP, u16 gained SP,
// and u16 gained TP. These are deltas from the committed progression plan.
var writer packetWriter
writer.writeByte(1)
writer.writeUint16(uint16(result.SlotIndex))
writer.writeByte(result.ListType)
writer.writeInt32(int32(result.DirectExperienceGain))
writer.writeUint16(uint16(result.DirectExperienceSPGain))
writer.writeUint16(uint16(result.DirectExperienceTPGain))
return writer.bytes()
}
func buildEventServerLevelUpFailureAck(errorCode byte) []byte {
// On failure sub_1D197C0 consumes only the generic result and error bytes.
return []byte{0, errorCode}
}
// Current NoPack class1/op30 handler sub_0191EDE0 (registered at 0x019368E9)
// reads u16/u8/u32/u16/u16 through the stream readers 0x030E39E0, 0x030E3970
// and 0x030E3A50, but the stream it receives has already consumed the body's
// first byte: that byte is the class1 result flag, exactly as in the op418 ACK
// above. Omitting it printed an SP+5 gain as 5<<24 = 83886080 on a live client.
//
// kind selects the branch, with the live message text as ground truth for
// string 0x303 ("增加%d点SP点数"):
//
// 0x00 SP gain, amount from the u32, HUD gauge 0x01B44720, string 0x303
// 0x11 TP gain, amount from the u32, HUD gauge 0x01B44780, string 0x331C
// 0x01 level-up, the ONLY branch that reads spGain/tpGain, and it also
// prints string 0x304 unconditionally
// 0x02..0x09 per-stat lines whose amount is the u32 divided by ten
// other values miss the branch chain entirely and print nothing
//
// Skill books therefore use kind 0x00/0x11 with the amount in the u32 and both
// u16 gain fields left at zero. A first attempt used kind 1 with the gain in
// spGain and produced both a misaligned number and a spurious level line.
const (
increaseStatusKindSP byte = 0x00
increaseStatusKindTP byte = 0x11
// Experience books have no op30 branch of their own: the client never
// prints "gained EXP" here. A kind outside the branch chain acknowledges
// the request silently and releases the pending item state, leaving the
// experience bar and the level-up presentation to the following op37.
increaseStatusKindSilent byte = 0xFF
increaseStatusAckBodySize = 12
)
// buildIncreaseStatusSuccessAck writes the current op30 success body. The u16 is
// the SOURCE ITEM SLOT the client refreshes/consumes, exactly like the slot the
// op44 (buildUseStackableSuccessAck) and op418
// (buildEventServerLevelUpSuccessAck) acknowledgements carry in the same
// position.
//
// Client evidence, class1/op30 handler RVA 0x0191EDE0: it reads u16/u8/u32/u16/
// u16, and immediately passes that u16 to the main-inventory finder
// (sub_1F26860, manager at 0x51B2768). That finder is a plain slot-indexed array
// lookup -- it bounds-checks against (end-begin)/4 and then does
// `mov eax, dword ptr [eax + ecx*4]` -- and the handler consumes/refreshes that
// row for EVERY kind before it branches on kind.
//
// This used to be passed the character id. Character id 1 sits at main-inventory
// slot 1, which is the 复活币 wallet, so every SP/TP book use made the client
// consume mainInventory[1]: the on-screen 复活币 dropped by one per book while
// the server (correctly) never touched it, and the true count came back on the
// next login. Every other character id landed on an empty slot, which is why the
// symptom only ever showed for the character whose id collides with a populated
// slot.
func buildIncreaseStatusSuccessAck(sourceSlot uint16, kind byte, amount uint32) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeUint16(sourceSlot)
writer.writeByte(kind)
writer.writeUint32(amount)
// spGain/tpGain are read only by the kind==1 level-up branch. They must
// stay zero here or the client prints an extra level line.
writer.writeUint16(0)
writer.writeUint16(0)
return writer.bytes()
}
// buildIncreaseStatusFailureAck mirrors the op418 failure shape: the class1
// result flag plus one error code. op30 has no visible failure text; the packet
// exists only so the client leaves its pending item-use state. Without it every
// later action reports "请关闭正在使用的其他窗口后再尝试".
func buildIncreaseStatusFailureAck(errorCode byte) []byte {
return []byte{0, errorCode}
}
func buildUseSharedEffectItemSuccessAck() []byte {
// Current type251 response processing consumes only the generic result byte;
// unlike op44 it does not read a slot/list/instance/item tail.
return []byte{1}
}
func buildUseStackableActionSuccessAck(sourceSlotIndex int16, remainingCount int64) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeInt16(sourceSlotIndex)
writer.writeInt16(0)
writer.writeInt16(int16(remainingCount))
writer.writeInt16(0)
writer.writeByte(1)
return writer.bytes()
}
func buildUseStackableActionFailureAck(errorCode byte) []byte {
// Current class1/op515 handler sub_1E785B0 receives success and error as
// dispatcher arguments. Its failure branch reads no command-specific tail,
// so the exact terminal frame is only the common result/error pair. This
// must be emitted even when parsing, PVF authorization, or persistence fails
// so the shared native item-operation controller cannot remain pending.
return []byte{0, errorCode}
}
func buildSelectDamageFontSuccessAck(fontIndex uint16) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeUint16(fontIndex)
return writer.bytes()
}
func buildSelectDamageFontErrorAck(errorCode byte) []byte {
return []byte{0, errorCode}
}
// buildSortItemAck 对齐 C# SortItemAckBuilder;handler 只在后续 0x000D 列表刷新可生成时使用。
func buildSortItemAck(listType byte) []byte {
protobuf := protowire.AppendTag(nil, 1, protowire.VarintType)
protobuf = protowire.AppendVarint(protobuf, uint64(listType))
protobuf = protowire.AppendTag(protobuf, 2, protowire.VarintType)
protobuf = protowire.AppendVarint(protobuf, 0)
return buildCurrentProtobufAck(protobuf)
}
func buildCurrentProtobufAck(protobuf []byte) []byte {
body := make([]byte, 5, 5+len(protobuf))
body[0] = 1
binary.LittleEndian.PutUint32(body[1:5], uint32(len(protobuf)))
return append(body, protobuf...)
}
// buildCommonItemListRefreshBody 生成 Main/PersonalCargo 的 0x000D 全量列表刷新体。
// 当前 NoPack EXE 的 op13/op14 handler 读取 0x77 字节 entry。
func buildCommonItemListRefreshBodyWithState(listType byte, slots map[string]dnfrepo.ItemStack, state dnfrepo.CharacterContainerState) []byte {
entries := commonItemListEntries(listType, slots)
var writer packetWriter
writer.writeByte(listType)
switch listType {
case listTypeMain:
writer.writeUint16(state.MainSlotCount)
case listTypeAvatar:
writer.writeUint16(state.AvatarExpansion)
case listTypePersonalCargo:
writer.writeUint16(state.PersonalCargoSlotCount)
default:
writer.writeUint16(0)
}
writer.writeUint16(uint16(len(entries)))
for _, entry := range entries {
writeCommonItemListEntry(&writer, listType, entry.slot, entry.stack)
if listType == listTypeAvatar {
// list1 appends two length-prefixed optional blobs after every
// fixed 0x77-byte row. Their payload formats are not yet owned;
// two zero lengths preserve the current constructor defaults.
writer.writeInt32(0)
writer.writeInt32(0)
}
}
if listType == listTypePersonalCargo {
// Current sub_1D72380 requires a final u8 groupCount followed by
// groupCount*raw[8]. The repository has no group owner yet, so emit
// the explicit empty terminator instead of leaving the reader short.
writer.writeByte(0)
}
return writer.bytes()
}
// buildCommonItemListUpdateBody encodes current class0/op14: list type,
// changed-row count, then fixed 0x77-byte current item rows.
func buildCommonItemListUpdateBody(listType byte, entries []commonItemListEntry) []byte {
var writer packetWriter
writer.writeByte(listType)
writer.writeUint16(uint16(len(entries)))
for _, entry := range entries {
writeCommonItemListEntry(&writer, listType, entry.slot, entry.stack)
}
return writer.bytes()
}
func buildPetItemListUpdateBody(entries []commonItemListEntry) []byte {
var writer packetWriter
writer.writeByte(listTypePet)
writer.writeUint16(uint16(len(entries)))
for _, entry := range entries {
writePetItemListEntry(&writer, entry.slot, entry.stack)
}
return writer.bytes()
}
func currentRawEntryForStack(slot int16, stack dnfrepo.ItemStack) []byte {
var writer packetWriter
writeCommonItemListEntry(&writer, listTypeMain, slot, stack)
return writer.bytes()
}
// buildPetItemListRefreshBody 生成 Pet 背包的 0x000D 全量列表刷新体。
// 当前 NoPack EXE 的 pet list 同样读取 0x77 字节 entry。
func buildPetItemListRefreshBody(slots map[string]dnfrepo.ItemStack) []byte {
entries := commonItemListEntries(listTypePet, slots)
var writer packetWriter
writer.writeByte(listTypePet)
writer.writeUint16(uint16(len(entries)))
for _, entry := range entries {
writePetItemListEntry(&writer, entry.slot, entry.stack)
}
return writer.bytes()
}
// BuildPetItemListRefreshBody exposes the single current-EXE list-7 encoder
// to the pet owner. Keeping one 0x77-row implementation prevents hatch and
// normal inventory refreshes from drifting to different wire layouts.
func BuildPetItemListRefreshBody(slots map[string]dnfrepo.ItemStack) []byte {
return buildPetItemListRefreshBody(slots)
}
func buildSellItemAck(item DeletedItem, updatedGold int64) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeInt32(clampInt32(updatedGold))
writer.writeByte(item.ListType)
writer.writeInt16(item.SlotIndex)
writer.writeInt16(clampInt16(item.AppliedCount))
return writer.bytes()
}
// buildRepairEquipmentAck 对齐 C# RepairEquipmentAckBuilder:成功标记、当前金币、原 invenType、slot 和尾部 u16。
func buildRepairEquipmentAck(invenType byte, slotIndex int16, updatedGold int64) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeInt32(clampInt32(updatedGold))
writer.writeByte(invenType)
writer.writeInt16(slotIndex)
writer.writeInt16(0)
return writer.bytes()
}
func buildUpgradeItemSuccessAck(result UpgradeResult) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeByte(upgradeModeByte(result.Mode))
writer.writeInt16(result.MaterialSlotIndex)
writer.writeInt32(clampInt32(result.MaterialRemainingStackCount))
writer.writeInt16(result.OptionalTicketSlotIndex)
writer.writeByte(0)
writer.writeByte(result.OldLevel)
writer.writeByte(result.ResultCode)
writer.writeByte(result.NewLevel)
// byte[14] is the target list selector, not a pad byte: the client repaints
// the slot in the list named here. The 86JP builder's constant 0 happened to
// be right for backpack targets and made a worn-item upgrade repaint the
// same slot index in the backpack instead of the equipped row.
writer.writeByte(result.TargetListType)
writer.writeInt16(result.TargetSlotIndex)
writer.writeInt16(result.OptionalTicketSlotIndex)
if result.ResultCode == upgradeResultCodeDestroy {
// u8 row count, then (i16 slot, i32 item, i32 count) per granted row:
// the compensation item first, then any fixed extra refunds (usdof
// opcode0050 build_upgrade_item_success destroy_rewards).
rows := make([]UpgradeDestroyGrantedReward, 0, 1+len(result.DestroyExtraRewards))
if result.DestroyBonusItemID > 0 {
rows = append(rows, UpgradeDestroyGrantedReward{Slot: result.DestroyBonusSlot, ItemID: result.DestroyBonusItemID, Count: result.DestroyBonusCount})
}
rows = append(rows, result.DestroyExtraRewards...)
if len(rows) > 0xff {
rows = rows[:0xff]
}
writer.writeByte(byte(len(rows)))
for _, row := range rows {
writer.writeInt16(row.Slot)
writer.writeInt32(row.ItemID)
writer.writeInt32(row.Count)
}
}
if result.TargetListType == listTypeGuildMedal {
// The 公会徽章 window reads a trailing (i16 -1, i32 0) after the common
// prefix: no destroy-compensation slot and no material remainder are
// shown for a flag. Upstream writes it only for list 38.
writer.writeInt16(-1)
writer.writeInt32(0)
}
return writer.bytes()
}
func buildUpgradeItemErrorAck(errorCode byte) []byte {
return []byte{0, errorCode}
}
// buildForgingResultAck matches the command-response wrapper and callback
// registered for current NoPack op438. The wrapper consumes the leading 1;
// the callback then reads the remaining 12 bytes. The forging outcome is 0
// for success and 1 for failure.
func buildForgingResultAck(result ForgingResult) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeInt16(result.MaterialSlotIndex)
writer.writeInt32(clampInt32(result.MaterialRemainingCount))
writer.writeByte(result.OldLevel)
if result.UpgradeSucceeded {
writer.writeByte(0)
} else {
writer.writeByte(1)
}
writer.writeByte(result.NewLevel)
writer.writeByte(result.TargetListType)
writer.writeInt16(result.TargetSlotIndex)
return writer.bytes()
}
func buildForgingErrorAck(errorCode byte) []byte {
return []byte{0, errorCode}
}
// buildChronicleRefineResultAck is the current client's complete op370 result
// body. A chance failure is still a handled attempt; trailing rows describe the
// two granted destruction rewards.
func buildChronicleRefineResultAck(result ChronicleRefineResult) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeInt16(result.MaterialSlotIndex)
writer.writeUint16(clampUint16(result.MaterialRemainingCount))
if result.RefineSucceeded {
writer.writeByte(1)
} else {
writer.writeByte(0)
}
writer.writeByte(0)
writer.writeInt16(result.TargetSlotIndex)
rewardCount := len(result.FailureRewards)
if rewardCount > 255 {
rewardCount = 255
}
writer.writeByte(byte(rewardCount))
for index := 0; index < rewardCount; index++ {
reward := result.FailureRewards[index]
writer.writeInt16(reward.SlotIndex)
writer.writeInt32(clampInt32(reward.ItemID))
writer.writeInt32(clampInt32(reward.Count))
}
return writer.bytes()
}
func buildChronicleRefineErrorAck(errorCode byte) []byte {
return []byte{0, errorCode}
}
// buildForgingNoticeBody is current NoPack op86 subtype 14:
// success, town actor ID, weapon item ID, reinforce level, forging level.
func buildForgingNoticeBody(success bool, userID uint16, itemID int32, reinforceLevel, forgingLevel byte) []byte {
var writer packetWriter
writer.writeByte(14)
if success {
writer.writeByte(1)
} else {
writer.writeByte(0)
}
writer.writeUint16(userID)
writer.writeInt32(itemID)
writer.writeByte(reinforceLevel)
writer.writeByte(forgingLevel)
return writer.bytes()
}
// buildUpgradeNoticeBody builds NOTI 0x0056 subtype 1 upgrade announcement.
// The current client resolves userID against the active town actor table and
// suppresses the chat message when that lookup fails.
// Layout: u8 subtype, u8 successFlag, u16 userID, i32 itemID, u8 level.
func buildUpgradeNoticeBody(success bool, userID uint16, itemID int32, level byte) []byte {
var writer packetWriter
writer.writeByte(1) // subtype
if success {
writer.writeByte(1)
} else {
writer.writeByte(0)
}
writer.writeUint16(userID)
writer.writeInt32(itemID)
writer.writeByte(level)
return writer.bytes()
}
// buildUpgradeTicketSuccessAck matches the 86JP ItemUpgradeAckBuilder layout
// consumed by the current EXE op50 handler: u8 0x01, u8 mode, i16 material
// slot, i32 material remaining, i16 optional ticket slot (always -1), u8 0,
// u8 old level, u8 result code, u8 new level, u8 target list type (byte[14],
// a list selector the client repaints - see buildUpgradeItemSuccessAck), i16
// target slot, i16 optional ticket slot (always -1).
func buildUpgradeTicketSuccessAck(result UpgradeTicketResult) []byte {
var writer packetWriter
writer.writeByte(1)
writer.writeByte(upgradeModeByte(result.Mode))
writer.writeInt16(result.MaterialSlotIndex)
writer.writeInt32(clampInt32(result.MaterialRemainingStackCount))
writer.writeInt16(-1)
writer.writeByte(0)
writer.writeByte(result.OldLevel)
writer.writeByte(result.ResultCode)
writer.writeByte(result.NewLevel)
writer.writeByte(result.TargetListType)
writer.writeInt16(result.TargetSlotIndex)
writer.writeInt16(-1)
return writer.bytes()
}
func buildUpgradeTicketErrorAck(errorCode byte) []byte {
return []byte{0, errorCode}
}
// buildEnchantByBeadSuccessAck matches the current EXE S2C 0x0110 success
// reader (sub_1D0B960): u8 nonzero flag, u8 target list type, i16 target
// slot. The reference 86JP server emits the identical four-byte body.
//
// 2026-09-13 现场实验记录:把这里改成 3 字节 [flag][slot u16](依据是
// sub_30E3970/sub_30E39E0 的 u8+u16 读取)之后,客户端连"附魔成功"弹窗都不再
// 出现,账号晶体/灵魂面板依旧被清空,说明 sub_1D0B960 实际读取的字段不止这三个
// 字节、或调用方对包长有约束。该实验已回滚,恢复 4 字节形状;下一次改动前必须
// 先把 sub_1D0B960 两条分支完整反汇编,确认逐字段布局。
func buildEnchantByBeadSuccessAck(result EnchantResult) []byte {
return []byte{
1,
result.TargetListType,
byte(result.TargetSlotIndex),
byte(result.TargetSlotIndex >> 8),
}
}
func buildEnchantByBeadErrorAck(errorCode byte) []byte {
return []byte{0, errorCode}
}
// buildMonsterCardUpgradeSuccessAck matches current NoPack op603 and the
// retained native card-upgrade reader: generic success, roll result, target
// item id, then the resulting enchant-table index.
func buildMonsterCardUpgradeSuccessAck(result MonsterCardUpgradeResult) []byte {
var writer packetWriter
writer.writeByte(1)
if result.UpgradeSucceeded {
writer.writeByte(1)
} else {
writer.writeByte(0)
}
writer.writeInt32(int32(result.TargetItemID))
writer.writeByte(result.UpgradeCount)
return writer.bytes()
}
func buildMonsterCardUpgradeErrorAck(errorCode byte) []byte {
return []byte{0, errorCode}
}
func upgradeModeByte(mode string) byte {
if strings.EqualFold(strings.TrimSpace(mode), "amplify") {
return 1
}
return 0
}
type commonItemListEntry struct {
slot int16
stack dnfrepo.ItemStack
}
func commonItemListEntries(listType byte, slots map[string]dnfrepo.ItemStack) []commonItemListEntry {
entries := make([]commonItemListEntry, 0, len(slots))
for key, stack := range slots {
keyListType, slot, ok := parseSlotKey(key)
if !ok || keyListType != listType {
continue
}
entries = append(entries, commonItemListEntry{slot: slot, stack: stack})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].slot < entries[j].slot
})
return entries
}
// commonItemListRowAmount resolves the u32 the current 0x77 row carries at
// raw+0x06 for one class0 item row.
//
// For an equipment row in any container the client reads grade from -- backpack
// 0, personal cargo 2, worn 3, account cargo 12 -- that word is the instance's
// durable quality seed (品级), never a stack count: an equipment row always
// stores Count 1, and the client derives the displayed grade from this word.
// Resolving it through itemquality.StackSeed keeps these re-encoded rows
// bit-identical to dnfbridge's login/full-list projection, which is the
// authority the client re-reads on select-character. Writing the count here
// made an enchant's 0x000D main-list refresh repaint the whole bag as seed 1
// (最下级) until the next character load.
//
// The container set must stay identical to dnfbridge's
// currentItemListEquipmentContainer. They diverged once already: this encoder
// listed only 0/3 while the bridge listed only 0/3, so equipment parked in
// personal cargo (list 2) went out with +0x06 = Count = 1 and the client
// repainted it with the grade of seed 1 after re-select. A row whose grade is
// only in Extra["quality_seed"] (everything itemquality.Apply creates) shows
// it; rows that also mirror the seed into amount_or_count hid it because the
// fallback below happens to read that alias.
//
// Avatar 1 and guild medal 38 are deliberately absent: their rows carry 1 at
// +0x06 by design. Pet list 7 has its own encoder. Every other list type keeps
// the historical amount aliases, because that is exactly what its own login
// projection encodes.
func commonItemListRowAmount(listType byte, stack dnfrepo.ItemStack) int64 {
switch listType {
case listTypeMain, listTypePersonalCargo, listTypeEquipment, listTypeAccountCargo:
if seed := itemquality.StackSeed(stack); seed != 0 {
return int64(seed)
}
}
return firstExtraInt(stack.Extra, stack.Count, "amount_or_count", "amount", "count", "stack", "quantity")
}
func writeCommonItemListEntry(writer *packetWriter, listType byte, slot int16, stack dnfrepo.ItemStack) {
extra := stack.Extra
// The character wallet is main-list slot 0 with item id 0 and the balance
// in the count field (dnfbridge's currentGoldWalletItemListEntry, see also
// currentGoldWalletReservedSlot). It is the one legitimate zero-id row, so
// it must never be rewritten into the "slot is empty" marker below.
goldWallet := slot == 0 && stack.ItemID == 0 && stack.Count > 0
if stack.ItemID <= 0 && !goldWallet {
// An empty stack means "this slot is now empty", and the current EXE
// only accepts that as id -1. Its op14 handler sub_1D73120 tests the
// row's id against -1 at 0x1D73912 and calls RemoveItem; any other id
// -- including 0 -- falls through to the in-place update path, which
// at 0x1D73AB2 refuses to change an object's item id unless the object
// type is 2. Equipment is not type 2, so a 0 row was silently dropped
// and the client kept painting the old piece until the next full list
// refresh. The bridge-side refresh (current_item_list_send.go) has
// always written math.MaxUint32 here, and the enchant path already
// spells it ItemStack{ItemID: -1}; this just makes a zero-value stack
// mean the same thing instead of emitting id 0.
writer.writeBytes(makeCurrentItemListEntry(slot, -1, 0))
return
}
entry := makeCurrentItemListEntry(
slot,
stack.ItemID,
commonItemListRowAmount(listType, stack),
stack.RawEntry,
)
entry[0x0A] = clampByte(firstExtraInt(extra, 0, "ext_data0", "ext0", "packed_flag_byte", "packed_flag", "packed"))
binary.LittleEndian.PutUint16(entry[0x0B:0x0D], clampUint16(firstExtraInt(extra, 0, "durability", "max_durability")))
entry[0x0D] = clampByte(firstExtraInt(extra, boolInt64(stack.Bind), "seal_flag", "seal", "bind_flag", "bind"))
binary.LittleEndian.PutUint32(entry[0x0E:0x12], uint32(clampInt32(commonItemListStackValueA(stack))))
entry[0x12] = clampByte(firstExtraInt(extra, 0, "byte_12", "value_12"))
entry[0x13] = clampByte(firstExtraInt(extra, 0, "byte_13", "value_13", "value_c"))
binary.LittleEndian.PutUint16(entry[0x14:0x16], clampUint16(firstExtraInt(extra, 0, "marker_16", "marker16", "value_d")))
if expire := itemListStackExpireUnix(stack); expire != 0 {
if binary.LittleEndian.Uint32(entry[legacyWrongItemExpireTimeOffset:legacyWrongItemExpireTimeOffset+4]) == expire {
binary.LittleEndian.PutUint32(entry[legacyWrongItemExpireTimeOffset:legacyWrongItemExpireTimeOffset+4], 0)
}
binary.LittleEndian.PutUint32(entry[currentItemListExpireTimeOffset:currentItemListExpireTimeOffset+4], expire)
}
entry[currentForgingRawOffset] = clampByte(firstExtraInt(extra, int64(entry[currentForgingRawOffset]), "forging_level", "byte_57", "value_57"))
entry[0x58] = clampByte(firstExtraInt(extra, 0, "byte_58", "value_58"))
entry[0x59] = clampByte(firstExtraInt(extra, 0, "byte_59", "value_59"))
copy(entry[0x65:0x6E], fixedExtraBytes(extra, 9, "raw_data_65", "raw65"))
copy(entry[0x72:0x77], fixedExtraBytes(extra, 5, "tail_data_72", "tail72"))
writer.writeBytes(entry)
}
func commonItemListStackValueA(stack dnfrepo.ItemStack) int64 {
if value := firstExtraInt(stack.Extra, 0, "value_a", "instance_value", "item_uid", "serial", "count_or_instance"); value != 0 {
return value
}
// Keep op14 row refreshes aligned with dnfbridge's login/full-list
// projection: current op44 requires a nonzero raw+0x0E identity before the
// client will emit a right-click request. Imported stackables without an
// explicit historical identity use their stable item id on the wire.
if strings.EqualFold(strings.TrimSpace(stack.Extra["item_kind"]), "stackable") && stack.ItemID > 0 {
return stack.ItemID
}
return 0
}
func writePetItemListEntry(writer *packetWriter, slot int16, stack dnfrepo.ItemStack) {
extra := stack.Extra
entry := makeCurrentItemListEntry(
slot,
stack.ItemID,
firstExtraInt(extra, stack.Count, "creature_serial_or_handle", "serial", "handle", "instance_value", "item_uid"),
stack.RawEntry,
)
entry[0x0A] = clampByte(firstExtraInt(extra, 0, "ext_data0", "ext0", "packed_flag_byte", "packed_flag", "packed"))
binary.LittleEndian.PutUint16(entry[0x0B:0x0D], clampUint16(firstExtraInt(extra, 0, "durability", "max_durability")))
entry[0x0D] = clampByte(firstExtraInt(extra, boolInt64(stack.Bind), "seal_flag", "seal", "bind_flag", "bind"))
binary.LittleEndian.PutUint32(entry[0x0E:0x12], uint32(clampInt32(firstExtraInt(extra, 0, "value_a"))))
copy(entry[0x12:0x77], fixedExtraBytes(extra, currentItemListEntrySize-0x12, "tail_data_12", "tail12", "pet_tail_12"))
entry[0x12] = clampByte(firstExtraInt(extra, 0, "byte_12", "value_12", "enchant_upgrade_count", "pet_enchant_upgrade_count"))
expire := itemListStackExpireUnix(stack)
binary.LittleEndian.PutUint32(
entry[currentPetRemainSecondsOffset:currentPetRemainSecondsOffset+4],
petRemainingSecondsAt(expire, time.Now().UTC()),
)
if expire != 0 {
if binary.LittleEndian.Uint32(entry[legacyWrongItemExpireTimeOffset:legacyWrongItemExpireTimeOffset+4]) == expire {
binary.LittleEndian.PutUint32(entry[legacyWrongItemExpireTimeOffset:legacyWrongItemExpireTimeOffset+4], 0)
}
binary.LittleEndian.PutUint32(entry[currentItemListExpireTimeOffset:currentItemListExpireTimeOffset+4], expire)
}
writer.writeBytes(entry)
}
func petRemainingSecondsAt(expire uint32, now time.Time) uint32 {
if expire == 0 {
return 0
}
if now.IsZero() {
now = time.Now().UTC()
}
remaining := int64(expire) - now.Unix()
if remaining <= 0 {
return 0
}
return uint32(remaining)
}
func itemListStackExpireUnix(stack dnfrepo.ItemStack) uint32 {
if value := firstExtraInt(stack.Extra, 0, "expire_time", "expire_unix"); value > 0 {
return uint32(clampInt32(value))
}
if !stack.ExpireAt.IsZero() && stack.ExpireAt.Unix() > 0 {
return uint32(clampInt32(stack.ExpireAt.Unix()))
}
return 0
}
func makeCurrentItemListEntry(slot int16, itemID int64, amount int64, rawEntries ...[]byte) []byte {
entry := make([]byte, currentItemListEntrySize)
if len(rawEntries) > 0 && len(rawEntries[0]) == currentItemListEntrySize {
copy(entry, rawEntries[0])
}
binary.LittleEndian.PutUint16(entry[0x00:0x02], uint16(slot))
binary.LittleEndian.PutUint32(entry[0x02:0x06], uint32(clampInt32(itemID)))
binary.LittleEndian.PutUint32(entry[0x06:0x0A], uint32(clampInt32(amount)))
return entry
}
func boolInt64(value bool) int64 {
if value {
return 1
}
return 0
}
func firstExtraInt(extra map[string]string, fallback int64, keys ...string) int64 {
for _, key := range keys {
raw := strings.TrimSpace(extra[key])
if raw == "" {
continue
}
value, err := strconv.ParseInt(raw, 0, 64)
if err == nil {
return value
}
}
return fallback
}
func fixedExtraBytes(extra map[string]string, length int, keys ...string) []byte {
for _, key := range keys {
raw := strings.TrimSpace(extra[key])
if raw == "" {
continue
}
raw = strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "", "0x", "", "0X", "").Replace(raw)
decoded, err := hex.DecodeString(raw)
if err != nil || len(decoded) == 0 {
continue
}
out := make([]byte, length)
copy(out, decoded)
return out
}
return nil
}
func clampInt32(value int64) int32 {
if value > 2147483647 {
return 2147483647
}
if value < -2147483648 {
return -2147483648
}
return int32(value)
}
func clampByte(value int64) byte {
if value < 0 {
return 0
}
if value > 255 {
return 255
}
return byte(value)
}
func clampUint16(value int64) uint16 {
if value < 0 {
return 0
}
if value > 65535 {
return 65535
}
return uint16(value)
}
func clampInt16(value int64) int16 {
if value > 32767 {
return 32767
}
if value < -32768 {
return -32768
}
return int16(value)
}