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,125 @@
package dnfbridge
import (
"encoding/hex"
"strings"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
// S2C 回包二次加密的服务端接线(设计见 `服务端回包二次加密_客户端DLL解密_设计文档_20260930.md`)。
//
// 三个开关值:
//
// off —— 完全不变(连 challenge 都不带标记),这是默认值;
// shadow —— 带**不带**标记、只记录"如果开了会启用哪条连接",用来先证明协商链路;
// on —— challenge 带标记;客户端回 version 3 时才加密这条连接的回包。
//
// 关键安全性质:**服务端只在客户端明确回话(version 3)之后才加密**,
// 因此任何"没准备好解密"的一端(旧 DLL、本地直连、第三方客户端)都保持明文,
// 不会收到乱码——这正是"服务端沉默/乱码 = 客户端卡住"这条老故障形状的防线。
// gameS2CCipherMode 当前模式的归一化结果。
func (s *Service) gameS2CCipherMode() string {
if s == nil {
return gameS2CCipherOff
}
return normalizeGameS2CCipher(s.options.gameS2CCipher)
}
// gameS2CMasterKey 返回固化主密钥(可用 DNFBRIDGE_GAME_S2C_KEY_BASE64 覆盖,仅供联调/灰度)。
func (s *Service) gameS2CMasterKey() ([]byte, error) {
if s != nil {
if override := strings.TrimSpace(s.options.gameS2CMasterKeyBase64); override != "" {
return dnfproto.ParseS2CMasterKey([]byte(override))
}
}
return dnfproto.DefaultS2CMasterKey(), nil
}
// prepareGameS2CCipher 在**发 challenge 之前**就把这条连接的加解密器建好。
//
// 为什么顺序不能反:客户端一旦看到 challenge 里的标记就会回 version 3(= 我会解密)。
// 如果服务端到那时才发现"主密钥坏了 / 派生失败"而回落明文,客户端却已经在解密明文,
// 结果是全乱码。所以这里先建好,建不出来就**根本不发标记**——客户端不会确认,也就不会解密。
func (s *Service) prepareGameS2CCipher(session *gameSession, nonce []byte, logEvent func(string, ...any)) (*dnfproto.S2CCipher, bool) {
if s == nil || logEvent == nil {
return nil, false
}
mode := s.gameS2CCipherMode()
if mode != gameS2CCipherOn {
return nil, false
}
masterKey, err := s.gameS2CMasterKey()
if err != nil {
logEvent("game-s2c-cipher-skipped", "reason", "master-key-invalid", "error", err)
return nil, false
}
cipher, err := dnfproto.NewS2CCipher(masterKey, nonce)
if err != nil {
logEvent("game-s2c-cipher-skipped", "reason", "derive-failed", "error", err)
return nil, false
}
return cipher, true
}
// negotiateGameS2CCipher 在 LANA 握手之后决定这条连接是否启用回包加密。
//
// nonce 是服务端写进 challenge 的 16 字节随机数:客户端也拿得到同一段字节,
// 因此两端各自就能派生出同一把连接密钥,**不需要任何额外往返**。
// prepared 是发 challenge 之前就建好的加解密器(nil = 本次没请求加密)。
func (s *Service) negotiateGameS2CCipher(
session *gameSession,
frame launcherAuthFrame,
prepared *dnfproto.S2CCipher,
logEvent func(string, ...any),
) {
if s == nil || session == nil || logEvent == nil {
return
}
mode := s.gameS2CCipherMode()
if mode == gameS2CCipherOff {
return
}
requested := prepared != nil
clientReady := frame.Version == launcherAuthFrameVersionS2C
if mode == gameS2CCipherShadow {
logEvent("game-s2c-cipher-shadow",
"requested", requested,
"client_frame_version", frame.Version,
"client_ready", clientReady,
"would_enable", requested && clientReady)
return
}
if !requested {
// 上面 prepare 阶段已经记过具体原因(主密钥坏了/派生失败),这里不再重复。
return
}
if !clientReady {
// 旧 90CN.dll(或不装 DLL 的客户端):保持明文,这是设计上允许的结果。
logEvent("game-s2c-cipher-skipped",
"reason", "client-did-not-acknowledge",
"client_frame_version", frame.Version)
return
}
session.s2cCipher = prepared
logEvent("game-s2c-cipher-enabled",
"codec", dnfproto.S2CCipherCodecName,
"nonce_prefix", hex.EncodeToString(prepared.NoncePrefix()))
}
// applyGameS2CBodyCipher 在协商成功的连接上就地加密 body,返回是否真的加密了。
//
// 调用点必须在**算 checksum 之前**(checksum 覆盖 seq+body,客户端按线上字节校验):
// 顺序反了客户端会整包拒绝,表现同样是"卡住"。
func (s *Service) applyGameS2CBodyCipher(session *gameSession, body []byte, msgID uint16, seq uint16) bool {
if s == nil || session == nil || len(body) == 0 {
return false
}
cipher := session.s2cCipher
if cipher == nil {
return false
}
cipher.Apply(body, msgID, seq)
return true
}