服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
324 lines
13 KiB
Go
324 lines
13 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
dnfonlineevent "longheng.io/server/internal/modules/dnf/onlineevent"
|
||
dnfpvp "longheng.io/server/internal/modules/dnf/pvp"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
func writeNoPackRosterEntryFromStats(writer *packetWriter, index int, character dnfrepo.CharacterRecord) {
|
||
job := rosterByteValue(int64(numericCharacterStat(character.Job)), 0)
|
||
grow := rosterByteValue(int64(currentClientProjectedGrow(character)), 0)
|
||
level := rosterLevel(character)
|
||
slot := rosterSlotValue(character.Slot, index)
|
||
objectID := numericCharacterID(character)
|
||
|
||
writer.writeUint16(uint16(slot))
|
||
// IDA sub_200B250 证明新版 roster 名字是 DSTR,名字后还有 2 个保留字节,再读 job、grow、level。
|
||
writer.writeRawDstr(currentCharacterRenameRosterNameBytes(character))
|
||
writer.writeByte(job)
|
||
writer.writeByte(grow)
|
||
writer.writeByte(level)
|
||
writer.writeByte(0)
|
||
writer.writeByte(rosterState0Value(numericCharacterStatValue(character, "roster_state0")))
|
||
writer.writeUint32(statU32(character, "roster_time_a", 0))
|
||
writer.writeUint32(statU32(character, "roster_time_b", 0))
|
||
writeNoPackRosterEquipSummary(writer, nil)
|
||
writer.writeUint32(statU32(character, "roster_value0", 0))
|
||
writer.writeByte(statU8(character, "roster_value1", 0))
|
||
writer.writeByte(statU8(character, "roster_value2", 0))
|
||
writer.writeByte(statU8(character, "roster_reserved_a", 0))
|
||
writer.writeByte(statU8(character, "roster_reserved_b", 0))
|
||
writeRosterByteBlock(writer, rosterLinkedIDBlock(character), rosterLinkedIDBlockSize)
|
||
writer.writeByte(statU8(character, "roster_value3", 0))
|
||
writer.writeUint32(uint32(objectID))
|
||
writeNoPackRosterUITail(writer, character)
|
||
}
|
||
|
||
func writeNoPackRosterEntry(writer *packetWriter, index int, character dnfrepo.CharacterRecord) {
|
||
job := rosterByteValue(int64(numericCharacterStat(character.Job)), 0)
|
||
grow := rosterByteValue(int64(currentClientProjectedGrow(character)), 0)
|
||
level := rosterLevel(character)
|
||
slot := rosterSlotValue(character.Slot, index)
|
||
|
||
writer.writeUint16(uint16(slot))
|
||
writer.writeRawDstr(rosterRawNameBytes(character))
|
||
writeNoPackRosterPendingRenameFlags(writer, character)
|
||
// IDA sub_200B250 -> upper_pkt_read_wstr 先读 u32 len,再读 <31 字节名字并转宽字符;
|
||
// 不是固定 UTF16。固定写 62 字节会让名字失败并把后续 job/grow/level/state 整体读偏。
|
||
// The 2026-07-19 visible role-select chain writes job and grow as separate
|
||
// bytes. Packing them into one byte kept the row length but poisoned the
|
||
// current selector's class/grow cursor for non-base roles.
|
||
writer.writeByte(job)
|
||
writer.writeByte(grow)
|
||
writer.writeByte(level)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeUint32(0)
|
||
writer.writeUint32(0)
|
||
// The visible 2026-07-19/23:15 role-select chain uses the SQL slot as the
|
||
// row key and keeps the real DB/PVF equipment summary in the roster row.
|
||
// The later character-id/empty-summary experiment left the selector black
|
||
// with the current source-built upper-body-bypass DLL.
|
||
writeNoPackRosterEquipSummary(writer, characterEquipSummary(character))
|
||
// role:3 = 已预约升级、5 = 已取消/未预约的专用角色,其它一律 0xff(普通)。
|
||
// 升级预约活动(onlineevent level_aiming,活动 222)把这三种状态记在
|
||
// `level_aiming_222_role` stat 上;选角界面靠这一字节决定该行显示成
|
||
// "预约中/可预约"还是普通角色。之前本树没有这条链路,恒写 0xff。
|
||
role := byte(0xff)
|
||
if value := character.Stats[dnfonlineevent.LevelAimingRoleStat]; value == 3 || value == 5 {
|
||
role = byte(value)
|
||
}
|
||
writeNoPackRosterPostEquip(writer, character.Roster.PVPEntryQuestCompleted, role, dnfpvp.IsPrivateCharacter(character))
|
||
}
|
||
|
||
// writeNoPackRosterPendingRenameFlags writes the two roster bytes the current
|
||
// client reads between the character-name DSTR and job/grow/level. They are the
|
||
// character rename-card pending flags: 01 01 marks the card as in effect, and
|
||
// the selection screen then renders that card as waiting for a rename instead
|
||
// of letting the player enter the game.
|
||
//
|
||
// Evidence: the 86-generation reference server
|
||
// (S4A12 Network/Builders/Init/AccountCharacterListBodyBuilder) writes exactly
|
||
// this pair from its pending-rename set, and the A21 generation keeps the same
|
||
// two bytes in the same place as reserved zeros. The current 90 NoPack layout
|
||
// has the field at the same offset, so only the value changes here.
|
||
//
|
||
// A character without an active rename entitlement keeps the previous 00 00
|
||
// bytes, so every ordinary roster row stays byte-for-byte identical to the
|
||
// output this writer produced before.
|
||
func writeNoPackRosterPendingRenameFlags(writer *packetWriter, character dnfrepo.CharacterRecord) {
|
||
if !currentCharacterRenamePending(character) {
|
||
writer.writeZero(noPackRosterPreJobBytes)
|
||
return
|
||
}
|
||
for idx := 0; idx < noPackRosterPreJobBytes; idx++ {
|
||
writer.writeByte(currentCharacterRenamePendingFlag)
|
||
}
|
||
}
|
||
|
||
func writeNoPackRosterNormalPostEquip(writer *packetWriter, pvpEntryQuestCompleted bool) {
|
||
writeNoPackRosterPostEquip(writer, pvpEntryQuestCompleted, 0xff, false)
|
||
}
|
||
|
||
// writeNoPackRosterPostEquip 写装备摘要之后的选择界面尾部。
|
||
// role 与 private 来自决斗场/活动角色语义:-1(0xff) 是普通角色,
|
||
// 非负值会让选择器把整行当成活动角色;private 标记"决斗场专用角色"。
|
||
func writeNoPackRosterPostEquip(writer *packetWriter, pvpEntryQuestCompleted bool, role byte, private bool) {
|
||
writer.writeZero(24)
|
||
writer.writeByte(3)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
// sub_200B250 stores this signed byte at template+930. -1 is ordinary;
|
||
// nonnegative values make the selector treat every row as an event role.
|
||
writer.writeByte(role)
|
||
writer.writeZero(6)
|
||
// sub_200B250: template+980 owns the dedicated PVP character route.
|
||
writer.writeByte(boolByteValue(private, 1, 0))
|
||
writer.writeZero(2)
|
||
// sub_200B250 stores the final byte at +1032; sub_33E0CA0 checks it
|
||
// before character selection and emits dstr 31715 when quest 2670 is missing.
|
||
writer.writeByte(boolByteValue(pvpEntryQuestCompleted, 1, 0))
|
||
}
|
||
|
||
func writeNoPackRosterEntryLatest(writer *packetWriter, index int, character dnfrepo.CharacterRecord) {
|
||
job := byte(numericCharacterStat(character.Job))
|
||
grow := currentClientProjectedGrow(character)
|
||
level := rosterLevel(character)
|
||
slot := rosterSlotValue(character.Slot, index)
|
||
objectID := rosterObjectID(character)
|
||
|
||
// sub_200B250 用首个 u16 查找客户端槽位对象;这里必须写真实 slot,而不是压缩后的列表序号。
|
||
writer.writeUint16(uint16(slot))
|
||
writeFixedUTF16(writer, character.Name, rosterWideNameUnits)
|
||
// WStr31 后 sub_2005D90 还会先读两个 u8;普通角色用 0,避免 level/job 被整体读偏。
|
||
writer.writeByte(level)
|
||
writer.writeByte(packRosterJobGrow(job, grow))
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeUint32(0)
|
||
writer.writeUint32(0)
|
||
writeNoPackRosterEquipSummary(writer, nil)
|
||
writer.writeUint32(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writeRosterByteBlock(writer, nil, rosterLinkedIDBlockSize)
|
||
writer.writeByte(0)
|
||
writer.writeUint32(uint32(objectID))
|
||
writer.writeByte(3)
|
||
// 普通角色的特殊、决斗、改名、display flag 状态必须全清零;透传旧 roster_json 会把 UI 路由到目标/决斗卡片。
|
||
writer.writeByte(3)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(4)
|
||
writer.writeZero(13)
|
||
}
|
||
|
||
func writeNoPackRosterEntryDstr(writer *packetWriter, index int, character dnfrepo.CharacterRecord) {
|
||
job := byte(numericCharacterStat(character.Job))
|
||
grow := currentClientProjectedGrow(character)
|
||
level := rosterLevel(character)
|
||
name := rosterRawNameBytes(character)
|
||
|
||
// 对齐 C# CharacterSelectHandler.BuildCharacterListBody:
|
||
// u16 entryIndex + DSTR(rawName) + 两个保留字节 + job/grow/level。
|
||
// 当前客户端从这个顺序读取名字、职业和普通/决斗卡片状态。
|
||
writer.writeUint16(uint16(rosterSlot(index)))
|
||
writer.writeRawDstr(name)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(job)
|
||
writer.writeByte(grow)
|
||
writer.writeByte(level)
|
||
writer.writeZero(10)
|
||
// MCP/live hook: sub_20026C0 会按装备摘要里的 item id 立即进资源加载链;
|
||
// 当前 DB/PVF 装备 id 与 NoPack 场景对象资源表还没闭合,先不在 op2 场景对象里内联装备,避免 0x30303030 崩溃。
|
||
writeNoPackRosterEquipSummary(writer, nil)
|
||
writer.writeZero(24)
|
||
writer.writeByte(3)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(4)
|
||
writer.writeZero(4)
|
||
}
|
||
|
||
func writeNoPackRosterEntryCurrent(writer *packetWriter, index int, character dnfrepo.CharacterRecord) {
|
||
job := byte(numericCharacterStat(character.Job))
|
||
grow := currentClientProjectedGrow(character)
|
||
level := rosterLevel(character)
|
||
slot := rosterSlotValue(character.Slot, index)
|
||
objectID := rosterObjectID(character)
|
||
|
||
// sub_200B250 的首个 u16 是槽位对象索引;后续字段按客户端 116 字节 entry 读取。
|
||
writer.writeUint16(uint16(slot))
|
||
writeFixedUTF16(writer, character.Name, rosterWideNameUnits)
|
||
writer.writeZero(noPackRosterPreJobBytes)
|
||
writer.writeByte(level)
|
||
writer.writeByte(packRosterJobGrow(job, grow))
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeUint32(0)
|
||
writer.writeUint32(0)
|
||
writeNoPackRosterEquipSummary(writer, nil)
|
||
writer.writeUint32(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writeRosterByteBlock(writer, nil, rosterLinkedIDBlockSize)
|
||
writer.writeByte(0)
|
||
writer.writeUint32(uint32(objectID))
|
||
// C# account_character_entries 普通角色尾状态为 03 00 00 04,避免改名/特殊状态 UI。
|
||
writer.writeByte(3)
|
||
writer.writeByte(0)
|
||
writer.writeByte(0)
|
||
writer.writeByte(4)
|
||
writer.writeZero(13)
|
||
}
|
||
|
||
func writeNoPackRosterEquipSummary(writer *packetWriter, rows []dnfrepo.CharacterRosterEquipSummary) {
|
||
rows = currentRosterEquipSummaryRows(rows)
|
||
count := len(rows)
|
||
if count > 0xff {
|
||
count = 0xff
|
||
}
|
||
writer.writeByte(byte(count))
|
||
for idx := 0; idx < count; idx++ {
|
||
row := rows[idx]
|
||
writer.writeByte(rosterByteValue(row.Slot, 0))
|
||
writer.writeUint32(rosterUint32Value(row.ItemIDOrIcon, 0))
|
||
// sub_225C9B0(type=44) always calls sub_3457C50. The roster
|
||
// summary owns no real type-44 payload yet, but the zero length itself
|
||
// is required grammar and must not be omitted.
|
||
writer.writeUint32(0)
|
||
writer.writeByte(rosterByteValue(row.PackedFlags, 0))
|
||
writer.writeUint32(rosterUint32Value(row.OptionalIDOrExpire, 0))
|
||
writer.writeUint32(rosterUint32Value(row.AuxValue, 0))
|
||
writer.writeByte(rosterByteValue(row.AuxFlag, 0))
|
||
}
|
||
}
|
||
|
||
func currentRosterFullAppearanceRows(rows []dnfrepo.CharacterRosterEquipSummary) []dnfrepo.CharacterRosterEquipSummary {
|
||
itemIDs := [currentActorMode0AppearanceSlotCount]int64{}
|
||
for slot := range itemIDs {
|
||
itemIDs[slot] = int64(currentActorMode0AppearanceEmptyItem)
|
||
}
|
||
for _, row := range currentRosterEquipSummaryRows(rows) {
|
||
if row.Slot < 0 || row.Slot >= currentActorMode0AppearanceSlotCount {
|
||
continue
|
||
}
|
||
itemIDs[int(row.Slot)] = row.ItemIDOrIcon
|
||
}
|
||
out := make([]dnfrepo.CharacterRosterEquipSummary, currentActorMode0AppearanceSlotCount)
|
||
for slot, itemID := range itemIDs {
|
||
out[slot] = dnfrepo.CharacterRosterEquipSummary{
|
||
Slot: int64(slot),
|
||
ItemIDOrIcon: itemID,
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func currentRosterEquipSummaryRows(rows []dnfrepo.CharacterRosterEquipSummary) []dnfrepo.CharacterRosterEquipSummary {
|
||
if len(rows) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]dnfrepo.CharacterRosterEquipSummary, 0, len(rows))
|
||
for _, row := range rows {
|
||
// sub_200B250 indexes a fixed local equipment array without a bounds
|
||
// check. The current client keeps 33 actor equipment slots (0..32).
|
||
if row.Slot < 0 || row.Slot > 32 || row.ItemIDOrIcon <= 0 || row.ItemIDOrIcon > int64(^uint32(0)) {
|
||
continue
|
||
}
|
||
out = append(out, row)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func writeRosterByteBlock(writer *packetWriter, values []int64, size int) {
|
||
for idx := 0; idx < size; idx++ {
|
||
value := int64(0)
|
||
if idx < len(values) {
|
||
value = values[idx]
|
||
}
|
||
writer.writeByte(rosterByteValue(value, 0))
|
||
}
|
||
}
|
||
|
||
func rosterLinkedIDBlock(character dnfrepo.CharacterRecord) []int64 {
|
||
values := make([]int64, rosterLinkedIDBlockSize)
|
||
for idx := range values {
|
||
values[idx] = numericCharacterStatValue(character, "roster_linked_id_"+twoDigit(idx))
|
||
}
|
||
return values
|
||
}
|
||
|
||
func writeNoPackRosterUITail(writer *packetWriter, character dnfrepo.CharacterRecord) {
|
||
// 这 17 个字节直接进入角色卡 UI 状态;普通角色默认必须全 0,否则会被渲染成改名、特殊或决斗状态。
|
||
for _, key := range []string{
|
||
"roster_flag0_eq1",
|
||
"roster_card_flag",
|
||
"roster_value5",
|
||
"roster_display_flags",
|
||
"roster_tail_00",
|
||
"roster_tail_01",
|
||
"roster_tail_02",
|
||
"roster_tail_03",
|
||
"roster_tail_04",
|
||
"roster_tail_05",
|
||
"roster_tail_06",
|
||
"roster_tail_07",
|
||
"roster_tail_08",
|
||
"roster_tail_09",
|
||
"roster_tail_10",
|
||
"roster_tail_11",
|
||
"roster_flag6_eq1",
|
||
} {
|
||
writer.writeByte(statU8(character, key, 0))
|
||
}
|
||
}
|