# 探测 IP 可配置化 + 移除组播 实施计划 > **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:** 删除实测无效的组播发现代码;开发机探测 IP 从代码写死改为设备本地配置 `persistentDataPath/dev_server.txt`,底包发现失败时弹 IMGUI 输入框录入并验证后持久化。 **Architecture:** 三个小单元:`DevServerConfig`(Base,dev_server.txt 读写)、`DevServerPrompt`(Base,纯 IMGUI 三态输入框)、`LoadDll` 协程编排两轮发现+弹框循环。`LocalCdnDiscovery.Discover` 加 `extraProbeIp` 参数(后台线程不能碰 persistentDataPath,配置由主线程读好传入);热更层 `DevServerDiscovery` 内联同语义配置读取(XWorld.Link 未引用 Base 程序集,沿用"内联+两边同步"惯例)。 **Tech Stack:** C#/.NET(netstandard2.1 共享协议)、Unity 客户端(IMGUI)、xUnit 服务端测试。 **Spec:** `docs/superpowers/specs/2026-07-06-dev-server-config-design.md` **注意:** 项目根 `.git` 是空目录、非有效 git 仓库,省略 commit 步骤,以"运行测试/目视核对"作为每任务收尾。 **关键背景(执行者必读):** - 前一个特性(组播叠加,plan `2026-07-06-multicast-discovery.md`)已实施但实测跨网段不通,本计划将其代码**全部删除**。删除时以"还原并改进"为准——不是逐字回滚,注释要更新为新语义(配置文件而非写死 IP)。 - `Server\Framework.Shared\Framework.Shared.csproj` 直接编译 `Client\Assets\Framework\Shared\**\*.cs`,共享协议常量改一处两边生效。**删 `DevDiscovery.MulticastGroup` 前必须先删掉它的最后一个使用点(`DevServerDiscovery.cs`),否则 Unity 编译会断**——任务顺序已按此安排,勿调换。 - `Client\Assets\Base\**` 是 AOT 底包程序集(Base.asmdef),不能引用热更程序集;`Client\Assets\Script\**` 是热更程序集(XWorld.Link.asmdef),其引用列表**没有 Base**,所以热更层也引用不到 Base 的新类。 - `Application.persistentDataPath` 只能在 Unity 主线程访问;两个发现器都跑在后台线程,配置读取必须在主线程完成(参数传入/构造函数存字段)。 - 代码注释用中文、全角标点;网络失败静默吞掉是本模块的既定风格。 - 新建 .cs 文件无需手写 .meta,Unity 编辑器打开时自动生成。 - 测试命令在仓库根执行,路径含 `#`,bash 中必须加引号:`cd "/d/UD/AI/AIC#Project"`。 --- ### Task 1: 删除服务端组播代码与测试 **Files:** - Modify: `Server\Gateway\DevDiscoveryResponder.cs` - Modify: `Server\Gateway.Tests\DevDiscoveryResponderTests.cs` (共享常量 `DevDiscovery.MulticastGroup` 此任务**不删**——客户端 `DevServerDiscovery.cs` 还在用,Task 4 收尾时删。) - [ ] **Step 1: 删响应器的组播 join** `Server\Gateway\DevDiscoveryResponder.cs` 三处: 1. 删文件头 `using System.Net.NetworkInformation;` 2. 删构造函数中这一行: ```csharp JoinMulticastOnAllNics(_udp, IPAddress.Parse(DevDiscovery.MulticastGroup)); ``` 3. 删整个 `JoinMulticastOnAllNics` 私有静态方法(含其上三行中文注释,从 `// 逐块 Up 状态非回环 IPv4 网卡加入组播组` 到方法闭括号)。 删后构造函数应恢复为: ```csharp _udp = new UdpClient(AddressFamily.InterNetwork); _udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _udp.Client.Bind(new IPEndPoint(IPAddress.Any, listenPort)); Port = ((IPEndPoint)_udp.Client.LocalEndPoint).Port; } public void Start() ``` - [ ] **Step 2: 删组播测试** `Server\Gateway.Tests\DevDiscoveryResponderTests.cs`:删整个 `MulticastQuery_ReceivesReply` 测试方法(`[Fact]` 到方法闭括号)。原有 3 条测试(`ValidQuery_ReceivesReply_WithAdvertisedAddresses`、`WrongToken_NoReply`、`GarbagePacket_Ignored_LoopStillAnswers`)与 `QueryAsync` 辅助方法不动。 - [ ] **Step 3: 跑 Gateway 测试确认绿** ```bash cd "/d/UD/AI/AIC#Project" && dotnet test Server/Gateway.Tests ``` 预期:全部通过,总数比之前少 1(42 条)。 --- ### Task 2: 新建 DevServerConfig **Files:** - Create: `Client\Assets\Base\DevServerConfig.cs` 无自动化测试(Unity 客户端代码);正确性靠 Task 7 编辑器/真机验收。 - [ ] **Step 1: 写文件** 完整内容: ```csharp using System; using System.IO; using System.Net; using System.Net.Sockets; using UnityEngine; // 内网联调配置:开发机探测 IP 的本地持久化(persistentDataPath/dev_server.txt,单行 IPv4)。 // 跨网段时广播/组播都出不了网段(路由器不转发),只能向已知开发机 IP 单播——该 IP 由本文件持久化, // 首次由 DevServerPrompt 弹框录入并验证成功后写入(见 LoadDll.CoLocalDiscoverThenDownload)。 // 注意:Application.persistentDataPath 只能在主线程访问,故 Load/Save 只能在主线程调; // 发现线程(后台)需要的值由调用方在主线程读好传入。 // 热更层 DevServerDiscovery 内联了同语义的读取(XWorld.Link 未引用 Base 程序集),改格式时两边须同步。 public static class DevServerConfig { public static string FilePath => Path.Combine(Application.persistentDataPath, "dev_server.txt"); // 读配置:文件不存在/IO 异常/内容不是合法 IPv4 → null(静默,等同"无配置")。 public static string Load() { try { string path = FilePath; if (!File.Exists(path)) return null; string line = File.ReadAllText(path).Trim(); if (line.Length == 0) return null; if (!IPAddress.TryParse(line, out IPAddress ip)) return null; if (ip.AddressFamily != AddressFamily.InterNetwork) return null; // 只认 IPv4 return ip.ToString(); } catch (Exception) { return null; } } // 写配置:单行覆盖;IO 异常静默吞(写不进只是下次还弹框,不致命)。 public static void Save(string ip) { try { File.WriteAllText(FilePath, ip ?? ""); } catch (Exception) { } } } ``` - [ ] **Step 2: 目视核对** 所有类型(`Path`/`File`/`IPAddress`/`AddressFamily`/`Application`)均被文件顶部 using 覆盖;类在全局命名空间(与 `LocalCdnDiscovery` 一致)。 --- ### Task 3: LocalCdnDiscovery 删组播、加 extraProbeIp 参数 **Files:** - Modify: `Client\Assets\Base\LocalCdnDiscovery.cs` - [ ] **Step 1: 删内联组播常量** 删这一行(const 块中): ```csharp private const string MulticastGroup = "239.192.48.92"; // = DevDiscovery.MulticastGroup ``` - [ ] **Step 2: 删 DevExtraProbeIps 整块** 删字段及其上方全部 4 行注释(从 `// 跨网段内网联调用:` 到 `private static readonly string[] DevExtraProbeIps = { "192.168.130.40" };`)。 - [ ] **Step 3: 改 Discover 签名与方法体** 方法头注释与签名改为: ```csharp // 阻塞式发现,须在后台线程调用(会最多阻塞 durationMs)。 // 在窗口内周期重发查询(向广播 + 127.0.0.1 单播),收到首个合法应答即返回其 ResourceBaseUrl(CDN 根);超时返回 null。 // extraProbeIp:额外单播探测目标(跨网段联调用,来自 dev_server.txt 或弹框输入,见 DevServerConfig/DevServerPrompt)。 // 广播/定向广播不被路由器转发(RFC 2644),组播亦已实测不通(路由器未开组播路由),跨网段只能靠单播已知 IP。 // 注意:本方法跑在后台线程,不能碰 Unity 主线程 API(如 persistentDataPath),配置由调用方在主线程读好传入。 public static string Discover(string token, int durationMs = 1500, string extraProbeIp = null) ``` 方法体内四处改动: 1. 删 TTL 设置(共 3 行,`ReceiveTimeout = 250;` 之后): ```csharp // 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由。单独 try/catch:个别设备设不上 TTL 时 // 仍能发 TTL=1 组播 + 全部原有路径,不能让它杀掉整个发现线程。 try { udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8); } catch { } ``` 2. targets 构造:把原 `foreach (string s in DevExtraProbeIps) ...` 两行替换为: ```csharp if (!string.IsNullOrEmpty(extraProbeIp) && IPAddress.TryParse(extraProbeIp, out IPAddress extra)) targetList.Add(new IPEndPoint(extra, Port)); ``` 3. 删组播端点/网卡列表构建及日志(共 5 行,targets 的 Debug.Log 之后): ```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)"); ``` 4. 删重发块内的逐网卡组播发送与零网卡兜底(targets foreach 之后、`nextSend = ...` 之前的 13 行): ```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: 更新 catch 注释、删辅助方法** 1. `catch (Exception)` 内注释改为: ```csharp // 忽略:发现失败由调用方处理(弹框输入或回退 127.0.0.1) ``` 2. 删整个 `GetLocalIPv4Addresses()` 方法(含其上一行注释 `// 枚举本机所有 Up 状态非回环网卡的 IPv4 地址,供逐网卡发组播用。`)。`GetDirectedBroadcasts()`、`IsPrivateV4`、编解码方法全部不动。 - [ ] **Step 5: 目视核对** 全文不再出现 `Multicast`/`multicast`/`DevExtraProbeIps`/`192.168.130.40`;大括号配平;`extraProbeIp` 是唯一新增参数且带默认值(既有调用 `Discover("xw-dev", 1500)` 不受影响)。 --- ### Task 4: DevServerDiscovery 删组播、内联配置读取;删共享组播常量 **Files:** - Modify: `Client\Assets\Script\xmain\MiniGame\DevServerDiscovery.cs` - Modify: `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs` - [ ] **Step 1: 替换 DevExtraProbeIps 为配置字段** 删字段及其上方 4 行注释(从 `// 跨网段内网联调用:` 到 `private static readonly string[] DevExtraProbeIps = { "192.168.130.40" };`)。 字段区加一行(`private readonly string _token;` 之后): ```csharp private readonly string _extraProbeIp; ``` 构造函数改为: ```csharp public DevServerDiscovery(string token) { _token = token; // 跨网段联调:广播/定向广播不被路由器转发(RFC 2644),组播亦实测不通(路由器未开组播路由), // 唯一可靠路径是向已知开发机 IP 单播。IP 来自 persistentDataPath/dev_server.txt——底包首启弹框 // 录入(见 Base 层 DevServerConfig/DevServerPrompt)。persistentDataPath 不能在后台线程访问, // 故在主线程构造时读好存字段,Run(后台线程)只用字段。 _extraProbeIp = ReadConfiguredProbeIp(); } ``` - [ ] **Step 2: 内联配置读取方法** 在 `Stop()` 方法之后、类闭括号之前新增: ```csharp // 读 dev_server.txt 首行并校验为 IPv4,失败一律 null。与 Base 层 DevServerConfig.Load() 语义一致—— // XWorld.Link 未引用 Base 程序集,按本项目"内联+两边同步"惯例复制最小实现,改格式时两边须同步。 private static string ReadConfiguredProbeIp() { try { string path = System.IO.Path.Combine(Application.persistentDataPath, "dev_server.txt"); if (!System.IO.File.Exists(path)) return null; string line = System.IO.File.ReadAllText(path).Trim(); if (line.Length == 0) return null; if (!IPAddress.TryParse(line, out IPAddress ip)) return null; if (ip.AddressFamily != AddressFamily.InterNetwork) return null; // 只认 IPv4 return ip.ToString(); } catch (Exception) { return null; } } ``` (`System.IO` 用全限定,避免新增 using;`Application`/`IPAddress`/`AddressFamily`/`Exception` 均已有 using 覆盖。) - [ ] **Step 3: Run() 删组播、改 targets** 1. 删 TTL 设置(共 3 行,`ReceiveTimeout = 250;` 之后): ```csharp // 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由。单独 try/catch:个别设备设不上 TTL 时 // 仍能发 TTL=1 组播 + 全部原有路径,不能让它杀掉整个发现线程。 try { udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8); } catch { } ``` 2. targets 构造:把原 `foreach (string s in DevExtraProbeIps) ...` 两行替换为: ```csharp if (!string.IsNullOrEmpty(_extraProbeIp) && IPAddress.TryParse(_extraProbeIp, out IPAddress extra)) targetList.Add(new IPEndPoint(extra, DevDiscovery.Port)); ``` 3. 删组播端点/网卡列表构建及日志(targets 的 Debug.Log 之后共 5 行,内容同 Task 3 Step 3 第 3 点,仅 `DevDiscovery.MulticastGroup`/`DevDiscovery.Port`/`[DevTest]` 之别)。 4. 删重发块内逐网卡组播发送与零网卡兜底(13 行,内容同 Task 3 Step 3 第 4 点)。 5. 删整个 `GetLocalIPv4Addresses()` 方法(含其上一行注释)。 - [ ] **Step 4: 删共享协议组播常量** `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs`:删 `MulticastGroup` 常量及其上两行注释: ```csharp // 组播发现组:组织本地范围(RFC 2365, 239.192.0.0/14),配合发送端 TTL>1 可跨内网多级路由; // 能否真正跨网段取决于路由器是否开启组播路由(PIM/IGMP Proxy)。 public const string MulticastGroup = "239.192.48.92"; ``` 删后 `DevDiscovery` 类只剩 `Port`/`ProtoVersion`/`Magic`/`TypeQuery`/`TypeReply`。 - [ ] **Step 5: 全仓库确认无残留引用** ```bash cd "/d/UD/AI/AIC#Project" && grep -rn "MulticastGroup\|239.192.48.92" --include="*.cs" Client/Assets/Base Client/Assets/Script Client/Assets/Framework Server/Gateway Server/Gateway.Tests ``` 预期:无输出(docs/ 里的历史记录不算)。 - [ ] **Step 6: 服务端全量回归** ```bash cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln ``` 预期:全部通过(172 条:比组播版少 1 条测试)。 --- ### Task 5: 新建 DevServerPrompt **Files:** - Create: `Client\Assets\Base\DevServerPrompt.cs` - [ ] **Step 1: 写文件** 完整内容: ```csharp using UnityEngine; // 内网联调:发现失败时的开发机 IP 输入框(纯 IMGUI,风格同热更层 DevServerSwitchUI)。 // 只负责输入与三态状态机,不做网络/文件 IO——探测与写配置由 LoadDll.CoLocalDiscoverThenDownload 编排: // [确定] → Confirmed,外部拿 InputIp 去探测;失败则外部调 ResetToWaiting(错误文案) 拨回弹框; // [跳过] → Skipped,外部走 127.0.0.1 回退。 public sealed class DevServerPrompt : MonoBehaviour { public enum PromptState { Waiting, Confirmed, Skipped } public PromptState State { get; private set; } = PromptState.Waiting; public string InputIp = ""; public string Error = ""; // 非空则红字显示 // 外部探测失败后调用:显示错误并回到可输入状态。 public void ResetToWaiting(string error) { Error = error ?? ""; State = PromptState.Waiting; } private void OnGUI() { if (State != PromptState.Waiting) return; const int W = 560, H = 190; GUILayout.BeginArea(new Rect((Screen.width - W) / 2, (Screen.height - H) / 3, W, H), GUI.skin.box); GUILayout.Label("内网发现失败:请输入开发机 IP(网关须 --lan --devToken xw-dev)"); InputIp = GUILayout.TextField(InputIp ?? "", GUILayout.Height(44)); if (!string.IsNullOrEmpty(Error)) { GUI.color = new Color(1f, 0.4f, 0.3f); GUILayout.Label(Error); GUI.color = Color.white; } GUILayout.FlexibleSpace(); GUILayout.BeginHorizontal(); if (GUILayout.Button("确定", GUILayout.Height(44))) { Error = ""; State = PromptState.Confirmed; } if (GUILayout.Button("跳过", GUILayout.Height(44), GUILayout.Width(120))) State = PromptState.Skipped; GUILayout.EndHorizontal(); GUILayout.EndArea(); } } ``` - [ ] **Step 2: 目视核对** 仅依赖 UnityEngine;无网络/文件 IO;`State` 外部只读(private set),状态回拨只经 `ResetToWaiting`。 --- ### Task 6: LoadDll 流程重写 **Files:** - Modify: `Client\Assets\Base\LoadDll.cs`(`CoLocalDiscoverThenDownload` 方法,约 191-220 行,`#if !UNITY_WEBGL` 块内) - [ ] **Step 1: 整体替换 CoLocalDiscoverThenDownload** 把方法(含其上两行注释)整体替换为: ```csharp //内网热更:先 UDP 广播发现开发机 CDN(拿其 LAN IP),成功则改用它;失败弹输入框让开发者输入开发机 IP, //探测成功写入 persistentDataPath/dev_server.txt(下次启动第 1 轮直接命中不再弹);[跳过]回退 127.0.0.1(真机需 adb reverse tcp:15081)。 //token 须与热更层 GlobalData.LanDiscoveryToken 及服务端 Gateway.Runner --devToken 一致(均为 "xw-dev")。 IEnumerator CoLocalDiscoverThenDownload() { Debug.Log($"[LoadDll] CoLocalDiscoverThenDownload begin"); //第 1 轮:广播/loopback/定向广播 + dev_server.txt 已存 IP(若有)。 //Discover 会阻塞(UDP 收发+超时),放后台线程跑,协程逐帧等它结束,避免卡加载界面; //persistentDataPath 不能在后台线程访问,配置在主线程先读好传入。 string cdnRoot = null; string savedIp = DevServerConfig.Load(); { Thread th = new Thread(() => { cdnRoot = LocalCdnDiscovery.Discover("xw-dev", 1500, savedIp); }) { IsBackground = true }; th.Start(); while (th.IsAlive) yield return null; } //第 1 轮失败 → 弹输入框(预填旧值,开发机换 IP 时可直接改);输入 IP 验证探测成功才写配置。 if (string.IsNullOrEmpty(cdnRoot)) { DevServerPrompt prompt = gameObject.AddComponent(); prompt.InputIp = savedIp ?? ""; while (string.IsNullOrEmpty(cdnRoot)) { while (prompt.State == DevServerPrompt.PromptState.Waiting) yield return null; if (prompt.State == DevServerPrompt.PromptState.Skipped) break; //Confirmed:先主线程校验格式,非法不发起探测直接回弹框。 string inputIp = (prompt.InputIp ?? "").Trim(); if (!System.Net.IPAddress.TryParse(inputIp, out _)) { prompt.ResetToWaiting($"IP 格式不对:{inputIp}"); continue; } Thread th = new Thread(() => { cdnRoot = LocalCdnDiscovery.Discover("xw-dev", 1500, inputIp); }) { IsBackground = true }; th.Start(); while (th.IsAlive) yield return null; if (string.IsNullOrEmpty(cdnRoot)) { prompt.ResetToWaiting($"探测失败:连不到 {inputIp}:48923(网关起了吗?防火墙放行 UDP 48923 了吗?)"); continue; } DevServerConfig.Save(inputIp); Debug.Log($"[LoadDll] dev server ip saved: {inputIp}"); } Destroy(prompt); } if (!string.IsNullOrEmpty(cdnRoot)) { if (!cdnRoot.EndsWith("/")) cdnRoot += "/"; //发现回复的 ResourceBaseUrl 是 CDN 根(http://:15081/),DLL 热更在其下的 XWorld//。 UpdateServer = $"{cdnRoot}XWorld/{sPlatform}/"; Debug.Log($"[LoadDll] LAN CDN discovered: {UpdateServer}"); } else { UpdateServer = $"http://127.0.0.1:15081/XWorld/{sPlatform}/"; Debug.LogWarning($"[LoadDll] LAN CDN discovery failed/skipped, fallback {UpdateServer} (真机需 adb reverse tcp:15081 tcp:15081,且开发机网关须 --lan --devToken xw-dev)"); } yield return DownLoadAssets(this.StartGame); } ``` 要点: - `Thread` 已有 using(原方法就用);`System.Net.IPAddress` 全限定,不新增 using。 - 探测成功但来源无法区分(广播/文件 IP),故第 1 轮成功**不**写文件——只有弹框输入并验证成功才 `Save`(spec 行为规格)。 - `Destroy(prompt)` 在跳出循环后统一执行(成功或跳过都销毁)。 - [ ] **Step 2: 目视核对** 方法内所有 `yield return` 都在协程主体(不在 lambda 内);`cdnRoot` 由后台线程赋值、主线程在 `th.IsAlive == false` 后才读(与原代码同模式);两处 `Thread` 变量分别在独立作用域(第 1 轮包在 `{}` 块里,无重名冲突)。 --- ### Task 7: 端到端验证 **Files:** 无代码改动,纯验证。 - [ ] **Step 1: 服务端全量测试** ```bash cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln ``` 预期:172 条全部通过。 - [ ] **Step 2: 残留扫描** ```bash cd "/d/UD/AI/AIC#Project" && grep -rn "Multicast\|239.192.48.92\|192.168.130.40\|DevExtraProbeIps\|GetLocalIPv4Addresses" --include="*.cs" Client/Assets/Base Client/Assets/Script Client/Assets/Framework Server/Gateway Server/Gateway.Tests ``` 预期:无输出。 - [ ] **Step 3: 编辑器同机自测(需用户配合)** 起网关(`--lan --devToken xw-dev`)+ 编辑器运行:loopback 一轮即中,弹框不出现,`[LoadDll] LAN CDN discovered` 正常——与改前行为一致。 - [ ] **Step 4: 真机验收(需用户配合,重打 APK——Base 是 AOT 底包,DLL 热更交付不了)** 按 spec 验收标准: 1. 跨网段首启(先清 App 数据或删 dev_server.txt):第 1 轮失败 → 弹框 → 输入 192.168.130.40 → 探测成功 → 正常加载 2. 重启:不弹框,第 1 轮直接命中(日志 targets 里含 192.168.130.40) 3. [跳过]:回退 127.0.0.1,与现状一致 4. (可选)改 dev_server.txt 为错误 IP 模拟开发机换 IP:弹框预填旧值可改