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

360 lines
14 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 (
"encoding/binary"
"sort"
"strconv"
"strings"
"golang.org/x/text/encoding/simplifiedchinese"
"longheng.io/server/internal/modules/dnf/adventuregroup"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 冒险团经验值在报文里的单位是 1/10 点原始经验。
//
// 现场取证(2026-09-29,冒险团 5 级):客户端把 `[adventure exp table]` 第 6 项
// 631,238,480 显示成 **63,123,848**(正好是表值 ÷10),而"当前值"是把报文里的
// u64 **原样打印**的(截图 227,362,848 既不是 10 的倍数,又正好等于服务端账本
// 里那个 5 级账号的进度)。也就是说客户端拿"表值÷10"当分母、拿报文值当分子,
// 我们过去直接发原始经验 → 分子是客户端的 10 倍,条一到 631,238,480/10 就冲破
// 100%(实测 360%、10 格全亮),而服务端要涨满 631,238,480 才升级。
// 这里统一除以刻度之后,客户端的条 = TotalPoint / RequiredExperience[Level+1],
// **条满 100% 的那一刻正好就是服务端升级的那一刻**,两边口径彻底对齐。
// 当前客户端冒险团进度条按完整 [adventure exp table] 值作为分母,
// 因此报文发送原始经验值;发送 /10 会让满级阈值处只显示 10%。
const currentAdventureWirePointScale = 1
// currentAdventureWirePoint 把账本里的原始冒险团经验换算成报文的客户端单位。
func currentAdventureWirePoint(totalPoint uint64) uint64 {
return totalPoint / currentAdventureWirePointScale
}
// buildCurrentAdventureActorRefreshBody follows current NoPack sub_C7A4C0.
func buildCurrentAdventureActorRefreshBody(
currentObjectKey uint16,
summary adventuregroup.Summary,
projection adventuregroup.Projection,
) []byte {
raw := make([]byte, currentAdventureActorRefreshRawLength)
binary.LittleEndian.PutUint32(raw[0:4], uint32(summary.ManageLevel))
binary.LittleEndian.PutUint64(raw[8:16], currentAdventureWirePoint(summary.TotalPoint))
var writer packetWriter
writer.writeUint16(currentObjectKey)
writer.writeUint32(uint32(len(raw)))
writer.writeBytes(raw)
// sub_C7A4C0 reads these u8 counters after its 16-byte raw state.
writeCurrentAdventureTowerCounters(&writer, projection)
return writer.bytes()
}
// writeCurrentAdventureTowerCounters writes the four native tower counters
// (remaining and limit, despair then grief) as u8 values.
func writeCurrentAdventureTowerCounters(writer *packetWriter, projection adventuregroup.Projection) {
for _, count := range [...]uint32{
projection.TowerRemaining[adventuregroup.TowerDespairType],
projection.TowerLimits[adventuregroup.TowerDespairType],
projection.TowerRemaining[adventuregroup.TowerGriefType],
projection.TowerLimits[adventuregroup.TowerGriefType],
} {
writer.writeByte(byte(min(count, 255)))
}
}
func buildCurrentAdventureInfoBody(
characterID uint16,
summary adventuregroup.Summary,
characters []dnfrepo.CharacterRecord,
) []byte {
return buildCurrentAdventureInfoBodyWithIdentity(characterID, summary, characters, "", 0)
}
func buildCurrentAdventureInfoBodyWithName(
characterID uint16,
summary adventuregroup.Summary,
characters []dnfrepo.CharacterRecord,
representAccountName string,
) []byte {
return buildCurrentAdventureInfoBodyWithIdentity(
characterID,
summary,
characters,
representAccountName,
0,
)
}
// buildCurrentAdventureInfoBody follows sub_C7F960 in the authoritative EXE.
func buildCurrentAdventureInfoBodyWithIdentity(
characterID uint16,
summary adventuregroup.Summary,
characters []dnfrepo.CharacterRecord,
representAccountName string,
createdDate uint32,
) []byte {
return buildCurrentAdventureInfoBodyWithState(
characterID,
summary,
characters,
representAccountName,
createdDate,
adventuregroup.Projection{},
)
}
func buildCurrentAdventureInfoBodyWithState(
characterID uint16,
summary adventuregroup.Summary,
characters []dnfrepo.CharacterRecord,
representAccountName string,
createdDate uint32,
projection adventuregroup.Projection,
) []byte {
raw := make([]byte, currentAdventureInfoRawLength)
binary.LittleEndian.PutUint32(
raw[currentAdventureInfoManageLevelOffset:currentAdventureInfoManageLevelOffset+4],
uint32(summary.ManageLevel),
)
binary.LittleEndian.PutUint64(
raw[currentAdventureInfoCurrentPointOffset:currentAdventureInfoCurrentPointOffset+8],
currentAdventureWirePoint(summary.TotalPoint),
)
binary.LittleEndian.PutUint32(
raw[currentAdventureInfoLoginDaysOffset:currentAdventureInfoLoginDaysOffset+4],
projection.ConsecutiveLoginDays,
)
writeCurrentAdventureHighestHonorSummary(raw, characters)
characterCount := len(characters)
if characterCount > currentAdventureInfoRosterCount {
characterCount = currentAdventureInfoRosterCount
}
binary.LittleEndian.PutUint16(
raw[currentAdventureInfoCharacterCountOffset:currentAdventureInfoCharacterCountOffset+2],
uint16(characterCount),
)
representatives := adventuregroup.RepresentativeState{}
if projection.RepresentativesEnabled {
representatives = projection.Representatives
}
selected := adventuregroup.ProjectRepresentativeCharacters(
characters,
representatives,
currentAdventureCharacterLevelCap,
)
writeCurrentAdventureInfoRoster(raw, selected)
// 原生 sub_A10AA0 通过 sub_A103A0 清掉被移除的代表,但**保留 job/baseGrow
// 那一格**。少写这一条会让 sub_A100B0 把这张卡锁死,玩家之后再也选不回该槽位。
index := len(selected)
for _, character := range adventuregroup.ProjectRepresentativeCharacters(
characters,
adventuregroup.RepresentativeState{},
currentAdventureCharacterLevelCap,
) {
slot, ok := adventuregroup.RepresentativeSlotForCharacter(character, currentAdventureCharacterLevelCap)
if !ok || index >= currentAdventureInfoRosterCount || !representatives.Selections[slot].Removed {
continue
}
offset := currentAdventureInfoRosterOffset + index*currentAdventureInfoRosterEntrySize
entry := raw[offset : offset+currentAdventureInfoRosterEntrySize]
clear(entry)
entry[0], entry[1] = slot.Job, slot.BaseGrow
binary.LittleEndian.PutUint32(entry[currentAdventureInfoRosterCardLevelOffset:], uint32(rosterLevel(character)))
index++
}
// 8 字节一条:**数量在前、当前窗口进度在后**。
// 现场取证(2026-09-23):把分类号写在第 0 个 dword 时,客户端把分类号当成
// 数量显示 —— 荣耀结晶显示 "1"、骁勇结晶显示 "0/250"。改成数量在前之后数量
// 显示正常;随后往第 0 条的第 2 个 dword 写 1,500,000,那条"获得1个结晶"
// 进度条正好显示 10%,从而确认第 2 个 dword 是进度(分母是 PVF 的 1500 万)。
for index, points := range projection.Runtime.ShopPoints {
if index >= currentAdventureInfoShopPointCount {
break
}
offset := currentAdventureInfoShopPointOffset + index*currentAdventureInfoShopPointEntrySize
binary.LittleEndian.PutUint32(raw[offset:offset+4], points)
binary.LittleEndian.PutUint32(
raw[offset+4:offset+8],
currentAdventureInfoShopPointProgress(index, projection),
)
}
purchaseIndex := 0
for _, category := range []byte{
adventuregroup.ShopPointBrave,
adventuregroup.ShopPointGlory,
adventuregroup.ShopPointPure,
} {
for _, product := range adventureGroupProductsForProjection(projection, category) {
if purchaseIndex >= currentAdventureInfoPurchaseCount {
break
}
offset := currentAdventureInfoPurchaseOffset + purchaseIndex*currentAdventureInfoPurchaseEntrySize
binary.LittleEndian.PutUint32(raw[offset:offset+4], product.ItemID)
binary.LittleEndian.PutUint32(raw[offset+4:offset+8], product.Count)
purchaseIndex++
}
}
for index := 0; index < adventuregroup.ContentTypeCount; index++ {
offset := currentAdventureInfoContentCountOffsets[index]
binary.LittleEndian.PutUint32(raw[offset:offset+4], projection.ContentCounts[index])
}
for index := 0; index < currentAdventureInfoTripleCount; index++ {
offset := currentAdventureInfoTripleOffset + index*currentAdventureInfoTripleSize
// Native row order is type:u8 then count:u16. The classification byte
// comes first, and the count is the per-profession character count.
raw[offset+currentAdventureInfoTripleTypeOffset] = byte(index)
binary.LittleEndian.PutUint16(
raw[offset+currentAdventureInfoTripleCountOffset:offset+currentAdventureInfoTripleCountOffset+2],
projection.ProfessionCounts[index],
)
}
binary.LittleEndian.PutUint64(
raw[currentAdventureInfoGrowthCapsuleOffset:currentAdventureInfoGrowthCapsuleOffset+8],
projection.Runtime.GrowthExperience,
)
binary.LittleEndian.PutUint32(
raw[currentAdventureInfoCapsuleClaimCountOffset:currentAdventureInfoCapsuleClaimCountOffset+4],
currentAdventureInfoCapsuleClaimCount(projection),
)
var writer packetWriter
writer.writeUint16(characterID)
writer.writeUint32(createdDate)
// sub_C7F960 reads four u8 counters at body+6..9. Its +96..108
// offsets belong to the destination object, not the raw roster block.
writeCurrentAdventureTowerCounters(&writer, projection)
writer.writeUint32(0)
writer.writeUint32(uint32(len(raw)))
writer.writeBytes(raw)
name, err := encodeRepresentAccountName(representAccountName)
if err != nil {
name = nil
}
writer.writeRawDstr(name)
return writer.bytes()
}
// writeCurrentAdventureHighestHonorSummary fills the basic-page profession,
// grow-type and HonorExpert level fields proved by current NoPack.exe
// sub_C79D40 (+44/+45/+48) and sub_9F44D0. Invalid persisted progress is
// omitted instead of being converted into a different character state.
func writeCurrentAdventureHighestHonorSummary(raw []byte, characters []dnfrepo.CharacterRecord) {
if len(raw) < currentAdventureInfoHighestHonorLevelOffset+4 {
return
}
var (
bestJob byte
bestGrow byte
bestLevel uint32
bestExperience uint64
)
for _, character := range characters {
progress, err := currentHonorExpertProgress(character)
if err != nil || progress.Level == 0 ||
(progress.Level < bestLevel ||
(progress.Level == bestLevel && progress.CurrentLevelExperience <= bestExperience)) {
continue
}
job, err := strconv.ParseUint(strings.TrimSpace(character.Job), 10, 8)
if err != nil {
continue
}
grow := character.Stats["grow_type"]
if grow < 0 || grow > 0xff {
continue
}
bestJob = byte(job)
bestGrow = byte(grow) & currentAdventureRepresentGrowMask
bestLevel = progress.Level
bestExperience = progress.CurrentLevelExperience
}
if bestLevel == 0 {
return
}
raw[currentAdventureInfoHighestHonorJobOffset] = bestJob
raw[currentAdventureInfoHighestHonorGrowOffset] = bestGrow
binary.LittleEndian.PutUint32(
raw[currentAdventureInfoHighestHonorLevelOffset:currentAdventureInfoHighestHonorLevelOffset+4],
bestLevel,
)
}
type adventureGroupProjectedPurchase struct {
ItemID uint32
Count uint32
}
func adventureGroupProductsForProjection(
projection adventuregroup.Projection,
category byte,
) []adventureGroupProjectedPurchase {
prefix := strconv.Itoa(int(category)) + ":"
products := make([]adventureGroupProjectedPurchase, 0)
for key, count := range projection.Runtime.Purchases {
if count == 0 || !strings.HasPrefix(key, prefix) {
continue
}
itemID, err := strconv.ParseUint(strings.TrimPrefix(key, prefix), 10, 32)
if err != nil || itemID == 0 {
continue
}
products = append(products, adventureGroupProjectedPurchase{ItemID: uint32(itemID), Count: count})
}
sort.Slice(products, func(left, right int) bool { return products[left].ItemID < products[right].ItemID })
return products
}
func writeCurrentAdventureInfoRoster(raw []byte, characters []dnfrepo.CharacterRecord) {
count := len(characters)
if count > currentAdventureInfoRosterCount {
count = currentAdventureInfoRosterCount
}
for index := 0; index < count; index++ {
entryOffset := currentAdventureInfoRosterOffset + index*currentAdventureInfoRosterEntrySize
entry := raw[entryOffset : entryOffset+currentAdventureInfoRosterEntrySize]
writeCurrentAdventureInfoRosterEntry(entry, characters[index])
}
}
func writeCurrentAdventureInfoRosterEntry(entry []byte, character dnfrepo.CharacterRecord) {
if len(entry) < currentAdventureInfoRosterEntrySize {
return
}
entry = entry[:currentAdventureInfoRosterEntrySize]
clear(entry)
entry[0] = byte(numericCharacterStat(character.Job))
entry[1] = byte(numericCharacterStatValue(character, "grow_type")) & currentAdventureRepresentGrowMask
if honor, err := currentHonorExpertProgress(character); err == nil {
binary.LittleEndian.PutUint32(
entry[currentAdventureInfoRosterHonorOffset:currentAdventureInfoRosterHonorOffset+4],
honor.Level,
)
}
name := currentAdventureInfoRosterNameBytes(character.Name)
copy(
entry[currentAdventureInfoRosterNameOffset:currentAdventureInfoRosterNameOffset+currentAdventureInfoRosterNameSize],
name,
)
level := rosterLevel(character)
entry[currentAdventureInfoRosterRegionOffset] = currentAdventureCharacterServerGroup(character)
// The collection row and op1467 candidate row are distinct layouts. The
// former carries base grow, HonorExpert level, region and character level;
// it does not carry a candidate character ID.
binary.LittleEndian.PutUint32(
entry[currentAdventureInfoRosterCardLevelOffset:currentAdventureInfoRosterCardLevelOffset+4],
uint32(level),
)
}
func currentAdventureInfoRosterNameBytes(name string) []byte {
encoded := make([]byte, 0, currentAdventureInfoRosterNameSize-1)
for _, value := range name {
part, err := simplifiedchinese.GB18030.NewEncoder().Bytes([]byte(string(value)))
if err != nil || len(encoded)+len(part) >= currentAdventureInfoRosterNameSize {
break
}
encoded = append(encoded, part...)
}
return encoded
}