跨设备互联
同一个星云 ID 在两台机器上登录,这两台机器可以互认:局域网里自动发现彼此,把存档推到另一台,或把一份版本配置推过去。这页讲这套链路的协议细节。
三条承载,一条数据面
- WiFi / 局域网 —— UDP 广播发现 + TCP 传输,主通道,Windows 上恒可用。
- 蓝牙 RFCOMM —— 数据面协议和 LAN 完全同一套(帧协议只面向
Stream),但 Windows 上 unpackaged 应用无法声明 bluetooth capability,探针只能返回NeedsPackaging。 - 星闪 NearLink —— 华为私有栈,判据是本机有没有 NearLink 驱动服务。
蓝牙和星闪的探测需要 Windows 专属 API(Windows.Devices.Radios、注册表),进不了纯 C# 的 Core,所以放在 App 层的 LinkProbes.cs,运行时注入给 Core(RegisterProbe("蓝牙", () => LinkProbes.BluetoothAsync()))。LAN 恒为 Available,其余按探针实时询问,探针抛异常就报「探测失败」而不是让整个列表挂掉。
uid:同一账号的匿名标识
uid = sha256(user_id) 的前 16 位小写 hex:
public static string UidHash(string userId) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant()[..16];广播包和握手都用它。它是单向哈希,不落盘、不上线原文——局域网里别人看到你的 uid 也推不出你的账户 id。
四步握手
数据走 TCP 47778,一条命令一个来回。连接建立后先握手,四步:
客户端 → 服务端:{"cmd":"hello","uid":"<uid>","name":"<设备名>","challenge":"c1"}
服务端 → 客户端:{"challenge":"c2"}
客户端 → 服务端:{"sig": HMAC-SHA256(apiKey, c1+c2)}
服务端 → 客户端:{"sig": HMAC-SHA256(apiKey, c2+c1)}c1 由客户端生成,c2 由服务端生成,两边各出一个随机数。签名是双向的:客户端要证明自己知道 apiKey,服务端也要向客户端证明自己知道同一个 apiKey。
apiKey 永不上线。它只参与 HMAC 运算,从不出现在任何报文里——报文里只有 uid 和 sig。HMAC 的密钥不在网络上传,攻击者截到握手也无法重放(c1/c2 每次都是新的)。
uid 不符是静默断开
服务端收到 hello 后,先查 hello.Str("uid") != _uid——账号不同直接 return 关掉连接,不回任何错误信息。挑战长度不在 8–128 之间也直接断。
理由是减少探测面。如果服务端回一句「uid 不匹配」,局域网里任何人都能拿它当「这个 uid 在不在线」的探测器。静默断开让扫端口的人什么也拿不到。
发现:UDP 广播 47777
PeerBeacon 每 3 秒向 255.255.255.255:47777 广播一个 JSON:
{"v":1,"uid":"a1b2c3d4e5f60718","name":"我的台式机","port":47778,"app":"starcloud","iid":"<本次实例 id>"}字段名刻意取短——广播包越小越好。监听端收到后:
v不等于CurrentVersion(1)或app不是"starcloud"→ 丢弃。uid与本机不同 → 丢弃。不同星云 ID 的设备互相不可见。iid与本机相同 → 丢弃。广播会从本机网卡回环收到自己,只能靠这个实例唯一 id 认出来。
对端表带 lastSeen,超 15 秒没再见到就剔除并触发 PeerDown 事件,UI 弹「设备已离线」。广播失败(无网络 / 防火墙)不终止循环,下个周期再试。
数据端口绑定失败时的退让
服务端默认监听 47778。但同一台机器可能已经跑了一个启动器实例,端口被占——这时退让到 OS 分配的随机端口(new TcpListener(IPAddress.Any, 0)),再由 beacon 把真实端口广播出去。所以「47778」只是首选值,不是硬约定——对端永远从广播包里读端口。
冲突处理:绝不覆盖进度
推存档时的裁决是一个纯函数 SyncConflict.Resolve(localLastPlayed, incomingLastPlayed):
- 本地没有同名世界 →
Accept,直接接收。 - 来件没有 LastPlayed,或来件更新 →
AcceptWithBackup,旧世界改名保留后接收。 - 本地更新 →
Reject,拒收。
判据是 level.dat 里的 LastPlayed(毫秒时间戳)。拿不准的时候两边都保留——「来件没有时间戳」不代表来件是旧的,可能是新写的文件还没落时间戳,删掉旧的会丢数据。
AcceptWithBackup 的落地方式是把旧目录改名(destDir + $".conflict-{DateTime.Now:yyyyMMdd-HHmmss}"),于是磁盘上留下 我的世界.conflict-20261014-120000/——旧世界还在,只是改了名。用户可以自己比对着决定留哪份。绝不静默删除。
推送前还会先跑一次 saves.check 预检(对端本地算一遍裁决返回 action),如果对端判定 Reject,推送方直接停下,对端的数据一个字节都不动。落盘时再判一次——两次都判,是因为从预检到落地之间对端可能又玩了。
推存档是三步:打包 zip → saves.check 预检 → saves.put 上传(zip 走长度前缀块随命令一起传,命令 JSON 里带 has_body=true 标记)。版本配置 JSON 附在同一条命令里,对端落到 pending-config/,版本还没装时先缓存,装完再应用。
单条命令 30 分钟上限
PeerLinkClient.CommandAsync 给每条命令挂一个 30 分钟的 CancelAfter。大世界的 zip 在慢速链路上传输可能很久,但也不能无限挂着——卡死的连接会一直占着对端的一个命令槽。
帧协议
链路的数据面承载无关,只面向 Stream:一行 JSON(UTF-8 + \n)后面可以跟一个长度前缀的二进制块(8 字节小端长度 + 文件流)。蓝牙和 LAN 用的是同一套代码,插上新的承载只要能拿到一个 Stream 就行。行长度上限 64 KiB,文件块上限 8 GiB。