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,619 @@
// channel.go 负责 DNF bridge 频道服 7001 的握手、兼容包解析和频道目录响应。
package dnfbridge
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"net"
"os"
"strings"
"syscall"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
type channelSession struct {
conn net.Conn
connID string
packetSeq uint64
seq uint16
scriptSent bool
closeAfterBootstrapDirectory bool
}
// Channel bootstrap can pause while the current client loads the downloaded
// script, initializes the patch DLLs, or is scanned by endpoint security.
// Eight seconds was too aggressive on cold/low-end release machines and made
// the native client fall back to its generic "connecting server" retry UI.
const channelReadIdleTimeout = 30 * time.Second
func (s *Service) handleChannelConn(ctx context.Context, conn net.Conn) {
defer conn.Close()
session := &channelSession{conn: conn, connID: s.nextPacketConnID("channel")}
reader := bufio.NewReader(conn)
s.logInfo("dnfbridge channel connection accepted",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String())
s.logPacketEvent("channel-connect",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String())
defer s.logPacketEvent("channel-close",
"conn_id", session.connID,
"remote", conn.RemoteAddr().String(),
"local", conn.LocalAddr().String())
setChannelReadDeadline(conn)
if err := s.acceptDofLoginPreface(reader, session); err != nil {
s.logChannelReadError(ctx, conn, err)
return
}
for {
setChannelReadDeadline(conn)
handled, err := s.handleLegacyChannelRequest(reader, session)
if err != nil {
s.logChannelReadError(ctx, conn, err)
return
}
if handled {
if session.closeAfterBootstrapDirectory {
s.logPacketEvent("channel-bootstrap-directory-committed",
"conn_id", session.connID,
"remote", session.conn.RemoteAddr().String())
return
}
continue
}
setChannelReadDeadline(conn)
packet, err := s.readChannelPacket(reader, session)
if err != nil {
s.logChannelReadError(ctx, conn, err)
return
}
if err := s.handleChannelPacket(session, packet); err != nil {
s.logWarn("dnfbridge handle channel packet failed", "msg_id", packet.Header.MsgID, "error", err)
return
}
}
}
func setChannelReadDeadline(conn net.Conn) {
_ = conn.SetReadDeadline(time.Now().Add(channelReadIdleTimeout))
}
func (s *Service) acceptDofLoginPreface(reader *bufio.Reader, session *channelSession) error {
header, err := reader.Peek(dnfproto.LegacyChannelHeaderSize)
if err != nil {
return err
}
if !dnfproto.IsDofLoginPrefacePrefix(header) {
return nil
}
raw := make([]byte, dnfproto.DofLoginPrefaceSize)
if _, err := io.ReadFull(reader, raw); err != nil {
return err
}
s.logChannelPacket(session, "RECV", "channel-dof-preface", raw)
ack := dnfproto.BuildDofLoginAck(s.aesKey())
s.logChannelPacket(session, "SEND", "channel-dof-ack", ack)
_, err = session.conn.Write(ack)
return err
}
func (s *Service) handleLegacyChannelRequest(reader *bufio.Reader, session *channelSession) (bool, error) {
header, err := reader.Peek(dnfproto.LegacyChannelHeaderSize)
if err != nil {
return false, err
}
if dnfproto.IsDofChannelProbePrefix(header) {
raw := make([]byte, dnfproto.LegacyChannelHeaderSize)
if _, err := io.ReadFull(reader, raw); err != nil {
return false, err
}
s.logChannelPacket(session, "RECV", "channel-dof-get-script", raw)
return true, s.sendLegacyScript(session)
}
if !dnfproto.IsDofAskChannelPrefix(header) {
return false, nil
}
raw := make([]byte, dnfproto.DofAskChannelSize)
if _, err := io.ReadFull(reader, raw); err != nil {
return false, err
}
s.logChannelPacket(session, "RECV", "channel-dof-ask", raw)
bootstrap := session.scriptSent
body, err := s.buildChannelInfoBodyFor(dnfproto.LegacyPayload(raw), bootstrap)
if err != nil {
return true, err
}
packet := dnfproto.BuildLegacyChannelPacket(dnfenum.LegacyMsgChannelInfo, body)
s.logChannelPacket(session, "SEND", "channel-dof-info", packet, "msg_id", dnfenum.LegacyMsgChannelInfo)
if _, err := session.conn.Write(packet); err != nil {
return true, err
}
s.logPacketEvent("channel-dof-info-sent",
"conn_id", session.connID,
"remote", session.conn.RemoteAddr().String(),
"local", session.conn.LocalAddr().String(),
"bootstrap", bootstrap)
session.closeAfterBootstrapDirectory = bootstrap
return true, nil
}
func (s *Service) sendLegacyScript(session *channelSession) error {
body, err := s.buildScriptBody()
if err != nil {
return err
}
packet := dnfproto.BuildLegacyChannelPacket(dnfenum.LegacyMsgDofScript, body)
s.logChannelPacket(session, "SEND", "channel-dof-script", packet, "msg_id", dnfenum.LegacyMsgDofScript)
if _, err := session.conn.Write(packet); err != nil {
return err
}
session.scriptSent = true
return nil
}
func (s *Service) readChannelPacket(reader io.Reader, session *channelSession) (dnfproto.ChannelPacket, error) {
header := make([]byte, dnfproto.ChannelHeaderSize)
if _, err := io.ReadFull(reader, header); err != nil {
return dnfproto.ChannelPacket{}, err
}
if packet, ok, err := s.tryReadMixedLegacyChannelPacket(reader, header, session); ok || err != nil {
return packet, err
}
length := binary.LittleEndian.Uint32(header[3:7])
maxPacketBytes := s.options.maxPacketBytes
if maxPacketBytes <= 0 {
maxPacketBytes = defaultMaxPacketBytes
}
if length < dnfproto.ChannelHeaderSize || int(length) > maxPacketBytes {
s.logChannelPacket(session, "RECV", "channel-invalid-header", header)
return dnfproto.ChannelPacket{}, dnfproto.ErrPacketLength
}
packet := make([]byte, int(length))
copy(packet, header)
if _, err := io.ReadFull(reader, packet[dnfproto.ChannelHeaderSize:]); err != nil {
return dnfproto.ChannelPacket{}, err
}
parsed, err := dnfproto.ParseChannelPacket(packet)
if err != nil {
s.logChannelPacket(session, "RECV", "channel-invalid", packet)
return dnfproto.ChannelPacket{}, err
}
s.logChannelPacket(session, "RECV", "channel", packet,
"msg_id", parsed.Header.MsgID,
"header_seq", parsed.Header.Seq,
"body_len", len(parsed.Body))
return parsed, nil
}
func (s *Service) tryReadMixedLegacyChannelPacket(reader io.Reader, header []byte, session *channelSession) (dnfproto.ChannelPacket, bool, error) {
if len(header) < dnfproto.ChannelHeaderSize || header[0] == dnfproto.DefaultChannelClassification {
return dnfproto.ChannelPacket{}, false, nil
}
bodyLen := int(binary.BigEndian.Uint16(header[0:2]))
msgID := binary.LittleEndian.Uint16(header[2:4])
total := 4 + bodyLen
if !isMixedLegacyChannelMsg(msgID) || total < dnfproto.ChannelHeaderSize {
return dnfproto.ChannelPacket{}, false, nil
}
maxPacketBytes := s.options.maxPacketBytes
if maxPacketBytes <= 0 {
maxPacketBytes = defaultMaxPacketBytes
}
if total > maxPacketBytes {
s.logChannelPacket(session, "RECV", "channel-mixed-legacy-invalid", header, "msg_id", msgID)
return dnfproto.ChannelPacket{}, true, dnfproto.ErrPacketLength
}
raw := make([]byte, total)
copy(raw, header)
if total > dnfproto.ChannelHeaderSize {
if _, err := io.ReadFull(reader, raw[dnfproto.ChannelHeaderSize:]); err != nil {
return dnfproto.ChannelPacket{}, true, err
}
}
body := append([]byte(nil), raw[4:]...)
s.logChannelPacket(session, "RECV", "channel-mixed-legacy", raw,
"msg_id", msgID,
"body_len", len(body))
return dnfproto.ChannelPacket{
Header: dnfproto.ChannelHeader{
Classification: dnfproto.DefaultChannelClassification,
MsgID: msgID,
Length: uint32(total),
Seq: mixedLegacySequence(body),
},
Body: body,
}, true, nil
}
func isMixedLegacyChannelMsg(msgID uint16) bool {
switch dnfenum.ChannelMsg(msgID) {
case dnfenum.MsgCSConnect, dnfenum.MsgCSAskChannelInfoNew:
return true
default:
return false
}
}
func mixedLegacySequence(body []byte) uint16 {
for _, value := range body {
if value != 0 {
return uint16(value)
}
}
return 1
}
func (s *Service) logChannelReadError(ctx context.Context, conn net.Conn, err error) {
if !isQuietChannelReadError(err) && ctx.Err() == nil {
s.logWarn("dnfbridge read channel packet failed", "remote", conn.RemoteAddr().String(), "error", err)
}
}
func isQuietChannelReadError(err error) bool {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
return errors.Is(err, io.EOF) ||
errors.Is(err, net.ErrClosed) ||
errors.Is(err, syscall.ECONNRESET)
}
func (s *Service) handleChannelPacket(session *channelSession, packet dnfproto.ChannelPacket) error {
switch dnfenum.ChannelMsg(packet.Header.MsgID) {
case dnfenum.MsgCSUpdateChannelInfo:
body, err := s.buildChannelUpdateBody(packet.Body)
if err != nil {
return err
}
return s.sendChannelClass(session, uint16(dnfenum.MsgSCAskChannelInfo), body, packet.Header.Classification)
case dnfenum.MsgCSConnect:
return s.sendChannel(session, uint16(dnfenum.MsgSCConnect), s.buildConnectBody())
case dnfenum.MsgCSCheckScript:
body, err := s.buildScriptVersionBody()
if err != nil {
return err
}
return s.sendChannel(session, uint16(dnfenum.MsgSCCheckScript), body)
case dnfenum.MsgCSGetScript:
return s.sendChannelScript(session)
case dnfenum.MsgCSAskChannelInfoNew:
if !session.scriptSent {
if err := s.sendChannelScript(session); err != nil {
return err
}
}
body, err := s.buildChannelInfoBodyFor(packet.Body, true)
if err != nil {
return err
}
return s.sendChannel(session, uint16(dnfenum.MsgSCAskChannelInfoNew), body)
default:
return nil
}
}
// sendChannelScript 用最新频道包下发 channel_info.etc,只影响客户端频道静态表加载,不写玩家状态。
func (s *Service) sendChannelScript(session *channelSession) error {
body, err := s.buildScriptBody()
if err != nil {
return err
}
if err := s.sendChannel(session, uint16(dnfenum.MsgSCGetScript), body); err != nil {
return err
}
session.scriptSent = true
return nil
}
func (s *Service) sendChannel(session *channelSession, msgID uint16, body []byte) error {
return s.sendChannelClass(session, msgID, body, dnfproto.DefaultChannelClassification)
}
func (s *Service) sendChannelClass(session *channelSession, msgID uint16, body []byte, class byte) error {
packet, err := dnfproto.BuildChannelPacket(msgID, body, session.seq, dnfproto.DefaultChannelClassification)
if err != nil {
return err
}
if class != 0 && class != dnfproto.DefaultChannelClassification {
packet, err = dnfproto.BuildChannelPacket(msgID, body, session.seq, class)
if err != nil {
return err
}
}
session.seq++
s.logChannelPacket(session, "SEND", "channel", packet,
"msg_id", msgID,
"header_seq", session.seq-1,
"body_len", len(body))
_, err = session.conn.Write(packet)
return err
}
func (s *Service) logChannelPacket(session *channelSession, direction string, kind string, data []byte, fields ...any) {
if session == nil {
s.logPacket(direction, kind, data, fields...)
return
}
session.packetSeq++
args := make([]any, 0, len(fields)+4)
args = append(args, "conn_id", session.connID, "pkt_seq", session.packetSeq)
args = append(args, fields...)
s.logPacket(direction, kind, data, args...)
}
func (s *Service) buildConnectBody() []byte {
key := s.aesKey()
body := make([]byte, 36)
copy(body[4:], []byte(key))
return body
}
func (s *Service) buildScriptVersionBody() ([]byte, error) {
raw := make([]byte, 20)
copy(raw[4:], []byte(s.options.scriptVersion))
return encryptChannelData(raw, s.aesKey(), false)
}
func (s *Service) buildScriptBody() ([]byte, error) {
script := s.currentChannelScript()
if len(script) == 0 {
raw, err := osReadFile(s.options.channelInfoFile)
if err != nil {
return nil, err
}
script, err = s.buildChannelScript(raw)
if err != nil {
return nil, fmt.Errorf("build 90CN online channel script: %w", err)
}
}
return encryptChannelData(script, s.aesKey(), true)
}
func (s *Service) buildChannelUpdateBody(_ []byte) ([]byte, error) {
channel, err := s.loginChannel()
if err != nil {
return nil, err
}
var writer packetWriter
writer.writeUint32(0)
writer.buffer.Write(fixedASCII(s.options.serverIP, 16))
writer.writeInt32(channel.Port)
writer.writeInt32(channel.MaxUsers)
writer.writeInt32(channel.CurrentUsers)
return writer.bytes(), nil
}
// currentSpecialAreaChannelType 特殊区域频道类型。
//
// 线上 channel_info.etc 里 121(ancient 远古)与 171(deathtower 绝望之塔)都是 type 22。
// 服主口径(2026-09-30):任何时刻必须至少有一个 22 类型频道处于开启状态——运维工具的关闭守卫
// (channelstore.checkDisableTargets)会拒绝关掉最后一个,服务端这边只告警,让手改表的人也能看到。
//
// 注意别用 dnfenum.DeathTowerChannelType(=11):那份 ETC 里 type 11 是普通城镇频道,
// 这个常量错位的问题在 channelcatalog.isResidentChannel 的注释里已经记过一次。
const currentSpecialAreaChannelType = 22
func catalogHasSpecialAreaChannel(catalog *channelcatalog.Catalog) bool {
if catalog == nil {
return false
}
for _, channel := range catalog.Channels() {
if int(channel.Type) == currentSpecialAreaChannelType {
return true
}
}
return false
}
// currentChannelTableWarnings 返回"表能跑、但玩家侧或运维约定会受影响"的告警。
// 启动与热重载共用;includeDefaultTown=false 时跳过"缺默认城镇频道"那条
// (严格模式已经把它当硬错误拒绝了,不该再重复告警一次)。
func currentChannelTableWarnings(catalog *channelcatalog.Catalog, includeDefaultTown bool) []string {
if catalog == nil {
return nil
}
warnings := make([]string, 0, 2)
if includeDefaultTown {
if _, ok := catalog.Channel(dnfenum.DefaultGameChannelID); !ok {
warnings = append(warnings, fmt.Sprintf(
"频道表没有默认城镇频道 %d:登录时客户端只会自动拨它记住的那个频道号,"+
"记住的不是表里这些频道的玩家会停在选频道界面(没有手动入口)",
dnfenum.DefaultGameChannelID))
}
}
if !catalogHasSpecialAreaChannel(catalog) {
warnings = append(warnings, fmt.Sprintf(
"频道表没有任何特殊区域频道(type %d):线上默认是 121 远古 / 171 绝望之塔,"+
"关闭它们的守卫要求至少留一个开启",
currentSpecialAreaChannelType))
}
return warnings
}
// loginChannel 选出 game class0/op1 首包与"刷新频道信息"回包要广告的那个频道端点。
//
// 顺序:默认城镇频道 11 → 引导频道 19 → 目录里第一个能承载城镇 actor 的频道。
// 最后这档是给"运维只开少数频道(甚至只开特殊区域频道)"用的:原来只认 11/19,
// 两个都不在表里就返回 error —— 而 buildChannelUpdateBody 的错误会让
// handleChannelPacket 失败、进而把 7001 那条频道连接关掉(见 handleChannelConn 的错误分支)。
func (s *Service) loginChannel() (channelcatalog.Channel, error) {
catalog := s.currentCatalog()
if catalog == nil {
return channelcatalog.Channel{}, channelcatalog.ErrEmpty
}
if channel, ok := catalog.Channel(dnfenum.DefaultGameChannelID); ok {
return channel, nil
}
if channel, ok := catalog.Channel(dnfenum.BootstrapChannelID); ok {
return channel, nil
}
for _, channel := range catalog.Channels() {
if channelFitsTownActorOwner(channel.ID) {
return channel, nil
}
}
return channelcatalog.Channel{}, fmt.Errorf(
"%w: resident channels %d and %d are missing and no catalog channel fits the town actor owner",
channelcatalog.ErrEmpty,
dnfenum.DefaultGameChannelID,
dnfenum.BootstrapChannelID,
)
}
func (s *Service) buildChannelInfoBody(request []byte) ([]byte, error) {
return s.buildChannelInfoBodyFor(request, false)
}
func (s *Service) buildChannelInfoBodyFor(request []byte, bootstrap bool) ([]byte, error) {
catalog := s.currentCatalog()
if catalog == nil {
return nil, channelcatalog.ErrEmpty
}
group := readNullASCII(request, 0, 20)
channels := s.channelsForRequestMode(group, bootstrap)
firstChannel := 0
lastChannel := 0
if len(channels) > 0 {
firstChannel = channels[0].ID
lastChannel = channels[len(channels)-1].ID
}
s.logPacketEvent("channel-advertise",
"requested_group", group,
"source_server", s.channelServerID(),
"advertise_server", s.channelAdvertiseID(),
"body_mode", s.channelInfoBodyMode(),
"bootstrap", bootstrap,
"count", len(channels),
"first_channel", firstChannel,
"last_channel", lastChannel)
var writer packetWriter
s.writeChannelInfoPrefix(&writer, len(channels))
s.writeChannelInfoEntries(&writer, channels)
return encryptChannelData(writer.bytes(), s.aesKey(), true)
}
func (s *Service) writeChannelInfoPrefix(writer *packetWriter, count int) {
// IDA/MCP 复核 sub_1CA2440:最新客户端按 server_count/server_id/channel_count 解析 0x12 明文头。
// 旧 C# 的 2 字节保留位前缀已确认会把客户端解析游标带偏,不能再作为当前桥接输出。
writer.writeUint32(1)
writer.writeUint32(uint32(s.channelAdvertiseID()))
writer.writeUint32(uint32(count))
}
func (s *Service) writeChannelInfoEntries(writer *packetWriter, channels []channelcatalog.Channel) {
for _, channel := range channels {
writer.buffer.Write(fixedASCII("#"+channel.Name, 20))
writer.writeInt32(channel.MaxUsers)
writer.writeInt32(channel.CurrentUsers)
writer.buffer.Write(fixedASCII(s.options.serverIP, 16))
writer.writeInt32(channel.Port)
}
}
func (s *Service) channelsForRequest(group string) []channelcatalog.Channel {
return s.channelsForRequestMode(group, false)
}
func (s *Service) channelsForRequestMode(group string, bootstrap bool) []channelcatalog.Channel {
catalog := s.currentCatalog()
if catalog == nil {
return nil
}
var channels []channelcatalog.Channel
if bootstrap {
channels = catalog.FilterForBootstrap(group)
} else {
channels = catalog.FilterForRequest(group)
}
channels = s.advertisableChannels(channels, group, bootstrap)
if len(channels) == 0 && isDefaultChannelGroup(group) {
if channel, ok := catalog.ForPort(dnfenum.GamePortBase + dnfenum.DefaultGameChannelID); ok {
return []channelcatalog.Channel{channel}
}
}
return channels
}
// advertisableChannels removes every channel the current client cannot own a
// town actor in. A published channel is selectable, and selecting one above the
// u8 town-actor-owner range produced a session that entered the game with an
// uncommitted owner. Refusing to publish the channel is what makes that state
// unreachable, because the client derives the owner byte from the advertised id
// and the server cannot renumber it on the wire without lying about which
// channel the player is in -- the ids have to be in range in channel_info.etc.
//
// The dropped ids are logged rather than silently trimmed: a shipped
// channel_info.etc that loses entries here must be visible in the log.
func (s *Service) advertisableChannels(
channels []channelcatalog.Channel,
group string,
bootstrap bool,
) []channelcatalog.Channel {
dropped := make([]int, 0)
for _, channel := range channels {
if !channelFitsTownActorOwner(channel.ID) {
dropped = append(dropped, channel.ID)
}
}
if len(dropped) == 0 {
return channels
}
kept := make([]channelcatalog.Channel, 0, len(channels)-len(dropped))
for _, channel := range channels {
if channelFitsTownActorOwner(channel.ID) {
kept = append(kept, channel)
}
}
s.logPacketEvent("channel-advertise-out-of-range-dropped",
"requested_group", group,
"bootstrap", bootstrap,
"kept", len(kept),
"dropped", len(dropped),
"dropped_channels", dropped,
"limit", math.MaxUint8,
"reason", "channel_id_cannot_fit_current_exe_u8_town_actor_owner")
return kept
}
func isDefaultChannelGroup(group string) bool {
group = strings.TrimSpace(strings.Trim(group, "`"))
group = strings.TrimPrefix(group, "[")
group = strings.TrimSuffix(group, "]")
group = strings.ToLower(strings.TrimSpace(group))
return group == "" || group == dnfenum.GroupCain
}
func readNullASCII(data []byte, offset int, size int) string {
if offset >= len(data) || size <= 0 {
return ""
}
limit := offset + size
if limit > len(data) {
limit = len(data)
}
end := offset
for end < limit && data[end] != 0 {
end++
}
return strings.TrimSpace(string(data[offset:end]))
}
func osReadFile(path string) ([]byte, error) {
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自部署配置,DNF bridge 只读取 channel_info.etc 活配置。
if err != nil {
return nil, fmt.Errorf("read channel info script: %w", err)
}
return data, nil
}