DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,327 @@
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)
}