DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,895 @@
|
||||
// 本文件负责背包类命令的旧客户端回包 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)
|
||||
}
|
||||
Reference in new issue
Block a user