一些废话
我一直想自己实现一个基于Scaffolding-MC的Minecraft联机客户端,但由于官方文档不完善加上可以参考的资料很少,而且也不怎么了解EasyTier所以一直没什么思路,但最近终于有了点眉目所以写篇blog记录一下
(毕竟我是一个非常不擅长写东西的人,目前到现在blog还只有个Hello World,之前写了篇讲MC启动原理的但是因为写的不好就丢草稿里没放出来)
关于Scaffolding-MC(后面就简称SCF吧)
我在研究的时候参考了几乎所有我能找到的资料然后AI总结了一下,这里放一下链接:
Scaffolding.NET(一个SCF的.NET实现)
Terracotta(陶瓦)
PCL-CE
Scaffolding-MC的官方定义
EasyTier CLI的参数文档
SCF时一个Minecraft 联机客户端数据交换协议,它只是一个协议规范而不是什么api或者cli,scf本身只是一套启动器间的数据交换规范,所以实现SCF的客户端目的就是通过ET构建的虚拟网络进行TCP数据交换传输一些和联机有关的数据而已
或者说,Minecraft的联机已经丢给了ET,SCF只不过是一个给启动器用来交换数据的统一标准,让不同启动器按统一的标准交流这个房间有哪些人之类
这里给个SCF联机的大概架构图:
graph TD
subgraph App [联机客户端程序]
direction TB
subgraph SCFLayer [SCF 协议层]
direction LR
Center[联机中心 Center]
Guest[联机访客 Guest]
Center ---|拥有| TcpC1[TCP 客户端]
Guest ---|拥有| TcpG1[TCP 客户端]
end
subgraph ETInterface [EasyTier 网络接口]
ETNode[EasyTier 节点进程]
end
TcpC1 <--交换协议数据--> TcpG1
TcpC1 ---|通过虚拟 IP 通信| ETNode
TcpG1 ---|通过虚拟 IP 通信| ETNode
end
ETNode <-->|P2P / 中继| OtherET[其他主机上的 EasyTier 节点]
创建和加入房间
由此就可以知道要怎么创建和加入房间了
EasyTier管理器
在创建和加入房间前,我们先实现一个 ET 管理类来加入和创建虚拟网络。
EasyTier 在整个联机里只干一件事:把分散在不同公网 NAT 后面的机器组进同一个虚拟局域网,让它们能像在局域网里一样互访。它的启动参数基本上就是在回答三个问题:
- 加入哪个网络? →
--network-name+--network-secret(来自房间码) - 我是谁? →
--hostname(被发现的关键) - 怎么连? → 虚拟 IP 分配方式 + 是否用用户态协议栈
房主和访客的差异集中在”怎么连”这一栏:
| 角色 | 虚拟 IP | hostname | 说明 |
|---|---|---|---|
| 房主 | 固定 10.144.144.1(–ipv4) |
scaffolding-mc-server-{SCF端口} | 地址稳定,方便访客发现 |
| 访客 | --dhcp 自动分配 |
scaffolding-mc-guest-{机器id} | 避免和别人的 IP 撞车 |
两者默认都是
--no-tun(用户态网络),好处是不需要管理员/root 权限就能组网;代价是本地系统访问不了虚拟 IP,所以才会有后面”直连 vs 端口转发”那一节的兜底方案。
ETManager 的主要职责就是:拼参数 → 起进程 → 从输出解析出虚拟 IP / node_id → 退出时把进程树杀干净。整段实现可以参考 Scaffolding.NET 的同名类,这里就不再贴整段代码了。
创建房间
梳理了下官方文档之后总结了个创建房间的流程图:
flowchart TD
A[开始创建房间,输入Minecraft端口]
D[生成房间码]
E[启动EasyTier进程,设置特殊Hostname]
F[获得虚拟IP]
G[在虚拟IP和端口上开启TCP监听]
H[显示房间码,等待客户端]
I[接受客户端TCP连接]
J[解析请求,构建响应]
K[返回响应]
A --> D
D --U/NNNN-NNNN-SSSS-SSSS--> E
E --> F
F --> G
G --> H
H --> I
I --> J
J --> K
K --> I
房间码的生成
协议定义:
联机中心应在 Minecraft 服务器启动后,通过密码学安全的随机数生成器,创建符合U/NNNN-NNNN-SSSS-SSSS形式的联机房间码,满足以下约束:
- N 和 S 为任意大写字母(除去 I 和 O)和数字;
- 按照 0-9、A-H、J-N、P-Z顺序映射至 [0, 33] 后,依“小端序”读得的整型应能被7整除。
所以生成房间码的步骤就是:
准备字符映射表 Chars = “0123456789ABCDEFGHJKLMNPQRSTUVWXYZ”。
表里按照 0-9、A-H、J-N、P-Z顺序,并且除去 I 和 O
循环执行(实际代码里为了效率,把 16 个字符拆成两个 8 字符块,分别校验):
使用密码学安全的随机数生成器,从映射表中随机选取 8 个字符作为一块。
将每个字符转换为对应的数值(按位置索引):
digit = Chars.IndexOf(code[i])。按小端序计算大整数:value = $\displaystyle\sum digit[i]\cdot 34^i$
检查 value % 7 == 0,成立则这块通过,否则重新生成。
这里还能偷个懒优化一下:因为 34 ≡ -1 (mod 7),所以 34^i ≡ (-1)^i (mod 7),判断「按小端序的大整数能否被 7 整除」其实等价于「偶数位之和减奇数位之和能被 7 整除」,根本不用算大整数:
1 | public static class RoomCodeGenerator |
因为每块都能被 7 整除,而 34^8 ≡ 1 (mod 7),拼起来的 16 位整体也必然能被 7 整除,依然符合协议要求。
格式化输出房间码:U/{4字符}-{4字符}-{4字符}-{4字符}。
正常输出的房间码应该长这样:
U/3UYV-XL8T-ZMYX-Z6J7
创建联机中心
生成好房间码之后,按照流程图搭建联机中心:
- 找一个空闲的 TCP 端口作为 SCF 协议的监听端口(代码里从 1025 开始扫描,取第一个能绑定的端口);
- 在这个端口上开启 TCP 监听,并注册好中心要提供的协议处理器;
- 启动 EasyTier 加入虚拟网络,hostname 设为
scaffolding-mc-server-{端口},把 SCF 端口和 MC 端口加进 TCP 白名单; - 把房主自己写进玩家列表(Kind = Host)。
用一张时序图来看这四步内部是怎么协作的(重点关注谁先谁后):
sequenceDiagram
participant App as 房主启动器
participant SVC as ScaffoldingCenter
participant ET as EasyTierManager
participant ETProc as easyTier-core 进程
participant TCP as TcpServer
App->>SVC: StartAsync(玩家名, MC端口)
SVC->>SVC: 生成房间码 + 扫描空闲 TCP 端口
SVC->>TCP: 注册协议处理器并开始监听
TCP-->>SVC: 端口就绪
SVC->>ET: 启动(网络名, 密钥, hostname, 白名单)
ET->>ETProc: 拉起 easyTier-core 进程
ETProc-->>ET: 输出 虚拟IP / node_id
ET-->>SVC: 加入虚拟网络完成
SVC->>SVC: 房主自己入列 (Kind=Host)
SVC-->>App: 返回房间码,等待访客
对应代码在 ScaffoldingCenter.StartAsync 里:
1 | internal async Task StartAsync(CancellationToken ct = default) |
其中 hostname 是整条发现链路的关键:访客就是靠它才能定位到中心监听的端口(后面讲)。TcpWhitelist 只放行了 SCF 端口和 MC 端口,虚拟网络里其它端口默认不让连。
中心用的是固定虚拟 IP
10.144.144.1,房主自己心里有数;访客那边则走 DHCP 自动分配。
中心的 TCP 服务
TcpServer 就是很常规的 accept-loop,每个连接单独一个 Task:
1 | while (!_cts.IsCancellationRequested) |
每个连接的请求处理就是下面这个循环,读懂这张图基本就懂整个中心的活了:
flowchart TD
A["连接已建立"] --> B["读请求帧
(typeLen/type/bodyLen/body)"]
B --> C{"按 ns:type 找处理器?"}
C --"找到"--> D["调用处理器 → 得到响应"]
C --"未找到"--> E["构造 status=255 错误响应"]
D --> F["写响应帧"]
E --> F
F --> G{"连接还活着?"}
G --"是"--> B
G --"否"--> H["清理连接
触发 ClientDisconnected"]
对应的循环代码,遇到不认识的协议就回 Status = 255:
1 | while (!ct.IsCancellationRequested && client.Connected) |
协议格式
SCF 的协议请求/响应是一个非常简单的自描述二进制格式:
请求:
1 | | typeLen(1) | type(ns:type, ASCII) | bodyLen(4, 大端) | body | |
响应:
1 | | status(1) | bodyLen(4, 大端) | body | |
画成图就是这样——请求和响应都只是「长度 + 内容」的裸拼接,没有任何加密或复杂协商,这也是它最好实现的地方:
flowchart LR
subgraph REQ["请求帧"]
direction LR
R1["typeLen
1B"] --> R2["type
ns:type
如 c:ping"] --> R3["bodyLen
4B 大端"] --> R4["body
任意字节"]
end
subgraph RSP["响应帧"]
direction LR
S1["status
1B
0=成功"] --> S2["bodyLen
4B 大端"] --> S3["body
任意字节"]
end
status == 0 表示成功。ProtocolSerializer 就这么几行:
1 | public static byte[] SerializeRequest(ProtocolRequest request) |
注意
type是命名空间:类型,比如c:ping。命名空间是用来给不同启动器厂商留扩展空间的,比如厂商可以注册qml:xxx之类的自定义协议,大家互不冲突。
标准协议
中心默认提供这样一组标准协议(都在 c: 命名空间下):
| 协议 | 方向 | 作用 |
|---|---|---|
c:ping |
双向 | 最简单的连通性测试,原样回显 body |
c:protocols |
双向 | 协议协商,返回中心支持的全部协议键 |
c:server_port |
访客→中心 | 查询房主的 MC 服务器端口(2 字节大端) |
c:player_ping |
访客→中心 | 上报玩家信息 + 充当心跳 |
c:player_profiles_list |
访客→中心 | 获取当前房间玩家列表(JSON) |
c:player_easytier_id |
协商用 | 用来协商「是否上报 easytier_id」 |
这些协议在 Protocols/IProtocol.cs 里都是实现了 IProtocol 的小类:
1 | public interface IProtocol |
比如 ServerPortProtocol,就是把端口塞进 2 个字节:
1 | public class ServerPortProtocol : IProtocol |
想扩展自定义协议也很简单,用 DelegateProtocol 包个委托就行,支持原始字节、无入参类型化、带入参类型化三种形式:
1 | var center = await client.CreateRoomAsync( |
加入房间
对应的加入流程是这样的:
flowchart TD
A[输入房间码] --> B[解析房间码 → EasyTier 网络名/密钥]
B --> C[启动 EasyTier 访客节点 no-tun]
C --> D[轮询发现联机中心 hostname]
D --> E[直连虚拟 IP]
E --失败--> F[重启 EasyTier 加端口转发]
E --成功--> G[连接中心 + 上报自己]
F --> G
G --> H[协商协议 / 启动心跳]
H --> I[映射 MC 端口]
I --> J[把地址填进 Minecraft]
解析房间码
访客拿到房间码后第一步是解析。房间码的 16 个字符拆成了两部分用途:
- 前 8 个字符 → EasyTier 的网络名(
scaffolding-mc-{前8位}) - 后 8 个字符 → EasyTier 的网络密钥
也就是说,房间码本身既是钥匙也是地址:只要知道房间码,就能算出应该加入哪个 EasyTier 网络:
graph LR
A["房间码
U/3UYV-XL8T-ZMYX-Z6J7"] --> B["前 8 位
3UYV-XL8T"]
A --> C["后 8 位
ZMYX-Z6J7"]
B --> D["EasyTier 网络名
scaffolding-mc-3UYV-XL8T"]
C --> E["EasyTier 网络密钥
ZMYX-Z6J7"]
1 | public static RoomCode Parse(string code) |
启动 EasyTier(访客模式)
访客启动 EasyTier 和房主不一样,主要区别是:
--no-tun --use-smoltcp:不开虚拟网卡,不需要管理员/root 权限,用用户态协议栈;--dhcp:虚拟 IP 交给网络分配,避免冲突;- hostname 用
scaffolding-mc-guest-{machineId前8位},方便排查。
1 | var config = new NetworkConfig |
发现联机中心
加入虚拟网络后,怎么找到房主的中心?答案就是前面埋的伏笔——hostname。
EasyTierManager 会从 EasyTier 的输出里解析出所有在线的节点(IP + hostname),CenterDiscoveryService 只要在里面找 hostname 匹配 scaffolding-mc-server-{端口} 的节点就行,hostname 里那个数字直接就是中心的 SCF 端口:
1 | public static CenterDiscoveryResult? TryParseCenter(IReadOnlyList<EasyTierNode> nodes) |
发现中心本质上就是在虚拟网络的节点列表里做 hostname 匹配,整体流程长这样:
flowchart TD
A["列出虚拟网络里所有在线节点
(IP + hostname)"] --> B{"存在 hostname 匹配
scaffolding-mc-server-{port}?"}
B --"否"--> C["等 500ms 再试"]
C --> A
B --"是"--> D["得到 虚拟IP + SCF端口"]
D --> E["连接中心"]
因为 P2P 打洞需要时间,节点列表一开始不完整,所以发现服务每 500ms 轮询一次,最多 60 次(30 秒超时)。
连接中心与协议协商
找到中心地址后连上 TCP,紧接着做两件事:
- 上报自己:发
c:player_ping,让房主把你加进玩家列表; - 协商协议:发
c:protocols带上你支持的协议列表,和中心支持列表的交集就是「本次联机可用的协议」。
时序图看这条线最清楚:
sequenceDiagram
participant G as 访客
participant C as 联机中心
G->>C: c:player_ping(玩家信息)
C-->>G: status=0
G->>C: c:protocols(我的协议列表)
C-->>G: 中心支持的协议列表
Note over G: 两边取交集 = 本次可用协议
G->>C: c:player_ping(按协商结果
带/不带 easytier_id)
1 | private async Task NegotiateProtocols(CancellationToken ct) |
协商结果用在哪?最典型的是 c:player_easytier_id——如果两端都支持,玩家上报信息时就会带上 easytier_id,方便 UI 展示节点信息;否则就发不带这个字段的版本。
直连还是端口转发?
这一步是我踩坑最多的地方。访客能不能直接连到中心的虚拟 IP,取决于当前环境有没有 TUN 权限(--no-tun 模式下本地是连不通虚拟 IP 的)。所以做法是先试直连,不行再走端口转发:
1 | // 先尝试直连虚拟 IP(有 TUN 权限时成功) |
决策过程画出来就是这样(核心思想:能直连就直连,不能就本地转发兜底):
flowchart TD
A["尝试直连 虚拟IP:SCF端口
(3 秒超时)"] --> B{"成功?"}
B --"是"--> C["直连模式
以后都走虚拟IP"]
B --"否"--> D["重启 EasyTier
加入 port-forward 规则"]
D --> E["连 127.0.0.1:本地端口
失败则重试, 最多 10 次"]
E --> F{"成功?"}
F --"是"--> G["端口转发模式
以后都走 127.0.0.1"]
F --"否"--> H["报错: 无法连接中心"]
- 直连模式:有 TUN 权限,虚拟 IP 可路由,直接
虚拟IP:端口; - 端口转发模式:没 TUN 权限,重启 EasyTier 加
--port-forward tcp://127.0.0.1:{本地端口}/{虚拟IP}:{端口},然后连本机的127.0.0.1:本地端口,让 EasyTier 帮忙把流量转发过去。
重试 10 次是因为两端如果是 PortRestricted NAT,P2P 打洞需要多轮探测才能成功,直接连一次大概率失败。
心跳与掉线检测
连上中心后,访客侧每 5 秒发一次 c:player_ping 充当心跳:
1 | _heartbeatService = new HeartbeatService(_logger, |
中心侧则把「心跳」当作一个状态机来看:
stateDiagram-v2
[*] --> 已连接
已连接 --> 已连接: 5 秒内收到 player_ping
已连接 --> 已超时: 超过 15 秒没心跳
已超时 --> 已移除: 服务端断开连接
已移除 --> [*]
实现上,中心记录每个连接最后一次心跳时间,TcpServer 里有个后台循环每 5 秒扫一遍,超过 15 秒没心跳就直接断开连接,并触发 ClientDisconnected 让房主把玩家从列表里移除:
1 | var timedOut = _lastHeartbeat |
拿到 MC 服务器地址
最后,访客需要知道往哪连 Minecraft。MapMinecraftPortAsync 先发 c:server_port 拿到房主的 MC 端口,然后:
这一步就是看前面定了哪种模式,往两条路走:
flowchart TB
A["c:server_port 查询
房主的 MC 端口"] --> B{"当前是哪种模式?"}
B --"直连"--> D1["MC 地址 = 虚拟IP:MC端口
(不用动 EasyTier)"]
B --"转发"--> D2["重启 EasyTier
加两条转发(中心+MC)"]
D2 --> D3["MC 地址 = 127.0.0.1:本地MC端口"]
- 直连模式:直接返回
虚拟IP:MC端口; - 端口转发模式:再把 EasyTier 重启一次,把「中心转发」和「MC 转发」两条规则一起加进去,返回
127.0.0.1:本地MC端口。
1 | _config.PortForwards = |
拿到这个地址,启动器直接把它当服务器地址塞给 Minecraft 就行。
玩家列表
要展示房间里有谁,发 c:player_profiles_list,响应是一个 PlayerProfileEntry 的 JSON 数组(kind 字段区分 HOST / GUEST):
1 | var response = await _tcpClient.SendAsync(new ProtocolRequest |
完整调用示例
最后放个完整的调用示例(控制台版,直接复刻自 Qomicex.Connector 的 Console 项目)。
创建房间:
1 | using var client = new ScaffoldingClient(easyTierPath, loggerFactory); |
加入房间:
1 | using var client = new ScaffoldingClient(easyTierPath, loggerFactory); |
总结
整体下来,SCF 联机其实就是 EasyTier 组虚拟网 + 一个轻量 TCP 协议做房间信息交换 的组合:
- 房间码即钥匙,直接映射成 EasyTier 的网络名 + 密钥,知道码就知道往哪连;
- 中心靠固定 hostname 让访客在虚拟网络里发现它;
- 协议是极简的
len + type + len + body自描述格式,status == 0表示成功; - 通过协议协商,新老启动器可以互相兼容、按需开启扩展字段;
- 没有 TUN 权限时用 EasyTier 的端口转发兜底,虚拟 IP 直连只是锦上添花。
最大的坑集中在 EasyTier 这块:官方文档不太完整、中文资料稀少,P2P 打洞、NAT 类型、TUN 权限这些概念一开始不熟的话容易绕弯路。希望这篇 blog 能帮到同样想折腾 SCF 的人(虽然估计没几个人看,毕竟我到现在 blog 上也就一个 Hello World(悲))。
说些什么吧!