服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
620 lines
21 KiB
Go
620 lines
21 KiB
Go
// 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
|
||
}
|