DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,358 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
currentDungeonPlayResultNoticeMsgID = uint16(34)
|
||||
currentDungeonClearRewardMsgID = uint16(35)
|
||||
currentDungeonPlayResultMaximumParticipants = 8
|
||||
// The all-empty dynamic form consumed by current NoPack sub_1D4D380 is
|
||||
// 210 bytes. After both eight-slot auxiliary tables the reader consumes
|
||||
// one standalone u32 (v67), followed by the 22-byte result-window tail:
|
||||
// u32(v50), u8(v101), u8(v97), and four u32 values. Omitting v67 shifts
|
||||
// the result-window flag by four bytes and makes the client read it as 0.
|
||||
currentDungeonClearRewardMinimumBodySize = 210
|
||||
currentDungeonClearRewardGroupCount = 8
|
||||
currentDungeonClearRewardBonusFieldCount = 22
|
||||
currentDungeonClearRewardPostBaseFieldCount = 7
|
||||
currentDungeonClearRewardScoreFieldCount = 4
|
||||
currentDungeonClearRewardTailSize = 22
|
||||
)
|
||||
|
||||
const (
|
||||
currentDungeonClearRewardBaseClearExperienceIndex = iota
|
||||
currentDungeonClearRewardBaseScoreBonusIndex
|
||||
// 组队通关经验(原生 tooltip DSTR 73062)落在 Base[2]。
|
||||
currentDungeonClearRewardBaseClearPartyIndex
|
||||
// Base[3] 在本树没有对应实现(参考实现的 ClearAvatar / DetailCategory0
|
||||
// 两条线本树都没有),保留占位以免下标漂移。
|
||||
currentDungeonClearRewardBaseDetailCategory3Index
|
||||
)
|
||||
|
||||
const (
|
||||
currentDungeonClearRewardBonusClearEventIndex = iota
|
||||
currentDungeonClearRewardBonusClearAncientElixirIndex
|
||||
currentDungeonClearRewardBonusClearBlackDiamondIndex
|
||||
currentDungeonClearRewardBonusClearInternetCafeIndex
|
||||
currentDungeonClearRewardBonusUnknown4Index
|
||||
currentDungeonClearRewardBonusClearPetIndex
|
||||
currentDungeonClearRewardBonusClearDetailCategory6Index
|
||||
currentDungeonClearRewardBonusMonsterReturnedHeroPart1Index
|
||||
currentDungeonClearRewardBonusClearGrowthContractIndex
|
||||
currentDungeonClearRewardBonusClearFatigueBurnIndex
|
||||
currentDungeonClearRewardBonusUnknown10Index
|
||||
currentDungeonClearRewardBonusClearGuildIndex
|
||||
currentDungeonClearRewardBonusMonsterPartyIndex
|
||||
currentDungeonClearRewardBonusMonsterLuckyCharacterPartyIndex
|
||||
currentDungeonClearRewardBonusMonsterReturnedHeroPart2Index
|
||||
currentDungeonClearRewardBonusMonsterFatigueBurnIndex
|
||||
currentDungeonClearRewardBonusMonsterFatigueBuffIndex
|
||||
currentDungeonClearRewardBonusMonsterGrowthContractIndex
|
||||
currentDungeonClearRewardBonusMonsterWeekendIndex
|
||||
currentDungeonClearRewardBonusMonsterEquipmentIndex
|
||||
currentDungeonClearRewardBonusMonsterGuildIndex
|
||||
currentDungeonClearRewardBonusUnknown21Index
|
||||
)
|
||||
|
||||
var (
|
||||
errCurrentDungeonPlayResultNoticeShape = errors.New("current dungeon play-result notice shape is invalid")
|
||||
errCurrentDungeonClearRewardShape = errors.New("current dungeon clear-reward shape is invalid")
|
||||
errCurrentDungeonClearRewardUncommitted = errors.New("current dungeon clear-reward snapshot is not committed")
|
||||
)
|
||||
|
||||
// currentDungeonPlayResultParticipant is the exact repeated row consumed by
|
||||
// current NoPack sub_1D3BAE0: u16 + u32 + u8. The u32 is kept as the same
|
||||
// clear-time value as the fixed header because that is the only current-reader
|
||||
// compatible domain ordering corroborated by the reference writer.
|
||||
type currentDungeonPlayResultParticipant struct {
|
||||
ObjectKey uint16
|
||||
ClearTimeMS uint32
|
||||
NewBest bool
|
||||
}
|
||||
|
||||
// currentDungeonPlayResultNotice is the exact current class0/op34 grammar.
|
||||
// RankPoint is the client result-state echo from C2S op46, not a server reward
|
||||
// input. The current client falls back to F when this byte is replaced with a
|
||||
// server-calculated threshold, so packet-plan validation keeps the values equal.
|
||||
type currentDungeonPlayResultNotice struct {
|
||||
RankGrade byte
|
||||
ClearTimeMS uint32
|
||||
TimeBonusPoint byte
|
||||
RankPoint byte
|
||||
AllVisitedClear bool
|
||||
Participants []currentDungeonPlayResultParticipant
|
||||
}
|
||||
|
||||
func buildCurrentDungeonPlayResultNoticeBody(notice currentDungeonPlayResultNotice) ([]byte, error) {
|
||||
if len(notice.Participants) == 0 || len(notice.Participants) > currentDungeonPlayResultMaximumParticipants {
|
||||
return nil, fmt.Errorf("%w: participants=%d", errCurrentDungeonPlayResultNoticeShape, len(notice.Participants))
|
||||
}
|
||||
var writer packetWriter
|
||||
writer.writeByte(notice.RankGrade)
|
||||
writer.writeUint32(notice.ClearTimeMS)
|
||||
writer.writeByte(notice.TimeBonusPoint)
|
||||
writer.writeByte(notice.RankPoint)
|
||||
writer.writeByte(boolByte(notice.AllVisitedClear))
|
||||
writer.writeByte(byte(len(notice.Participants)))
|
||||
for _, participant := range notice.Participants {
|
||||
if participant.ObjectKey == 0 {
|
||||
return nil, fmt.Errorf("%w: participant object key is zero", errCurrentDungeonPlayResultNoticeShape)
|
||||
}
|
||||
writer.writeUint16(participant.ObjectKey)
|
||||
writer.writeUint32(participant.ClearTimeMS)
|
||||
writer.writeByte(boolByte(participant.NewBest))
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
type currentDungeonClearRewardPair struct {
|
||||
Key uint32
|
||||
Value uint32
|
||||
}
|
||||
|
||||
// currentDungeonClearRewardDrop is the exact 15-byte row consumed by current
|
||||
// NoPack sub_1D4D380. Business names are limited to fields whose consumers are
|
||||
// structurally proved; the two trailing u16 values remain opaque.
|
||||
type currentDungeonClearRewardDrop struct {
|
||||
ObjectKey uint16
|
||||
TemplateID uint32
|
||||
StackCount uint32
|
||||
Value16A uint16
|
||||
Value8 byte
|
||||
Value16B uint16
|
||||
}
|
||||
|
||||
type currentDungeonClearRewardTail struct {
|
||||
// Current NoPack sub_1D4D380 reads this exact final sequence:
|
||||
// u32(v50), u8(v101), u8(v97), u32(v65), u32(v51), u32(v53), u32(v49).
|
||||
// ShowResult gates the normal result window. Current-EXE data flow proves
|
||||
// the first u32 after the flags is the complete monster EXP total, including
|
||||
// the monster reward categories that the client subtracts to display base EXP.
|
||||
Value0 uint32
|
||||
ShowResult bool
|
||||
Flag0 bool
|
||||
MonsterTotalExp uint32
|
||||
Value2 uint32
|
||||
Value3 uint32
|
||||
Value4 uint32
|
||||
}
|
||||
|
||||
// currentDungeonClearRewardSnapshot is an already-committed, immutable
|
||||
// settlement delta. This type deliberately does not calculate rewards. A
|
||||
// caller must freeze it from authoritative runtime/DB/PVF owners before op35
|
||||
// can be emitted; an empty source or an unowned entirely-zero reward is
|
||||
// rejected so a fabricated current-reader zero body cannot silently enter
|
||||
// production.
|
||||
type currentDungeonClearRewardSnapshot struct {
|
||||
CharacterID uint16
|
||||
CompletionKey string
|
||||
Source string
|
||||
Committed bool
|
||||
// AllowZeroReward is a non-wire ownership proof used only when another
|
||||
// atomic settlement component (currently recommended-dungeon Lucky Stars)
|
||||
// makes a zero-EXP clear meaningful. Ordinary empty rewards stay rejected.
|
||||
AllowZeroReward bool
|
||||
// These fields are transaction receipts only and are not serialized into
|
||||
// an op35 slot whose current-EXE meaning is still unknown.
|
||||
CommittedExperienceGain uint32
|
||||
CommittedSPGain int
|
||||
CommittedTPGain int
|
||||
|
||||
Base [4]uint32
|
||||
BaseFlag byte
|
||||
Bonus [currentDungeonClearRewardBonusFieldCount]uint32
|
||||
BonusGroupsA []currentDungeonClearRewardPair
|
||||
BonusGroupsB []currentDungeonClearRewardPair
|
||||
PostBase [currentDungeonClearRewardPostBaseFieldCount]uint32
|
||||
Score [currentDungeonClearRewardScoreFieldCount]uint32
|
||||
// MonsterExperienceByObjectKey and Quest share the single dynamic pair
|
||||
// table consumed immediately after Score. The client uses monster object
|
||||
// keys for the left-side kill rows and quest IDs for the task result rows.
|
||||
// They must be serialized together so the following drop/card sections do
|
||||
// not shift.
|
||||
MonsterExperienceByObjectKey []currentDungeonClearRewardPair
|
||||
Quest []currentDungeonClearRewardPair
|
||||
Drops []currentDungeonClearRewardDrop
|
||||
CardSlots [currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair
|
||||
TotalReward uint32
|
||||
AuxGroupsA [currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair
|
||||
AuxGroupsB [currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair
|
||||
// Current NoPack sub_1D4D380 reads this standalone u32 as v67 before the
|
||||
// final result-window tail. Its business meaning is not proved, so the
|
||||
// settlement owner leaves it neutral instead of fabricating a value.
|
||||
PreTailValue uint32
|
||||
Tail currentDungeonClearRewardTail
|
||||
}
|
||||
|
||||
func buildCurrentDungeonClearRewardBody(snapshot currentDungeonClearRewardSnapshot) ([]byte, error) {
|
||||
return buildCurrentDungeonClearRewardBodyWithPolicy(snapshot, snapshot.AllowZeroReward)
|
||||
}
|
||||
|
||||
func buildCurrentPracticeDungeonClearRewardBody(characterID uint16) ([]byte, error) {
|
||||
return buildCurrentDungeonClearRewardBodyWithPolicy(currentDungeonClearRewardSnapshot{
|
||||
CharacterID: characterID,
|
||||
CompletionKey: "practice-session-local",
|
||||
Source: "practice_zero_reward",
|
||||
Committed: true,
|
||||
Tail: currentDungeonClearRewardTail{ShowResult: true},
|
||||
}, true)
|
||||
}
|
||||
|
||||
func buildCurrentDungeonClearRewardBodyWithPolicy(snapshot currentDungeonClearRewardSnapshot, allowZeroReward bool) ([]byte, error) {
|
||||
if err := validateCurrentDungeonClearRewardSnapshotWithPolicy(snapshot, allowZeroReward); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var writer packetWriter
|
||||
for _, value := range snapshot.Base {
|
||||
writer.writeUint32(value)
|
||||
}
|
||||
writer.writeByte(snapshot.BaseFlag)
|
||||
for _, value := range snapshot.Bonus {
|
||||
writer.writeUint32(value)
|
||||
}
|
||||
writeCurrentDungeonRewardU8Pairs(&writer, snapshot.BonusGroupsA)
|
||||
writeCurrentDungeonRewardU8Pairs(&writer, snapshot.BonusGroupsB)
|
||||
for _, value := range snapshot.PostBase {
|
||||
writer.writeUint32(value)
|
||||
}
|
||||
for _, value := range snapshot.Score {
|
||||
writer.writeUint32(value)
|
||||
}
|
||||
dynamicRows := currentDungeonClearRewardDynamicRows(snapshot)
|
||||
writer.writeUint32(uint32(len(dynamicRows)))
|
||||
writeCurrentDungeonRewardPairs(&writer, dynamicRows)
|
||||
writer.writeByte(byte(len(snapshot.Drops)))
|
||||
for _, drop := range snapshot.Drops {
|
||||
writer.writeUint16(drop.ObjectKey)
|
||||
writer.writeUint32(drop.TemplateID)
|
||||
writer.writeUint32(drop.StackCount)
|
||||
writer.writeUint16(drop.Value16A)
|
||||
writer.writeByte(drop.Value8)
|
||||
writer.writeUint16(drop.Value16B)
|
||||
}
|
||||
for _, slot := range snapshot.CardSlots {
|
||||
writeCurrentDungeonRewardU8Pairs(&writer, slot)
|
||||
}
|
||||
writer.writeUint32(snapshot.TotalReward)
|
||||
for _, group := range snapshot.AuxGroupsA {
|
||||
writeCurrentDungeonRewardU8Pairs(&writer, group)
|
||||
}
|
||||
for _, group := range snapshot.AuxGroupsB {
|
||||
writeCurrentDungeonRewardU8Pairs(&writer, group)
|
||||
}
|
||||
writer.writeUint32(snapshot.PreTailValue)
|
||||
writer.writeUint32(snapshot.Tail.Value0)
|
||||
writer.writeByte(boolByte(snapshot.Tail.ShowResult))
|
||||
writer.writeByte(boolByte(snapshot.Tail.Flag0))
|
||||
writer.writeUint32(snapshot.Tail.MonsterTotalExp)
|
||||
writer.writeUint32(snapshot.Tail.Value2)
|
||||
writer.writeUint32(snapshot.Tail.Value3)
|
||||
writer.writeUint32(snapshot.Tail.Value4)
|
||||
body := writer.bytes()
|
||||
if len(body) < currentDungeonClearRewardMinimumBodySize {
|
||||
return nil, fmt.Errorf("%w: body=%d minimum=%d", errCurrentDungeonClearRewardShape, len(body), currentDungeonClearRewardMinimumBodySize)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func validateCurrentDungeonClearRewardSnapshot(snapshot currentDungeonClearRewardSnapshot) error {
|
||||
return validateCurrentDungeonClearRewardSnapshotWithPolicy(snapshot, false)
|
||||
}
|
||||
|
||||
func validateCurrentDungeonClearRewardSnapshotWithPolicy(snapshot currentDungeonClearRewardSnapshot, allowZeroReward bool) error {
|
||||
if !snapshot.Committed || snapshot.CharacterID == 0 || snapshot.CompletionKey == "" || snapshot.Source == "" {
|
||||
return fmt.Errorf("%w: character=%d key=%q source=%q", errCurrentDungeonClearRewardUncommitted, snapshot.CharacterID, snapshot.CompletionKey, snapshot.Source)
|
||||
}
|
||||
if len(snapshot.BonusGroupsA) > math.MaxUint8 || len(snapshot.BonusGroupsB) > math.MaxUint8 ||
|
||||
len(snapshot.Drops) > math.MaxUint8 || uint64(len(snapshot.MonsterExperienceByObjectKey))+uint64(len(snapshot.Quest)) > math.MaxUint32 {
|
||||
return fmt.Errorf("%w: top-level dynamic count overflow", errCurrentDungeonClearRewardShape)
|
||||
}
|
||||
for _, groups := range [][currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair{snapshot.CardSlots, snapshot.AuxGroupsA, snapshot.AuxGroupsB} {
|
||||
for _, group := range groups {
|
||||
if len(group) > math.MaxUint8 {
|
||||
return fmt.Errorf("%w: group count=%d", errCurrentDungeonClearRewardShape, len(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, drop := range snapshot.Drops {
|
||||
if drop.ObjectKey == 0 || drop.TemplateID == 0 || drop.StackCount == 0 {
|
||||
return fmt.Errorf("%w: invalid drop=%+v", errCurrentDungeonClearRewardShape, drop)
|
||||
}
|
||||
}
|
||||
if !allowZeroReward && !currentDungeonClearRewardHasCommittedValue(snapshot) {
|
||||
return fmt.Errorf("%w: committed snapshot contains no reward value", errCurrentDungeonClearRewardShape)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentDungeonClearRewardHasCommittedValue(snapshot currentDungeonClearRewardSnapshot) bool {
|
||||
// TotalReward is deliberately absent from this predicate: it carries the
|
||||
// gold-card commission the client prints, which is a price rather than a
|
||||
// value the character received, so it must never let an otherwise empty
|
||||
// settlement pass the fabricated-zero-body guard.
|
||||
if snapshot.CommittedExperienceGain != 0 || snapshot.CommittedSPGain != 0 || snapshot.CommittedTPGain != 0 ||
|
||||
snapshot.BaseFlag != 0 ||
|
||||
len(snapshot.BonusGroupsA) != 0 || len(snapshot.BonusGroupsB) != 0 ||
|
||||
len(snapshot.MonsterExperienceByObjectKey) != 0 || len(snapshot.Quest) != 0 || len(snapshot.Drops) != 0 {
|
||||
return true
|
||||
}
|
||||
for _, value := range snapshot.Base {
|
||||
if value != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, value := range snapshot.Bonus {
|
||||
if value != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, value := range snapshot.PostBase {
|
||||
if value != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, value := range snapshot.Score {
|
||||
if value != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, groups := range [][currentDungeonClearRewardGroupCount][]currentDungeonClearRewardPair{snapshot.CardSlots, snapshot.AuxGroupsA, snapshot.AuxGroupsB} {
|
||||
for _, group := range groups {
|
||||
if len(group) != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func currentDungeonClearRewardDynamicRows(snapshot currentDungeonClearRewardSnapshot) []currentDungeonClearRewardPair {
|
||||
rows := make([]currentDungeonClearRewardPair, 0,
|
||||
len(snapshot.MonsterExperienceByObjectKey)+len(snapshot.Quest))
|
||||
rows = append(rows, snapshot.MonsterExperienceByObjectKey...)
|
||||
rows = append(rows, snapshot.Quest...)
|
||||
return rows
|
||||
}
|
||||
|
||||
func writeCurrentDungeonRewardU8Pairs(writer *packetWriter, pairs []currentDungeonClearRewardPair) {
|
||||
writer.writeByte(byte(len(pairs)))
|
||||
writeCurrentDungeonRewardPairs(writer, pairs)
|
||||
}
|
||||
|
||||
func writeCurrentDungeonRewardPairs(writer *packetWriter, pairs []currentDungeonClearRewardPair) {
|
||||
for _, pair := range pairs {
|
||||
writer.writeUint32(pair.Key)
|
||||
writer.writeUint32(pair.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func boolByte(value bool) byte {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in new issue
Block a user