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

23 KiB
Raw Permalink Blame History

探测 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.DiscoverextraProbeIp 参数(后台线程不能碰 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. 删构造函数中这一行:
            JoinMulticastOnAllNics(_udp, IPAddress.Parse(DevDiscovery.MulticastGroup));
  1. 删整个 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_WithAdvertisedAddressesWrongToken_NoReplyGarbagePacket_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)

方法体内四处改动:

  1. 删 TTL 设置(共 3 行,ReceiveTimeout = 250; 之后):
            // 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由。单独 try/catch:个别设备设不上 TTL 时
            // 仍能发 TTL=1 组播 + 全部原有路径,不能让它杀掉整个发现线程。
            try { udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8); } catch { }
  1. targets 构造:把原 foreach (string s in DevExtraProbeIps) ... 两行替换为:
            if (!string.IsNullOrEmpty(extraProbeIp) && IPAddress.TryParse(extraProbeIp, out IPAddress extra))
                targetList.Add(new IPEndPoint(extra, Port));
  1. 删组播端点/网卡列表构建及日志(共 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)");
  1. 删重发块内的逐网卡组播发送与零网卡兜底(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 注释、删辅助方法
  1. catch (Exception) 内注释改为:
            // 忽略:发现失败由调用方处理(弹框输入或回退 127.0.0.1)
  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
  1. 删 TTL 设置(共 3 行,ReceiveTimeout = 250; 之后):
                // 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由。单独 try/catch:个别设备设不上 TTL 时
                // 仍能发 TTL=1 组播 + 全部原有路径,不能让它杀掉整个发现线程。
                try { udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8); } catch { }
  1. targets 构造:把原 foreach (string s in DevExtraProbeIps) ... 两行替换为:
                if (!string.IsNullOrEmpty(_extraProbeIp) && IPAddress.TryParse(_extraProbeIp, out IPAddress extra))
                    targetList.Add(new IPEndPoint(extra, DevDiscovery.Port));
  1. 删组播端点/网卡列表构建及日志(targets 的 Debug.Log 之后共 5 行,内容同 Task 3 Step 3 第 3 点,仅 DevDiscovery.MulticastGroup/DevDiscovery.Port/[DevTest] 之别)。

  2. 删重发块内逐网卡组播发送与零网卡兜底(13 行,内容同 Task 3 Step 3 第 4 点)。

  3. 删整个 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 验收标准:

  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:弹框预填旧值可改