Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

356 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}