23 KiB
探测 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 三处:
- 删文件头
using System.Net.NetworkInformation; - 删构造函数中这一行:
JoinMulticastOnAllNics(_udp, IPAddress.Parse(DevDiscovery.MulticastGroup));
- 删整个
JoinMulticastOnAllNics私有静态方法(含其上三行中文注释,从// 逐块 Up 状态非回环 IPv4 网卡加入组播组到方法闭括号)。
删后构造函数应恢复为:
_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 测试确认绿
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: 写文件
完整内容:
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 块中):
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 签名与方法体
方法头注释与签名改为:
// 阻塞式发现,须在后台线程调用(会最多阻塞 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)
方法体内四处改动:
- 删 TTL 设置(共 3 行,
ReceiveTimeout = 250;之后):
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由。单独 try/catch:个别设备设不上 TTL 时
// 仍能发 TTL=1 组播 + 全部原有路径,不能让它杀掉整个发现线程。
try { udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8); } catch { }
- targets 构造:把原
foreach (string s in DevExtraProbeIps) ...两行替换为:
if (!string.IsNullOrEmpty(extraProbeIp) && IPAddress.TryParse(extraProbeIp, out IPAddress extra))
targetList.Add(new IPEndPoint(extra, Port));
- 删组播端点/网卡列表构建及日志(共 5 行,targets 的 Debug.Log 之后):
// 组播端点不进 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)");
- 删重发块内的逐网卡组播发送与零网卡兜底(targets foreach 之后、
nextSend = ...之前的 13 行):
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 注释、删辅助方法
catch (Exception)内注释改为:
// 忽略:发现失败由调用方处理(弹框输入或回退 127.0.0.1)
- 删整个
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; 之后):
private readonly string _extraProbeIp;
构造函数改为:
public DevServerDiscovery(string token)
{
_token = token;
// 跨网段联调:广播/定向广播不被路由器转发(RFC 2644),组播亦实测不通(路由器未开组播路由),
// 唯一可靠路径是向已知开发机 IP 单播。IP 来自 persistentDataPath/dev_server.txt——底包首启弹框
// 录入(见 Base 层 DevServerConfig/DevServerPrompt)。persistentDataPath 不能在后台线程访问,
// 故在主线程构造时读好存字段,Run(后台线程)只用字段。
_extraProbeIp = ReadConfiguredProbeIp();
}
- Step 2: 内联配置读取方法
在 Stop() 方法之后、类闭括号之前新增:
// 读 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
- 删 TTL 设置(共 3 行,
ReceiveTimeout = 250;之后):
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由。单独 try/catch:个别设备设不上 TTL 时
// 仍能发 TTL=1 组播 + 全部原有路径,不能让它杀掉整个发现线程。
try { udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8); } catch { }
- targets 构造:把原
foreach (string s in DevExtraProbeIps) ...两行替换为:
if (!string.IsNullOrEmpty(_extraProbeIp) && IPAddress.TryParse(_extraProbeIp, out IPAddress extra))
targetList.Add(new IPEndPoint(extra, DevDiscovery.Port));
-
删组播端点/网卡列表构建及日志(targets 的 Debug.Log 之后共 5 行,内容同 Task 3 Step 3 第 3 点,仅
DevDiscovery.MulticastGroup/DevDiscovery.Port/[DevTest]之别)。 -
删重发块内逐网卡组播发送与零网卡兜底(13 行,内容同 Task 3 Step 3 第 4 点)。
-
删整个
GetLocalIPv4Addresses()方法(含其上一行注释)。
- Step 4: 删共享协议组播常量
Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs:删 MulticastGroup 常量及其上两行注释:
// 组播发现组:组织本地范围(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: 全仓库确认无残留引用
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: 服务端全量回归
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: 写文件
完整内容:
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
把方法(含其上两行注释)整体替换为:
//内网热更:先 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<DevServerPrompt>();
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://<LAN-IP>:15081/),DLL 热更在其下的 XWorld/<platform>/。
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: 服务端全量测试
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
预期:172 条全部通过。
- Step 2: 残留扫描
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 验收标准:
- 跨网段首启(先清 App 数据或删 dev_server.txt):第 1 轮失败 → 弹框 → 输入 192.168.130.40 → 探测成功 → 正常加载
- 重启:不弹框,第 1 轮直接命中(日志 targets 里含 192.168.130.40)
- [跳过]:回退 127.0.0.1,与现状一致
- (可选)改 dev_server.txt 为错误 IP 模拟开发机换 IP:弹框预填旧值可改