Compare commits

..

31 Commits

Author SHA1 Message Date
Yue-bin
75a713470f fix(P2P): 无法连接
- 添加Patch_P2PStatistics_Awake补丁,修复因静态字典重复Add导致的崩溃问题
  当Awake方法被调用多次时,通过检查字典是否已填充来避免ArgumentException

- 添加Patch_ReadMessageBuffer_Hello补丁,允许Hello消息在客户端初始化前通过
  解决KCP握手无法完成导致连接建立失败的问题

- 在Plugin.cs中注册新补丁类到Harmony实例
2026-04-29 00:16:52 +08:00
Yue-bin
e756c83c4e fix(Patch_SteamNetworking_Recv): 防止KCP数据包处理中的递归调用问题
通过引入[ThreadStatic] _isPumping标志位来防止递归:
当PumpKcpFromSteam内部调用Steam API时会触发相同的Prefix补丁,
此时通过_isPumping标志使调用直接放行到原始Steam API,
避免无限递归循环。同时修复了变量命名不一致的问题,
统一使用参数名如nChannel、pcubMsgSize等,并移除了
多余的调试日志输出以提高性能。
2026-04-29 00:09:17 +08:00
Yue-bin
926a33277f feat(P2P): 添加调试日志统计功能
添加多个调试计数器用于追踪KCP传输状态,包括:
- 总KCP接管次数统计
- Steam回退次数统计
- 保守模式绕过次数统计
- 递归深度检测防止委托循环调用
- 数据包接收发送详细日志记录
- 连接管理器中对等点查找失败日志
2026-04-27 04:51:43 +08:00
Yue-bin
a0119552ab feat(kcpable): 添加Steam网络发送底层拦截补丁
新增Patch_SteamNetworking_Send补丁类,用于拦截SteamNetworking.SendP2PPacket
最底层发送API,确保所有Steam P2P发送都经过KCP接管逻辑处理。

该补丁与Patch_SendP2PPacketToUser协同工作:
- Patch_SendP2PPacketToUser拦截P2PPackageHandler层语义层发送
- 本补丁拦截Steam API最底层,捕获所有绕过P2PPackageHandler的发送路径
- 支持Conservative模式下不可靠消息直通Steam,其他情况尝试KCP接管
- 包含调试统计功能,追踪KCP接管、Steam直通和内部流量数量
2026-04-27 04:51:33 +08:00
Yue-bin
439e0d6b80 fix(lobby): 抑制 CSteamID 值类型方法调用的 Harmony003 误报警告 2026-04-27 03:50:02 +08:00
Yue-bin
d0d06f1129 refactor(recv): 移除不再使用的 using Protocol 引用 2026-04-27 03:47:20 +08:00
Yue-bin
a5010f2879 feat(manager): 添加 ToKcpChannel() 辅助方法,统一 channel offset 计算 2026-04-27 03:46:34 +08:00
Yue-bin
afdab4e15b refactor(peer): PeerConnection 构造函数接受 startTime 参数,移除对 Time.time 的直接依赖 2026-04-27 03:45:46 +08:00
Yue-bin
dc427049dc refactor(mode): 将 KcpMode 枚举从 Const.cs 移至独立的 Transport/KcpMode.cs 2026-04-27 03:44:42 +08:00
Yue-bin
c3991ae561 perf(recv): PumpKcpFromSteam 反射调用改为预编译委托,消除装箱开销 2026-04-27 03:42:38 +08:00
Yue-bin
333b4c2254 refactor(peer): 重命名 DisposeKcpChannels 为 ClearKcpChannels 2026-04-27 03:38:08 +08:00
Yue-bin
38ff01a773 perf(conv): 将 Smaller/Bigger 从计算属性改为 readonly 字段 2026-04-27 03:37:28 +08:00
Yue-bin
97351d9eff refactor(manager): 移除 Shutdown() 中冗余的 buffer Clear() 调用 2026-04-27 03:30:27 +08:00
Yue-bin
9e38994185 refactor(buffer): 移除未使用的 HasPending() 方法 2026-04-27 03:29:37 +08:00
Yue-bin
2935cb0206 refactor(timer): 删除未使用的 Timer 类 2026-04-27 03:28:39 +08:00
Yue-bin
031e668c98 refactor(hello): 移除无用的 Send() 方法与 Lidgren.Network 引用 2026-04-27 03:28:11 +08:00
Yue-bin
eeebd28e2e feat(kcpable): 添加KCP连接状态指示器功能
- 创建Patch_OnlinePlayerUI_KcpIndicator补丁类,用于在线玩家界面中显示KCP连接状态
- 实现KcpIndicatorHelper辅助类,通过反射访问OnlinePlayerUI的私有字段
- 在玩家名称前添加紫色[K]标识来显示KCP连接状态
- 添加IsKcpConnected方法用于查询对端KCP连接状态
- 在Plugin.cs中注册新的Harmony补丁
- 使用VeryLow优先级确保补丁在其他mod之前执行
2026-04-27 03:16:44 +08:00
Yue-bin
38d416fe82 feat(stick.plugins.kcpable): 添加TextMeshPro依赖库
新增TextMeshPro-1.0.55.56.0b9.dll到项目引用中,
用于支持文本渲染功能。
2026-04-27 03:16:25 +08:00
Yue-bin
ac880e8964 docs: 添加架构设计文档与游戏参考源码 2026-04-27 02:25:30 +08:00
Yue-bin
40e3700370 feat(plugin): 集成 BepInEx 配置、Harmony 注册与 KCP 回调 2026-04-27 02:25:17 +08:00
Yue-bin
706e126e73 feat(patches): 添加 Harmony 补丁实现收发拦截与大厅事件同步 2026-04-27 02:25:00 +08:00
Yue-bin
6bc7b558bf feat(transport): 实现双模式KCP升级基础设施 2026-04-27 02:24:47 +08:00
Yue-bin
de24868e9b feat(transport): 添加大厅成员同步功能
添加了_currentLobbyId字段用于跟踪当前大厅ID,实现了基于SteamMatchmaking
API的大厅成员列表同步机制。新增SetCurrentLobby、ClearLobby和
RefreshLobbyMembers方法,支持自动检测大厅成员变化并同步连接状态。

BREAKING CHANGE: 重构了OnPeerJoined/OnPeerLeft为SetCurrentLobby/ClearLobby,
改变了外部调用接口。
2026-04-23 01:17:04 +08:00
Yue-bin
de6d88c46c docs(ConnectionManager): 实现大厅同步机制替代房间事件监听
- 引入 SteamMatchmaking API 通过 SetCurrentLobby 和
  RefreshLobbyMembers 接口进行大厅成员同步
- 采用集合差分算法精确识别新增/离开的对端玩家,
  避免全房间广播 Hello 包
- 重构 PeerConnection 握手重试机制,改用时间驱动
  的 HelloSchedule 替代计数驱动
- 更新 ConnectionManager API,移除 OnPeerJoined/
  OnPeerLeft 方法,新增大厅管理相关接口
- 完善 KCP 连接生命周期管理,确保离开玩家的
  连接能够及时清理
- 添加详细的状态机图表和时序说明文档
2026-04-23 01:16:09 +08:00
Yue-bin
55170a33d1 feat(kcpable): 添加KCP连接管理器实现可靠UDP传输
- 实现ConnectionManager作为全局KCP连接管理器,负责管理所有对端PeerConnection
- 添加PeerConnection类处理握手状态管理和KCP实例生命周期
- 支持Handshaking、Idle、Connected三种连接状态
- 实现Hello握手包冗余广播机制确保连接建立
- 提供TrySend方法供游戏逻辑发送数据到KCP通道
- 在Plugin中集成ConnectionManager的初始化和更新循环
- 添加Steam初始化检查,未初始化时禁用KCP功能
- 实现每帧Update驱动KCP状态更新和握手广播调度
2026-04-23 00:21:51 +08:00
Yue-bin
4ce3e62135 docs(ConnectionManager): 添加KCP切换状态管理设计文档
- 设计了透明握手机制,利用原游戏TCP栈发送Hello握手包,
  对不支持KCP的对端无影响
- 定义了基于PeerConnection粒度的状态机,管理从TCP到KCP的切换生命周期
- 实现频道隔离,高频同步(Channel 0)和事件下发(Channel 1)
  各自拥有独立的KCP实例
- 提供向下兼容方案,握手失败或超时时保持在原TCP栈(Idle状态)
- 包含完整的状态机设计、核心类结构和关键交互时序说明
2026-04-23 00:21:32 +08:00
Yue-bin
6ce38937eb 🐳 chore(kcp): copy from https://github.com/MirrorNetworking/kcp2k 2026-04-18 21:31:56 +08:00
Yue-bin
c818adb693 refactor(Transport): 优化ConvGenerator类的字段命名和实现
- 将smaller和bigger改为私有只读字段localId和remoteId
- 使用属性替代字段存储Math.Min和Math.Max的结果
- 保持原有的位运算逻辑不变,提高代码可读性
2026-04-18 20:22:04 +08:00
Yue-bin
ed34a4eece 📃 docs(plan): 嘻嘻这个不叫好友代码 2026-04-18 20:17:38 +08:00
Yue-bin
92e78f970f feat(Transport): 优化ConvGenerator算法
将CSteamID改为使用AccountID
2026-04-18 20:16:27 +08:00
Yue-bin
97f2ad38bb docs(plan): 优化conv生成算法
将conv的划分算法从steamid改为accountid(好友代码),简化了生成逻辑,
去除了原本64位中的高位噪声(多数情况下一致)
2026-04-18 20:16:02 +08:00
32 changed files with 6110 additions and 38 deletions

View File

@@ -1,16 +0,0 @@
namespace stick.plugins.kcpable.Helper;
public class Timer
{
private ulong _startTimeMs;
public void Start()
{
_startTimeMs = TimeStore.GetTimeMs();
}
public ulong ElapsedMs()
{
return TimeStore.GetTimeMs() - _startTimeMs;
}
}

View File

@@ -0,0 +1,45 @@
using HarmonyLib;
using Steamworks;
using stick.plugins.kcpable.Protocol;
using stick.plugins.kcpable.Transport;
using System;
using System.Reflection;
namespace stick.plugins.kcpable.Patches;
/// <summary>
/// Harmony Patch: 拦截 P2PPackageHandler.CheckMessageType处理 MsgType.Hello (75)。
/// 游戏原代码在 switch/case 中对未知 MsgType 会 throw Exception。
/// </summary>
[HarmonyPatch]
public static class Patch_CheckMessageType_Hello
{
private static Type _p2pHandlerType;
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
_p2pHandlerType = AccessTools.TypeByName("P2PPackageHandler");
if (_p2pHandlerType == null) return null;
var msgTypeEnum = AccessTools.TypeByName("P2PPackageHandler+MsgType");
return AccessTools.Method(_p2pHandlerType, "CheckMessageType",
new Type[] { typeof(byte[]), msgTypeEnum, typeof(CSteamID) });
}
[HarmonyPrefix]
public static bool Prefix(object __instance, byte[] data, object type, CSteamID steamIdRemote)
{
// type 是 P2PPackageHandler.MsgType 枚举Hello = 75
int msgTypeValue = (int)Convert.ChangeType(type, typeof(int));
if (msgTypeValue != (int)MsgType.Hello)
return true; // 非 Hello走原逻辑
// 解析 Hello 包: data = [version, mode]
var hello = Hello.FromBytes(data);
UnityEngine.Debug.Log($"[Kcpable] Hello received from {steamIdRemote}, mode={hello.mode}");
ConnectionManager.Instance.OnHelloReceived(steamIdRemote, hello);
return false; // 跳过原方法,不抛异常
}
}

View File

@@ -0,0 +1,209 @@
using HarmonyLib;
using Steamworks;
using stick.plugins.kcpable.Transport;
using System;
using System.Reflection;
using UnityEngine;
namespace stick.plugins.kcpable.Patches;
/// <summary>
/// Harmony Patch: 拦截 MatchmakingHandler.OnLobbyEnter(LobbyEnter_t, bool)。
/// 进入大厅时调用 ConnectionManager.SetCurrentLobby。
/// </summary>
[HarmonyPatch]
public static class Patch_LobbyEnter
{
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("MatchmakingHandler");
if (type == null) return null;
// 精确匹配 (LobbyEnter_t, bool) 重载,避免匹配到仅 LobbyEnter_t 的空重载
var lobbyEnterType = typeof(LobbyEnter_t);
return AccessTools.Method(type, "OnLobbyEnter",
new Type[] { lobbyEnterType, typeof(bool) });
}
[HarmonyPostfix]
public static void Postfix(object __instance, object pCallback)
{
try
{
// 通过反射获取 m_ulSteamIDLobby 字段
var field = typeof(LobbyEnter_t).GetField("m_ulSteamIDLobby");
if (field == null) return;
ulong lobbyIdRaw = (ulong)field.GetValue(pCallback);
var lobbyId = new CSteamID(lobbyIdRaw);
if (!lobbyId.IsValid() || !lobbyId.IsLobby()) return;
Debug.Log($"[Kcpable] LobbyEnter: {lobbyId}");
ConnectionManager.Instance.SetCurrentLobby(lobbyId);
}
catch (Exception ex)
{
Debug.LogError($"[Kcpable] Patch_LobbyEnter error: {ex}");
}
}
}
/// <summary>
/// Harmony Patch: 拦截 MatchmakingHandler.OnLobbyCreated(CSteamID)。
/// 创建大厅时调用 ConnectionManager.SetCurrentLobby。
/// </summary>
[HarmonyPatch]
public static class Patch_LobbyCreated
{
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("MatchmakingHandler");
if (type == null) return null;
return AccessTools.Method(type, "OnLobbyCreated",
new Type[] { typeof(CSteamID) });
}
[HarmonyPostfix]
public static void Postfix(CSteamID currentLobby)
{
try
{
#pragma warning disable Harmony003 // CSteamID 值类型方法调用误报
if (!currentLobby.IsValid() || !currentLobby.IsLobby()) return;
#pragma warning restore Harmony003
Debug.Log($"[Kcpable] LobbyCreated: {currentLobby}");
ConnectionManager.Instance.SetCurrentLobby(currentLobby);
}
catch (Exception ex)
{
Debug.LogError($"[Kcpable] Patch_LobbyCreated error: {ex}");
}
}
}
/// <summary>
/// Harmony Patch: 拦截 MatchmakingHandler.ClientInitLobbyAndOwner(CSteamID)。
/// 加入他人大厅时调用 ConnectionManager.SetCurrentLobby。
/// </summary>
[HarmonyPatch]
public static class Patch_ClientInitLobby
{
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("MatchmakingHandler");
if (type == null) return null;
return AccessTools.Method(type, "ClientInitLobbyAndOwner",
new Type[] { typeof(CSteamID) });
}
[HarmonyPostfix]
public static void Postfix(CSteamID lobby)
{
try
{
#pragma warning disable Harmony003 // CSteamID 值类型方法调用误报
if (!lobby.IsValid() || !lobby.IsLobby()) return;
#pragma warning restore Harmony003
Debug.Log($"[Kcpable] ClientInitLobby: {lobby}");
ConnectionManager.Instance.SetCurrentLobby(lobby);
}
catch (Exception ex)
{
Debug.LogError($"[Kcpable] Patch_ClientInitLobby error: {ex}");
}
}
}
/// <summary>
/// Harmony Patch: 拦截 MatchmakingHandler.OnLobbyDataUpdate(LobbyDataUpdate_t)。
/// Lobby 数据更新时刷新成员列表。
/// </summary>
[HarmonyPatch]
public static class Patch_LobbyDataUpdate
{
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("MatchmakingHandler");
if (type == null) return null;
return AccessTools.Method(type, "OnLobbyDataUpdate",
new Type[] { typeof(LobbyDataUpdate_t) });
}
[HarmonyPostfix]
public static void Postfix()
{
try
{
ConnectionManager.Instance.RefreshLobbyMembers();
}
catch (Exception ex)
{
Debug.LogError($"[Kcpable] Patch_LobbyDataUpdate error: {ex}");
}
}
}
/// <summary>
/// Harmony Patch: 拦截 MatchmakingHandler.OnLobbyChatUpdate(LobbyChatUpdate_t)。
/// 成员进入/离开时刷新成员列表。
/// </summary>
[HarmonyPatch]
public static class Patch_LobbyChatUpdate
{
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("MatchmakingHandler");
if (type == null) return null;
return AccessTools.Method(type, "OnLobbyChatUpdate",
new Type[] { typeof(LobbyChatUpdate_t) });
}
[HarmonyPostfix]
public static void Postfix()
{
try
{
ConnectionManager.Instance.RefreshLobbyMembers();
}
catch (Exception ex)
{
Debug.LogError($"[Kcpable] Patch_LobbyChatUpdate error: {ex}");
}
}
}
/// <summary>
/// Harmony Patch: 拦截 MatchmakingHandler.Disconnect(bool)。
/// 离开大厅时清理所有 KCP 连接状态。
/// </summary>
[HarmonyPatch]
public static class Patch_LobbyDisconnect
{
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("MatchmakingHandler");
if (type == null) return null;
return AccessTools.Method(type, "Disconnect",
new Type[] { typeof(bool) });
}
[HarmonyPrefix]
public static void Prefix()
{
try
{
Debug.Log("[Kcpable] Disconnect: clearing lobby");
ConnectionManager.Instance.ClearLobby();
}
catch (Exception ex)
{
Debug.LogError($"[Kcpable] Patch_LobbyDisconnect error: {ex}");
}
}
}

View File

@@ -0,0 +1,121 @@
using HarmonyLib;
using Steamworks;
using stick.plugins.kcpable.Transport;
using System;
using System.Reflection;
using TMPro;
using UnityEngine;
namespace stick.plugins.kcpable.Patches;
/// <summary>
/// 内部辅助类:对 OnlinePlayerUI 实例遍历 mClients为 KCP 已连接的对端在名前追加紫色 [K]。
/// </summary>
internal static class KcpIndicatorHelper
{
private const string KcpMarker = "<color=#9B59B6>[K]</color> ";
private static FieldInfo _mClientsField;
private static FieldInfo _mPlayerTextsField;
private static FieldInfo _clientIdField;
public static void AppendIndicator(object onlinePlayerUI)
{
if (onlinePlayerUI == null) return;
// 懒初始化反射缓存
if (_mClientsField == null)
{
_mClientsField = AccessTools.Field(AccessTools.TypeByName("OnlinePlayerUI"), "mClients");
_mPlayerTextsField = AccessTools.Field(AccessTools.TypeByName("OnlinePlayerUI"), "mPlayerTexts");
_clientIdField = AccessTools.Field(AccessTools.TypeByName("ConnectedClientData"), "ClientID");
}
if (_mClientsField == null || _mPlayerTextsField == null || _clientIdField == null)
return;
var mClients = _mClientsField.GetValue(onlinePlayerUI) as Array;
var mPlayerTexts = _mPlayerTextsField.GetValue(onlinePlayerUI) as TextMeshProUGUI[];
if (mClients == null || mPlayerTexts == null) return;
int count = Math.Min(mClients.Length, mPlayerTexts.Length);
for (int i = 0; i < count; i++)
{
var client = mClients.GetValue(i);
if (client == null) continue;
var clientId = (CSteamID)_clientIdField.GetValue(client);
if (!clientId.IsValid()) continue;
if (ConnectionManager.Instance.IsKcpConnected(clientId))
{
var text = mPlayerTexts[i].text;
if (!text.Contains("[K]"))
{
mPlayerTexts[i].text = KcpMarker + text;
}
}
}
}
}
/// <summary>
/// Harmony Patch: Hook OnlinePlayerUI.Update()。
/// 每帧设置 mPlayerTexts[i].text = PlayerName在此之后追加 [K] 标识。
/// 使用 [HarmonyPriority(Priority.VeryLow)] 保证 Postfix 在 CN mod 之前执行。
/// </summary>
[HarmonyPatch]
public static class Patch_OnlinePlayerUI_Update_KcpIndicator
{
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("OnlinePlayerUI");
if (type == null) return null;
return AccessTools.Method(type, "Update", Type.EmptyTypes);
}
[HarmonyPostfix]
[HarmonyPriority(Priority.VeryLow)]
public static void Postfix(object __instance)
{
try
{
KcpIndicatorHelper.AppendIndicator(__instance);
}
catch (Exception ex)
{
Debug.LogError($"[Kcpable] Patch_OnlinePlayerUI_Update_KcpIndicator error: {ex}");
}
}
}
/// <summary>
/// Harmony Patch: Hook OnlinePlayerUI.Populate()。
/// 初始化时设置 mPlayerTexts[i].text = PlayerName + Ping在此之后追加 [K] 标识。
/// </summary>
[HarmonyPatch]
public static class Patch_OnlinePlayerUI_Populate_KcpIndicator
{
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("OnlinePlayerUI");
if (type == null) return null;
return AccessTools.Method(type, "Populate", Type.EmptyTypes);
}
[HarmonyPostfix]
[HarmonyPriority(Priority.VeryLow)]
public static void Postfix(object __instance)
{
try
{
KcpIndicatorHelper.AppendIndicator(__instance);
}
catch (Exception ex)
{
Debug.LogError($"[Kcpable] Patch_OnlinePlayerUI_Populate_KcpIndicator error: {ex}");
}
}
}

View File

@@ -0,0 +1,67 @@
using HarmonyLib;
using System;
using System.Collections;
using System.Reflection;
namespace stick.plugins.kcpable.Patches;
/// <summary>
/// Harmony Patch: 修复 P2PStatistics.Awake() 因静态字典重复 Add 导致的崩溃。
///
/// 问题背景:
/// P2PStatistics.Awake() 会遍历 P2PPackageHandler.MsgType 枚举所有值,
/// 往 static Dictionary 中 Add 条目。若 Awake 被调用超过一次(场景重载等),
/// 第二次调用时字典已有所有条目Add 会抛 ArgumentException。
///
/// 本 Patch 在 Prefix 中检查 static Dictionary 是否已填充,若已填充则跳过 Awake。
/// </summary>
[HarmonyPatch]
public static class Patch_P2PStatistics_Awake
{
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("P2PStatistics");
if (type == null) return null;
return AccessTools.Method(type, "Awake");
}
/// <summary>
/// Prefix: 检查 static mPackageTypeStats 字典是否已包含条目。
/// 若已填充则跳过原始 Awake避免重复 Add 异常。
/// </summary>
[HarmonyPrefix]
public static bool Prefix()
{
try
{
var type = AccessTools.TypeByName("P2PStatistics");
if (type == null) return true;
var dictField = AccessTools.Field(type, "mPackageTypeStats");
if (dictField == null) return true;
// 尝试以多种方式读取字典的 Count
var dict = dictField.GetValue(null);
if (dict == null) return true;
// 反射获取 Count 属性
var countProp = dict.GetType().GetProperty("Count");
if (countProp != null)
{
int count = (int)countProp.GetValue(dict, null);
if (count > 0)
{
// 字典已填充,跳过 Awake
return false;
}
}
}
catch (Exception ex)
{
UnityEngine.Debug.LogWarning($"[Kcpable] Patch_P2PStatistics_Awake error (fallback to original): {ex.Message}");
}
return true; // 运行原始 Awake
}
}

View File

@@ -0,0 +1,73 @@
using HarmonyLib;
using Steamworks;
using stick.plugins.kcpable.Protocol;
using System;
using System.Reflection;
namespace stick.plugins.kcpable.Patches;
/// <summary>
/// Harmony Patch: 拦截 P2PPackageHandler.ReadMessageBuffer在 ClientInit 之前也放行 Hello (75)。
///
/// 问题背景:
/// ReadMessageBuffer 在 HasBeenInitializedFromServer == false 时只放行 ClientInit (4) 和
/// ClientAccepted (5),其他 MsgType包括我们的 Hello=75会被丢弃。
/// 这导致 KCP Hello 握手永远无法完成,两端 KCP 连接永远无法建立。
///
/// 本 Patch 在 Prefix 中检测 msgType==75直接调用 CheckMessageType 处理 Hello
/// 跳过原始方法中的前置过滤。
/// </summary>
[HarmonyPatch]
public static class Patch_ReadMessageBuffer_Hello
{
private static MethodInfo _checkMessageTypeMethod;
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("P2PPackageHandler");
if (type == null) return null;
var msgTypeEnum = AccessTools.TypeByName("P2PPackageHandler+MsgType");
_checkMessageTypeMethod = AccessTools.Method(type, "CheckMessageType",
new Type[] { typeof(byte[]), msgTypeEnum, typeof(CSteamID) });
return AccessTools.Method(type, "ReadMessageBuffer",
new Type[] { typeof(byte[]), typeof(CSteamID) });
}
/// <summary>
/// Prefix: 若 rawData 中的 msgType 为 Hello (75),绕过原始 ReadMessageBuffer 过滤,
/// 直接调用 CheckMessageType 处理。
/// </summary>
[HarmonyPrefix]
public static bool Prefix(object __instance, byte[] rawData, CSteamID SteamIdRemote)
{
// rawData 格式: [4 bytes timestamp] [1 byte msgType] [payload...]
if (rawData == null || rawData.Length < 5)
return true;
byte msgTypeByte = rawData[4];
if (msgTypeByte != (byte)MsgType.Hello)
return true; // 非 Hello走原始逻辑
if (_checkMessageTypeMethod == null)
{
UnityEngine.Debug.LogError("[Kcpable] CheckMessageType method not found, cannot process Hello!");
return true; // 回退到原始逻辑(会被丢弃,但不抛异常)
}
// 提取 payload跳过 5 字节头部)
int payloadLen = rawData.Length - 5;
byte[] payload = new byte[payloadLen];
if (payloadLen > 0)
Array.Copy(rawData, 5, payload, 0, payloadLen);
// 构造 P2PPackageHandler.MsgType 枚举值 75
var msgTypeEnum = AccessTools.TypeByName("P2PPackageHandler+MsgType");
object helloMsgType = Enum.ToObject(msgTypeEnum, (int)MsgType.Hello);
// 直接调用 CheckMessageType(payload, Hello, SteamIdRemote)
_checkMessageTypeMethod.Invoke(__instance, new object[] { payload, helloMsgType, SteamIdRemote });
return false; // 跳过原始 ReadMessageBuffer
}
}

View File

@@ -0,0 +1,96 @@
using HarmonyLib;
using Steamworks;
using stick.plugins.kcpable.Protocol;
using stick.plugins.kcpable.Transport;
using System;
using System.Reflection;
namespace stick.plugins.kcpable.Patches;
/// <summary>
/// Harmony Patch: 拦截 P2PPackageHandler.SendP2PPacketToUser(CSteamID, ...)。
/// 根据 effectiveMode 决定走 KCP 或保留原 Steam 发送。
/// 使用 [HarmonyTargetMethod] 动态查找目标方法(因为 MsgType 是嵌套枚举)。
/// </summary>
[HarmonyPatch]
public static class Patch_SendP2PPacketToUser
{
private static MethodInfo _writeMessageBufferMethod;
private static Type _p2pHandlerType;
#if DEBUG
private static ulong _totalKcpTaken = 0;
private static ulong _totalSteamFallback = 0;
private static ulong _totalConservativeBypass = 0;
#endif
private static bool ResolveTypes()
{
if (_p2pHandlerType != null) return true;
_p2pHandlerType = AccessTools.TypeByName("P2PPackageHandler");
if (_p2pHandlerType == null)
{
UnityEngine.Debug.LogWarning("[Kcpable] P2PPackageHandler type not found, send patch disabled.");
return false;
}
_writeMessageBufferMethod = AccessTools.Method(_p2pHandlerType, "WriteMessageBuffer");
return _writeMessageBufferMethod != null;
}
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
var type = AccessTools.TypeByName("P2PPackageHandler");
if (type == null) return null;
var msgTypeEnum = AccessTools.TypeByName("P2PPackageHandler+MsgType");
return AccessTools.Method(type, "SendP2PPacketToUser",
new Type[] { typeof(CSteamID), typeof(byte[]), msgTypeEnum, typeof(EP2PSend), typeof(int) });
}
[HarmonyPrefix]
public static bool Prefix(object __instance, CSteamID clientID, byte[] data,
object messageType, EP2PSend sendMethod, int channel)
{
if (!ResolveTypes()) return true;
var mgr = ConnectionManager.Instance;
var effectiveMode = mgr.GetEffectiveMode(clientID);
#if DEBUG
int msgTypeVal = (int)Convert.ChangeType(messageType, typeof(int));
#pragma warning disable Harmony003 // CSteamID 值类型,仅读取不修改
var sid = clientID.m_SteamID;
#pragma warning restore Harmony003
UnityEngine.Debug.Log($"[Kcpable|DEBUG] SendP2PPacketToUser ch={channel} to={sid} msgType={msgTypeVal} send={sendMethod} mode={effectiveMode}");
#endif
if (effectiveMode == KcpMode.Conservative)
{
if (sendMethod == EP2PSend.k_EP2PSendUnreliable ||
sendMethod == EP2PSend.k_EP2PSendUnreliableNoDelay)
{
#if DEBUG
_totalConservativeBypass++;
UnityEngine.Debug.Log($"[Kcpable|DEBUG] SendP2PPacketToUser ch={channel} → Conservative Unreliable BYPASS (x{_totalConservativeBypass})");
#endif
return true;
}
}
byte[] rawPacket = (byte[])_writeMessageBufferMethod.Invoke(__instance, new object[] { data, messageType });
if (mgr.TrySend(clientID, channel, rawPacket, rawPacket.Length))
{
#if DEBUG
_totalKcpTaken++;
UnityEngine.Debug.Log($"[Kcpable|DEBUG] SendP2PPacketToUser ch={channel} → KCP TOOK OVER (x{_totalKcpTaken})");
#endif
return false;
}
#if DEBUG
_totalSteamFallback++;
UnityEngine.Debug.Log($"[Kcpable|DEBUG] SendP2PPacketToUser ch={channel} → KCP not ready, Steam fallback (x{_totalSteamFallback})");
#endif
return true;
}
}

View File

@@ -0,0 +1,233 @@
using HarmonyLib;
using Steamworks;
using stick.plugins.kcpable.Transport;
using System;
using System.Reflection;
namespace stick.plugins.kcpable.Patches;
// 预编译委托:消除 MethodInfo.Invoke 的装箱/拆箱开销
delegate bool IsP2PPacketAvailableDelegate(out uint msgSize, int channel);
delegate bool ReadP2PPacketDelegate(byte[] dest, uint cubDest, out uint msgSize, out CSteamID sender, int channel);
/// <summary>
/// Harmony Patch: 拦截 SteamNetworking.IsP2PPacketAvailable。
///
/// 使用 [ThreadStatic] _isPumping 标志位防止递归:
/// PumpKcpFromSteam 内调用 isAvail/read 时也会触发本 Prefix
/// 此时 _isPumping = truePrefix 直接放行到原始 Steam API
/// 不再二次进入 PumpKcpFromSteam。
/// </summary>
[HarmonyPatch]
public static class Patch_IsP2PPacketAvailable
{
private static Type SteamNetType => AccessTools.TypeByName("SteamNetworking");
// 预编译委托,初始化时一次性创建,之后零分配直接调用
private static IsP2PPacketAvailableDelegate _isAvailDel;
private static ReadP2PPacketDelegate _readDel;
/// <summary>
/// 线程本地标志:当前是否正在 PumpKcpFromSteam 中执行。
/// 用于阻止 PumpKcpFromSteam 内部调用 Steam API 时再次触发 Prefix → 无限递归。
/// </summary>
[ThreadStatic]
private static bool _isPumping;
#if DEBUG
private static int _recursionDepth = 0;
#endif
private static IsP2PPacketAvailableDelegate IsAvailDel
{
get
{
if (_isAvailDel == null)
{
var method = AccessTools.Method(SteamNetType, "IsP2PPacketAvailable",
new Type[] { typeof(uint).MakeByRefType(), typeof(int) });
if (method != null)
_isAvailDel = (IsP2PPacketAvailableDelegate)Delegate.CreateDelegate(
typeof(IsP2PPacketAvailableDelegate), method);
}
return _isAvailDel;
}
}
private static ReadP2PPacketDelegate ReadDel
{
get
{
if (_readDel == null)
{
var method = AccessTools.Method(SteamNetType, "ReadP2PPacket",
new Type[] { typeof(byte[]), typeof(uint), typeof(uint).MakeByRefType(),
typeof(CSteamID).MakeByRefType(), typeof(int) });
if (method != null)
_readDel = (ReadP2PPacketDelegate)Delegate.CreateDelegate(
typeof(ReadP2PPacketDelegate), method);
}
return _readDel;
}
}
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
return AccessTools.Method(SteamNetType, "IsP2PPacketAvailable",
new Type[] { typeof(uint).MakeByRefType(), typeof(int) });
}
[HarmonyPrefix]
public static bool Prefix(ref bool __result, out uint pcubMsgSize, int nChannel)
{
// ── 递归保护:若当前已在 PumpKcpFromSteam 内,直接放行到原始 Steam API ──
if (_isPumping)
{
pcubMsgSize = 0u;
return true;
}
#if DEBUG
_recursionDepth++;
if (_recursionDepth > 5)
UnityEngine.Debug.LogError($"[Kcpable|DEBUG] IsP2PPacketAvailable RECURSION depth={_recursionDepth} channel={nChannel} — still recursing!");
else if (_recursionDepth > 2)
UnityEngine.Debug.LogWarning($"[Kcpable|DEBUG] IsP2PPacketAvailable depth={_recursionDepth} channel={nChannel}");
else;
//UnityEngine.Debug.Log($"[Kcpable|DEBUG] IsP2PPacketAvailable ENTER depth={_recursionDepth} channel={nChannel}");
#endif
// 1. 先检查 KCP 解码缓冲区
if (DecodedPacketBuffer.Instance.TryPeekSize(nChannel, out int pendingSize))
{
pcubMsgSize = (uint)pendingSize;
__result = true;
#if DEBUG
UnityEngine.Debug.Log($"[Kcpable|DEBUG] IsP2PPacketAvailable channel={nChannel} → DecodedPacketBuffer HIT, size={pendingSize}");
_recursionDepth--;
#endif
return false;
}
// 2. 从 Steam offset channel 读取 raw KCP 数据并解码
int kcpChannel = ConnectionManager.ToKcpChannel(nChannel);
PumpKcpFromSteam(nChannel, kcpChannel);
// 3. 再次检查解码缓冲区
if (DecodedPacketBuffer.Instance.TryPeekSize(nChannel, out pendingSize))
{
pcubMsgSize = (uint)pendingSize;
__result = true;
#if DEBUG
UnityEngine.Debug.Log($"[Kcpable|DEBUG] IsP2PPacketAvailable channel={nChannel} → after PumpKcp, buffer HIT size={pendingSize}");
_recursionDepth--;
#endif
return false;
}
// 4. 回退:让游戏检查原始 channel兼容无 KCP 对端)
pcubMsgSize = 0u;
#if DEBUG
//UnityEngine.Debug.Log($"[Kcpable|DEBUG] IsP2PPacketAvailable channel={nChannel} → FALLBACK to original Steam");
_recursionDepth--;
#endif
return true;
}
private static void PumpKcpFromSteam(int origChannel, int kcpChannel)
{
var isAvail = IsAvailDel;
var read = ReadDel;
if (isAvail == null || read == null)
{
#if DEBUG
UnityEngine.Debug.LogWarning($"[Kcpable|DEBUG] PumpKcpFromSteam orig={origChannel} kcp={kcpChannel} → delegate null, ABORT");
#endif
return;
}
#if DEBUG
int pumpCount = 0;
//UnityEngine.Debug.Log($"[Kcpable|DEBUG] PumpKcpFromSteam orig={origChannel} kcp={kcpChannel} → reading raw KCP packets...");
#endif
// 设置递归保护标志:此后本线程内所有 Steam API 调用直接放行
_isPumping = true;
try
{
while (isAvail(out uint rawSize, kcpChannel))
{
byte[] raw = new byte[rawSize];
if (!read(raw, rawSize, out _, out CSteamID sender, kcpChannel))
{
#if DEBUG
UnityEngine.Debug.LogError($"[Kcpable|DEBUG] PumpKcpFromSteam orig={origChannel} kcp={kcpChannel} → ReadP2PPacket FAILED!");
#endif
break;
}
#if DEBUG
pumpCount++;
UnityEngine.Debug.Log($"[Kcpable|DEBUG] PumpKcpFromSteam orig={origChannel} kcp={kcpChannel} → raw pkt {pumpCount}: {rawSize}B from {sender.m_SteamID}");
#endif
ConnectionManager.Instance.OnKcpDataReceived(sender, origChannel, raw, 0, (int)rawSize);
}
}
finally
{
_isPumping = false;
}
#if DEBUG
if (pumpCount == 0)
;
//UnityEngine.Debug.Log($"[Kcpable|DEBUG] PumpKcpFromSteam orig={origChannel} kcp={kcpChannel} → no raw packets found");
else
UnityEngine.Debug.Log($"[Kcpable|DEBUG] PumpKcpFromSteam orig={origChannel} kcp={kcpChannel} → pumped {pumpCount} raw KCP packets total");
#endif
}
}
/// <summary>
/// Harmony Patch: 拦截 SteamNetworking.ReadP2PPacket。
/// 从 KCP 解码缓冲区出队已解码的数据返回给调用者。
/// </summary>
[HarmonyPatch]
public static class Patch_ReadP2PPacket
{
private static Type SteamNetType => AccessTools.TypeByName("SteamNetworking");
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
return AccessTools.Method(SteamNetType, "ReadP2PPacket",
new Type[] { typeof(byte[]), typeof(uint), typeof(uint).MakeByRefType(),
typeof(CSteamID).MakeByRefType(), typeof(int) });
}
[HarmonyPrefix]
public static bool Prefix(ref bool __result, byte[] pubDest, uint cubDest,
out uint pcubMsgSize, out CSteamID psteamIDRemote, int nChannel)
{
if (DecodedPacketBuffer.Instance.TryDequeue(nChannel, out psteamIDRemote, out byte[] data))
{
int copyLen = Math.Min(data.Length, (int)cubDest);
Array.Copy(data, 0, pubDest, 0, copyLen);
pcubMsgSize = (uint)copyLen;
__result = true;
#if DEBUG
UnityEngine.Debug.Log($"[Kcpable|DEBUG] ReadP2PPacket channel={nChannel} → KCP decoded: {copyLen}B from {psteamIDRemote.m_SteamID}");
#endif
return false;
}
pcubMsgSize = 0u;
psteamIDRemote = default;
#if DEBUG
UnityEngine.Debug.Log($"[Kcpable|DEBUG] ReadP2PPacket channel={nChannel} → no KCP data, fallback to original Steam read");
#endif
return true;
}
}

View File

@@ -0,0 +1,130 @@
using HarmonyLib;
using Steamworks;
using stick.plugins.kcpable.Protocol;
using stick.plugins.kcpable.Transport;
using System;
using System.Reflection;
namespace stick.plugins.kcpable.Patches;
/// <summary>
/// Harmony Patch: 拦截 SteamNetworking.SendP2PPacket最底层发送 API
/// 这是所有 Steam P2P 发送的最终出口,覆盖 P2PPackageHandler、MultiplayerManager、
/// NetworkPlayer、SyncableObjectManager 等所有调用方。
///
/// 与 Patch_SendP2PPacketToUser 的协作:
/// - SendP2PPacketToUser 拦截在 P2PPackageHandler 层Game→Steam 的语义层),
/// 可访问 MsgType/WriteMessageBuffer 做编码
/// - 本 Patch 拦截在 Steam API 层(最底层),捕获所有绕过 P2PPackageHandler
/// 直接调用 SteamNetworking 的发送路径(如 MultiplayerManager.OnPlayerMoved 等)
/// - 两 Patch 不冲突SendP2PPacketToUser 成功 KCP 接管时,原方法不执行,
/// SteamNetworking.SendP2PPacket 不被调用;失败回退时,本 Patch 做一致性判断
/// </summary>
[HarmonyPatch]
public static class Patch_SteamNetworking_Send
{
private static Type SteamNetType => AccessTools.TypeByName("SteamNetworking");
#if DEBUG
private static ulong _totalKcpTookover = 0;
private static ulong _totalSteamPassthrough = 0;
private static ulong _totalKcpInternal = 0;
#endif
[HarmonyTargetMethod]
public static MethodBase TargetMethod()
{
return AccessTools.Method(SteamNetType, "SendP2PPacket",
new Type[] { typeof(CSteamID), typeof(byte[]), typeof(uint),
typeof(EP2PSend), typeof(int) });
}
/// <summary>
/// Prefix: 决定是否将此次 Steam 发送升级为 KCP。
/// 返回 false = KCP 已接管,跳过原始 Steam 发送。
/// 返回 true = 走原始 Steam 发送KCP 未就绪 / 模式不匹配 / KCP 内部流量)。
/// </summary>
[HarmonyPrefix]
public static bool Prefix(CSteamID steamIDRemote, byte[] pubData, uint cubData,
EP2PSend eP2PSendType, int nChannel)
{
#if DEBUG
#pragma warning disable Harmony003 // CSteamID 值类型,仅读取不修改
var steamID = steamIDRemote.m_SteamID;
#pragma warning restore Harmony003
#endif
// ────────────────────────────────────────
// 1. KCP 内部流量channel >= 10000 → 永不过滤
// KCP 输出回调自身使用 Unreliable + channel+OFFSET 发送底层数据
// ────────────────────────────────────────
if (nChannel >= Const.KCP_CHANNEL_OFFSET)
{
#if DEBUG
_totalKcpInternal++;
if (_totalKcpInternal <= 3 || _totalKcpInternal % 200 == 0)
UnityEngine.Debug.Log($"[Kcpable|DEBUG] SendP2PPacket channel={nChannel} → KCP INTERNAL (pass x{_totalKcpInternal})");
#endif
return true;
}
// ────────────────────────────────────────
// 2. 查询对该对端生效的 KcpMode
// ────────────────────────────────────────
var mgr = ConnectionManager.Instance;
var effectiveMode = mgr.GetEffectiveMode(steamIDRemote);
// ────────────────────────────────────────
// 3. Conservative 模式Unreliable* 直通原始 Steam
// ────────────────────────────────────────
if (effectiveMode == KcpMode.Conservative)
{
if (eP2PSendType == EP2PSend.k_EP2PSendUnreliable ||
eP2PSendType == EP2PSend.k_EP2PSendUnreliableNoDelay)
{
#if DEBUG
_totalSteamPassthrough++;
if (_totalSteamPassthrough <= 5 || _totalSteamPassthrough % 200 == 0)
UnityEngine.Debug.Log($"[Kcpable|DEBUG] SendP2PPacket ch={nChannel} to={steamID} send={eP2PSendType} → Conservative Unreliable PASSTHROUGH (x{_totalSteamPassthrough})");
#endif
return true; // Steam 直通
}
}
// ────────────────────────────────────────
// 4. 尝试路由到 KCP
// pubData 可能来自 WriteMessageBuffer 编码(经 P2PPackageHandler
// 也可能来自 MultiplayerManager 等直接调用方的自有格式
// KCP 作为透明传输层,不关心数据格式
// ────────────────────────────────────────
// 截取有效数据长度(数组可能比 cubData 大)
int length = (int)Math.Min(cubData, (uint)(pubData?.Length ?? 0));
if (length == 0)
{
#if DEBUG
UnityEngine.Debug.LogWarning($"[Kcpable|DEBUG] SendP2PPacket ch={nChannel} to={steamID} → ZERO length data!");
#endif
return true;
}
#if DEBUG
UnityEngine.Debug.Log($"[Kcpable|DEBUG] SendP2PPacket ch={nChannel} to={steamID} send={eP2PSendType} mode={effectiveMode} len={length} → trying KCP takeover...");
#endif
if (mgr.TrySend(steamIDRemote, nChannel, pubData, length))
{
#if DEBUG
_totalKcpTookover++;
if (_totalKcpTookover <= 10 || _totalKcpTookover % 100 == 0)
UnityEngine.Debug.Log($"[Kcpable|DEBUG] SendP2PPacket ch={nChannel} to={steamID} → KCP TOOK OVER (x{_totalKcpTookover})");
#endif
return false; // KCP 已接管,跳过原始 Steam 发送
}
// KCP 未就绪peer 不存在 / 未 Connected回退到原始 Steam 发送
#if DEBUG
UnityEngine.Debug.Log($"[Kcpable|DEBUG] SendP2PPacket ch={nChannel} to={steamID} mode={effectiveMode} → KCP NOT READY, fallback to Steam");
#endif
return true;
}
}

101
Plugin.cs
View File

@@ -1,12 +1,12 @@
using System.Collections.Generic;
using System.IO;
using BepInEx;
using BepInEx;
using BepInEx.Configuration;
using UnityEngine;
using Steamworks;
using HarmonyLib;
using System.Data;
using Steamworks;
using stick.plugins.kcpable.Helper;
using stick.plugins.kcpable.Patches;
using stick.plugins.kcpable.Protocol;
using stick.plugins.kcpable.Transport;
using UnityEngine;
namespace stick.plugins.kcpable;
@@ -14,9 +14,98 @@ namespace stick.plugins.kcpable;
[BepInProcess("StickFight.exe")]
public class Plugin : BaseUnityPlugin
{
private ConfigEntry<string> _kcpModeConfig;
private Harmony _harmony;
void Awake()
{
// BepInEx Config
_kcpModeConfig = Config.Bind(
"Kcpable",
"KcpMode",
"Conservative",
"KCP upgrade mode:\n" +
" Conservative = only upgrade Reliable channels (safe)\n" +
" Aggressive = upgrade ALL channels including Unreliable (may cause micro-stutters on high packet loss)"
);
if (!SteamManager.Initialized)
{
Logger.LogWarning("Steam not initialized, Kcpable will not be active.");
return;
}
// 解析配置为 KcpMode
var localMode = ParseMode(_kcpModeConfig.Value);
ConnectionManager.Instance.Initialize(SteamUser.GetSteamID());
ConnectionManager.Instance.LocalMode = localMode;
// 注册 KCP 输出回调:通过 Steam P2P Unreliable 发送到 channel + OFFSET
ConnectionManager.Instance.OnKcpOutput = (peerId, channel, data, size) =>
{
int kcpChannel = ConnectionManager.ToKcpChannel(channel);
SteamNetworking.SendP2PPacket(peerId, data, (uint)size,
EP2PSend.k_EP2PSendUnreliable, kcpChannel);
};
// 注册 Hello 发送回调:通过 Steam P2P 发送,格式兼容游戏消息解析
// 游戏消息格式:[uint32 timestamp] [byte msgType] [bytes payload]
// Hello: timestamp=ServerRealTime, msgType=75, payload=[version, mode]
ConnectionManager.Instance.SendHello = (peerId, helloBytes) =>
{
uint timestamp = SteamUtils.GetServerRealTime();
byte[] packet = new byte[5 + helloBytes.Length];
using (var ms = new System.IO.MemoryStream(packet))
using (var bw = new System.IO.BinaryWriter(ms))
{
bw.Write(timestamp);
bw.Write((byte)Protocol.MsgType.Hello); // 75
bw.Write(helloBytes); // [version, mode]
}
SteamNetworking.SendP2PPacket(peerId, packet, (uint)packet.Length,
EP2PSend.k_EP2PSendReliable, 0);
};
// 注册 Harmony 补丁
_harmony = new Harmony("stick.plugins.kcpable");
_harmony.PatchAll(typeof(Patch_SendP2PPacketToUser));
_harmony.PatchAll(typeof(Patch_IsP2PPacketAvailable));
_harmony.PatchAll(typeof(Patch_ReadP2PPacket));
_harmony.PatchAll(typeof(Patch_CheckMessageType_Hello));
_harmony.PatchAll(typeof(Patch_ReadMessageBuffer_Hello));
_harmony.PatchAll(typeof(Patch_P2PStatistics_Awake));
// Steam API 层拦截:覆盖所有绕过 P2PPackageHandler 的直接发送路径
_harmony.PatchAll(typeof(Patch_SteamNetworking_Send));
// 大厅事件 Hook
_harmony.PatchAll(typeof(Patch_LobbyEnter));
_harmony.PatchAll(typeof(Patch_LobbyCreated));
_harmony.PatchAll(typeof(Patch_ClientInitLobby));
_harmony.PatchAll(typeof(Patch_LobbyDataUpdate));
_harmony.PatchAll(typeof(Patch_LobbyChatUpdate));
_harmony.PatchAll(typeof(Patch_LobbyDisconnect));
// [K] 标识 Hook
_harmony.PatchAll(typeof(Patch_OnlinePlayerUI_Update_KcpIndicator));
_harmony.PatchAll(typeof(Patch_OnlinePlayerUI_Populate_KcpIndicator));
Logger.LogInfo($"Kcpable initialized in {localMode} mode.");
}
void Update()
{
TimeStore.UpdateTime();
ConnectionManager.Instance.Update();
}
void OnDestroy()
{
_harmony?.UnpatchSelf();
ConnectionManager.Instance.Shutdown();
}
private static KcpMode ParseMode(string value)
{
if (string.Equals(value, "Aggressive", System.StringComparison.OrdinalIgnoreCase))
return KcpMode.Aggressive;
return KcpMode.Conservative; // 默认保守模式
}
}

View File

@@ -3,4 +3,11 @@ namespace stick.plugins.kcpable.Protocol;
public static class Const
{
public const byte version = 1;
/// <summary>
/// KCP 传输层使用的 channel 偏移量。
/// 游戏读写 channel XKCP 在 Steam 层实际使用 channel X + OFFSET。
/// 10000 远大于游戏最大 channel (约 2*maxPlayers+2),确保永不冲突。
/// </summary>
public const int KCP_CHANNEL_OFFSET = 10000;
}

View File

@@ -1,17 +1,37 @@
using Lidgren.Network;
using stick.plugins.kcpable.Transport;
namespace stick.plugins.kcpable.Protocol;
// 在懒得发的原有网络栈上握手使用的Hello消息
/// <summary>
/// Hello 握手消息,纯粹的数据传输对象。
/// 格式:[version:1 byte] [mode:1 byte]
/// mode 声明本地的 KcpMode对端取 min 自动降级,无需额外握手。
/// </summary>
public class Hello
{
public byte version = Const.version;
public KcpMode mode = KcpMode.Conservative;
// 暂时懒得实现重试和超时
// 事实上我甚至没想好要不要在这层实现
// 先留个可选参数
public void Send(P2PPackageHandler p2pHandler, NetConnection user, ushort retryTimes = 3, ulong timeoutMs = 5000)
/// <summary>
/// 从原始字节数组解析 Hello 包。
/// 兼容旧版 1 字节 Hello默认视为 Conservative
/// </summary>
public static Hello FromBytes(byte[] data)
{
p2pHandler.SendSocketP2PPacketToUser(user, [version], (P2PPackageHandler.MsgType)MsgType.Hello);
var hello = new Hello();
if (data.Length >= 1)
hello.version = data[0];
if (data.Length >= 2)
hello.mode = (KcpMode)data[1];
// data.Length == 1 时 mode 保持默认 Conservative
return hello;
}
/// <summary>
/// 构建 Hello 包的字节数组(用于 SendHello 回调)。
/// </summary>
public byte[] ToBytes()
{
return [version, (byte)mode];
}
}

View File

@@ -0,0 +1,320 @@
using System;
using System.Collections.Generic;
using Steamworks;
using stick.plugins.kcpable.Helper;
using stick.plugins.kcpable.Protocol;
using UnityEngine;
namespace stick.plugins.kcpable.Transport;
/// <summary>
/// 全局 KCP 连接管理器:管理所有对端 PeerConnection驱动握手与 KCP 状态更新。
/// Patch 层通过注册 <see cref="OnKcpOutput"/> 和 <see cref="SendHello"/> 完成实际收发。
/// </summary>
public class ConnectionManager
{
public static ConnectionManager Instance { get; private set; } = new();
private CSteamID _localSteamId;
private CSteamID? _currentLobbyId;
private readonly Dictionary<CSteamID, PeerConnection> _peers = new();
/// <summary>
/// 本地配置的 KcpMode由 Plugin 从 BepInEx Config 读取后设置。
/// </summary>
public KcpMode LocalMode { get; set; } = KcpMode.Conservative;
/// <summary>
/// Patch 层注册:当 KCP 需要发送底层 UDP 数据时触发。
/// 参数peerSteamId, originalChannel, buffer, size
/// Patch 层应使用 originalChannel + KCP_CHANNEL_OFFSET 调用 Steam SendP2PPacket。
/// </summary>
public Action<CSteamID, int, byte[], int> OnKcpOutput { get; set; }
/// <summary>
/// Patch 层注册:当需要发送 Hello 握手包时触发。
/// 参数peerSteamId, helloBytes (包含 version + mode)
/// </summary>
public Action<CSteamID, byte[]> SendHello { get; set; }
/// <summary>
/// 将游戏 channel 映射为 KCP 底层传输 channel。
/// KCP 数据在 Steam 层使用 channel + KCP_CHANNEL_OFFSET避免与游戏原始包冲突。
/// </summary>
public static int ToKcpChannel(int channel) => channel + Const.KCP_CHANNEL_OFFSET;
public void Initialize(CSteamID localSteamId)
{
_localSteamId = localSteamId;
_peers.Clear();
_currentLobbyId = null;
}
public void Shutdown()
{
// ClearLobby() 内部已调用 DecodedPacketBuffer.Instance.Clear()
ClearLobby();
}
/// <summary>
/// Patch 层在进入大厅后调用,设置当前大厅 ID 并立即开始轮询成员列表。
/// </summary>
public void SetCurrentLobby(CSteamID lobbyId)
{
if (_currentLobbyId.HasValue && _currentLobbyId.Value == lobbyId)
return;
ClearLobby();
_currentLobbyId = lobbyId;
SyncLobbyMembers();
}
/// <summary>
/// Patch 层在离开大厅或退出到主菜单时调用,清理所有连接状态。
/// </summary>
public void ClearLobby()
{
foreach (var peer in _peers.Values)
{
peer.MarkIdle();
}
_peers.Clear();
_currentLobbyId = null;
DecodedPacketBuffer.Instance.Clear();
}
/// <summary>
/// Patch 层可选调用:在收到 lobby 数据更新事件时立即刷新一次成员列表。
/// </summary>
public void RefreshLobbyMembers()
{
if (_currentLobbyId.HasValue)
SyncLobbyMembers();
}
/// <summary>
/// Patch 层收到 Hello 包时调用。
/// 解析对端 version + mode注入 LocalMode 后标记 Connected。
/// </summary>
public void OnHelloReceived(CSteamID fromPeer, Hello hello)
{
if (fromPeer == _localSteamId)
return;
if (!_peers.TryGetValue(fromPeer, out var peer))
{
peer = new PeerConnection(fromPeer, _localSteamId, OnPeerKcpOutput, Time.time);
_peers[fromPeer] = peer;
}
peer.LocalMode = LocalMode;
peer.RemoteMode = hello.mode;
peer.MarkConnected();
}
/// <summary>
/// 查询与指定对端实际生效的 KcpMode。
/// 若对端不存在或未 Connected返回 Conservative。
/// </summary>
public KcpMode GetEffectiveMode(CSteamID peerId)
{
if (_peers.TryGetValue(peerId, out var peer) && peer.State == PeerState.Connected)
return peer.EffectiveMode;
return KcpMode.Conservative;
}
/// <summary>
/// 查询对端是否已建立 KCP 连接State == Connected
/// 用于 UI 层判断是否显示 [K] 标识等。
/// </summary>
public bool IsKcpConnected(CSteamID peerId)
{
return _peers.TryGetValue(peerId, out var peer)
&& peer.State == PeerState.Connected;
}
/// <summary>
/// Patch 层收到 KCP 底层 UDP 数据时调用。
/// 解码后自动入队 DecodedPacketBuffer。
/// </summary>
public void OnKcpDataReceived(CSteamID peerId, int channel, byte[] data, int offset, int length)
{
if (!_peers.TryGetValue(peerId, out var peer))
{
#if DEBUG
UnityEngine.Debug.LogWarning($"[Kcpable|DEBUG] OnKcpDataReceived ch={channel} from={peerId.m_SteamID} → peer NOT FOUND, data DROPPED ({length}B)");
#endif
return;
}
if (peer.State != PeerState.Connected)
{
#if DEBUG
UnityEngine.Debug.LogWarning($"[Kcpable|DEBUG] OnKcpDataReceived ch={channel} from={peerId.m_SteamID} → peer state={peer.State}, data DROPPED ({length}B)");
#endif
return;
}
var kcp = peer.EnsureKcpForChannel(channel);
#if DEBUG
UnityEngine.Debug.Log($"[Kcpable|DEBUG] OnKcpDataReceived ch={channel} from={peerId.m_SteamID} → kcp.Input {length}B");
#endif
kcp.Input(data, offset, length);
// 尝试从 KCP 解码并放入 DecodedPacketBuffer
int decodedCount = 0;
while (true)
{
int peekSize = kcp.PeekSize();
if (peekSize <= 0)
break;
byte[] buf = new byte[peekSize];
int recvLen = kcp.Receive(buf, buf.Length);
if (recvLen > 0)
{
DecodedPacketBuffer.Instance.Enqueue(channel, peerId, buf);
decodedCount++;
}
else
{
break;
}
}
#if DEBUG
if (decodedCount > 0)
UnityEngine.Debug.Log($"[Kcpable|DEBUG] OnKcpDataReceived ch={channel} from={peerId.m_SteamID} → decoded {decodedCount} packets from KCP");
#endif
}
/// <summary>
/// 游戏逻辑尝试发送数据时调用。
/// 若对端已 Connected数据进入 KCP 发送队列并返回 true。
/// 若未就绪,返回 falsePatch 层继续走原 Steam 栈。
/// </summary>
public bool TrySend(CSteamID peerId, int channel, byte[] data, int length)
{
if (!_peers.TryGetValue(peerId, out var peer))
{
#if DEBUG
UnityEngine.Debug.Log($"[Kcpable|DEBUG] TrySend ch={channel} to={peerId.m_SteamID} → peer NOT FOUND ({length}B)");
#endif
return false;
}
if (peer.State != PeerState.Connected)
{
#if DEBUG
UnityEngine.Debug.Log($"[Kcpable|DEBUG] TrySend ch={channel} to={peerId.m_SteamID} → peer state={peer.State} ({length}B)");
#endif
return false;
}
var kcp = peer.EnsureKcpForChannel(channel);
kcp.Send(data, 0, length);
#if DEBUG
UnityEngine.Debug.Log($"[Kcpable|DEBUG] TrySend ch={channel} to={peerId.m_SteamID} → kcp.Send {length}B OK");
#endif
return true;
}
/// <summary>
/// 每帧调用:驱动 Hello 冗余广播调度 + KCP Update。
/// </summary>
public void Update()
{
float currentTime = Time.time;
uint currentMs = (uint)TimeStore.GetTimeMs();
foreach (var peer in _peers.Values)
{
// 驱动 Hello 冗余广播
if (peer.State == PeerState.Handshaking)
{
if (peer.TryTakeHelloSend(currentTime, out _))
{
var hello = new Hello { version = Const.version, mode = LocalMode };
SendHello?.Invoke(peer.PeerSteamId, hello.ToBytes());
}
}
// 驱动 KCP 状态机
peer.UpdateKcp(currentMs);
}
}
/// <summary>
/// 通过 SteamMatchmaking API 拉取当前大厅全量成员列表,与内部 _peers 做集合差分。
/// 新增成员创建 Handshaking 连接,离开成员清理并移除。
/// </summary>
private void SyncLobbyMembers()
{
if (!_currentLobbyId.HasValue)
return;
var lobbyId = _currentLobbyId.Value;
int memberCount = SteamMatchmaking.GetNumLobbyMembers(lobbyId);
var currentMembers = new HashSet<CSteamID>();
for (int i = 0; i < memberCount; i++)
{
CSteamID memberId = SteamMatchmaking.GetLobbyMemberByIndex(lobbyId, i);
if (memberId != _localSteamId)
currentMembers.Add(memberId);
}
// 新增 peer大厅有但 _peers 没有
foreach (var peerId in currentMembers)
{
if (!_peers.ContainsKey(peerId))
{
AddPeer(peerId);
}
}
// 离开 peer_peers 有,但大厅没有
var toRemove = new List<CSteamID>();
foreach (var peerId in _peers.Keys)
{
if (!currentMembers.Contains(peerId))
toRemove.Add(peerId);
}
foreach (var peerId in toRemove)
{
RemovePeer(peerId);
}
}
/// <summary>
/// 内部方法:创建 Handshaking 状态的 PeerConnection。
/// </summary>
private void AddPeer(CSteamID peerId)
{
if (peerId == _localSteamId)
return;
if (_peers.ContainsKey(peerId))
return;
var peer = new PeerConnection(peerId, _localSteamId, OnPeerKcpOutput, Time.time);
_peers[peerId] = peer;
}
/// <summary>
/// 内部方法:清理对应 KCP 实例并移除连接。
/// </summary>
private void RemovePeer(CSteamID peerId)
{
if (_peers.TryGetValue(peerId, out var peer))
{
peer.MarkIdle();
_peers.Remove(peerId);
}
}
private void OnPeerKcpOutput(CSteamID peerId, int channel, byte[] data, int size)
{
OnKcpOutput?.Invoke(peerId, channel, data, size);
}
}

View File

@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using Steamworks;
using stick.plugins.kcpable.Protocol;
using stick.plugins.kcpable.Transport.kcp;
namespace stick.plugins.kcpable.Transport;
public enum PeerState
{
Handshaking, // 握手中,此为初始状态,也就是应该立刻开始握手,这之前的状态是不相干的
Idle, // 保持在原本的tcp连接状态
Connected, // 使用kcp连接
}
public class PeerConnection
{
// 固定分时冗余广播时间点(秒,相对 Handshaking 开始时刻)
private static readonly float[] HelloSchedule = { 0f, 0.5f, 1.0f };
public CSteamID PeerSteamId { get; }
public PeerState State { get; private set; } = PeerState.Handshaking;
// 握手冗余广播
public float HelloPhaseStartTime { get; private set; }
private int _helloSentIndex = -1; // -1=未开始, 0/1/2=已发到第几个
// KCP 实例延迟初始化key: channel
public Dictionary<int, Kcp> KcpChannels { get; } = new();
// 按 channel 的 conv 生成器(懒创建)
private readonly Dictionary<int, ConvGenerator> _convGens = new();
// KCP 数据输出回调peerId, channel, buffer, size
private readonly Action<CSteamID, int, byte[], int> _kcpOutput;
private readonly CSteamID _localSteamId;
/// <summary>
/// 本地配置的 KcpMode由 ConnectionManager 在 MarkConnected 时注入)。
/// </summary>
public KcpMode LocalMode { get; set; } = KcpMode.Conservative;
/// <summary>
/// 对端通过 Hello 包声明的 KcpMode。
/// </summary>
public KcpMode RemoteMode { get; set; } = KcpMode.Conservative;
/// <summary>
/// 该对端实际生效的 KcpMode = min(LocalMode, RemoteMode)。
/// </summary>
public KcpMode EffectiveMode => (KcpMode)Math.Min((byte)LocalMode, (byte)RemoteMode);
public PeerConnection(CSteamID peerSteamId, CSteamID localSteamId, Action<CSteamID, int, byte[], int> kcpOutput, float startTime)
{
PeerSteamId = peerSteamId;
_localSteamId = localSteamId;
_kcpOutput = kcpOutput;
HelloPhaseStartTime = startTime;
}
/// <summary>
/// 检查当前是否需要发送 Hello。若 3 次全部发完仍未升级,则降级为 Idle。
/// </summary>
public bool TryTakeHelloSend(float currentTime, out float? nextSendTime)
{
nextSendTime = null;
if (State != PeerState.Handshaking)
return false;
int nextIndex = _helloSentIndex + 1;
if (nextIndex >= HelloSchedule.Length)
{
// 冗余广播耗尽,降级为 Idle
MarkIdle();
return false;
}
float targetTime = HelloPhaseStartTime + HelloSchedule[nextIndex];
if (currentTime >= targetTime)
{
_helloSentIndex = nextIndex;
if (nextIndex + 1 < HelloSchedule.Length)
nextSendTime = HelloPhaseStartTime + HelloSchedule[nextIndex + 1];
return true;
}
nextSendTime = targetTime;
return false;
}
public void MarkConnected()
{
if (State == PeerState.Connected)
return;
State = PeerState.Connected;
}
public void MarkIdle()
{
if (State == PeerState.Idle)
return;
State = PeerState.Idle;
ClearKcpChannels();
}
/// <summary>
/// 懒创建指定 channel 的 KCP 实例。若已存在则直接返回。
/// ConvGenerator 同样懒创建。
/// </summary>
public Kcp EnsureKcpForChannel(int channel)
{
if (KcpChannels.TryGetValue(channel, out var existing))
return existing;
if (!_convGens.TryGetValue(channel, out var convGen))
{
convGen = new ConvGenerator(_localSteamId, PeerSteamId, channel);
_convGens[channel] = convGen;
}
uint conv = convGen.Generate();
int capturedChannel = channel;
var kcp = new Kcp(conv, (data, size) => _kcpOutput(PeerSteamId, capturedChannel, data, size));
// Turbo 模式:低延迟、快速重传、关闭拥塞窗口限制
kcp.SetNoDelay(1, 10, 2, true);
KcpChannels[channel] = kcp;
return kcp;
}
private void ClearKcpChannels()
{
KcpChannels.Clear();
}
public void UpdateKcp(uint currentTimeMs)
{
if (State != PeerState.Connected)
return;
foreach (var kvp in KcpChannels)
{
kvp.Value.Update(currentTimeMs);
}
}
}

View File

@@ -1,13 +1,12 @@
using System;
using Steamworks;
namespace stick.plugins.kcpable.Protocol;
namespace stick.plugins.kcpable.Transport;
public class ConvGenerator(CSteamID local, CSteamID remote, int channel)
{
readonly ulong smaller = Math.Min(local.m_SteamID, remote.m_SteamID);
readonly ulong bigger = Math.Max(local.m_SteamID, remote.m_SteamID);
readonly int channel = channel;
private ulong Combined => smaller ^ (bigger << 1) ^ ((ulong)channel << 2); // 位交错
public uint Generate() => (uint)(Combined & 0xFFFFFFFF) ^ (uint)(Combined >> 32); // 全部64位异或压缩到32位的conv
private readonly uint smaller = Math.Min(local.GetAccountID().m_AccountID, remote.GetAccountID().m_AccountID);
private readonly uint bigger = Math.Max(local.GetAccountID().m_AccountID, remote.GetAccountID().m_AccountID);
private readonly uint channel = (uint)channel;
public uint Generate() => smaller ^ (bigger << 1) ^ (channel << 2); // 位交错
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Generic;
using Steamworks;
namespace stick.plugins.kcpable.Transport;
/// <summary>
/// KCP 解码后的包缓冲队列,按原始 channel 分组。
/// Harmony Patch 层在 IsP2PPacketAvailable / ReadP2PPacket 拦截时使用。
/// </summary>
public class DecodedPacketBuffer
{
public static DecodedPacketBuffer Instance { get; } = new();
private readonly Dictionary<int, Queue<DecodedPacket>> _buffers = new();
public void Enqueue(int origChannel, CSteamID sender, byte[] data)
{
if (!_buffers.TryGetValue(origChannel, out var queue))
{
queue = new Queue<DecodedPacket>();
_buffers[origChannel] = queue;
}
queue.Enqueue(new DecodedPacket { sender = sender, data = data });
}
public bool TryDequeue(int origChannel, out CSteamID sender, out byte[] data)
{
if (_buffers.TryGetValue(origChannel, out var queue) && queue.Count > 0)
{
var pkt = queue.Dequeue();
sender = pkt.sender;
data = pkt.data;
return true;
}
sender = default;
data = null;
return false;
}
public bool TryPeekSize(int origChannel, out int size)
{
if (_buffers.TryGetValue(origChannel, out var queue) && queue.Count > 0)
{
size = queue.Peek().data.Length;
return true;
}
size = 0;
return false;
}
public void Clear()
{
_buffers.Clear();
}
private struct DecodedPacket
{
public CSteamID sender;
public byte[] data;
}
}

13
Transport/KcpMode.cs Normal file
View File

@@ -0,0 +1,13 @@
namespace stick.plugins.kcpable.Transport;
/// <summary>
/// KCP 升级模式:
/// Conservative = 仅 k_EP2PSendReliable 走 KCPUnreliable 直通
/// Aggressive = 全部 EP2PSend 走 KCP
/// 对端模式取 min 自动降级,确保兼容。
/// </summary>
public enum KcpMode : byte
{
Conservative = 0,
Aggressive = 1,
}

8
Transport/kcp/AckItem.cs Normal file
View File

@@ -0,0 +1,8 @@
namespace stick.plugins.kcpable.Transport.kcp
{
internal struct AckItem
{
internal uint serialNumber;
internal uint timestamp;
}
}

1118
Transport/kcp/Kcp.cs Normal file

File diff suppressed because it is too large Load Diff

46
Transport/kcp/Pool.cs Normal file
View File

@@ -0,0 +1,46 @@
// Pool to avoid allocations (from libuv2k & Mirror)
using System;
using System.Collections.Generic;
namespace stick.plugins.kcpable.Transport.kcp
{
public class Pool<T>
{
// Mirror is single threaded, no need for concurrent collections
readonly Stack<T> objects = new Stack<T>();
// some types might need additional parameters in their constructor, so
// we use a Func<T> generator
readonly Func<T> objectGenerator;
// some types might need additional cleanup for returned objects
readonly Action<T> objectResetter;
public Pool(Func<T> objectGenerator, Action<T> objectResetter, int initialCapacity)
{
this.objectGenerator = objectGenerator;
this.objectResetter = objectResetter;
// allocate an initial pool so we have fewer (if any)
// allocations in the first few frames (or seconds).
for (int i = 0; i < initialCapacity; ++i)
objects.Push(objectGenerator());
}
// take an element from the pool, or create a new one if empty
public T Take() => objects.Count > 0 ? objects.Pop() : objectGenerator();
// return an element to the pool
public void Return(T item)
{
objectResetter(item);
objects.Push(item);
}
// clear the pool
public void Clear() => objects.Clear();
// count to see how many objects are in the pool. useful for tests.
public int Count => objects.Count;
}
}

78
Transport/kcp/Segment.cs Normal file
View File

@@ -0,0 +1,78 @@
using System.IO;
namespace stick.plugins.kcpable.Transport.kcp
{
// KCP Segment Definition
internal class Segment
{
internal uint conv; // conversation
internal uint cmd; // command, e.g. Kcp.CMD_ACK etc.
// fragment (sent as 1 byte).
// 0 if unfragmented, otherwise fragment numbers in reverse: N,..,32,1,0
// this way the first received segment tells us how many fragments there are.
internal uint frg;
internal uint wnd; // window size that the receive can currently receive
internal uint ts; // timestamp
internal uint sn; // sequence number
internal uint una;
internal uint resendts; // resend timestamp
internal int rto;
internal uint fastack;
internal uint xmit; // retransmit count
// we need an auto scaling byte[] with a WriteBytes function.
// MemoryStream does that perfectly, no need to reinvent the wheel.
// note: no need to pool it, because Segment is already pooled.
// -> default MTU as initial capacity to avoid most runtime resizing/allocations
//
// .data is only used for Encode(), which always fits it into a buffer.
// the buffer is always Kcp.buffer. Kcp ctor creates the buffer of size:
// (mtu + OVERHEAD) * 3 bytes.
// in other words, Encode only ever writes up to the above amount of bytes.
internal MemoryStream data = new MemoryStream(Kcp.MTU_DEF);
// ikcp_encode_seg
// encode a segment into buffer.
// buffer is always Kcp.buffer. Kcp ctor creates the buffer of size:
// (mtu + OVERHEAD) * 3 bytes.
// in other words, Encode only ever writes up to the above amount of bytes.
internal int Encode(byte[] ptr, int offset)
{
int previousPosition = offset;
offset += Utils.Encode32U(ptr, offset, conv);
offset += Utils.Encode8u(ptr, offset, (byte)cmd);
// IMPORTANT kcp encodes 'frg' as 1 byte.
// so we can only support up to 255 fragments.
// (which limits max message size to around 288 KB)
offset += Utils.Encode8u(ptr, offset, (byte)frg);
offset += Utils.Encode16U(ptr, offset, (ushort)wnd);
offset += Utils.Encode32U(ptr, offset, ts);
offset += Utils.Encode32U(ptr, offset, sn);
offset += Utils.Encode32U(ptr, offset, una);
offset += Utils.Encode32U(ptr, offset, (uint)data.Position);
int written = offset - previousPosition;
return written;
}
// reset to return a fresh segment to the pool
internal void Reset()
{
conv = 0;
cmd = 0;
frg = 0;
wnd = 0;
ts = 0;
sn = 0;
una = 0;
rto = 0;
xmit = 0;
resendts = 0;
fastack = 0;
// keep buffer for next pool usage, but reset length (= bytes written)
data.SetLength(0);
}
}
}

76
Transport/kcp/Utils.cs Normal file
View File

@@ -0,0 +1,76 @@
using System.Runtime.CompilerServices;
namespace stick.plugins.kcpable.Transport.kcp
{
public static partial class Utils
{
// Clamp so we don't have to depend on UnityEngine
public static int Clamp(int value, int min, int max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
// encode 8 bits unsigned int
public static int Encode8u(byte[] p, int offset, byte value)
{
p[0 + offset] = value;
return 1;
}
// decode 8 bits unsigned int
public static int Decode8u(byte[] p, int offset, out byte value)
{
value = p[0 + offset];
return 1;
}
// encode 16 bits unsigned int (lsb)
public static int Encode16U(byte[] p, int offset, ushort value)
{
p[0 + offset] = (byte)(value >> 0);
p[1 + offset] = (byte)(value >> 8);
return 2;
}
// decode 16 bits unsigned int (lsb)
public static int Decode16U(byte[] p, int offset, out ushort value)
{
ushort result = 0;
result |= p[0 + offset];
result |= (ushort)(p[1 + offset] << 8);
value = result;
return 2;
}
// encode 32 bits unsigned int (lsb)
public static int Encode32U(byte[] p, int offset, uint value)
{
p[0 + offset] = (byte)(value >> 0);
p[1 + offset] = (byte)(value >> 8);
p[2 + offset] = (byte)(value >> 16);
p[3 + offset] = (byte)(value >> 24);
return 4;
}
// decode 32 bits unsigned int (lsb)
public static int Decode32U(byte[] p, int offset, out uint value)
{
uint result = 0;
result |= p[0 + offset];
result |= (uint)(p[1 + offset] << 8);
result |= (uint)(p[2 + offset] << 16);
result |= (uint)(p[3 + offset] << 24);
value = result;
return 4;
}
// timediff was a macro in original Kcp. let's inline it if possible.
// copy from https://github.com/MirrorNetworking/kcp2k[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int TimeDiff(uint later, uint earlier)
{
return (int)(later - earlier);
}
}
}

Binary file not shown.

View File

@@ -56,14 +56,13 @@
conv的划分应该精确到单人单channel
1. 取两边steamid
1. 取两边steamid的accountid
2. 按大小分成bigger和smaller
3. 取需要发送到的目标channel懒得发只定义了两个
4. 计算
``` csharp
private ulong Combined => smaller ^ (bigger << 1) ^ ((ulong)channel << 2); // 位交错
public uint Generate() => (uint)(Combined & 0xFFFFFFFF) ^ (uint)(Combined >> 32); // 全部64位异或压缩到32位的conv
public uint Generate() => smaller ^ (bigger << 1) ^ (channel << 2); // 位交错
```
## 其它

View File

@@ -0,0 +1,359 @@
# Kcpable 架构变更Channel Offset + 全 Channel 支持
## 1. 差距分析:当前 kcpable vs 游戏实际网络层
### 1.1 致命差距:只管理 Channel 0 和 1
| 项目 | 当前 kcpable | 游戏实际 |
| -------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| 管理的 channel | 仅 `0`, `1` (硬编码在 [`EnsureKcpCreated()`](../Transport/ConnectionState.cs:103)) | `0,1` + 动态 `2~N` + `10,11` |
| 发送 Hook 点 | 未实现 Patch 层 | 需 Hook [`SendP2PPacketToUser(CSteamID,...)`](../reference/P2PPackageHandler.cs:400) |
| 接收 Hook 点 | 未实现 Patch 层 | 需覆盖 **三条独立读取路径** |
| KCP 配置 | 全部 Turbo 模式 | 需分 Reliable/Unreliable 策略 |
### 1.2 三条接收路径都绕过 P2PPackageHandler
| 路径 | 读取位置 | 涉及 Channel |
| ---- | -------------------------------------------------------------------------------------------- | ----------------- |
| ① | [`P2PPackageHandler.CheckForPackagesOnChannel()`](../reference/P2PPackageHandler.cs:139) | 0, 1 |
| ② | [`SyncableObjectManager.ListenForPackages()`](../reference/SyncableObjectManager.cs:27) | 10, 11 |
| ③ | [`NetworkPlayer.ListenForPositionPackages()`](../reference/NetworkPlayer.cs:503) 等 4 个方法 | 2,3,4,5... (动态) |
三条路径都**直接调用** `SteamNetworking.IsP2PPacketAvailable()` + `ReadP2PPacket()`,完全绕过 `P2PPackageHandler`
---
## 2. Channel Offset 方案:完全可行且是最佳方案
### 核心思路
```
游戏读写 channel X (0~11+) ──→ Harmony Patch 拦截 ──→ Steam 实际读写 channel (X + 10000)
KCP 编解码 (基于原始 X) ←──────┘
```
### 为什么这是最佳方案
1. **无需修改游戏任何代码**:游戏仍认为自己读写 channel 0~11
2. **透明拦截所有三条接收路径**:在 `SteamNetworking` API 层统一 Hook三条路径全部覆盖
3. **发送端也自动覆盖**`SendP2PPacketToUser(CSteamID,...)` 是唯一 Steam P2P 发送出口
4. **不会与游戏自身 channel 冲突**10000 远大于游戏使用的 0~11+
### 架构图
```mermaid
flowchart TB
subgraph GameSend["游戏发送层"]
SendToUser["SendP2PPacketToUser<br/>CSteamID + channel X"]
end
subgraph HarmonyPatch["Harmony Patch 层"]
SendPrefix["Prefix: 拦截发送"]
RecvPrefix["Prefix: 拦截 IsP2PPacketAvailable<br/>+ ReadP2PPacket"]
end
subgraph KcpCore["KCP 核心层 kcpable"]
TrySend["ConnectionManager.TrySend<br/>key=remoteSteamID, origChannel"]
KcpSession["KcpSession<br/>conv=fsteamID, channel"]
DecodedBuf["解码包缓冲队列<br/>per origChannel"]
end
subgraph SteamTransport["Steam P2P 传输层 全 Unreliable"]
SteamSend["SendP2PPacket<br/>channel = X + 10000"]
SteamRecv["IsP2PPacketAvailable<br/>+ ReadP2PPacket<br/>channel = X + 10000"]
end
SendToUser -->|"① 拦截"| SendPrefix
SendPrefix -->|"② 原始 channel X"| TrySend
TrySend -->|"③ kcp.Send"| KcpSession
KcpSession -->|"④ output 回调"| SteamSend
SteamRecv -->|"⑤ 读取 raw KCP 数据"| RecvPrefix
RecvPrefix -->|"⑥ kcp.Input"| KcpSession
KcpSession -->|"⑦ kcp.Recv 解码"| DecodedBuf
DecodedBuf -->|"⑧ 返回给游戏调用者"| RecvPrefix
```
---
## 3. 需要变更的具体内容
### 变更 1`PeerConnection` — 动态 Channel 懒创建
**当前代码** ([`ConnectionState.cs:98-112`](../Transport/ConnectionState.cs:98))
```csharp
private void EnsureKcpCreated()
{
if (KcpChannels.Count > 0) return; // ← 问题:只要有一个 channel 就跳过
foreach (int channel in new[] { 0, 1 }) // ← 问题:硬编码 0,1
{
// ... 创建 KCP 实例
}
}
```
**需改为**:按需懒创建任意 channel 的 KCP 实例。
```csharp
// 新增方法:确保特定 channel 的 KCP 实例存在
public Kcp EnsureKcpForChannel(int channel)
{
if (!KcpChannels.TryGetValue(channel, out var kcp))
{
if (!_convGens.TryGetValue(channel, out var convGen))
{
convGen = new ConvGenerator(_localSteamId, PeerSteamId, channel);
_convGens[channel] = convGen;
}
uint conv = convGen.Generate();
int capturedChannel = channel;
kcp = new Kcp(conv, (data, size) => _kcpOutput(PeerSteamId, capturedChannel, data, size));
kcp.SetNoDelay(1, 10, 2, true); // 默认 Turbo
KcpChannels[channel] = kcp;
}
return kcp;
}
```
同时 `ConnectionManager.TrySend()``OnKcpDataReceived()` 需改为调用 `EnsureKcpForChannel()` 而非检查 `TryGetValue` 后直接返回 false。
### 变更 2添加 Channel Offset 常量
新文件或在 [`Const.cs`](../Protocol/Const.cs) 中添加:
```csharp
// Protocol/Const.cs 追加
public static class Const
{
public const byte version = 1;
/// <summary>
/// KCP 传输层使用的 channel 偏移量。
/// 游戏读写 channel XKCP 在 Steam 层实际使用 channel X + OFFSET。
/// 10000 远大于游戏最大 channel (约 2*maxPlayers+2),确保永不冲突。
/// </summary>
public const int KCP_CHANNEL_OFFSET = 10000;
}
```
### 变更 3`ConnectionManager` 输出回调带 Offset
当前 `OnPeerKcpOutput` 直接透传 channel
```csharp
private void OnPeerKcpOutput(CSteamID peerId, int channel, byte[] data, int size)
{
OnKcpOutput?.Invoke(peerId, channel, data, size); // channel = 原始 channel
}
```
Patch 层在 `OnKcpOutput` 回调中应使用 `channel + KCP_CHANNEL_OFFSET` 调用 `SteamNetworking.SendP2PPacket()`
> **设计决策**offset 放在 Patch 层而非 `ConnectionManager` 内部,保持 `ConnectionManager` 只关心**原始** channel职责单一。
### 变更 4接收侧架构 — 解码包缓冲队列 + Steam API Hook
需要新增一个**解码包缓冲**结构:
```csharp
// Transport/DecodedPacketBuffer.cs (新文件)
public class DecodedPacketBuffer
{
// 按原始 channel 分组的已解码包队列
// key = originalChannel, value = 等待被游戏消费的包队列
private readonly Dictionary<int, Queue<DecodedPacket>> _buffers = new();
public void Enqueue(int channel, CSteamID sender, byte[] data);
public bool TryDequeue(int channel, out CSteamID sender, out byte[] data, out int size);
public bool HasPending(int channel);
}
```
Patch 层需实现两个 Harmony Prefix
#### Prefix on `IsP2PPacketAvailable`
```csharp
[HarmonyPrefix]
[HarmonyPatch(typeof(SteamNetworking), "IsP2PPacketAvailable",
typeof(uint), typeof(int))]
static bool IsP2PPacketAvailable_Prefix(ref bool __result, out uint msgSize, int channel)
{
// 1. 检查解码缓冲区是否有待消费数据
if (DecodedPacketBuffer.Instance.TryPeek(channel, out int pendingSize))
{
msgSize = (uint)pendingSize;
__result = true;
return false; // 跳过原方法
}
// 2. 从 Steam channel (channel + OFFSET) 读取原始 KCP 数据
int kcpChannel = channel + Const.KCP_CHANNEL_OFFSET;
if (SteamNetworking.IsP2PPacketAvailable(out uint rawSize, kcpChannel))
{
// 解码并放入缓冲区
byte[] raw = new byte[rawSize];
SteamNetworking.ReadP2PPacket(raw, rawSize, out _, out CSteamID sender, kcpChannel);
ConnectionManager.Instance.OnKcpDataReceived(sender, channel, raw, 0, raw.Length);
// 再次检查解码缓冲区
if (DecodedPacketBuffer.Instance.TryPeek(channel, out pendingSize))
{
msgSize = (uint)pendingSize;
__result = true;
return false;
}
}
// 3. 回退到原始行为(允许游戏读原始 channel 的数据,用于兼容)
return true;
}
```
#### Prefix on `ReadP2PPacket`
```csharp
[HarmonyPrefix]
[HarmonyPatch(typeof(SteamNetworking), "ReadP2PPacket",
typeof(byte[]), typeof(uint), typeof(uint), typeof(CSteamID), typeof(int))]
static bool ReadP2PPacket_Prefix(byte[] dest, uint cubDest, out uint msgSize,
out CSteamID sender, int channel)
{
if (DecodedPacketBuffer.Instance.TryDequeue(channel, out sender, out byte[] data, out int size))
{
Array.Copy(data, 0, dest, 0, size);
msgSize = (uint)size;
return false; // 返回 KCP 解码数据
}
// 回退到原始行为
return true;
}
```
> **为什么 Hook SteamNetworking 而非游戏层**三条接收路径P2PPackageHandler、SyncableObjectManager、NetworkPlayer都直接调用 SteamNetworking API。Hook 这一层可一次性覆盖全部。
### 变更 5发送侧 — Hook `SendP2PPacketToUser(CSteamID,...)`
这是游戏**唯一的 Steam P2P 发送出口**Hook 它覆盖所有发送场景(含 `SendMessageToAllClients` 广播和 `SendP2PPacketToServer` 代理)。
```csharp
[HarmonyPrefix]
[HarmonyPatch(typeof(P2PPackageHandler), "SendP2PPacketToUser",
new Type[] { typeof(CSteamID), typeof(byte[]),
typeof(P2PPackageHandler.MsgType), typeof(EP2PSend), typeof(int) })]
static bool SendP2PPacketToUser_Prefix(P2PPackageHandler __instance,
CSteamID clientID, byte[] data, P2PPackageHandler.MsgType messageType,
EP2PSend sendMethod, int channel)
{
// 1. 打包消息(复用游戏原有的序列化)
byte[] rawPacket = WriteMessageBuffer(__instance, data, messageType);
// 2. 尝试 KCP 发送
if (ConnectionManager.Instance.TrySend(clientID, channel, rawPacket, rawPacket.Length))
{
return false; // KCP 已接管,跳过原 Steam 发送
}
// 3. KCP 未就绪(比如握手未完成),回退原行为
return true;
}
// 需要反射或直接调用 private 方法
private static byte[] WriteMessageBuffer(P2PPackageHandler instance,
byte[] data, P2PPackageHandler.MsgType messageType)
{
// P2PPackageHandler.WriteMessageBuffer 是 private 方法
// 可通过 Harmony AccessTools 或反射调用
}
```
### 变更 6Reliable / Unreliable 分策略
| 原始发送方式 | Channel 示例 | KCP 策略 |
| ----------------------------- | --------------- | ---------------------------------------------------------------- |
| `k_EP2PSendReliable` | 0,1,3,5,7...,11 | KCP Turbo 模式 (`nodelay=1, interval=10, resend=2, nocwnd=true`) |
| `k_EP2PSendUnreliableNoDelay` | 2,4,6...,10 | **直通**(不走 KCP因为位置更新不应被 KCP 有序可靠传输阻塞 |
**关键问题**KCP **始终保证有序可靠交付**。对于 Unreliable 位置更新,这会产生两个问题:
1. 丢失的旧位置包会阻塞后续新位置包(有序性)
2. 重传到达时位置数据已陈旧
**决策**:第一阶段**只 KCP-ify Reliable channel**。Unreliable channel 直接走 Steam `k_EP2PSendUnreliableNoDelay`,不做任何拦截。
实现方式:`SendP2PPacketToUser_Prefix` 中检查 `sendMethod`
- `k_EP2PSendUnreliable` / `k_EP2PSendUnreliableNoDelay` → 跳过 KCP执行原方法
- `k_EP2PSendReliable` → 走 KCP
### 变更 7`ConnectionManager` 接口调整
`TrySend``OnKcpDataReceived` 需支持动态 channel
```csharp
// TrySend — 改为懒创建 KCP
public bool TrySend(CSteamID peerId, int channel, byte[] data, int length)
{
if (!_peers.TryGetValue(peerId, out var peer)) return false;
if (peer.State != PeerState.Connected) return false;
var kcp = peer.EnsureKcpForChannel(channel); // 懒创建
kcp.Send(data, 0, length);
return true;
}
// OnKcpDataReceived — 同样懒创建
public void OnKcpDataReceived(CSteamID peerId, int channel, byte[] data, int offset, int length)
{
if (!_peers.TryGetValue(peerId, out var peer)) return;
if (peer.State != PeerState.Connected) return;
var kcp = peer.EnsureKcpForChannel(channel); // 懒创建
kcp.Input(data, offset, length);
// 尝试从 KCP 解码并放入 DecodedPacketBuffer
int recvSize = kcp.PeekSize();
if (recvSize > 0)
{
byte[] buf = new byte[recvSize];
kcp.Receive(buf, buf.Length);
DecodedPacketBuffer.Instance.Enqueue(channel, peerId, buf);
}
}
```
### 变更 8`ConvGenerator` 验证
当前实现:
```csharp
public uint Generate() => Smaller ^ (Bigger << 1) ^ (channel << 2);
```
- `channel` 最大约 `2*maxPlayers + 2`(比如 8 人 → channel 最大 18
- `channel << 2` = 72远小于 `uint.MaxValue`
- XOR 位交错方案不会溢出,**当前实现已足够,无需修改**
---
## 4. 需要新增的文件
| 文件 | 职责 |
| ---------------------------------- | --------------------------------------------------------------- |
| `Transport/DecodedPacketBuffer.cs` | 接收侧 KCP 解码后的包缓冲队列,按 originalChannel 分组 |
| `Patches/` 目录 | Harmony Patch 类(发送/接收 Hook`Plugin.cs` 同层或子目录 |
## 5. 需要修改的现有文件
| 文件 | 修改内容 |
| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| [`Protocol/Const.cs`](../Protocol/Const.cs) | 添加 `KCP_CHANNEL_OFFSET = 10000` |
| [`Transport/ConnectionState.cs`](../Transport/ConnectionState.cs) | `EnsureKcpCreated()` → 改为 `EnsureKcpForChannel(int channel)` 懒创建;`PeerConnection` 构造函数需存储 `_localSteamId` |
| [`Transport/ConnectionManager.cs`](../Transport/ConnectionManager.cs) | `TrySend()``OnKcpDataReceived()` 改用懒创建;`OnKcpDataReceived()` 新增解码后入队逻辑 |
| [`Plugin.cs`](../Plugin.cs) | 注册 Harmony Patch初始化 `DecodedPacketBuffer` |
---
## 6. 不在此阶段处理的事项
- Unreliable channel 的 KCP 化(需要 KCP 的非可靠模式或独立配置)
- 协议降级(从 Connected 退回 Idle/TCP
- 重连恢复
- 带宽统计与调试 UI

281
plans/design-kcp-state.md Normal file
View File

@@ -0,0 +1,281 @@
# KCP 切换状态管理设计文档
## 1. 设计目标
- **透明握手**利用原游戏TCP栈发送Hello握手包对不支持KCP的对端无影响。
- **状态驱动**按PeerConnection粒度管理从TCP到KCP的切换生命周期。
- **频道隔离**高频同步Channel 0和事件下发Channel 1各自拥有独立的KCP实例。
- **向下兼容**握手失败或超时后保持在原TCP栈Idle状态不影响游戏。
- **大厅同步**:通过 SteamMatchmaking API 拉取大厅成员列表,与内部 _peers 做集合差分,精确触发 Hello 和连接清理,避免向全房间广播。
## 2. 状态机
```mermaid
stateDiagram-v2
[*] --> Handshaking : 大厅同步发现新对端 / 收到对端Hello
Handshaking --> Idle : 握手超时MaxRetry次
Handshaking --> Connected : 收到对端Hello双向确认
Idle --> Handshaking : 大厅同步发现新对端
Connected --> [*] : 大厅同步发现对端离开 / 连接断开
```
### 状态说明
| 状态 | 含义 | 发送行为 | 接收行为 |
| --------------- | --------------- | ----------------- | -------------------------- |
| **Handshaking** | 正在尝试协商KCP | 周期性发送Hello包 | 收到Hello后升级到Connected |
| **Idle** | 保持原TCP连接 | 不发Hello | 走游戏默认TCP栈 |
| **Connected** | 使用KCP连接 | 走KCP发送 | KCP Input处理数据 |
> 注意:`Handshaking`是本地视角的"我已发送Hello"。只要收到对端的Hello包无论之前是否发过就视为对端支持KCP立即升级到`Connected`。这简化了握手逻辑——不需要显式Ack因为收到合法Hello本身就说明对端已确认。
## 3. 核心类设计
### 3.1 PeerConnection单对端连接
```csharp
public class PeerConnection
{
// 身份标识
public CSteamID PeerSteamId { get; }
// 状态机
public PeerState State { get; private set; } = PeerState.Handshaking;
// 握手重试(时间驱动,非计数驱动)
public float HelloPhaseStartTime { get; private set; }
private int _helloSentIndex = -1;
private static readonly float[] HelloSchedule = { 0f, 0.5f, 1.0f };
// KCP实例按channel隔离
// channel 0: 高频位置同步
// channel 1: 事件下发
public Dictionary<int, Kcp> KcpChannels { get; }
// 按 channel 的 conv 生成器(预协商,无需网络交换)
private readonly Dictionary<int, ConvGenerator> _convGens;
// 状态转换方法
public void MarkConnected();
public void MarkIdle();
public bool TryTakeHelloSend(float currentTime, out float? nextSendTime);
}
```
### 3.2 ConnectionManager全局管理
```csharp
public class ConnectionManager
{
// 单例
public static ConnectionManager Instance { get; }
// 所有对端连接
private readonly Dictionary<CSteamID, PeerConnection> _peers = new();
// 本地SteamID缓存避免重复Get
private CSteamID _localSteamId;
// 当前大厅ID为null时表示未进入大厅
private CSteamID? _currentLobbyId;
// === 生命周期 ===
public void Initialize(CSteamID localSteamId);
public void Shutdown(); // 清理所有KCP实例
// === 大厅同步 ===
public void SetCurrentLobby(CSteamID lobbyId); // 进入大厅时调用
public void ClearLobby(); // 离开大厅时调用
public void RefreshLobbyMembers(); // 大厅数据更新时调用
// === 握手处理 ===
public void OnHelloReceived(CSteamID fromPeer); // Patch层收到Hello时调用
// === 数据收发Patch层调用===
public bool TrySend(CSteamID peerId, int channel, byte[] data, int length);
public void OnKcpDataReceived(CSteamID peerId, int channel, byte[] data, int offset, int length);
// === Tick驱动 ===
public void Update(); // 每帧调用驱动KCP Update + 握手重试。大厅同步由Patch层事件触发。
// === 内部方法 ===
private void SyncLobbyMembers(); // 通过 Steam API 拉取并做集合差分
private void AddPeer(CSteamID peerId);
private void RemovePeer(CSteamID peerId);
}
```
### 3.3 Patch层接口由你实现
Patch层只需要调用以下接口无需关心内部状态
```csharp
// 初始化Plugin.Awake
ConnectionManager.Instance.Initialize(SteamUser.GetSteamID());
// 进入大厅Patch OnLobbyEnter
ConnectionManager.Instance.SetCurrentLobby(lobbyId);
// 离开大厅或退出到主菜单Patch OnLobbyLeave 或相应逻辑)
ConnectionManager.Instance.ClearLobby();
// 大厅数据更新Patch OnLobbyDataUpdate / OnLobbyChatUpdate
ConnectionManager.Instance.RefreshLobbyMembers();
// 收到Hello包Patch消息分发
ConnectionManager.Instance.OnHelloReceived(fromPeer);
// 收到KCP原始UDP数据Patch P2P接收回调
ConnectionManager.Instance.OnKcpDataReceived(fromPeer, channel, data, offset, length);
// 发送游戏数据Patch发送前判断是否走KCP
// 若返回true表示已由KCP接管Patch应拦截原TCP发送
bool sent = ConnectionManager.Instance.TrySend(peerId, channel, data, length);
// 每帧更新Plugin.Update
ConnectionManager.Instance.Update();
```
## 4. 关键交互时序
### 4.1 正常握手双方都支持KCP
```
Local Peer Remote Peer
| |
|--- Hello (via TCP) ---------------->|
|<-- Hello (via TCP) -----------------|
| |
|[State: Handshaking -> Connected] |[State: Handshaking -> Connected]
| |
|--- KCP Data (via UDP) ------------->|
|<-- KCP Data (via UDP) --------------|
```
### 4.2 单方支持KCP另一方丢弃Hello
```
Local Peer Remote Peer (原版)
| |
|--- Hello (via TCP) ---------------->|
| [Drop: unknown MsgType]
| |
|[Retry 1] |
|--- Hello (via TCP) ---------------->|
| [Drop]
|[Retry 2] |
|--- Hello (via TCP) ---------------->|
| [Drop]
|[Retry 3] |
|--- Hello (via TCP) ---------------->|
| [Drop]
|[State: Handshaking -> Idle] |
|[后续数据继续走TCP] |
```
### 4.3 大厅同步触发 Hello新成员加入
```
Patch 层收到大厅事件OnLobbyEnter / OnLobbyDataUpdate / OnLobbyChatUpdate
|
v
调用 SetCurrentLobby() 或 RefreshLobbyMembers()
|
v
SteamMatchmaking.GetNumLobbyMembers -> GetLobbyMemberByIndex
|
v
与 _peers.Keys 做集合差分
|
+-- 新增 peerId --> AddPeer() --> Handshaking --> 按 HelloSchedule 发 Hello
|
+-- 离开 peerId --> RemovePeer() --> MarkIdle() --> 清理 KCP
|
+-- 已有 peerId --> 无操作
```
## 5. 数据流图
```mermaid
graph TD
A[游戏逻辑] -->|发送数据| B[Patch层]
B --> C{ConnectionManager}
C -->|State=Connected| D[Kcp.Send]
C -->|State!=Connected| E[原TCP发送]
D --> F[Steam P2P UDP]
E --> G[原游戏TCP栈]
H[Steam P2P UDP接收] --> I[Patch层]
I --> J{MsgType}
J -->|Hello| C
J -->|KCP Raw| K[Kcp.Input]
K --> L[游戏逻辑接收]
M[SteamMatchmaking API] -->|大厅成员列表| C
```
## 6. 实现要点
### 6.1 Hello发送策略
- Patch 层通过 `SetCurrentLobby()``RefreshLobbyMembers()` 触发 `SyncLobbyMembers()`
- `SyncLobbyMembers()` 通过 Steam API 拉取全量成员列表,与 `_peers.Keys` 做集合差分。
- 对于**新 diff 出来的成员**,创建 `PeerConnection` 并置为 `Handshaking`,按固定时间表发送 Hello默认 `0s, 0.5s, 1.0s`)。
- Hello 冗余次数后续会做成配置项,当前固定为 3 次。
- 对于**已握手的 peer**,不再重复发送 Hello。
- 对于**已离开的 peer**,立即清理连接。
### 6.2 KCP实例初始化时机
- **延迟初始化**:不要在创建 `PeerConnection` 时就创建KCP实例而是等到状态变为 `Connected` 时才创建。
- **原因**避免与不支持KCP的对端浪费资源同时确保conv生成器的参数已就绪。
```csharp
private void EnsureKcpCreated()
{
if (KcpChannels.Count > 0) return;
foreach (int channel in new[] { 0, 1 })
{
uint conv = _convGens[channel].Generate();
int capturedChannel = channel;
var kcp = new Kcp(conv, (data, size) => _kcpOutput(PeerSteamId, capturedChannel, data, size));
kcp.SetNoDelay(1, 10, 2, true); // Turbo模式适合游戏
KcpChannels[channel] = kcp;
}
}
```
### 6.3 KCP Update调度
-`ConnectionManager.Update()` 中,对所有 `Connected` 状态的连接遍历其 `KcpChannels`
- 大厅同步由 Patch 层事件触发,不在 `Update()` 中执行。
- 每个KCP实例调用 `Update(currentTimeMs)`
- 使用 `TimeStore.GetTimeMs()` 作为统一时间源。
### 6.4 发送接管逻辑
```csharp
public bool TrySend(CSteamID peerId, int channel, byte[] data, int length)
{
if (!_peers.TryGetValue(peerId, out var peer)) return false;
if (peer.State != PeerState.Connected) return false;
if (!peer.KcpChannels.TryGetValue(channel, out var kcp)) return false;
kcp.Send(data, 0, length);
return true;
}
```
Patch层在拦截到游戏发送逻辑时先调用 `TrySend`
- 返回 `true`已由KCP接管不再走原TCP。
- 返回 `false`KCP未就绪继续走原TCP。
### 6.5 大厅同步边界情况
| 场景 | 处理 |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| 玩家闪退Steam API 短暂仍显示该玩家 | 下次 `RefreshLobbyMembers` 调用会自然 diff 出来Hello 发送窗口已过的新 peer 不会重发;离开的 peer 连接会被清理。 |
| 对端先发了 Hello但本地 Steam API 还没同步到 | `OnHelloReceived` 会兜底创建 `PeerConnection` 并直接 `Connected`。后续 `SyncLobbyMembers` 发现大厅列表里有此人,不会做重复添加。 |
| 本地尚未进入大厅(`_currentLobbyId` 为 null | `SyncLobbyMembers` 不执行,`_peers` 保持为空。 |
| Host 拒绝新人加入 | 大厅人数固定后,`SyncLobbyMembers` 结果稳定,无影响。 |

303
plans/final-architecture.md Normal file
View File

@@ -0,0 +1,303 @@
# Kcpable 最终架构设计
## 1. 核心决策汇总
| 决策项 | 结论 | 理由 |
| -------------- | ------------------------------- | ----------------------------------------------- |
| Channel Offset | `channel + 10000` | 游戏最大 channel ≈ 2×players+210000 永不冲突 |
| 升级策略 | 双模式,配置化 | Conservative 稳Aggressive 可测试 |
| 模式协商 | Hello 包声明,取 `min` 降级 | 不握手,不增加往返,自动兼容 |
| 接收 Hook | `SteamNetworking` API 层 | 一次性覆盖全部 3 条接收路径 |
| 发送 Hook | `SendP2PPacketToUser(CSteamID)` | 游戏唯一 Steam P2P 发送出口 |
| KCP 配置 | Turbo 模式统一 | `nodelay=1, interval=10, resend=2, nocwnd=true` |
---
## 2. 双模式定义
```csharp
public enum KcpMode : byte
{
Conservative = 0, // 仅 k_EP2PSendReliable 走 KCP
Aggressive = 1, // 全部 EP2PSend 走 KCP
}
```
### 模式效果矩阵
| 原始 sendMethod | Conservative | Aggressive |
| --------------------------------- | ------------ | ---------- |
| `k_EP2PSendReliable` | ✅ KCP | ✅ KCP |
| `k_EP2PSendReliableWithBuffering` | ✅ KCP | ✅ KCP |
| `k_EP2PSendUnreliable` | ❌ Steam 直通 | ✅ KCP |
| `k_EP2PSendUnreliableNoDelay` | ❌ Steam 直通 | ✅ KCP |
### 有效模式计算Per-Peer
```
effectiveMode = min(localConfigMode, remoteHelloMode)
```
| 本地配置 | 对端声明 | 生效模式 |
| ------------ | ------------ | --------------------------- |
| Conservative | Conservative | Conservative |
| Conservative | Aggressive | **Conservative** (本地限制) |
| Aggressive | Conservative | **Conservative** (对端限制) |
| Aggressive | Aggressive | Aggressive |
> **关键安全性**:只要有一方是 Conservative最终就是 Conservative。Aggressive 必须双方都是 Aggressive 才会启用。
---
## 3. Hello 包格式变更
```
旧格式: [version:1 byte]
新格式: [version:1 byte] [mode:1 byte]
```
| Byte | 字段 | 值 |
| ---- | --------- | ------------------------------------ |
| 0 | `version` | `1` (Const.version) |
| 1 | `mode` | `0` = Conservative, `1` = Aggressive |
### 兼容性
- 旧版 kcpable只发 1 字节):对端收到后 `mode` 读取越界 → 默认视为 Conservative。**安全降级。**
- 原版游戏(无 kcpableHello 包的 MsgType=75 被忽略。**透明。**
---
## 4. 架构图
```mermaid
flowchart TB
subgraph Config["BepInEx Config"]
CfgMode["KcpMode: Conservative | Aggressive"]
end
subgraph GameSend["游戏发送"]
SendSteam["SendP2PPacketToUser<br/>CSteamID + EP2PSend + channel"]
end
subgraph HarmonySend["Harmony Prefix: 发送拦截"]
CheckMode{"effectiveMode<br/>= min(local, remote)"}
CheckSend{"sendMethod?"}
end
subgraph KcpSend["KCP 发送"]
TrySend["ConnectionManager.TrySend<br/>key=peerId, origChannel"]
KcpOut["KCP output → Steam<br/>channel + 10000, Unreliable"]
end
subgraph SteamDirect["Steam 直通"]
OrigSend["SendP2PPacket<br/>原 channel, 原 sendMethod"]
end
SendSteam --> CheckMode
CheckMode -->|"Aggressive"| TrySend
CheckMode -->|"Conservative"| CheckSend
CheckSend -->|"Reliable"| TrySend
CheckSend -->|"Unreliable*"| OrigSend
subgraph GameRecv["游戏接收 三条路径"]
P2PHandler["P2PPackageHandler<br/>ch 0,1"]
SyncObj["SyncableObjectManager<br/>ch 10,11"]
NetPlayer["NetworkPlayer<br/>ch 2,3,4..."]
end
subgraph HarmonyRecv["Harmony Prefix: 接收拦截"]
IsAvail["IsP2PPacketAvailable<br/>channel X → read X+10000"]
ReadPkt["ReadP2PPacket<br/>返回 KCP 解码数据"]
DecodeBuf["DecodedPacketBuffer<br/>per origChannel"]
end
subgraph KcpRecv["KCP 接收"]
KcpInput["OnKcpDataReceived<br/>kcp.Input + kcp.Recv"]
end
P2PHandler --> IsAvail
SyncObj --> IsAvail
NetPlayer --> IsAvail
IsAvail --> KcpInput
KcpInput --> DecodeBuf
DecodeBuf --> ReadPkt
```
---
## 5. 需要变更的文件清单
### 5.1 修改现有文件
| 文件 | 变更内容 |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| [`Protocol/Const.cs`](../Protocol/Const.cs) | 添加 `KCP_CHANNEL_OFFSET = 10000`,添加 `KcpMode` 枚举 |
| [`Protocol/Hello.cs`](../Protocol/Hello.cs) | 发送时追加 `mode` 字节;新增 `FromBytes` 解析静态方法 |
| [`Protocol/MsgType.cs`](../Protocol/MsgType.cs) | 无需变更Hello 仍用 75 |
| [`Transport/ConnectionState.cs`](../Transport/ConnectionState.cs) | `EnsureKcpCreated()``EnsureKcpForChannel(int channel)` 懒创建;存储 `_localSteamId`;存储 `_remoteMode` |
| [`Transport/ConnectionManager.cs`](../Transport/ConnectionManager.cs) | `TrySend` 改懒创建;`OnKcpDataReceived` 改懒创建+解码入队;`OnHelloReceived` 解析 mode新增 `GetEffectiveMode()` |
| [`Plugin.cs`](../Plugin.cs) | 添加 BepInEx Config entry注册 Harmony Patch初始化 DecodedPacketBuffer |
### 5.2 新增文件
| 文件 | 职责 |
| --------------------------------------- | ---------------------------------------------------------- |
| `Transport/DecodedPacketBuffer.cs` | 按 originalChannel 分组的解码包缓冲队列 |
| `Transport/KcpMode.cs` | `KcpMode` 枚举 + `EffectiveMode` 计算 |
| `Patches/Patch_SendP2PPacket.cs` | Harmony Prefix on `SendP2PPacketToUser(CSteamID,...)` |
| `Patches/Patch_SteamNetworking_Recv.cs` | Harmony Prefix on `IsP2PPacketAvailable` + `ReadP2PPacket` |
| `Patches/Patch_LobbyEvents.cs` | Harmony Patch 大厅进入/离开/成员变更事件 |
---
## 6. 核心接口设计
### 6.1 ConnectionManager 新增/变更
```csharp
public class ConnectionManager
{
// 新增:本地配置模式
public KcpMode LocalMode { get; set; } = KcpMode.Conservative;
// 变更OnHelloReceived 解析 mode
public void OnHelloReceived(CSteamID fromPeer, KcpMode remoteMode);
// 新增:查询某对端的有效模式
public KcpMode GetEffectiveMode(CSteamID peerId);
// 变更TrySend 内部使用 EnsureKcpForChannel
public bool TrySend(CSteamID peerId, int channel, byte[] data, int length);
// 变更OnKcpDataReceived 解码后自动入队 DecodedPacketBuffer
public void OnKcpDataReceived(CSteamID peerId, int channel, byte[] data, int offset, int length);
}
```
### 6.2 PeerConnection 变更
```csharp
public class PeerConnection
{
// 新增:对端声明的模式
public KcpMode RemoteMode { get; private set; }
// 新增:有效模式
public KcpMode EffectiveMode =>
(KcpMode)Math.Min((byte)LocalMode, (byte)RemoteMode);
// 变更:懒创建
public Kcp EnsureKcpForChannel(int channel);
// 删除EnsureKcpCreated() 和硬编码的 new[] { 0, 1 }
}
```
### 6.3 DecodedPacketBuffer
```csharp
public class DecodedPacketBuffer
{
private readonly Dictionary<int, Queue<DecodedPacket>> _buffers = new();
public void Enqueue(int origChannel, CSteamID sender, byte[] data);
public bool TryDequeue(int origChannel, out CSteamID sender, out byte[] data);
public bool TryPeekSize(int origChannel, out int size);
public bool HasPending(int origChannel);
}
```
### 6.4 Harmony Patch 伪代码
#### 发送端
```csharp
[HarmonyPrefix]
[HarmonyPatch(typeof(P2PPackageHandler), "SendP2PPacketToUser",
new Type[] { typeof(CSteamID), typeof(byte[]), typeof(MsgType), typeof(EP2PSend), typeof(int) })]
static bool Prefix(P2PPackageHandler __instance, CSteamID clientID,
byte[] data, MsgType messageType, EP2PSend sendMethod, int channel)
{
var mgr = ConnectionManager.Instance;
var effectiveMode = mgr.GetEffectiveMode(clientID);
// Conservative 模式下 Unreliable 直通
if (effectiveMode == KcpMode.Conservative)
{
if (sendMethod == EP2PSend.k_EP2PSendUnreliable ||
sendMethod == EP2PSend.k_EP2PSendUnreliableNoDelay)
return true; // Steam 直通
}
// 进入 KCP
byte[] rawPacket = WriteMessageBuffer(__instance, data, messageType);
if (mgr.TrySend(clientID, channel, rawPacket, rawPacket.Length))
return false; // KCP 已接管
return true; // KCP 未就绪,回退
}
```
#### 接收端
```csharp
[HarmonyPrefix]
[HarmonyPatch(typeof(SteamNetworking), "IsP2PPacketAvailable",
typeof(uint), typeof(int))]
static bool IsP2PPacketAvailable_Prefix(ref bool __result, out uint msgSize, int channel)
{
// 1. 先检查 KCP 解码缓冲区
if (DecodedPacketBuffer.Instance.TryPeekSize(channel, out int size))
{
msgSize = (uint)size;
__result = true;
return false;
}
// 2. 从 Steam offset channel 读取 raw KCP 数据
int kcpChannel = channel + Const.KCP_CHANNEL_OFFSET;
while (SteamNetworking.IsP2PPacketAvailable(out uint rawSize, kcpChannel))
{
byte[] raw = new byte[rawSize];
SteamNetworking.ReadP2PPacket(raw, rawSize, out _, out CSteamID sender, kcpChannel);
ConnectionManager.Instance.OnKcpDataReceived(sender, channel, raw, 0, raw.Length);
}
// 3. 再检查一次
if (DecodedPacketBuffer.Instance.TryPeekSize(channel, out size))
{
msgSize = (uint)size;
__result = true;
return false;
}
// 4. 回退(让游戏也检查原始 channel兼容无 KCP 场景)
return true;
}
```
---
## 7. BepInEx Config
```ini
[Kcpable]
## KCP mode: Conservative = only upgrade Reliable channels;
## Aggressive = upgrade ALL channels (may cause micro-stutters on packet loss)
# Setting type: Conservative / Aggressive
# Default value: Conservative
KcpMode = Conservative
```
---
## 8. 阶段规划
| Phase | 内容 | 状态 |
| ------- | ------------------------------------------- | ------ |
| Phase 0 | 本架构文档 + 设计审查 | ✅ 当前 |
| Phase 1 | 动态 Channel + Channel Offset + 基础设施 | 待实现 |
| Phase 2 | Conservative 模式Reliable → KCP | 待实现 |
| Phase 3 | Aggressive 模式 + Hello 模式声明 + 自动降级 | 待实现 |
| Phase 4 | 真实网络测试 + 调优 | 待测试 |

View File

@@ -0,0 +1,87 @@
# Lobby 同步与 Hello 优化设计
## 背景与问题
当前 `ConnectionManager` 依赖 Patch 层通过 `OnPeerJoined`/`OnPeerLeft` 提供精确的成员变动事件,但游戏 hook 只能告知"成员有变化",无法给出 delta谁进了/谁出了)。这导致:
1. 不得不对全房间所有人发 Hello对已握手的 peer 重复发送显得粗鲁。
2. 无法准确清理已离开玩家的 KCP 连接。
3. 另一个插件已经在做大厅成员列表维护,不想重复也不想产生依赖。
## 方案概述
**采用方案一**`ConnectionManager` 内部通过 `SteamMatchmaking` API 拉取当前大厅成员列表,并与内部 `_peers` 字典做集合差分。Patch 层在进入大厅时调用 `SetCurrentLobby(lobbyId)`,在 lobby 数据更新事件时调用 `RefreshLobbyMembers()` 触发同步。
## 核心设计
### 1. ConnectionManager 新增大厅同步
- 增加字段:`private CSteamID? _currentLobbyId`
- 增加方法:`public void SetCurrentLobby(CSteamID lobbyId)``public void RefreshLobbyMembers()`
- `SetCurrentLobby``RefreshLobbyMembers` 内部调用 `SyncLobbyMembers()` 逻辑:
1. 调用 `SteamMatchmaking.GetNumLobbyMembers(_currentLobbyId.Value)` 获取人数(上限 4含自己
2. 遍历索引,用 `GetLobbyMemberByIndex` 收集所有成员 `CSteamID`
3. 排除自己(`_localSteamId`)。
4.`_peers.Keys` 做集合差分:
- **大厅有,`_peers` 无** → 内部调用 `AddPeer(peerId)`(创建 `PeerConnection`,状态 `Handshaking`)。
- **大厅无,`_peers` 有** → 内部调用 `RemovePeer(peerId)`(清理 KCP移除字典
- **两边都有** → 无操作。
### 2. Hello 调度
Hello 冗余策略保持现状,**不做修改**。后续用户会将其做成配置项。当前只需确保 Hello 只发给真正通过 diff 新增出来的 peer 即可。
### 3. 对端先发 Hello 的场景
`OnHelloReceived` 保持现有逻辑不变:
- 如果 `_peers` 中已有该 peer升级到 `Connected`
- 如果 `_peers` 中没有(对端先发了 Hello但本地 Steam API 尚未同步到该成员),则直接创建并标记为 `Connected`
### 4. Patch 层职责简化
Patch 层不再需要 hook 任何与"具体哪位玩家加入/离开"相关的方法。
需要保留的 hook均为 lobby 级事件):
- `OnLobbyEnter``ConnectionManager.Instance.SetCurrentLobby(lobbyId)`(内部自动执行一次同步)
- `OnLobbyDataUpdate` / `OnLobbyChatUpdate` → 调用 `ConnectionManager.Instance.RefreshLobbyMembers()` 触发同步,不传递成员信息。
离开大厅时(如 `OnLobbyLeave` 或游戏退出到主菜单):
- 调用 `ConnectionManager.Instance.ClearLobby()`,内部清空 `_currentLobbyId` 并清理所有 `_peers`
### 5. 原有公开 API 调整
- `public void OnPeerJoined(CSteamID peerId)``public void OnPeerLeft(CSteamID peerId)` 改为 `private`
- 新增 `public void SetCurrentLobby(CSteamID lobbyId)`
- 新增 `public void ClearLobby()`
- 新增 `public void RefreshLobbyMembers()`(供 lobby 更新事件调用,立即执行一次同步)。
## 边界情况
| 场景 | 处理 |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| 玩家闪退Steam API 短暂仍显示该玩家 | 下次 `RefreshLobbyMembers` 调用会自然 diff 出来Hello 发送窗口已过的新 peer 不会重发;离开的 peer 连接会被清理。 |
| 对端先发了 Hello但本地 Steam API 还没同步到 | `OnHelloReceived` 会兜底创建 `PeerConnection` 并直接 `Connected`。后续 `SyncLobbyMembers` 发现大厅列表里有此人,不会做重复添加。 |
| 本地尚未进入大厅(`_currentLobbyId` 为 null | `SyncLobbyMembers` 不执行,`_peers` 保持为空。 |
| Host 拒绝新人加入 | 大厅人数固定后,`SyncLobbyMembers` 结果稳定,无影响。 |
## 实现步骤
1. **修改 `Transport/ConnectionManager.cs`**
- 增加 `_currentLobbyId``AddPeer``RemovePeer``SyncLobbyMembers`
- 移除 `Update()` 中的同步调用,大厅同步完全由 Patch 层触发。
- 调整 `OnPeerJoined`/`OnPeerLeft` 可见性(改为 private 或内部调用)。
- 新增 `SetCurrentLobby` / `ClearLobby` / `RefreshLobbyMembers` 公开 API。
2. **更新 Patch 层代码**(在另一个项目中或后续实现)
- `OnLobbyEnter` hook → `SetCurrentLobby`
- `OnLobbyDataUpdate` / `OnLobbyChatUpdate` hook → `RefreshLobbyMembers`
- 移除所有需要知道具体谁加入/谁离开的 hook。
3. **测试验证**
- 进入 2/3/4 人房,确认 Hello 只发给新成员。
- 成员离开后确认 KCP 连接被清理。
- 对端先 Hello 的场景确认兜底逻辑正常。
## 性能评估
大厅人数上限为 4`RefreshLobbyMembers` 仅在 lobby 事件触发时执行一次集合差分(最多 3 个 peer开销几乎为零。相比减少的无效 Hello 包和更准确的连接生命周期管理,收益显著。

View File

@@ -0,0 +1,209 @@
# KCP 升级策略Reliable vs Unreliable 协议对比分析
## 1. 游戏实际使用情况(来自完整反编译源码)
### 全量统计
| 发送方式 | 消息类型 | 所在 Channel | 数量占比 |
| ----------------------------- | ------------------------------ | ------------------ | -------- |
| `k_EP2PSendUnreliableNoDelay` | `PlayerUpdate`, `ObjectUpdate` | 动态 2,4,6... ; 10 | **2 种** |
| `k_EP2PSendReliable` | 其余全部 (~30+ 种) | 0,1,3,5,7...,11 | **~95%** |
### UnreliableNoDelay 仅有的两个调用点
```csharp
// MultiplayerManager.cs:1745 — 玩家位置/武器状态同步
this.SendMessageToAllClients(data, MsgType.PlayerUpdate, false, num,
EP2PSend.k_EP2PSendUnreliableNoDelay, channel); // channel = 2,4,6...
// MultiplayerManager.cs:1758 — 可同步对象位置更新
this.SendMessageToAllClients(data, MsgType.ObjectUpdate, true, 0UL,
EP2PSend.k_EP2PSendUnreliableNoDelay, channel); // channel = 10
```
**其他所有消息PlayerTookDamage, PlayerFallOut, WeaponSpawned, MapChange...)全部走 `k_EP2PSendReliable`。**
---
## 2. 三种协议的本质对比
> 注意Steam "Reliable" 并非 TCP而是 Steam 自有的基于 UDP 的可靠传输协议。与 KCP 是同类型的东西。
| 特性 | Steam Reliable | Steam UnreliableNoDelay | KCP Turbo (nodelay=1,10,2,true) |
| -------------- | ------------------------ | ----------------------- | ------------------------------- |
| **重传** | ✅ 超时重传 (RTO ~200ms+) | ❌ 丢弃 | ✅ 快速重传 (2 ACK span) |
| **有序交付** | ✅ 严格有序 | ❌ 无序 | ✅ 严格有序 |
| **Nagle 缓冲** | ✅ 有(等待合并小包) | ❌ 立即发送 | ❌ 10ms 间隔立即发 |
| **拥塞控制** | ✅ 保守 | ❌ 无 | ❌ 无 (nocwnd=true) |
| **最小 RTO** | ~200ms | N/A | **30ms** |
| **重复包检测** | ✅ | ❌ | ✅ (基于 SN) |
### 关键差异可视化
```
Steam Reliable 发送时间线:
发包1 ──[Nagle等待~200ms]──→ 发包1+2合并 ──[丢包]──→ 等200ms RTO ──→ 重传
KCP Turbo 发送时间线:
发包1 ──[10ms]──→ 发 ──[丢包]──→ 收2个后续ACK ──[~20ms]──→ 快速重传
Steam Unreliable 发送时间线:
发包1 ──[立即]──→ 发 ──[丢包]──→ 丢弃,发下一个
```
---
## 3. 两种升级策略在游戏场景中的对比
### 策略 AKCP 覆盖 ReliableUnreliable 保持原样
| | 升级前 | 升级后 | 预期效果 |
| --------------------------- | ---------------- | --------- | ------------------------------------------- |
| Channel 0,1 (控制/流程) | Steam Reliable | KCP Turbo | Nagle 消除 + 快重传 → 握手/地图加载更快 |
| Channel 3,5,7... (玩家事件) | Steam Reliable | KCP Turbo | 伤害/跌落/弹射事件延迟从 ~200ms RTO → ~30ms |
| Channel 11 (对象事件) | Steam Reliable | KCP Turbo | 对象销毁/生成事件更低延迟 |
| Channel 2,4,6... (玩家位置) | Steam Unreliable | **不变** | 无影响 |
| Channel 10 (对象位置) | Steam Unreliable | **不变** | 无影响 |
**收益**:约 95% 的消息量获得延迟改善
**风险**:极低 — 语义不变(都是可靠有序),只是更换底层实现
### 策略 BKCP 覆盖 UnreliableReliable 保持原样
| | 升级前 | 升级后 | 风险 |
| --------------------------- | -------------- | ------------ | ---------------------------------- |
| Channel 2,4,6... (玩家位置) | 无序不可靠 | **有序可靠** | ⚠️ 队头阻塞:丢失的旧位置阻断新位置 |
| Channel 10 (对象位置) | 无序不可靠 | **有序可靠** | ⚠️ 同上 |
| 其他全部 | Steam Reliable | **不变** | 无改善 |
**收益**:只有约 5% 的消息量受影响(且效果存疑)
**风险****高** — 位置更新的语义被破坏
### 策略 B 的具体风险分析
```
场景:玩家在快速移动,每秒发送 60 个位置包
Steam UnreliableNoDelay原始
Pkt1✓ Pkt2✗ Pkt3✓ Pkt4✓ Pkt5✗ Pkt6✓
→ 接收侧收到 1,3,4,6丢失的 2,5 用插值填充,平滑
KCP 可靠有序(策略 B
Pkt1✓ Pkt2✗ Pkt3(暂存) Pkt4(暂存) Pkt5(暂存)
→ 等 Pkt2 重传... 30ms 后 Pkt2 到达
→ 一次性释放 Pkt2,3,4,5 — 玩家突然跳到 Pkt5 位置!
→ 而且 Pkt2,3,4 的位罝数据已经过时
```
**结论**:对位置更新使用可靠有序传输,等于用"数据完整性"换"时效性",在快节奏物理格斗游戏中是负优化。
---
## 4. 推荐方案:策略 A + 分 Channel KCP 配置
### 整体策略
```
┌─────────────────────────────┐
│ SendP2PPacketToUser Hook │
│ 检查 sendMethod │
└─────────────┬───────────────┘
┌───────────────────┴───────────────────┐
│ │
k_EP2PSendReliable k_EP2PSendUnreliable*
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ KCP Turbo │ │ Steam 直通 │
│ channel+10000 │ │ 原始 channel │
└─────────────────┘ └─────────────────┘
```
### 两种通道的 KCP 配置区分
虽然 KCP 只接管 Reliable 通道,但不同通道的流量特征不同,应使用不同配置:
| Channel 组 | 流量特征 | KCP 配置 | 理由 |
| ----------------------- | ------------------ | ------------------------------------------------------- | ----------------------- |
| **0** (控制) | 低频、关键、小包 | `nodelay=1, interval=10, resend=2, nocwnd=true` (Turbo) | 握手/加入必须快速 |
| **1** (流程) | 中频、中等包 | `nodelay=1, interval=10, resend=2, nocwnd=true` (Turbo) | 武器/地图切换需要低延迟 |
| **3,5,7...** (玩家事件) | 中频、伤害/力/聊天 | `nodelay=1, interval=10, resend=2, nocwnd=true` (Turbo) | 伤害反馈必须即时 |
| **11** (对象事件) | 低频 | `nodelay=1, interval=10, resend=2, nocwnd=true` (Turbo) | 对象交互需要低延迟 |
> 当前所有通道统一使用 Turbo 模式即可,后续可根据实际测试数据微调。
---
## 5. 为什么不"全部走 KCP + 全降级为 Unreliable"
你之前提到的方案:**Steam 层全部改用 `Unreliable`,由 KCP 统一提供可靠性**。
这在理论上最优雅,但存在一个实际问题:
```
PlayerUpdate 每秒 60 次,每次约 50-100 bytes
全部走 KCP 时:
优点:丢失的位置包被 KCP 快速重传30ms vs 完全丢失)
缺点KCP 的有序性意味着丢失的包会阻塞后续所有包
实际体验:
60fps 位置更新下,丢包率 2% → 每秒约 1 个包丢失
→ KCP 需要 ~30ms 重传
→ 这 30ms 内所有后续位置包被阻塞
→ 30ms 后一次性释放 2-3 个累积的位置包
→ 玩家看到对手"冻结 30ms 然后瞬移"
```
**对比原版 Unreliable**:丢包率 2%,每 50 个包丢 1 个,接收侧用上一个有效包插值,几乎无感知。
所以"全部走 KCP"在理论上是干净的,但在位置更新的场景下反而劣化体验。
### 如果未来想做"全部走 KCP"
需要 KCP 的 **非可靠非有序模式**(类似 UDP 直通),这需要对 KCP 本身做修改:
- 去除有序性约束rcv_buf 不做排序等待)
- 去除重传(或极低 dead_link=1
- 保留 nodelay 的低延迟特性
这可以作为第二阶段优化方向,但不建议在第一阶段实施。
---
## 6. 决策结论
| 决策项 | 结论 |
| ------------------------------- | ------------------------------------------------------------- |
| Reliable 通道 (0,1,3,5,7...,11) | ✅ **走 KCP** — 语义兼容,延迟明显降低 |
| Unreliable 通道 (2,4,6...,10) | ❌ **不走 KCP** — 语义冲突,会造成负优化 |
| Steam 层发送方式 | KCP 通道用 `k_EP2PSendUnreliable`,非 KCP 通道保持原样 |
| Channel Offset | 仅 KCP 通道使用 `channel + 10000` |
| 后续优化方向 | 考虑给 PlayerUpdate/ObjectUpdate 加 KCP 非可靠模式Phase 2 |
### 对应的 Hook 实现伪代码
```csharp
[HarmonyPrefix]
[HarmonyPatch(typeof(P2PPackageHandler), "SendP2PPacketToUser",
new Type[] { typeof(CSteamID), typeof(byte[]),
typeof(P2PPackageHandler.MsgType), typeof(EP2PSend), typeof(int) })]
static bool SendP2PPacketToUser_Prefix(P2PPackageHandler __instance,
CSteamID clientID, byte[] data, P2PPackageHandler.MsgType messageType,
EP2PSend sendMethod, int channel)
{
// 只拦截 Reliable 发送Unreliable 直通原方法
if (sendMethod == EP2PSend.k_EP2PSendUnreliable ||
sendMethod == EP2PSend.k_EP2PSendUnreliableNoDelay)
return true; // 走原 Steam 方法
// Reliable → KCP
byte[] rawPacket = WriteMessageBuffer(__instance, data, messageType);
if (ConnectionManager.Instance.TrySend(clientID, channel, rawPacket, rawPacket.Length))
return false; // KCP 已接管
return true; // KCP 未就绪,回退原 Steam Reliable
}
```
> **重要**`k_EP2PSendReliableWithBuffering` 也应进入 KCP因为 KCP 的 nodelay 模式本质就是"Reliable 但不 buffering",比 Steam 的可靠缓冲模式更快。

1248
reference/NetworkPlayer.cs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,631 @@
using System;
using System.IO;
using Lidgren.Network;
using Steamworks;
using UnityEngine;
// Token: 0x020000CC RID: 204
public class P2PPackageHandler : MonoBehaviour
{
// Token: 0x170000B8 RID: 184
// (get) Token: 0x0600061A RID: 1562 RVA: 0x0002ADCE File Offset: 0x000291CE
public static P2PPackageHandler Instance
{
get
{
return P2PPackageHandler._instance;
}
}
// Token: 0x0600061B RID: 1563 RVA: 0x0002ADD5 File Offset: 0x000291D5
private void Awake()
{
if (P2PPackageHandler._instance != null)
{
global::UnityEngine.Object.Destroy(this);
return;
}
P2PPackageHandler._instance = this;
this.m_MsInSeconds = this.m_SimulatedMS / 1000f;
}
// Token: 0x0600061C RID: 1564 RVA: 0x0002AE08 File Offset: 0x00029208
public void Init()
{
this.mNetworkHandler = global::UnityEngine.Object.FindObjectOfType<MultiplayerManager>();
if (this.mNetworkHandler != null)
{
this.mHasHandler = true;
}
}
// Token: 0x0600061D RID: 1565 RVA: 0x0002AE30 File Offset: 0x00029230
private void OnEnable()
{
if (!SteamManager.Initialized)
{
return;
}
this.m_P2PSessionRequest = Callback<P2PSessionRequest_t>.Create(new Callback<P2PSessionRequest_t>.DispatchDelegate(this.OnP2PSessionRequest));
this.m_P2PSessionConnectFail = Callback<P2PSessionConnectFail_t>.Create(new Callback<P2PSessionConnectFail_t>.DispatchDelegate(this.OnP2PSessionConnectFail));
this.m_SocketStatusCallback_t = Callback<SocketStatusCallback_t>.Create(new Callback<SocketStatusCallback_t>.DispatchDelegate(this.OnSocketStatusCallback));
}
// Token: 0x0600061E RID: 1566 RVA: 0x0002AE90 File Offset: 0x00029290
private void Update()
{
if (!SteamManager.Initialized || this.mPauseTraffic)
{
return;
}
this.m_MsInSeconds = this.m_SimulatedMS / 1000f;
if (!MatchmakingHandler.Instance.IsInsideLobby || !this.mHasHandler)
{
this.CheckForPackagesOnChannelInMainMenu(0);
this.CheckForPackagesOnChannelInMainMenu(1);
return;
}
this.CheckForPackagesOnChannel(1, true);
this.CheckForPackagesOnChannel(0, true);
}
// Token: 0x0600061F RID: 1567 RVA: 0x0002AF00 File Offset: 0x00029300
private void CheckForPackagesOnChannelInMainMenu(int channel = 0)
{
this.CheckForPackagesOnChannel(channel, false);
}
// Token: 0x06000620 RID: 1568 RVA: 0x0002AF0C File Offset: 0x0002930C
private void ReadSocketMessages()
{
NetIncomingMessage netIncomingMessage;
while ((netIncomingMessage = MatchMakingHandlerSockets.Instance.ReadMessage()) != null)
{
NetIncomingMessageType messageType = netIncomingMessage.MessageType;
if (messageType != NetIncomingMessageType.StatusChanged)
{
if (messageType != NetIncomingMessageType.Data)
{
if (messageType != NetIncomingMessageType.DiscoveryRequest)
{
if (messageType != NetIncomingMessageType.DiscoveryResponse)
{
if (messageType != NetIncomingMessageType.DebugMessage)
{
Debug.Log("unhandled message with type: " + netIncomingMessage.MessageType);
}
else
{
Debug.Log(netIncomingMessage.ReadString());
}
}
else
{
Debug.Log(string.Concat(new object[]
{
"Found server at ",
netIncomingMessage.SenderEndPoint,
" name: ",
netIncomingMessage.ReadString()
}));
MatchMakingHandlerSockets.Instance.JoinServerAt(netIncomingMessage.SenderEndPoint);
}
}
else
{
MatchMakingHandlerSockets.Instance.SendServerDiscoveryResponse(netIncomingMessage);
}
}
else
{
byte[] data = netIncomingMessage.Data;
this.ReadMessageBuffer(data, new CSteamID(1UL));
}
}
else
{
switch (netIncomingMessage.SenderConnection.Status)
{
case NetConnectionStatus.Connected:
MatchMakingHandlerSockets.Instance.OnConnectedMessageRecieved(netIncomingMessage);
break;
}
Debug.Log("Status Changed: " + netIncomingMessage.SenderConnection.Status.ToString());
}
}
}
// Token: 0x06000621 RID: 1569 RVA: 0x0002B0A4 File Offset: 0x000294A4
private void CheckForPackagesOnChannel(int channel = 0, bool checkHandler = true)
{
if (checkHandler && !this.mHasHandler)
{
Debug.LogError("Checking messages with no handler: Returning...");
return;
}
if (this.mPauseTraffic)
{
Debug.Log("Traffic Is Paused, returning");
return;
}
if (MatchmakingHandler.RunningOnSockets)
{
this.ReadSocketMessages();
return;
}
uint num;
while (SteamNetworking.IsP2PPacketAvailable(out num, channel))
{
byte[] array = new byte[num];
uint num2;
CSteamID csteamID;
if (!SteamNetworking.ReadP2PPacket(array, num, out num2, out csteamID, channel))
{
Debug.Log("Failed to read P2P Package!");
return;
}
this.ReadMessageBuffer(array, csteamID);
}
}
// Token: 0x06000622 RID: 1570 RVA: 0x0002B138 File Offset: 0x00029538
private void ReadMessageBuffer(byte[] rawData, CSteamID SteamIdRemote)
{
using (MemoryStream memoryStream = new MemoryStream(rawData))
{
using (BinaryReader binaryReader = new BinaryReader(memoryStream))
{
uint lastTimeStamp = MultiplayerManager.LastTimeStamp;
uint num = binaryReader.ReadUInt32();
P2PPackageHandler.MsgType msgType = (P2PPackageHandler.MsgType)binaryReader.ReadByte();
if (!this.mNetworkHandler.HasBeenInitializedFromServer && msgType != P2PPackageHandler.MsgType.ClientInit && msgType != P2PPackageHandler.MsgType.ClientAccepted)
{
Debug.Log("Stopping packet: " + msgType + " Has not been inited by server yet!");
}
else
{
if (num < lastTimeStamp)
{
Debug.LogWarning("Packet is obsolete!");
}
byte[] array = binaryReader.ReadBytes(rawData.Length - 1);
this.CheckMessageType(array, msgType, SteamIdRemote);
}
}
}
}
// Token: 0x06000623 RID: 1571 RVA: 0x0002B214 File Offset: 0x00029614
private void CheckMessageType(byte[] data, P2PPackageHandler.MsgType type, CSteamID steamIdRemote)
{
switch (type)
{
case P2PPackageHandler.MsgType.Ping:
if (data.Length > 0)
{
this.SendP2PPacketToUser(steamIdRemote, data, P2PPackageHandler.MsgType.PingResponse, EP2PSend.k_EP2PSendReliable, 0);
}
return;
case P2PPackageHandler.MsgType.PingResponse:
if (steamIdRemote == SteamUser.GetSteamID())
{
}
PingHandler.PingMessageRecieved(steamIdRemote.m_SteamID, data);
return;
case P2PPackageHandler.MsgType.ClientJoined:
this.mNetworkHandler.OnClientJoined(data);
return;
case P2PPackageHandler.MsgType.ClientRequestingAccepting:
this.SendP2PPacketToUser(steamIdRemote, new byte[0], P2PPackageHandler.MsgType.ClientAccepted, EP2PSend.k_EP2PSendReliable, 0);
return;
case P2PPackageHandler.MsgType.ClientAccepted:
this.mNetworkHandler.OnClientAcceptedByServer();
return;
case P2PPackageHandler.MsgType.ClientInit:
this.mNetworkHandler.OnInitFromServer(data);
return;
case P2PPackageHandler.MsgType.ClientRequestingIndex:
this.mNetworkHandler.OnPlayerRequestingIndex(data);
return;
case P2PPackageHandler.MsgType.ClientRequestingToSpawn:
this.mNetworkHandler.OnPlayerRequestingToSpawn(data);
return;
case P2PPackageHandler.MsgType.ClientSpawned:
this.mNetworkHandler.OnPlayerSpawned(data);
return;
case P2PPackageHandler.MsgType.ClientReadyUp:
this.mNetworkHandler.OnClientReadyUp(data);
return;
case P2PPackageHandler.MsgType.MapChange:
this.mNetworkHandler.OnMapChanged(data);
return;
case P2PPackageHandler.MsgType.WeaponSpawned:
this.mNetworkHandler.OnWeaponSpawned(data);
return;
case P2PPackageHandler.MsgType.ClientRequestWeaponDrop:
this.mNetworkHandler.OnPlayerRequestingWeaponDrop(data);
return;
case P2PPackageHandler.MsgType.WeaponDropped:
this.mNetworkHandler.OnWeaponDropped(data);
return;
case P2PPackageHandler.MsgType.WeaponWasPickedUp:
this.mNetworkHandler.OnWeaponWasPickedUp(data);
return;
case P2PPackageHandler.MsgType.ClientRequestingWeaponPickUp:
this.mNetworkHandler.OnPlayerRequestingWeaponPickUp(data);
return;
case P2PPackageHandler.MsgType.ObjectSpawned:
this.mNetworkHandler.OnObjectSpawned(data);
return;
case P2PPackageHandler.MsgType.GroundWeaponsInit:
this.mNetworkHandler.OnGroundWeaponsInit(data);
return;
case P2PPackageHandler.MsgType.MapInfo:
this.mNetworkHandler.OnMapInfoRecieved(data);
return;
case P2PPackageHandler.MsgType.MapInfoSync:
this.mNetworkHandler.OnMapDataRecieved(data);
return;
case P2PPackageHandler.MsgType.WorkshopMapsLoaded:
this.mNetworkHandler.OnNewWorkshopMapsRecieved(data);
return;
case P2PPackageHandler.MsgType.StartMatch:
this.mNetworkHandler.OnMatchStart(data);
return;
case P2PPackageHandler.MsgType.OptionsChanged:
OptionsHolder.NetworkOptionsChanged(data);
return;
case P2PPackageHandler.MsgType.KickPlayer:
this.mNetworkHandler.OnKicked(data);
return;
}
throw new Exception("Messagetype: " + type + " Is not setup!");
}
// Token: 0x06000624 RID: 1572 RVA: 0x0002B494 File Offset: 0x00029894
public void SendP2PPacketToServer(byte[] data, P2PPackageHandler.MsgType messageType, EP2PSend sendMethod = EP2PSend.k_EP2PSendReliable, int channel = -1)
{
CSteamID lobbyOwner = MatchmakingHandler.Instance.LobbyOwner;
int num = ((channel != -1) ? channel : this.GetChannelForMsgType(messageType));
this.SendP2PPacketToUser(lobbyOwner, data, messageType, sendMethod, num);
}
// Token: 0x06000625 RID: 1573 RVA: 0x0002B4D0 File Offset: 0x000298D0
private int GetChannelForMsgType(P2PPackageHandler.MsgType messageType)
{
switch (messageType)
{
case P2PPackageHandler.MsgType.Ping:
return 0;
case P2PPackageHandler.MsgType.PingResponse:
return 0;
case P2PPackageHandler.MsgType.ClientJoined:
return 0;
case P2PPackageHandler.MsgType.ClientRequestingAccepting:
return 1;
case P2PPackageHandler.MsgType.ClientAccepted:
return 1;
case P2PPackageHandler.MsgType.ClientInit:
return 0;
case P2PPackageHandler.MsgType.ClientRequestingIndex:
return 0;
case P2PPackageHandler.MsgType.ClientRequestingToSpawn:
return 0;
case P2PPackageHandler.MsgType.ClientSpawned:
return 0;
case P2PPackageHandler.MsgType.ClientReadyUp:
return 1;
case P2PPackageHandler.MsgType.PlayerForceAddedAndBlock:
return 0;
case P2PPackageHandler.MsgType.PlayerFallOut:
return 1;
case P2PPackageHandler.MsgType.MapChange:
return 1;
case P2PPackageHandler.MsgType.WeaponSpawned:
return 1;
case P2PPackageHandler.MsgType.ClientRequestWeaponDrop:
return 1;
case P2PPackageHandler.MsgType.WeaponDropped:
return 1;
case P2PPackageHandler.MsgType.WeaponWasPickedUp:
return 1;
case P2PPackageHandler.MsgType.ClientRequestingWeaponPickUp:
return 1;
case P2PPackageHandler.MsgType.ObjectSpawned:
return 1;
case P2PPackageHandler.MsgType.ObjectSimpleDestruction:
return 1;
case P2PPackageHandler.MsgType.ObjectInvokeDestructionEvent:
return 1;
case P2PPackageHandler.MsgType.GroundWeaponsInit:
return 1;
case P2PPackageHandler.MsgType.MapInfo:
return 1;
case P2PPackageHandler.MsgType.MapInfoSync:
return 0;
case P2PPackageHandler.MsgType.WorkshopMapsLoaded:
return 1;
case P2PPackageHandler.MsgType.StartMatch:
return 1;
case P2PPackageHandler.MsgType.OptionsChanged:
return 1;
case P2PPackageHandler.MsgType.KickPlayer:
return 1;
}
throw new Exception("Message Type is not setup: " + messageType.ToString());
}
// Token: 0x06000626 RID: 1574 RVA: 0x0002B5D8 File Offset: 0x000299D8
public void SendSocketP2PPacketToUser(NetConnection user, byte[] data, P2PPackageHandler.MsgType messageType, NetDeliveryMethod sendMethod = NetDeliveryMethod.ReliableOrdered, int channel = 0)
{
uint maxValue = uint.MaxValue;
byte[] array = new byte[data.Length + 4 + 1];
using (MemoryStream memoryStream = new MemoryStream(array))
{
using (BinaryWriter binaryWriter = new BinaryWriter(memoryStream))
{
binaryWriter.Write(maxValue);
binaryWriter.Write((byte)messageType);
binaryWriter.Write(data);
}
}
this.SendSocketMessage(user, array, channel);
}
// Token: 0x06000627 RID: 1575 RVA: 0x0002B664 File Offset: 0x00029A64
private byte[] WriteMessageBuffer(byte[] data, P2PPackageHandler.MsgType messageType)
{
uint serverRealTime = SteamUtils.GetServerRealTime();
byte[] array = new byte[data.Length + 4 + 1];
using (MemoryStream memoryStream = new MemoryStream(array))
{
using (BinaryWriter binaryWriter = new BinaryWriter(memoryStream))
{
binaryWriter.Write(serverRealTime);
binaryWriter.Write((byte)messageType);
binaryWriter.Write(data);
}
}
return array;
}
// Token: 0x06000628 RID: 1576 RVA: 0x0002B6E8 File Offset: 0x00029AE8
public void SendP2PPacketToUser(NetConnection user, byte[] data, P2PPackageHandler.MsgType messageType, EP2PSend sendMethod = EP2PSend.k_EP2PSendReliable, int channel = 0)
{
byte[] array = this.WriteMessageBuffer(data, messageType);
this.SendSocketMessage(user, array, channel);
}
// Token: 0x06000629 RID: 1577 RVA: 0x0002B708 File Offset: 0x00029B08
public void SendP2PPacketToUser(CSteamID clientID, byte[] data, P2PPackageHandler.MsgType messageType, EP2PSend sendMethod = EP2PSend.k_EP2PSendReliable, int channel = 0)
{
byte[] array = this.WriteMessageBuffer(data, messageType);
uint num = (uint)array.Length;
if (!SteamNetworking.SendP2PPacket(clientID, array, num, sendMethod, channel))
{
Debug.Log("FAILED send package to User: " + clientID.m_SteamID);
}
else
{
P2PStatistics.BytesWasSent(num, (P2PPackageHandler.MsgType)array[4]);
if (messageType == P2PPackageHandler.MsgType.Ping && data.Length > 0)
{
PingHandler.AddPingMessage(clientID.m_SteamID, data);
}
}
}
// Token: 0x0600062A RID: 1578 RVA: 0x0002B77A File Offset: 0x00029B7A
private void SendSocketMessage(CSteamID user, byte[] data, int channel)
{
}
// Token: 0x0600062B RID: 1579 RVA: 0x0002B77C File Offset: 0x00029B7C
private void SendSocketMessage(NetConnection user, byte[] data, int channel)
{
MatchMakingHandlerSockets.Instance.SendSocketMessage(user, data, channel);
}
// Token: 0x0600062C RID: 1580 RVA: 0x0002B78B File Offset: 0x00029B8B
public void ResumeNetworkTraffic()
{
this.mPauseTraffic = false;
Debug.Log("Resumed Network Traffic On time: " + Time.time);
}
// Token: 0x0600062D RID: 1581 RVA: 0x0002B7AD File Offset: 0x00029BAD
public void PauseNetworkTraffic()
{
this.mPauseTraffic = true;
Debug.Log("Paused Network Traffic On time: " + Time.time);
}
// Token: 0x0600062E RID: 1582 RVA: 0x0002B7D0 File Offset: 0x00029BD0
private void OnP2PSessionRequest(P2PSessionRequest_t pCallback)
{
Debug.Log(string.Concat(new object[] { "[", 1202, " - P2PSessionRequest] - ", pCallback.m_steamIDRemote }));
if (!MatchmakingHandler.Instance.IsInsideLobby)
{
Debug.LogError("Got a P2P request when not in lobby, denying request!");
return;
}
CSteamID steamIDRemote = pCallback.m_steamIDRemote;
if (!MatchmakingHandler.Instance.IsUserInsideMyLobby(steamIDRemote))
{
Debug.LogError(string.Concat(new object[]
{
"Requestee user: ",
steamIDRemote,
" : ",
SteamFriends.GetFriendPersonaName(steamIDRemote),
" Wants to establish a p2p connection but is not in lobby, denying..."
}));
return;
}
bool flag = SteamNetworking.AcceptP2PSessionWithUser(steamIDRemote);
MonoBehaviour.print(string.Concat(new object[] { "SteamNetworking.AcceptP2PSessionWithUser(", steamIDRemote, ") - ", flag }));
if (SteamFriends.RequestUserInformation(steamIDRemote, false))
{
}
if (flag)
{
this.SendP2PPacketToUser(steamIDRemote, new byte[0], P2PPackageHandler.MsgType.Ping, EP2PSend.k_EP2PSendReliable, 0);
}
}
// Token: 0x0600062F RID: 1583 RVA: 0x0002B8E8 File Offset: 0x00029CE8
private void OnPersoneStateChanged(PersonaStateChange_t pCallback)
{
Debug.Log("OnPersoneStateChanged: " + pCallback.m_nChangeFlags);
int smallFriendAvatar = SteamFriends.GetSmallFriendAvatar(new CSteamID(pCallback.m_ulSteamID));
if (smallFriendAvatar == 0)
{
Debug.LogError("No Image is set for remote user!");
}
int smallFriendAvatar2 = SteamFriends.GetSmallFriendAvatar(SteamUser.GetSteamID());
if (smallFriendAvatar2 == 0)
{
Debug.LogError("No Image is set for user!");
}
uint num = 8192U;
byte[] array = new byte[num];
byte[] array2 = new byte[num];
if (!SteamUtils.GetImageRGBA(smallFriendAvatar, array, array.Length))
{
Debug.LogError("Image was not stored correctly!");
}
if (!SteamUtils.GetImageRGBA(smallFriendAvatar2, array2, array.Length))
{
Debug.LogError("Image was not stored correctly!");
}
}
// Token: 0x06000630 RID: 1584 RVA: 0x0002B997 File Offset: 0x00029D97
private void OnSocketStatusCallback(SocketStatusCallback_t pCallback)
{
throw new NotImplementedException();
}
// Token: 0x06000631 RID: 1585 RVA: 0x0002B9A0 File Offset: 0x00029DA0
private void OnP2PSessionConnectFail(P2PSessionConnectFail_t pCallback)
{
string text = "P2p SessionConnectFail! ";
EP2PSessionError eP2PSessionError = (EP2PSessionError)pCallback.m_eP2PSessionError;
Debug.LogError(text + eP2PSessionError.ToString());
if (pCallback.m_eP2PSessionError == 4 && !MatchmakingHandler.IsNetworkMatch)
{
return;
}
}
// Token: 0x04000515 RID: 1301
protected Callback<P2PSessionRequest_t> m_P2PSessionRequest;
// Token: 0x04000516 RID: 1302
protected Callback<P2PSessionConnectFail_t> m_P2PSessionConnectFail;
// Token: 0x04000517 RID: 1303
protected Callback<SocketStatusCallback_t> m_SocketStatusCallback_t;
// Token: 0x04000518 RID: 1304
private bool mHasHandler;
// Token: 0x04000519 RID: 1305
private MultiplayerManager mNetworkHandler;
// Token: 0x0400051A RID: 1306
[SerializeField]
private uint m_SimulatedMS;
// Token: 0x0400051B RID: 1307
private float m_MsInSeconds;
// Token: 0x0400051C RID: 1308
private static P2PPackageHandler _instance;
// Token: 0x0400051D RID: 1309
private bool mPauseTraffic;
// Token: 0x020000CD RID: 205
public enum MsgType : byte
{
// Token: 0x0400051F RID: 1311
Ping,
// Token: 0x04000520 RID: 1312
PingResponse,
// Token: 0x04000521 RID: 1313
ClientJoined,
// Token: 0x04000522 RID: 1314
ClientRequestingAccepting,
// Token: 0x04000523 RID: 1315
ClientAccepted,
// Token: 0x04000524 RID: 1316
ClientInit,
// Token: 0x04000525 RID: 1317
ClientRequestingIndex,
// Token: 0x04000526 RID: 1318
ClientRequestingToSpawn,
// Token: 0x04000527 RID: 1319
ClientSpawned,
// Token: 0x04000528 RID: 1320
ClientReadyUp,
// Token: 0x04000529 RID: 1321
PlayerUpdate,
// Token: 0x0400052A RID: 1322
PlayerTookDamage,
// Token: 0x0400052B RID: 1323
PlayerTalked,
// Token: 0x0400052C RID: 1324
PlayerForceAdded,
// Token: 0x0400052D RID: 1325
PlayerForceAddedAndBlock,
// Token: 0x0400052E RID: 1326
PlayerLavaForceAdded,
// Token: 0x0400052F RID: 1327
PlayerFallOut,
// Token: 0x04000530 RID: 1328
PlayerWonWithRicochet,
// Token: 0x04000531 RID: 1329
MapChange,
// Token: 0x04000532 RID: 1330
WeaponSpawned,
// Token: 0x04000533 RID: 1331
WeaponThrown,
// Token: 0x04000534 RID: 1332
RequestingWeaponThrow,
// Token: 0x04000535 RID: 1333
ClientRequestWeaponDrop,
// Token: 0x04000536 RID: 1334
WeaponDropped,
// Token: 0x04000537 RID: 1335
WeaponWasPickedUp,
// Token: 0x04000538 RID: 1336
ClientRequestingWeaponPickUp,
// Token: 0x04000539 RID: 1337
ObjectUpdate,
// Token: 0x0400053A RID: 1338
ObjectSpawned,
// Token: 0x0400053B RID: 1339
ObjectSimpleDestruction,
// Token: 0x0400053C RID: 1340
ObjectInvokeDestructionEvent,
// Token: 0x0400053D RID: 1341
ObjectDestructionCollision,
// Token: 0x0400053E RID: 1342
GroundWeaponsInit,
// Token: 0x0400053F RID: 1343
MapInfo,
// Token: 0x04000540 RID: 1344
MapInfoSync,
// Token: 0x04000541 RID: 1345
WorkshopMapsLoaded,
// Token: 0x04000542 RID: 1346
StartMatch,
// Token: 0x04000543 RID: 1347
ObjectHello,
// Token: 0x04000544 RID: 1348
OptionsChanged,
// Token: 0x04000545 RID: 1349
KickPlayer
}
}

View File

@@ -46,4 +46,15 @@
</Reference>
</ItemGroup>
<ItemGroup>
<Reference Include="TextMeshPro-1.0.55.56.0b9">
<HintPath>lib/TextMeshPro-1.0.55.56.0b9.dll</HintPath>
</Reference>
</ItemGroup>
<!-- 排除 reference/ 目录:仅为参考源码,不应参与编译 -->
<ItemGroup>
<Compile Remove="reference/**" />
</ItemGroup>
</Project>