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,355 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// currentPartyUDPEndpointObservationTTL bounds the address-keyed observations,
|
||||
// whose only consumer is the fail-closed "exactly one session on this remote
|
||||
// IP" correlation. Keeping them short prevents a stale port from a previous
|
||||
// client on the same NAT from being attributed to the next one.
|
||||
const currentPartyUDPEndpointObservationTTL = 30 * time.Second
|
||||
|
||||
// currentPartyUDPEndpointPIDObservationTTL bounds the PID-keyed observations.
|
||||
// The current client sends its single native reachability probe right after
|
||||
// the login handshake, before any character is selected, and never repeats it;
|
||||
// a player who sits on the character screen (live 2026-09-06: probes at 22:08:09
|
||||
// and 22:11:37, characters bound at 22:12:09-22:12:12) therefore binds several
|
||||
// minutes later. With the 30-second TTL the PID entry had already been pruned
|
||||
// by then, restoreCurrentPartyUDPEndpoint found nothing, both members kept a
|
||||
// zero port, runtimePartyNativePeerEndpointsReady stayed false and every party
|
||||
// formation was silently deferred. A PID is an exact local-process identity:
|
||||
// a reused PID belongs to a new client process that probes again on login and
|
||||
// overwrites the entry, so the observation may live as long as the process.
|
||||
const currentPartyUDPEndpointPIDObservationTTL = 24 * time.Hour
|
||||
|
||||
type currentPartyUDPEndpointObservation struct {
|
||||
endpoint netip.AddrPort
|
||||
observedAt time.Time
|
||||
}
|
||||
|
||||
type currentPartyUDPEndpointRegistry struct {
|
||||
byPID map[uint32]currentPartyUDPEndpointObservation
|
||||
byAddress map[netip.Addr]map[uint16]time.Time
|
||||
}
|
||||
|
||||
func newCurrentPartyUDPEndpointRegistry() currentPartyUDPEndpointRegistry {
|
||||
return currentPartyUDPEndpointRegistry{
|
||||
byPID: make(map[uint32]currentPartyUDPEndpointObservation),
|
||||
byAddress: make(map[netip.Addr]map[uint16]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) observeCurrentPartyUDPEndpoint(endpoint netip.AddrPort) {
|
||||
if s == nil || !endpoint.IsValid() || !endpoint.Addr().Is4() || endpoint.Port() == 0 {
|
||||
return
|
||||
}
|
||||
endpoint = netip.AddrPortFrom(endpoint.Addr().Unmap(), endpoint.Port())
|
||||
now := time.Now().UTC()
|
||||
|
||||
var ownerPID uint32
|
||||
serverAddress, _ := netip.ParseAddr(s.options.serverIP)
|
||||
if endpoint.Addr().IsLoopback() || serverAddress.IsValid() && endpoint.Addr() == serverAddress.Unmap() {
|
||||
resolver := s.partyUDPOwnerPID
|
||||
if resolver == nil {
|
||||
resolver = localUDPEndpointOwnerPID
|
||||
}
|
||||
ownerPID, _ = resolver(endpoint)
|
||||
}
|
||||
|
||||
s.partyEndpointMu.Lock()
|
||||
s.pruneCurrentPartyUDPEndpointsLocked(now)
|
||||
if s.partyEndpoints.byPID == nil {
|
||||
s.partyEndpoints = newCurrentPartyUDPEndpointRegistry()
|
||||
}
|
||||
ports := s.partyEndpoints.byAddress[endpoint.Addr()]
|
||||
if ports == nil {
|
||||
ports = make(map[uint16]time.Time)
|
||||
s.partyEndpoints.byAddress[endpoint.Addr()] = ports
|
||||
}
|
||||
ports[endpoint.Port()] = now
|
||||
if ownerPID != 0 {
|
||||
s.partyEndpoints.byPID[ownerPID] = currentPartyUDPEndpointObservation{
|
||||
endpoint: endpoint, observedAt: now,
|
||||
}
|
||||
}
|
||||
s.partyEndpointMu.Unlock()
|
||||
|
||||
sessions := s.currentPartyUDPEndpointSessions(endpoint.Addr(), ownerPID)
|
||||
for _, session := range sessions {
|
||||
if currentPartyPeerPort(session) != 0 {
|
||||
continue
|
||||
}
|
||||
if s.applyCurrentPartyUDPEndpoint(session, endpoint, "native_udp_probe") {
|
||||
s.logGameEvent(session, "game-party-peer-endpoint-observed",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"client_pid", session.clientPID,
|
||||
"ipv4", endpoint.Addr(),
|
||||
"udp_port", endpoint.Port(),
|
||||
"source", "native_udp_reachability_probe")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) currentPartyUDPEndpointSessions(address netip.Addr, ownerPID uint32) []*gameSession {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result := make([]*gameSession, 0, 1)
|
||||
seen := make(map[*gameSession]struct{})
|
||||
for _, session := range s.gameSessions {
|
||||
if session == nil {
|
||||
continue
|
||||
}
|
||||
if ownerPID != 0 {
|
||||
if session.clientPID != ownerPID {
|
||||
continue
|
||||
}
|
||||
} else if currentPartySessionRemoteAddress(session) != address {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[session]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[session] = struct{}{}
|
||||
result = append(result, session)
|
||||
}
|
||||
// A source PID is an exact local-process identity. Remote IP correlation is
|
||||
// accepted for any current session: each observed endpoint binds to the
|
||||
// first unbound session for that address, letting shared-NAT dual-login
|
||||
// clients (owner PID is not resolvable on public addresses) register their
|
||||
// probe ports instead of failing closed on ambiguity.
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) restoreCurrentPartyUDPEndpoint(session *gameSession) {
|
||||
if s == nil || session == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var observation currentPartyUDPEndpointObservation
|
||||
var found bool
|
||||
s.partyEndpointMu.Lock()
|
||||
s.pruneCurrentPartyUDPEndpointsLocked(now)
|
||||
if session.clientPID != 0 {
|
||||
observation, found = s.partyEndpoints.byPID[session.clientPID]
|
||||
}
|
||||
if !found {
|
||||
address := currentPartySessionRemoteAddress(session)
|
||||
ports := s.partyEndpoints.byAddress[address]
|
||||
if len(ports) == 1 && len(s.currentPartyUDPEndpointSessionsWithoutLock(address)) == 1 {
|
||||
for port, observedAt := range ports {
|
||||
observation = currentPartyUDPEndpointObservation{
|
||||
endpoint: netip.AddrPortFrom(address, port), observedAt: observedAt,
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
s.partyEndpointMu.Unlock()
|
||||
if found {
|
||||
s.applyCurrentPartyUDPEndpoint(session, observation.endpoint, "session_restore")
|
||||
}
|
||||
}
|
||||
|
||||
// currentPartyUDPEndpointSessionsWithoutLock is used while partyEndpointMu is
|
||||
// held. It takes only Service.mu, preserving the global lock order
|
||||
// partyEndpointMu -> Service.mu used by endpoint restoration.
|
||||
func (s *Service) currentPartyUDPEndpointSessionsWithoutLock(address netip.Addr) []*gameSession {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result := make([]*gameSession, 0, 1)
|
||||
seen := make(map[*gameSession]struct{})
|
||||
for _, session := range s.gameSessions {
|
||||
if session == nil || currentPartySessionRemoteAddress(session) != address {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[session]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[session] = struct{}{}
|
||||
result = append(result, session)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) pruneCurrentPartyUDPEndpointsLocked(now time.Time) {
|
||||
pidCutoff := now.Add(-currentPartyUDPEndpointPIDObservationTTL)
|
||||
for pid, observation := range s.partyEndpoints.byPID {
|
||||
if observation.observedAt.Before(pidCutoff) {
|
||||
delete(s.partyEndpoints.byPID, pid)
|
||||
}
|
||||
}
|
||||
cutoff := now.Add(-currentPartyUDPEndpointObservationTTL)
|
||||
for address, ports := range s.partyEndpoints.byAddress {
|
||||
for port, observedAt := range ports {
|
||||
if observedAt.Before(cutoff) {
|
||||
delete(ports, port)
|
||||
}
|
||||
}
|
||||
if len(ports) == 0 {
|
||||
delete(s.partyEndpoints.byAddress, address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// currentPartyUDPEndpointObservedFrom reports whether the native reachability
|
||||
// probe has seen a datagram from exactly this address/port tuple: either as a
|
||||
// PID-owned observation or as a still-live address-keyed one.
|
||||
func (s *Service) currentPartyUDPEndpointObservedFrom(address netip.Addr, port uint16) bool {
|
||||
if s == nil || !address.IsValid() || port == 0 {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
s.partyEndpointMu.Lock()
|
||||
defer s.partyEndpointMu.Unlock()
|
||||
if s.partyEndpoints.byPID == nil {
|
||||
return false
|
||||
}
|
||||
s.pruneCurrentPartyUDPEndpointsLocked(now)
|
||||
if _, live := s.partyEndpoints.byAddress[address][port]; live {
|
||||
return true
|
||||
}
|
||||
for _, observation := range s.partyEndpoints.byPID {
|
||||
if observation.endpoint.Addr() == address && observation.endpoint.Port() == port {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// captureCurrentPartyPeerEndpointFromOp2 is the cross-validated fallback for a
|
||||
// session whose native probe observation could not be attributed to it.
|
||||
//
|
||||
// The 31-byte class1/op2 body carries the client's own inner/outer IPv4 and a
|
||||
// network-order UDP port (the old tree's SET_UDP_IP_PORT reading). Live
|
||||
// 2026-09-06: both clients' op2 ports (5063, 62926) were exactly the source
|
||||
// ports the native probe had answered ten milliseconds earlier. The same-version
|
||||
// evidence that op2 alone is unsafe (a false 39077 endpoint) still stands, so
|
||||
// the port is accepted only when the probe has actually observed it from this
|
||||
// connection's remote address, and never over a registration the probe already
|
||||
// bound. This rescues two local clients whose UDP socket owner PID could not be
|
||||
// resolved, where remote-IP correlation is deliberately fail-closed.
|
||||
func (s *Service) captureCurrentPartyPeerEndpointFromOp2(session *gameSession, body []byte) {
|
||||
if s == nil || session == nil || len(body) != currentChannelReconnectProbeSize {
|
||||
return
|
||||
}
|
||||
port := uint16(body[9])<<8 | uint16(body[10])
|
||||
if port == 0 {
|
||||
return
|
||||
}
|
||||
// 31 字节 op2 前 15 字节即 SET_UDP_IP_PORT:nat(1) + inner(4) + outer(4)
|
||||
// + port(2, 大端) + mtu(4, 小端)。90CN 客户端不单独发 op434 path report,
|
||||
// 路径 MTU 只在这里携带;忽略它会让 op11 一直下发 512,把伤害/增益同步
|
||||
// 数据拆成大量小包而丢包(伤害变 1、buff 0 级)。
|
||||
if mtu := binary.LittleEndian.Uint32(body[11:15]); currentPartyPathMTU(mtu) {
|
||||
session.partyPeerMu.Lock()
|
||||
session.partyPathMTU = mtu
|
||||
session.partyPeerMu.Unlock()
|
||||
}
|
||||
if currentPartyPeerEndpointSnapshot(session).Port != 0 {
|
||||
return
|
||||
}
|
||||
address := currentPartySessionRemoteAddress(session)
|
||||
if !address.IsValid() || !s.currentPartyUDPEndpointObservedFrom(address, port) {
|
||||
s.logGameEvent(session, "game-party-peer-endpoint-op2-rejected",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"client_pid", session.clientPID,
|
||||
"udp_port", port,
|
||||
"reason", "op2_port_not_observed_by_native_probe")
|
||||
return
|
||||
}
|
||||
endpoint := netip.AddrPortFrom(address, port)
|
||||
if session.clientPID != 0 {
|
||||
now := time.Now().UTC()
|
||||
s.partyEndpointMu.Lock()
|
||||
if s.partyEndpoints.byPID == nil {
|
||||
s.partyEndpoints = newCurrentPartyUDPEndpointRegistry()
|
||||
}
|
||||
if _, known := s.partyEndpoints.byPID[session.clientPID]; !known {
|
||||
s.partyEndpoints.byPID[session.clientPID] = currentPartyUDPEndpointObservation{
|
||||
endpoint: endpoint, observedAt: now,
|
||||
}
|
||||
}
|
||||
s.partyEndpointMu.Unlock()
|
||||
}
|
||||
if s.applyCurrentPartyUDPEndpoint(session, endpoint, "op2_port_cross_validated_by_native_probe") {
|
||||
// op2 的 inner IPv4(body[1:5])是客户端本机/内网地址。公网部署下两个
|
||||
// 客户端常在同一内网,用 inner 直连才不丢包;OuterIPv4 保持 probe
|
||||
// 观察到的公网出口(applyCurrentPartyUDPEndpoint 已写入)。
|
||||
if inner := [4]byte{body[1], body[2], body[3], body[4]}; isUsablePartyPeerIPv4(inner) {
|
||||
session.partyPeerMu.Lock()
|
||||
session.partyPeer.InnerIPv4 = inner
|
||||
session.partyPeerMu.Unlock()
|
||||
}
|
||||
s.logGameEvent(session, "game-party-peer-endpoint-observed",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"client_pid", session.clientPID,
|
||||
"ipv4", address,
|
||||
"inner_ipv4", netip.AddrFrom4([4]byte{body[1], body[2], body[3], body[4]}).String(),
|
||||
"udp_port", port,
|
||||
"source", "op2_port_cross_validated_by_native_probe")
|
||||
}
|
||||
}
|
||||
|
||||
// isUsablePartyPeerIPv4 rejects unspecified/class-0 and multicast/reserved
|
||||
// class-E ranges, matching the legacy SET_UDP_IP_PORT wire parser. Private,
|
||||
// CGNAT, link-local and loopback addresses remain valid; reachability is
|
||||
// decided by the direct/relay path.
|
||||
func isUsablePartyPeerIPv4(ip [4]byte) bool {
|
||||
return ip[0] != 0 && ip[0] < 224
|
||||
}
|
||||
|
||||
func (s *Service) applyCurrentPartyUDPEndpoint(session *gameSession, endpoint netip.AddrPort, source string) bool {
|
||||
if session == nil || !endpoint.IsValid() || !endpoint.Addr().Is4() || endpoint.Port() == 0 {
|
||||
return false
|
||||
}
|
||||
ipv4 := endpoint.Addr().As4()
|
||||
registration := currentPartyPeerEndpointRegistration{
|
||||
InnerIPv4: ipv4,
|
||||
OuterIPv4: ipv4,
|
||||
Port: endpoint.Port(),
|
||||
MTU: currentPartyPeerDefaultPathMTU,
|
||||
}
|
||||
session.partyPeerMu.Lock()
|
||||
changed := session.partyPeer.InnerIPv4 != registration.InnerIPv4 ||
|
||||
session.partyPeer.OuterIPv4 != registration.OuterIPv4 ||
|
||||
session.partyPeer.Port != registration.Port
|
||||
session.partyPeer = registration
|
||||
session.partyPeerMu.Unlock()
|
||||
if changed {
|
||||
s.logPacketEvent("game-party-peer-endpoint-bound",
|
||||
"conn_id", session.connID,
|
||||
"char_id", session.selectedCharacterID,
|
||||
"client_pid", session.clientPID,
|
||||
"ipv4", endpoint.Addr(),
|
||||
"udp_port", endpoint.Port(),
|
||||
"source", source)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func currentPartySessionRemoteAddress(session *gameSession) netip.Addr {
|
||||
if session == nil || session.conn == nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
tcpAddress, ok := session.conn.RemoteAddr().(*net.TCPAddr)
|
||||
if !ok || tcpAddress == nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
ipv4 := tcpAddress.IP.To4()
|
||||
if ipv4 == nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
address, ok := netip.AddrFromSlice(ipv4)
|
||||
if !ok {
|
||||
return netip.Addr{}
|
||||
}
|
||||
return address.Unmap()
|
||||
}
|
||||
Reference in new issue
Block a user