Files
90CNServer/server/internal/services/dnfbridge/character_roster_entries.go
T
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

324 lines
13 KiB
Go
Raw 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.
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))
}
}