Files
AIC-Project/docs/superpowers/plans/2026-07-06-multicast-discovery.md
2026-07-10 10:24:29 +08:00

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.csDevDiscovery 静态类中新增(放在 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 条、GameServerHostDiscoveryTestsDevDiscoveryCodecTests)。


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.NetSystem.Net.NetworkInformationSystem.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 验收标准:

  1. 真机同网段:发现成功。
  2. 真机跨网段:观察组播路径是否通。通 → 建后续任务删 DevExtraProbeIps 写死 IP;不通 → 保留写死 IP,评估"探测 IP 可配置化"方案(spec 非目标节)。