服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
328 lines
15 KiB
Go
328 lines
15 KiB
Go
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)
|
||
}
|