# 组播服务发现叠加 实施计划 > **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` 通过 `` 直接编译 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(); } 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 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 GetLocalIPv4Addresses() { List list = new List(); 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 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 GetLocalIPv4Addresses() { List list = new List(); 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 非目标节)。