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,359 @@
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
}