服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
340 lines
15 KiB
Go
340 lines
15 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"sort"
|
|
"strings"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const currentMode1EquipmentCreateRowBaseWireSize = 56
|
|
const currentMode1EquipmentRawBlockHeaderSize = 4
|
|
const currentMode1EquipmentRawBlockMaxLen = 1024
|
|
const currentMode1EquipmentVectorRecordSize = 8
|
|
const currentMode1EquipmentVectorRecordMaxCount = 2
|
|
const currentMode1EquipmentDwordVectorMaxCount = 2
|
|
|
|
const currentMode1EquipmentIndexedStateMaxRecords = 3
|
|
|
|
type currentMode1EquipmentIndexedStateRecord struct {
|
|
value0 byte
|
|
value1 byte
|
|
value2 byte
|
|
}
|
|
|
|
type currentMode1EquipmentIndexedState struct {
|
|
records []currentMode1EquipmentIndexedStateRecord
|
|
activeValue byte
|
|
selectedIndex byte
|
|
selectedValue byte
|
|
selectedRecord currentMode1EquipmentIndexedStateRecord
|
|
hasSelectedRecord bool
|
|
currentItemMismatch bool
|
|
}
|
|
|
|
type currentMode1EquipmentObjectRow struct {
|
|
createEnabled bool
|
|
currentItemStateDerived bool
|
|
slot byte
|
|
itemID uint32
|
|
equipmentType uint64
|
|
equipmentTypeKnown bool
|
|
instance uint32
|
|
extData byte
|
|
durability uint16
|
|
durabilityKnown bool
|
|
linkedItemID uint32
|
|
auxValue uint32
|
|
auxByte byte
|
|
bindFlag byte
|
|
marker16 uint16
|
|
linkedRaw []byte
|
|
readsRawBlocks bool
|
|
rawA []byte
|
|
rawB []byte
|
|
readsDurabilityOverrideU32 bool
|
|
durabilityOverride uint32
|
|
vector1D6E020 []byte
|
|
state112 uint32
|
|
vector225CCA0 []byte
|
|
state1E636E0 uint16
|
|
indexedState1C61F40 currentMode1EquipmentIndexedState
|
|
state120 byte
|
|
state160 byte
|
|
state128 byte
|
|
state140 uint16
|
|
state144 byte
|
|
resourceByte byte
|
|
state168 byte
|
|
state169 byte
|
|
state170 byte
|
|
lateRawC []byte
|
|
lateRawD []byte
|
|
state183 byte
|
|
lateRawE []byte
|
|
update currentItemListEntry
|
|
}
|
|
|
|
func (r *csharpLegacyUserInfoReader) currentMode1EquipmentObjectRows() []currentMode1EquipmentObjectRow {
|
|
if r == nil || r.service == nil || strings.TrimSpace(r.characterID) == "" {
|
|
return nil
|
|
}
|
|
repos, ok := r.service.projectionRepositories(r.ctx)
|
|
if !ok || repos.Equipment == nil {
|
|
return nil
|
|
}
|
|
record, found, err := repos.Equipment.Load(r.ctx, r.characterID)
|
|
if err != nil {
|
|
if r.session != nil {
|
|
r.service.logGameEvent(r.session, "game-upper-selected-userinfo-mode1-equipment-load-failed", "character_id", r.characterID, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
if !found || len(record.Entries) == 0 {
|
|
return nil
|
|
}
|
|
var petRecord dnfrepo.PetRecord
|
|
if repos.Pet != nil {
|
|
if loaded, petFound, petErr := repos.Pet.Load(r.ctx, r.characterID); petErr == nil && petFound {
|
|
petRecord = loaded
|
|
}
|
|
}
|
|
// 转职光环的 USERINFO 关联块要带**被覆盖光环实例**的镶嵌字节,所以这里必须
|
|
// 读一次背包:穿戴时快照会在"穿好之后才打徽章"的场景下过期。缺记录/读失败都
|
|
// 只意味着"没有可用的关联实例",投影照常发出(不猜、不阻塞)。
|
|
var inventorySlots map[string]dnfrepo.ItemStack
|
|
if repos.Inventory != nil {
|
|
if inv, invFound, invErr := repos.Inventory.Load(r.ctx, r.characterID); invErr == nil && invFound {
|
|
inventorySlots = inv.Slots
|
|
}
|
|
}
|
|
rows := make([]currentMode1EquipmentObjectRow, 0, len(record.Entries))
|
|
for _, equipped := range record.Entries {
|
|
if equipped.SlotIndex < 0 || equipped.SlotIndex > 0xff || equipped.ItemID <= 0 {
|
|
continue
|
|
}
|
|
mergeCurrentPetEnchantFromRecord(&equipped, petRecord)
|
|
entry, entryOK := currentItemListEntryFromEquipment(equipped)
|
|
if !entryOK {
|
|
continue
|
|
}
|
|
equipmentType, equipmentTypeKnown := r.currentMode1EquipmentType(equipped)
|
|
durability, durabilityKnown := currentMode1EquipmentDurabilityWord(equipped, entry)
|
|
indexedState := currentMode1EquipmentIndexedStateFromCurrentItem(entry)
|
|
// Slot 8 is the sole skin-avatar slot. Historical rows often lack the
|
|
// optional PVF type label, but their mode1 reader still requires the
|
|
// avatar detail blocks to materialize the full-body render.
|
|
readsRawBlocks := currentMode1EquipmentReadsRawBlocks(equipped.Extra, equipmentType, equipmentTypeKnown) ||
|
|
currentMode1EquipmentIsSkinAvatar(equipped)
|
|
rawA, rawB := currentMode1EquipmentAvatarRawBlocks(equipped, entry, readsRawBlocks)
|
|
rows = append(rows, currentMode1EquipmentObjectRow{
|
|
createEnabled: currentMode1EquipmentCreateEnabled(equipped),
|
|
currentItemStateDerived: true,
|
|
slot: byte(equipped.SlotIndex),
|
|
itemID: sceneInventoryUint32FromInt64(equipped.ItemID),
|
|
equipmentType: equipmentType,
|
|
equipmentTypeKnown: equipmentTypeKnown,
|
|
// sub_1D77560's create-row v74 field is the equipped object's
|
|
// grade/instance value. A zero default happens to render as top
|
|
// quality, so rebuilding an already-owned actor after op19 must
|
|
// carry the same equipment quality seed as the authoritative
|
|
// 0x77 item row. Keep the historical PVF starter projection at
|
|
// zero because its create scalar is an initialization marker,
|
|
// not a durable quality seed.
|
|
instance: currentMode1EquipmentGradeOrInstance(equipped),
|
|
extData: entry.data[0x0a],
|
|
durability: durability,
|
|
durabilityKnown: durabilityKnown,
|
|
// The link field (+12) carries whichever template this row must be
|
|
// read against: a 转职光环 records the aura it covered, a 透明克隆
|
|
// 时装 records the template it clones. The two keys never coexist on
|
|
// one row, so the order only decides which is consulted first.
|
|
linkedItemID: sceneInventoryExtraUint32(equipped.Extra, dnfrepo.AuraLinkedTemplateExtraKey, dnfrepo.CloneAvatarAppearanceExtraKey, "mode1_linked_item_id", "mode1_clone_or_linked_id", "clone_or_linked_id"),
|
|
auxValue: currentMode1EquipmentExtraUint32Or(equipped.Extra, binary.LittleEndian.Uint32(entry.data[0x0e:0x12]), "mode1_aux_value", "mode1_state_24", "state_24"),
|
|
auxByte: currentMode1EquipmentExtraByteOr(equipped.Extra, entry.data[0x12], "mode1_aux_byte", "mode1_state_32", "state_32"),
|
|
bindFlag: currentMode1EquipmentExtraByteOr(equipped.Extra, entry.data[0x13], "mode1_state_56", "state_56", "value_c"),
|
|
marker16: currentMode1EquipmentExtraUint16Or(equipped.Extra, binary.LittleEndian.Uint16(entry.data[0x14:0x16]), "marker_16", "marker16", "value_d"),
|
|
linkedRaw: currentMode1EquipmentLinkedRaw(equipped.Extra, inventorySlots),
|
|
readsRawBlocks: readsRawBlocks,
|
|
rawA: rawA,
|
|
rawB: rawB,
|
|
readsDurabilityOverrideU32: currentMode1EquipmentReadsDurabilityOverrideU32(equipped.Extra, equipmentType, equipmentTypeKnown),
|
|
durabilityOverride: currentMode1EquipmentDurabilityOverride(equipped),
|
|
vector1D6E020: currentMode1EquipmentVectorRecords(equipped.Extra, "mode1_sub_1d6e020_records_hex", "mode1_vector_records_hex"),
|
|
state112: currentMode1EquipmentState112(equipped, entry),
|
|
// sub_1D77560 reads this through sub_225CCA0 and applies it to
|
|
// ordinary equipment through the same vtable +280 method used
|
|
// after op14. Derive it from the durable raw+0x3C vector so a
|
|
// cold-login create row restores sockets without a wear cycle.
|
|
vector225CCA0: currentMode1EquipmentDwordVectorFromCurrentItem(
|
|
entry,
|
|
equipped.Extra,
|
|
"mode1_sub_225cca0_dwords_hex",
|
|
"mode1_dword_vector_hex",
|
|
),
|
|
state1E636E0: binary.LittleEndian.Uint16(entry.data[0x45:0x47]),
|
|
indexedState1C61F40: indexedState,
|
|
state120: currentMode1EquipmentForgingLevel(equipped, entry),
|
|
state160: sceneInventoryExtraByte(equipped.Extra, "mode1_state_160", "state_160"),
|
|
state128: sceneInventoryExtraByte(equipped.Extra, "mode1_state_128", "state_128"),
|
|
state140: sceneInventoryExtraUint16(equipped.Extra, "mode1_state_140", "state_140"),
|
|
state144: sceneInventoryExtraByte(equipped.Extra, "mode1_state_144", "state_144"),
|
|
resourceByte: sceneInventoryExtraByte(equipped.Extra, "mode1_resource_byte", "resource_byte"),
|
|
state168: sceneInventoryExtraByte(equipped.Extra, "mode1_state_168", "state_168"),
|
|
state169: sceneInventoryExtraByte(equipped.Extra, "mode1_state_169", "state_169"),
|
|
state170: sceneInventoryExtraByte(equipped.Extra, "mode1_state_170", "state_170"),
|
|
lateRawC: currentMode1EquipmentExtraHex(equipped.Extra, currentMode1EquipmentRawBlockMaxLen, "mode1_raw_c_hex", "mode1_state_171_raw_hex"),
|
|
lateRawD: currentMode1EquipmentExtraHex(equipped.Extra, currentMode1EquipmentRawBlockMaxLen, "mode1_raw_d_hex", "mode1_state_175_179_raw_hex"),
|
|
state183: sceneInventoryExtraByte(equipped.Extra, "mode1_state_183", "state_183"),
|
|
// The current EXE copies this final five-byte block into equipment
|
|
// state +188..+192. Restore authoritative raw+0x72..0x76 on cold
|
|
// login when no explicit mode1 alias exists.
|
|
lateRawE: currentMode1EquipmentRawBlockOrCurrentItem(
|
|
equipped.Extra,
|
|
entry,
|
|
currentEpicProductionAbilityTailOffset,
|
|
currentEpicProductionAbilityTailOffset+currentEpicProductionAbilityTailSize,
|
|
"mode1_raw_e_hex",
|
|
"mode1_state_188_192_raw_hex",
|
|
),
|
|
update: entry,
|
|
})
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
return rows[i].slot < rows[j].slot
|
|
})
|
|
if len(rows) > 0xff {
|
|
rows = rows[:0xff]
|
|
}
|
|
if len(rows) > 0 && r.session != nil {
|
|
r.service.logGameEvent(r.session, "game-upper-selected-userinfo-mode1-equipment-create-rows",
|
|
"character_id", r.characterID,
|
|
"entry_count", len(rows),
|
|
"unknown_type_count", currentMode1EquipmentUnknownTypeCount(rows),
|
|
"row_summary", currentMode1EquipmentRowSummary(rows),
|
|
"mcp_handler", "sub_1D77560")
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// currentMode1EquipmentLinkedRaw returns the payload written after the link
|
|
// field (+12) in a create row, i.e. the block the client reads via sub_3457C50.
|
|
//
|
|
// An explicit captured alias always wins. Otherwise a worn 转职光环 resolves the
|
|
// covered aura's socket bytes from the live inventory row the equip recorded, so
|
|
// emblems socketed AFTER the 转职光环 was worn are still carried. Everything else
|
|
// (clone avatars included) keeps the previous behaviour of emitting no block.
|
|
func currentMode1EquipmentLinkedRaw(extra map[string]string, slots map[string]dnfrepo.ItemStack) []byte {
|
|
if raw := currentMode1EquipmentExtraHex(extra, currentMode1EquipmentRawBlockMaxLen, "mode1_linked_raw_hex", "mode1_clone_raw_hex"); len(raw) != 0 {
|
|
return raw
|
|
}
|
|
return currentAuraLinkedSocketBlock(extra, slots)
|
|
}
|
|
|
|
// currentAuraLinkedSocketBlock resolves the covered aura INSTANCE's socket block
|
|
// for a worn 转职光环. The link field only carries the covered aura's template id,
|
|
// so the instance has to be looked up; a row that is gone, moved, or now holds a
|
|
// different template means the binding is dead and nothing is emitted. Never
|
|
// substitute another aura from the bag or a template guess.
|
|
func currentAuraLinkedSocketBlock(extra map[string]string, slots map[string]dnfrepo.ItemStack) []byte {
|
|
templateID := sceneInventoryExtraUint32(extra, dnfrepo.AuraLinkedTemplateExtraKey)
|
|
if templateID == 0 {
|
|
return nil
|
|
}
|
|
sourceKey := strings.TrimSpace(extra[dnfrepo.AuraLinkedSourceExtraKey])
|
|
if sourceKey == "" || len(slots) == 0 {
|
|
return nil
|
|
}
|
|
stack, found := slots[sourceKey]
|
|
if !found || stack.ItemID != int64(templateID) {
|
|
return nil
|
|
}
|
|
return currentItemListAvatarSocketData(stack.Extra)
|
|
}
|
|
|
|
func currentMode1EquipmentRawBlockOrCurrentItem(
|
|
extra map[string]string,
|
|
entry currentItemListEntry,
|
|
start int,
|
|
end int,
|
|
keys ...string,
|
|
) []byte {
|
|
if data := currentMode1EquipmentExtraHex(extra, currentMode1EquipmentRawBlockMaxLen, keys...); len(data) != 0 {
|
|
return data
|
|
}
|
|
if start < 0 || end <= start || end > len(entry.data) {
|
|
return nil
|
|
}
|
|
for _, value := range entry.data[start:end] {
|
|
if value != 0 {
|
|
return append([]byte(nil), entry.data[start:end]...)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func currentMode1EquipmentForgingLevel(equipped dnfrepo.EquipmentEntry, entry currentItemListEntry) byte {
|
|
// Current NoPack sub_1D77560 passes this byte as the forgingLevel argument
|
|
// to ConstructEquipmentState_FromProjectionRow. That constructor stores it
|
|
// in the encoded equipment-property field at +0x78, which is exactly what
|
|
// AppendWeaponForgingTooltipLines reads after a character reselect.
|
|
return sceneInventoryExtraByteDefault(
|
|
equipped.Extra,
|
|
entry.data[currentItemListForgingLevelOffset],
|
|
"forging_level", "byte_57", "value_57", "mode1_state_120", "state_120",
|
|
)
|
|
}
|
|
|
|
const currentMode1SkinAvatarColorStateBytes = 4
|
|
|
|
func currentMode1EquipmentAvatarRawBlocks(equipped dnfrepo.EquipmentEntry, entry currentItemListEntry, readsRawBlocks bool) ([]byte, []byte) {
|
|
extra := equipped.Extra
|
|
rawA := currentMode1EquipmentExtraHex(extra, currentMode1EquipmentRawBlockMaxLen, "mode1_raw_a_hex", "mode1_sub_225c960_raw_hex", "mode1_raw_block_a_hex")
|
|
rawB := currentMode1EquipmentExtraHex(extra, currentMode1EquipmentRawBlockMaxLen, "mode1_raw_b_hex", "mode1_sub_225c9b0_raw_hex", "mode1_raw_block_b_hex")
|
|
if !readsRawBlocks {
|
|
return rawA, rawB
|
|
}
|
|
// Current EXE sub_1D77560 uses the same two type<=11 extension readers as
|
|
// list-1/op13 and the avatar branch of op14: socket state first, color state
|
|
// second. Socket mutations persist those canonical blobs on the item row,
|
|
// so use them as the mode1 create-state fallback instead of requiring a
|
|
// second set of mode1-only aliases. Explicit captured mode1 blocks retain
|
|
// precedence when present.
|
|
if len(rawA) == 0 {
|
|
rawA = append([]byte(nil), entry.avatarSocketData...)
|
|
}
|
|
if len(rawB) == 0 {
|
|
rawB = append([]byte(nil), entry.avatarColorData...)
|
|
}
|
|
if currentMode1EquipmentIsSkinAvatar(equipped) {
|
|
// A skin avatar is a complete character render rather than an
|
|
// independent paper-doll part. The current actor's mode-1 reader
|
|
// distinguishes a materialized avatar detail from an absent one by the
|
|
// two length-prefixed detail blocks. A zero-length pair leaves slot 8
|
|
// registered as an ordinary item and the renderer falls back to the
|
|
// visible 0..7 avatar parts. The roster/card path does not consume
|
|
// these blocks, which is why it can still show the skin correctly.
|
|
//
|
|
// A newly granted skin has no emblem or dye data, but still needs its
|
|
// canonical empty detail shape: five empty six-byte sockets and two
|
|
// zero colour words. Explicit captured/persisted mode-1 data above
|
|
// always takes precedence.
|
|
if len(rawA) == 0 {
|
|
rawA = make([]byte, currentAvatarSocketBytes)
|
|
}
|
|
if len(rawB) == 0 {
|
|
rawB = make([]byte, currentMode1SkinAvatarColorStateBytes)
|
|
}
|
|
}
|
|
return rawA, rawB
|
|
}
|
|
|
|
func currentMode1EquipmentIsSkinAvatar(equipped dnfrepo.EquipmentEntry) bool {
|
|
// The current actor's equipment table reserves slot 8 exclusively for the
|
|
// skin-avatar render. The PVF type string is helpful on newly granted
|
|
// items but is absent on old/equipped rows imported before that metadata was
|
|
// persisted, so it must not gate the wire shape.
|
|
return equipped.SlotIndex == 8
|
|
}
|