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

328 lines
15 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.
package dnfbridge
import (
"encoding/binary"
"unicode/utf16"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func writeCurrentSceneObjectEntryTail(writer *packetWriter, character dnfrepo.CharacterRecord, hasCharacter bool) {
writeCurrentSceneObjectEntryTailWithCreature(writer, character, hasCharacter, currentEquippedCreatureSnapshot{})
}
func writeCurrentSceneObjectEntryTailWithCreature(writer *packetWriter, character dnfrepo.CharacterRecord, hasCharacter bool, creature currentEquippedCreatureSnapshot) {
writeCurrentSceneObjectEntryTailWithCreatureAndAdventureName(writer, character, hasCharacter, creature, "")
}
func writeCurrentSceneObjectEntryTailWithCreatureAndAdventureName(writer *packetWriter, character dnfrepo.CharacterRecord, hasCharacter bool, creature currentEquippedCreatureSnapshot, adventureName string) {
writeCurrentSceneObjectEntryTailWithCreatureAndNames(writer, character, hasCharacter, creature, adventureName, adventureName)
}
func writeCurrentSceneObjectEntryTailWithCreatureAndNames(writer *packetWriter, character dnfrepo.CharacterRecord, hasCharacter bool, creature currentEquippedCreatureSnapshot, adventureName string, guildName string) {
job := byte(0)
grow := byte(0)
level := byte(0)
if hasCharacter {
job = byte(numericCharacterStat(character.Job))
grow = currentClientProjectedGrow(character)
level = rosterLevel(character)
}
writer.writeByte(job)
// The current actor tail carries the complete packed grow type: low four
// bits are the stage and the upper three bits carry the awakening family.
writer.writeByte(packCurrentSceneObjectGrow(grow&0x0f, (grow>>4)&0x07))
// Current EXE proof: sub_2003810 stores this byte at template+20. sub_24DC280
// copies it to actor ability context 0, and the recompute chain copies contexts
// 0 -> 1 -> 2 -> 3. The level getter sub_2416DB0 reads context 3 offset 0.
writer.writeByte(level)
// 下面严格对应 sub_2009160 在 selector 之后的所有 upper_pkt_read_*。
// 旧 DOVE entry 在这里带版本相关资源号;当前 NoPack 先用安全空状态创建对象,再让后续 359/124 刷新。
writer.writeByte(0) // sub_20038C0
writer.writeByte(0) // sub_2003970
writer.writeByte(0) // v112[1]
writeCurrentSceneObjectEquipSummary(writer, currentSceneObjectMode0EquipSummary(character, hasCharacter))
writer.writeUint32(currentCloneTitleItemID(character, hasCharacter)) // v113: clone-title state consumed by the object virtual setter.
writer.writeByte(0) // v114
writer.writeByte(0) // v115
writer.writeByte(0) // v104
writer.writeByte(0) // v97
// Current EXE ReadAndApplyActorNameTagState (sub_2008D80) reads this exact
// pair and resolves worn endpoint 30. Keep the existing empty sentinel when
// no name tag is equipped; otherwise project the durable item and expiry.
nameTagItemID, nameTagExpireTime := currentSceneObjectNameTagState(character, hasCharacter)
writer.writeUint32(nameTagItemID)
writer.writeUint32(nameTagExpireTime)
writer.writeByte(0) // sub_2003A20
// sub_2003AD0 sets template+736, used by sub_26B0CE0 to resolve membership.
writer.writeUint32(statU32(character, "__scene_guild_id", 0))
// Current NoPack sub_2009160 reads this byte immediately before the
// creature template. The original server writes IsPermissionPrivateStore
// at the same mode0 position. It creates the idle town trade companion;
// active opened stores remain separate op78..84 village objects.
writer.writeByte(boolByte(hasCharacter && character.Stats[currentPrivateStoreScenePermissionStat] > 0)) // v116
// Current EXE sub_20028B0 reads the equipped creature template ID here.
// A live trace proved that UINT32_MAX resolves to runtime type 8 and leaves
// the empty green overhead marker. The C# no-equipped-creature path writes
// zero, so do not reuse the DOVE template sentinel for this field.
creatureItemID := currentSceneNoCreatureTemplateID
var creatureName []byte
creatureAliveState := byte(0)
if creature.valid() {
creatureItemID = creature.itemID
creatureName = rosterNameBytes(creature.name)
creatureAliveState = creature.aliveState
}
writer.writeUint32(creatureItemID)
writer.writeRawDstr(creatureName)
writer.writeByte(creatureAliveState)
writer.writeByte(0) // sub_2003B80: premium-PC-room badge
// Current NoPack sub_2003C90/sub_23EBE50 consumes this dedicated China
// membership flag. Value 1 selects the cyan actor name and draws the Black
// Diamond badge; it is not the runtime-PVF premium type.
writer.writeByte(boolByte(hasCharacter && character.Stats[currentBlackDiamondNameplateStat] > 0))
writer.writeUint32(0) // v120
// Current EXE sub_2003D40 consumes the organization name as a DSTR followed
// immediately by a u32 guild mark (sub_26AE4F0). Adventure-group data lives in the fixed raw actor state above
// and must not be substituted for the guild name here.
guildNameBytes := rosterNameBytes(guildName)
writer.writeRawDstr(guildNameBytes)
writer.writeUint32(statU32(character, "__scene_guild_mark", 0))
writer.writeUint32(currentSceneActorStateEventArg) // v121 -> selected actor event 17/6599
writer.writeByte(0) // v122
writer.writeByte(0) // sub_2005B70
writer.writeByte(0) // sub_2005B70
// sub_2003F20 reads this pair and is the ONLY place the current client fills
// the sub-profession fields of the actor's profile record:
//
// FUN_034e3970(&type, 1); // this u8
// FUN_034e3a50(&experience); // this u32
// ...
// *(uint *)(record + 0x368) = type; // u8 -> the record's +0x368
// <encoded u32 setter>(experience); // u32 -> the encoded slot at +0x370
//
// and the early-out above that store only fires when the record's field is
// already non-zero. +0x368 is the gate the whole profession UI reads through
// sub_3A4C1A0(): sub_322E590 (the op205 continuation for professions 2 and 4)
// builds the UI only while it is non-zero, and sub_3A4C1A0-derived readers
// compare it against 1/2/3. Nothing else in any packet the server sends writes
// it, which is why the profession row stayed empty while both this pair and
// the profession state were zero.
//
// 礼白666 = 4 (人偶师), 6666 = 2 (炼金术士), 3333 = 3 (分解师), test5 = 1 (附魔师).
// The experience goes into the same encoded slot the client's own op205
// experience writer uses (sub_3A4C1A0(actor)+0x36C with the alignment-selected
// setter writing +0x370), so it must be the durable expert-job experience.
writer.writeByte(currentSceneExpertJobType(character, hasCharacter)) // sub_2003F20 -> record+0x368
writer.writeUint32(currentSceneExpertJobExperience(character, hasCharacter)) // sub_2003F20 -> encoded +0x370
writer.writeByte(0) // v125
writer.writeUint32(0) // v126
writer.writeUint16(0) // v128
writer.writeByte(0) // sub_2004E00
writer.writeByte(0) // sub_2004E00
writer.writeUint16(0) // sub_2004E00
writeCurrentHonorExpertState(writer, character) // sub_2003150 HonorExpert {u32 level, u64 progress EXP}
writer.writeByte(currentSceneNormalTownVisualState) // sub_20042F0 normal-town actor visual state
writer.writeUint32(0) // sub_20042F0 后的对象 flag dword
writer.writeByte(0) // internal field +197
writer.writeUint16(0) // sub_2691CF0/sub_2691B70
writer.writeByte(0) // sub_34AEA80
writer.writeUint16(0) // sub_20029A0
writer.writeByte(0) // sub_20029A0
writer.writeUint16(0) // sub_2002A20
writer.writeByte(0) // sub_2004510
writer.writeByte(0) // sub_2690AF0
writer.writeUint32(0) // object internal +267
}
func currentSceneObjectNameTagState(character dnfrepo.CharacterRecord, hasCharacter bool) (uint32, uint32) {
if !hasCharacter {
return currentSceneNoAttachedUIResourceID, currentSceneNoAttachedUIResourceID
}
itemID := statU32(character, "name_tag_item_id", 0)
if itemID == 0 {
return currentSceneNoAttachedUIResourceID, currentSceneNoAttachedUIResourceID
}
return itemID, statU32(character, "name_tag_expire_time", 0)
}
func currentCloneTitleItemID(character dnfrepo.CharacterRecord, hasCharacter bool) uint32 {
if !hasCharacter {
return 0
}
return statU32(character, "clone_title_item_id", 0)
}
// currentSceneExpertJobType is the sub-profession type carried by the mode-0 actor
// record (0 = no profession, 1..4 = 附魔师 / 炼金术士 / 分解师 / 人偶师).
//
// Current EXE sub_2009160 -> sub_2003F20 reads it as one u8 and stores it at the
// actor profile record's +0x368; that field is the gate every profession reader
// tests through sub_3A4C1A0() (sub_322E590 for professions 2/4, and the readers
// that compare it against 1/2/3). An out-of-range value would push an unknown
// profession into the client's UI, so anything outside 1..4 is projected as "no
// profession" exactly like a missing stat.
func currentSceneExpertJobType(character dnfrepo.CharacterRecord, hasCharacter bool) byte {
if !hasCharacter {
return 0
}
value := numericCharacterStatValue(character, "expert_job_type")
if value <= 0 || value > 4 {
return 0
}
return byte(value)
}
// currentSceneExpertJobExperience is the durable sub-profession experience that
// rides beside the type in the same mode-0 field pair. The client hands it to the
// encoded-u32 setter for sub_3A4C1A0(actor)+0x370, which is the same slot the
// module's op205 experience projection uses.
// currentSceneRecordWithoutExpertJob 返回一份**清掉副职业字段**的角色记录副本,专门给
// "对端 actor"(队友/同屏其他人)的 mode-0 行使用。
//
// 为什么必须清(2026-10-08 现场):模式0 是客户端唯一填 actor profile 记录 +0x368/+0x370
// 的地方;而对端 actor 在**观看者**的客户端里是"从 0 新建的一条记录"。创建行里带非零副职业
// 字节,客户端就把"别人早就有的副职业"当成**刚刚获得** —— 在对方角色身上播一次获得特效
// 与一次性提示。最清楚的复现:AB 组队,A 先单独回城,B 随后回城 → B 看到 A 身上冒
// "副职业任务完成"特效(本机自己的那条 mode-0 行保持原样,所以 A 自己看不到异常)。
//
// 真正的副职业**变更**仍由 currentSendExpertJobActorRefresh 的对端扇出负责,所以对端状态
// 不会因此变陈旧;被清掉的只是"场景重建时把既有副职业当新获得"的那一份。
func currentSceneRecordWithoutExpertJob(character dnfrepo.CharacterRecord) dnfrepo.CharacterRecord {
if character.Stats == nil {
return character
}
_, hasType := character.Stats["expert_job_type"]
_, hasExperience := character.Stats["expert_job_exp"]
if !hasType && !hasExperience {
return character
}
stats := make(map[string]int64, len(character.Stats))
for key, value := range character.Stats {
stats[key] = value
}
delete(stats, "expert_job_type")
delete(stats, "expert_job_exp")
character.Stats = stats
return character
}
func currentSceneExpertJobExperience(character dnfrepo.CharacterRecord, hasCharacter bool) uint32 {
if !hasCharacter {
return 0
}
value := numericCharacterStatValue(character, "expert_job_exp")
if value <= 0 {
return 0
}
if value > int64(^uint32(0)) {
return ^uint32(0)
}
return uint32(value)
}
func currentSceneObjectMode0EquipSummary(character dnfrepo.CharacterRecord, hasCharacter bool) []dnfrepo.CharacterRosterEquipSummary {
return currentSceneObjectEquipSummary(character, hasCharacter)
}
func rewriteCurrentSceneObjectTailEquipSummary(tail []byte, rows []dnfrepo.CharacterRosterEquipSummary) ([]byte, bool) {
const equipOffset = 6
oldEnd, ok := currentSceneObjectEquipSummaryEnd(tail, equipOffset)
if !ok {
return nil, false
}
rows = cloneRosterEquipSummary(rows)
sortRosterEquipSummary(rows)
var writer packetWriter
writeCurrentSceneObjectEquipSummary(&writer, rows)
replacement := writer.bytes()
out := make([]byte, 0, len(tail)-oldEnd+equipOffset+len(replacement))
out = append(out, tail[:equipOffset]...)
out = append(out, replacement...)
out = append(out, tail[oldEnd:]...)
return out, true
}
const currentSceneObjectEquipSummaryRowBytes = 19
func currentSceneObjectEquipSummaryEnd(tail []byte, offset int) (int, bool) {
if offset < 0 || len(tail) <= offset {
return 0, false
}
pos := offset + 1
count := int(tail[offset])
for idx := 0; idx < count; idx++ {
if pos+5 > len(tail) {
return 0, false
}
pos += 5
if pos+4 > len(tail) {
return 0, false
}
rawLen := int(binary.LittleEndian.Uint32(tail[pos : pos+4]))
pos += 4
if rawLen < 0 || pos+rawLen > len(tail) {
return 0, false
}
pos += rawLen
if pos+10 > len(tail) {
return 0, false
}
pos += 10
}
return pos, true
}
func writeCurrentSceneObjectEquipSummary(writer *packetWriter, rows []dnfrepo.CharacterRosterEquipSummary) {
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))
writer.writeUint32(0) // sub_225C9B0(type=44) -> sub_3457C50 u32 len + raw; legacy InvenItem raw is not this current block.
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 packCurrentSceneObjectGrow(grow byte, subGrow byte) byte {
// MCP sub_2009160:name 后第二个 u8 直接拆成 low4=grow、high3=subgrow;
// 这里不能复用 roster 的 job|grow 打包,否则会把 job 写进动作/状态链并跳到 0x1818181C。
return (grow & 0x0f) | ((subGrow & 0x07) << 4)
}
func writeCurrentSceneObjectName(writer *packetWriter, value string) {
// MCP sub_2009160 -> upper_pkt_read_wstr -> sub_3457C80:
// 字符串实际是 u32 字节长度 + raw name,再由客户端转宽字;不能写固定 UTF-16,
// 否则名字长度和后续 job/grow/resource selector 会整体错位,城镇对象会变成“没有名字”。
writer.writeRawDstr(rosterNameBytes(value))
}
func writeFixedUTF16(writer *packetWriter, value string, units int) {
encoded := utf16.Encode([]rune(value))
for idx := 0; idx < units; idx++ {
var unit uint16
if idx < len(encoded) {
unit = encoded[idx]
}
writer.writeUint16(unit)
}
}
func writeNullTerminatedUTF16(writer *packetWriter, value string, maxUnits int) {
encoded := utf16.Encode([]rune(value))
if maxUnits > 0 && len(encoded) >= maxUnits {
encoded = encoded[:maxUnits-1]
}
for _, unit := range encoded {
writer.writeUint16(unit)
}
writer.writeUint16(0)
}