16 KiB
组播服务发现叠加 实施计划
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 之后):
// 组播发现组:组织本地范围(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 中新增用例(与现有用例同级):
[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: 跑测试确认失败
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 增加:
using System.Net.NetworkInformation;
构造函数中 Port = ... 赋值行之后新增一行调用:
JoinMulticastOnAllNics(_udp, IPAddress.Parse(DevDiscovery.MulticastGroup));
类中新增私有静态方法(放在构造函数之后):
// 逐块 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: 跑测试确认通过
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Gateway.Tests --filter MulticastQuery_ReceivesReply
预期:PASS。
- Step 6: 跑服务端全量测试防回归
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 上方注释(两行)替换为:
// 跨网段内网联调用:手机与开发机不在同一子网时,广播到不了开发机(定向广播默认不被路由器转发,RFC 2644,已实测不通)。
// 故直接向"已知开发机单播 IP"发发现查询——单播能跨网段路由,响应器绑 0.0.0.0:48923 单播照收照回。换机器/网段改这里。
// 已叠加组播探测(DevDiscovery.MulticastGroup):若实测组播能跨网段,可删本兜底。
- Step 2: socket 初始化后设组播 TTL
在 Run() 中 udp.Client.ReceiveTimeout = 250; 之后新增:
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8);
- Step 3: 每轮重发时逐网卡发组播
在 Run() 中 IPEndPoint[] targets = targetList.ToArray(); 之后新增:
// 组播端点不进 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 = ... 之前新增:
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() 之后新增(枚举骨架与其一致,但无需掩码):
// 枚举本机所有 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 之后)新增:
private const string MulticastGroup = "239.192.48.92"; // = DevDiscovery.MulticastGroup
- Step 2: 更新写死 IP 的注释
将字段 DevExtraProbeIps 上方注释(两行)替换为:
// 跨网段内网联调用:手机与开发机不在同一子网时,广播到不了开发机(定向广播默认不被路由器转发,RFC 2644,已实测不通)。
// 故直接向"已知开发机单播 IP"发发现查询——单播能跨网段路由,响应器绑 0.0.0.0:48923 单播照收照回。换机器/网段改这里。
// 已叠加组播探测(MulticastGroup):若实测组播能跨网段,可删本兜底。
- Step 3: socket 初始化后设组播 TTL
在 Discover() 中 udp.Client.ReceiveTimeout = 250; 之后新增:
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8);
- Step 4: 每轮重发时逐网卡发组播
在 Discover() 中 IPEndPoint[] targets = targetList.ToArray(); 之后新增:
// 组播端点不进 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 = ... 之前新增:
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() 之后新增:
// 枚举本机所有 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: 服务端全量测试
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 验收标准:
- 真机同网段:发现成功。
- 真机跨网段:观察组播路径是否通。通 → 建后续任务删
DevExtraProbeIps写死 IP;不通 → 保留写死 IP,评估"探测 IP 可配置化"方案(spec 非目标节)。