Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
@@ -0,0 +1,372 @@
# 组播服务发现叠加 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在现有 UDP 广播发现机制上叠加组播探测路径(组 `239.192.48.92:48923`,TTL=8),使发现请求有机会跨网段到达开发机;现有广播/loopback/写死 IP 路径全部保留。
**Architecture:** 服务端响应器在现有 `0.0.0.0:48923` socket 上逐网卡加入组播组(收发逻辑零改动);客户端两份探测器(热更层 + 底包 AOT 层,协议内联须同步)在每轮重发中逐网卡设 `MulticastInterface` 向组播组发查询,回复仍走单播。客户端只发不收组播,Android 无需 MulticastLock。
**Tech Stack:** C# / .NET(netstandard2.1 共享协议)、Unity 客户端、xUnit 服务端测试。
**Spec:** `docs/superpowers/specs/2026-07-06-multicast-discovery-design.md`
**注意:** 项目根 `.git` 是空目录、非有效 git 仓库,故本计划省略 commit 步骤,以"运行测试/编译验证"作为每任务收尾。若后续 `git init`,可按任务边界补提交。
**关键背景(执行者必读):**
- `Server\Framework.Shared\Framework.Shared.csproj` 通过 `<Compile Include="..\..\Client\Assets\Framework\Shared\**\*.cs" />` 直接编译 Client 共享源码 → 协议常量只需改 `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs` 一处,服务端自动生效。
- `Client\Assets\Base\LocalCdnDiscovery.cs` 属底包(AOT)程序集,**不能**引用上述共享程序集(运行时才由 LoadDll 加载),协议常量是手工内联的,改动须与共享层同步(文件头注释已有此约定)。
- 发现机制是"多路齐发、谁通算谁",所有网络操作失败均静默吞掉,不中断其余路径——新增代码须保持此风格。
- 测试命令均在仓库根 `D:\UD\AI\AIC#Project` 下执行;路径含 `#`,bash 中必须加引号。
---
### Task 1: 协议常量 + 服务端组播 join(TDD)
**Files:**
- Modify: `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs`(加常量)
- Modify: `Server\Gateway\DevDiscoveryResponder.cs`(逐网卡 join 组播组)
- Test: `Server\Gateway.Tests\DevDiscoveryResponderTests.cs`(加组播用例)
- [ ] **Step 1: 加协议常量**
`Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs``DevDiscovery` 静态类中新增(放在 `Port` 之后):
```csharp
// 组播发现组:组织本地范围(RFC 2365, 239.192.0.0/14),配合发送端 TTL>1 可跨内网多级路由;
// 能否真正跨网段取决于路由器是否开启组播路由(PIM/IGMP Proxy)。
public const string MulticastGroup = "239.192.48.92";
```
- [ ] **Step 2: 写失败测试**
`Server\Gateway.Tests\DevDiscoveryResponderTests.cs` 中新增用例(与现有用例同级):
```csharp
[Fact]
public async Task MulticastQuery_ReceivesReply()
{
var responder = new DevDiscoveryResponder("dev-pc", "ws://x", "http://y", token: "secret", listenPort: 0);
responder.Start();
try
{
using var client = new UdpClient(AddressFamily.InterNetwork);
// 同机回环验证组播收包路径:loopback 开、TTL=1 不出网段
client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true);
client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1);
byte[] q = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery
{
ProtoVersion = DevDiscovery.ProtoVersion,
Nonce = 21,
Token = "secret",
});
var groupEp = new IPEndPoint(IPAddress.Parse(DevDiscovery.MulticastGroup), responder.Port);
await client.SendAsync(q, q.Length, groupEp);
var recv = client.ReceiveAsync();
var done = await Task.WhenAny(recv, Task.Delay(2000));
Assert.Same(recv, done); // 超时即失败:响应器没收到组播查询
UdpReceiveResult result = await recv;
Assert.True(DevDiscoveryCodec.TryDecodeReply(result.Buffer, out var reply));
Assert.Equal(21u, reply.Nonce);
Assert.Equal("secret", reply.Token);
}
finally { await responder.StopAsync(); }
}
```
- [ ] **Step 3: 跑测试确认失败**
```bash
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Gateway.Tests --filter MulticastQuery_ReceivesReply
```
预期:FAIL,`Assert.Same` 断言失败(2 秒超时,响应器未 join 组播组收不到查询)。
- [ ] **Step 4: 实现服务端逐网卡 join**
修改 `Server\Gateway\DevDiscoveryResponder.cs`:
文件头 using 增加:
```csharp
using System.Net.NetworkInformation;
```
构造函数中 `Port = ...` 赋值行之后新增一行调用:
```csharp
JoinMulticastOnAllNics(_udp, IPAddress.Parse(DevDiscovery.MulticastGroup));
```
类中新增私有静态方法(放在构造函数之后):
```csharp
// 逐块 Up 状态非回环 IPv4 网卡加入组播组:默认 JoinMulticastGroup(group) 只加默认网卡,
// 多网卡开发机(有线+无线/虚拟网卡)会漏收。逐网卡 try/catch,单卡失败不中断其余;
// 一块都没 join 上时兜底按默认网卡 join 一次。
private static void JoinMulticastOnAllNics(UdpClient udp, IPAddress group)
{
bool joinedAny = false;
NetworkInterface[] nics;
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
catch { nics = Array.Empty<NetworkInterface>(); }
foreach (NetworkInterface ni in nics)
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
IPInterfaceProperties props;
try { props = ni.GetIPProperties(); }
catch { continue; }
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
try { udp.JoinMulticastGroup(group, ua.Address); joinedAny = true; }
catch (SocketException) { }
}
}
if (!joinedAny)
{
try { udp.JoinMulticastGroup(group); }
catch (SocketException) { }
}
}
```
- [ ] **Step 5: 跑测试确认通过**
```bash
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Gateway.Tests --filter MulticastQuery_ReceivesReply
```
预期:PASS。
- [ ] **Step 6: 跑服务端全量测试防回归**
```bash
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
```
预期:全部 PASS(尤其 `DevDiscoveryResponderTests` 原有 3 条、`GameServerHostDiscoveryTests``DevDiscoveryCodecTests`)。
---
### Task 2: 客户端热更层 DevServerDiscovery 发组播
**Files:**
- Modify: `Client\Assets\Script\xmain\MiniGame\DevServerDiscovery.cs`
无法为 Unity 客户端写自动化测试,验证靠 Task 4 的编辑器实测。
- [ ] **Step 1: 更新写死 IP 的注释**
将字段 `DevExtraProbeIps` 上方注释(两行)替换为:
```csharp
// 跨网段内网联调用:手机与开发机不在同一子网时,广播到不了开发机(定向广播默认不被路由器转发,RFC 2644,已实测不通)。
// 故直接向"已知开发机单播 IP"发发现查询——单播能跨网段路由,响应器绑 0.0.0.0:48923 单播照收照回。换机器/网段改这里。
// 已叠加组播探测(DevDiscovery.MulticastGroup):若实测组播能跨网段,可删本兜底。
```
- [ ] **Step 2: socket 初始化后设组播 TTL**
`Run()``udp.Client.ReceiveTimeout = 250;` 之后新增:
```csharp
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8);
```
- [ ] **Step 3: 每轮重发时逐网卡发组播**
`Run()``IPEndPoint[] targets = targetList.ToArray();` 之后新增:
```csharp
// 组播端点不进 targets:发组播走哪块网卡由 IP_MULTICAST_IF 决定(默认只走默认路由网卡),
// 故须逐网卡切换后各发一次,覆盖编辑器/PC 多网卡;手机一般单网卡。
IPEndPoint multicastEp = new IPEndPoint(IPAddress.Parse(DevDiscovery.MulticastGroup), DevDiscovery.Port);
List<IPAddress> multicastNics = GetLocalIPv4Addresses();
Debug.Log("[DevTest] multicast=" + multicastEp + " via " + multicastNics.Count + " nic(s)");
```
在发送循环(`foreach (IPEndPoint t in targets) { try { udp.Send(...) } catch { } }`)之后、`nextSend = ...` 之前新增:
```csharp
foreach (IPAddress nicAddr in multicastNics)
{
try
{
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, nicAddr.GetAddressBytes());
udp.Send(query, query.Length, multicastEp);
}
catch { }
}
if (multicastNics.Count == 0)
{
try { udp.Send(query, query.Length, multicastEp); } catch { } // 枚举不到网卡时按默认网卡发
}
```
- [ ] **Step 4: 新增网卡地址枚举辅助方法**
`GetDirectedBroadcasts()` 之后新增(枚举骨架与其一致,但无需掩码):
```csharp
// 枚举本机所有 Up 状态非回环网卡的 IPv4 地址,供逐网卡发组播用。
private static List<IPAddress> GetLocalIPv4Addresses()
{
List<IPAddress> list = new List<IPAddress>();
NetworkInterface[] nics;
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
catch (Exception e) { Debug.LogWarning("[DevTest] enum nics for multicast failed: " + e.Message); return list; }
foreach (NetworkInterface ni in nics)
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
IPInterfaceProperties props;
try { props = ni.GetIPProperties(); }
catch { continue; }
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
list.Add(ua.Address);
}
}
return list;
}
```
- [ ] **Step 5: 确认编译无错**
Unity 编辑器会自动编译;若编辑器未开,至少目视核对:新代码只用了文件已 using 的命名空间(`System.Net``System.Net.NetworkInformation``System.Net.Sockets`),`DevDiscovery.MulticastGroup` 来自已 using 的 `XWorld.Framework.Protocol`
---
### Task 3: 客户端底包 LocalCdnDiscovery 发组播(与热更层同步)
**Files:**
- Modify: `Client\Assets\Base\LocalCdnDiscovery.cs`
底包不能引用共享程序集,常量手工内联;逻辑与 Task 2 完全对应。
- [ ] **Step 1: 内联组播常量**
`private const int Port = 48923;` 一组常量之后(`TypeReply` 之后)新增:
```csharp
private const string MulticastGroup = "239.192.48.92"; // = DevDiscovery.MulticastGroup
```
- [ ] **Step 2: 更新写死 IP 的注释**
将字段 `DevExtraProbeIps` 上方注释(两行)替换为:
```csharp
// 跨网段内网联调用:手机与开发机不在同一子网时,广播到不了开发机(定向广播默认不被路由器转发,RFC 2644,已实测不通)。
// 故直接向"已知开发机单播 IP"发发现查询——单播能跨网段路由,响应器绑 0.0.0.0:48923 单播照收照回。换机器/网段改这里。
// 已叠加组播探测(MulticastGroup):若实测组播能跨网段,可删本兜底。
```
- [ ] **Step 3: socket 初始化后设组播 TTL**
`Discover()``udp.Client.ReceiveTimeout = 250;` 之后新增:
```csharp
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8);
```
- [ ] **Step 4: 每轮重发时逐网卡发组播**
`Discover()``IPEndPoint[] targets = targetList.ToArray();` 之后新增:
```csharp
// 组播端点不进 targets:发组播走哪块网卡由 IP_MULTICAST_IF 决定(默认只走默认路由网卡),
// 故须逐网卡切换后各发一次,覆盖编辑器/PC 多网卡;手机一般单网卡。
IPEndPoint multicastEp = new IPEndPoint(IPAddress.Parse(MulticastGroup), Port);
List<IPAddress> multicastNics = GetLocalIPv4Addresses();
UnityEngine.Debug.Log("[Discovery] multicast=" + multicastEp + " via " + multicastNics.Count + " nic(s)");
```
在发送循环(`foreach (IPEndPoint t in targets) { try { udp.Send(...) } catch { } }`)之后、`nextSend = ...` 之前新增:
```csharp
foreach (IPAddress nicAddr in multicastNics)
{
try
{
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, nicAddr.GetAddressBytes());
udp.Send(query, query.Length, multicastEp);
}
catch { }
}
if (multicastNics.Count == 0)
{
try { udp.Send(query, query.Length, multicastEp); } catch { } // 枚举不到网卡时按默认网卡发
}
```
- [ ] **Step 5: 新增网卡地址枚举辅助方法**
`GetDirectedBroadcasts()` 之后新增:
```csharp
// 枚举本机所有 Up 状态非回环网卡的 IPv4 地址,供逐网卡发组播用。
private static List<IPAddress> GetLocalIPv4Addresses()
{
List<IPAddress> list = new List<IPAddress>();
NetworkInterface[] nics;
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
catch (Exception e) { UnityEngine.Debug.LogWarning("[Discovery] enum nics for multicast failed: " + e.Message); return list; }
foreach (NetworkInterface ni in nics)
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
IPInterfaceProperties props;
try { props = ni.GetIPProperties(); }
catch { continue; }
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
list.Add(ua.Address);
}
}
return list;
}
```
- [ ] **Step 6: 目视核对两边一致性**
对照 `DevServerDiscovery.cs`:组播组地址、TTL 值、逐网卡发送逻辑、兜底发送逻辑须完全一致(仅日志前缀 `[Discovery]`/`[DevTest]` 和常量引用方式不同)。
---
### Task 4: 端到端验证
**Files:** 无代码改动,纯验证。
- [ ] **Step 1: 服务端全量测试**
```bash
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
```
预期:全部 PASS。
- [ ] **Step 2: 编辑器同机自测(需用户配合)**
启动服务端网关(带发现响应器)与 Unity 编辑器客户端,观察客户端日志:
- 出现 `[DevTest] multicast=239.192.48.92:48923 via N nic(s)`(N ≥ 1)
- 出现 `[Discovery] multicast=239.192.48.92:48923 via N nic(s)`(底包路径,若走到)
- 发现成功(同机场景下 loopback 路径本来就通,此步主要确认组播代码不报错、不拖慢发现)
- [ ] **Step 3: 真机验证(需用户配合,自动化范围之外)**
按 spec 验收标准:
1. 真机同网段:发现成功。
2. 真机跨网段:观察组播路径是否通。通 → 建后续任务删 `DevExtraProbeIps` 写死 IP;不通 → 保留写死 IP,评估"探测 IP 可配置化"方案(spec 非目标节)。