Exercise Forge round-trip through plugin transfer API

This commit is contained in:
layue13 committed 2026-09-26 01:41:20 +08:00
1 parent f0e9abdd57
commit 2eeb342d74
5 files changed
+146 -29

No files matched your search

+2 -2
View File
@@ -5,7 +5,7 @@ StrataProxy 是面向 Minecraft 1.7.10 Forge 的玩家会话代理。项目正
## 核心边界
- **玩家会话**:代理负责入服、连接后端、转发、切换后端和断线清理。玩家身份包含 UUID 与连接代次,避免旧连接的异步结果作用到新连接。在线玩家视图由 Session 持有,监听器只保留按 UUID 查找会话的索引。
- **服务器目录**:静态配置与插件调用同一个注册接口。目录维护地址、声明的容量,以及本代理连接和入服预留的数量;它不采集后端 CPU、TPS、内存等负载。同名重注册生成新句柄,旧异步结果不能修改新条目;同名同地址的旧连接仍占用容量,地址变更后旧连接只计入旧地址。容量预留同时确定本次连接使用的地址,避免动态更新夹在查询和预留之间时把会话计入错误后端。
- **服务器目录**:静态配置与插件调用同一个注册接口。目录维护地址、声明的容量,以及本代理连接和入服预留的数量;它不采集后端 CPU、TPS、内存等负载,也不提供后端负载观测服务。玩法插件可自行获取其选服所需的数据。同名重注册生成新句柄,旧异步结果不能修改新条目;同名同地址的旧连接仍占用容量,地址变更后旧连接只计入旧地址。容量预留同时确定本次连接使用的地址,避免动态更新夹在查询和预留之间时把会话计入错误后端。
- **插件 API**:插件可查询 `PlayerView`、`ServerView`,动态注册后端,并以一个异步回调决定初始落点。空岛分配、实例唤醒和玩法数据由插件自行实现。单次选服失败或超时只结束当前请求,不停用插件;玩家断线会取消未决请求并移除尚未执行的回调,已启动的插件异步任务只做尽力取消。宿主关闭时会结束未决选服请求并撤销插件注册。插件返回的拒绝原因会作为登录断开消息发给玩家。
- **发现方式**:DNS 是独立插件,核心不认识 DNS 或 Agent。配置只启用列出的插件;插件关闭时其注册会清理。普通 DNS 主机名通过独立解析器查询 A 与 AAAA 地址,不使用 JVM 的进程级地址缓存;`localhost` 按系统回环地址解析。最小默认选路按注册顺序选取后端,因此 DNS 插件先注册 IPv4 地址。一次查询失败会保留已有注册,连续三次失败会移除旧地址,恢复解析后重新注册。
- **协议热路径**:同一玩家的前端和后端通道绑定同一个 Netty 事件循环,会话控制状态在该循环上串行更新。会话始终保留协议 5 的帧边界,普通数据包不重新编码并尽量复用 Netty `ByteBuf`;Keep Alive ID 在会话内转换,可同时跟踪多个未回复请求,转服后旧后端的迟到回复会被丢弃;转服后只改写少数与玩家实体 ID 相关的包,并按目标通道可写状态控制读取。后端主机名由 Netty 异步解析,避免把 DNS 等待放在玩家 I/O 线程上。
@@ -76,7 +76,7 @@ PR 的 `.gitea/workflows/verify.yml` 独立运行 `gradlew check`,不使用 Ma
如果本地已有 Uranium 1.7.10 可运行包及其编译好的 `MinecraftProtocolProbe`,可运行 `smoke/local-uranium.ps1 -BundlePath <包目录> -ProbeClassesPath <探针类目录>`。脚本复制服务端到忽略目录,启动 Java 8 后端与当前安装包,再让探针经代理完成状态查询、FML 登录、Join Game 和持续 Keep Alive,结束时停止两个进程。本地一次结果与具体前提见 [最小 Uranium 联机记录](smoke/results/2026-09-25-local-uranium.md)。这项测试使用协议探针,目标整合包客户端与实服转服仍待验证。
`smoke/local-uranium-transfer.ps1 -BundlePath <包目录>` 复制并启动两台 Uranium,然后编译仓库中的协议探针,通过实际 `ProxySessionListener` 请求从旧服切换到新服。加上 `-InstalledPlugin` 则启动已安装的 `ProxyMain`:旧服由静态配置注册,临时插件用 `Servers.register` 注册目标服,在初始落点回调读取 `ServerView`,再通过 `Players.transfer` 发起转服。默认模式使用协议探针核对 FML 重置、重新握手、世界切换包和目标连接的 Keep Alive,见 [两台 Uranium 转服记录](smoke/results/2026-09-25-local-uranium-transfer.md)。`-InstalledPlugin -PrismClient` 改用本机 Prism 中的 `1.7.10` Forge 实例,要求客户端在目标服保持连接 10 秒;两个真实客户端脚本都可用 `-PrismInstance <实例名>` 选择其他实例。若实例的文件夹名与启动名不同,另传 `-PrismInstanceFolder <文件夹名>`,以便准确定位并清理这次启动的客户端;`-PrismPath` 指向其他 Prism 安装位置。`smoke/local-uranium.ps1 -PrismClient` 可单独验证首次登录。这些实测仍不能替代目标整合包验收。
`smoke/local-uranium-transfer.ps1 -BundlePath <包目录>` 复制并启动两台 Uranium,然后编译仓库中的协议探针,通过实际 `ProxySessionListener` 请求从旧服切换到新服。加上 `-InstalledPlugin` 则启动已安装的 `ProxyMain`:旧服由静态配置注册,临时插件用 `Servers.register` 注册目标服,在初始落点回调读取 `ServerView`,再通过 `Players.transfer` 发起转服。默认模式使用协议探针核对 FML 重置、重新握手、世界切换包和目标连接的 Keep Alive,见 [两台 Uranium 转服记录](smoke/results/2026-09-25-local-uranium-transfer.md)。`-InstalledPlugin -ReturnToOld` 让协议探针验证旧服→新服→旧服的两次切换。`-InstalledPlugin -PrismClient` 改用本机 Prism 中的 `1.7.10` Forge 实例,要求客户端在目标服保持连接 10 秒;可同时使用 `-ReturnToOld` 验证真实客户端往返。两个真实客户端脚本都可用 `-PrismInstance <实例名>` 选择其他实例。若实例的文件夹名与启动名不同,另传 `-PrismInstanceFolder <文件夹名>`,以便准确定位并清理这次启动的客户端;`-PrismPath` 指向其他 Prism 安装位置。`smoke/local-uranium.ps1 -PrismClient` 可单独验证首次登录。这些实测仍不能替代目标整合包验收。
## 合成 relay 基准
+25 -10
View File
@@ -20,11 +20,17 @@ import java.util.concurrent.TimeUnit;
public final class UraniumTransferPlugin implements Plugin {
private PluginContext context;
private ServerRegistration targetRegistration;
private boolean returnToOld;
private volatile Thread worker;
@Override public void onLoad(PluginContext loadedContext) {
context = loadedContext;
int port = Integer.parseInt(context.settings().get("newPort"));
String returnSetting = context.settings().getOrDefault("returnToOld", "false");
if (!returnSetting.equals("true") && !returnSetting.equals("false")) {
throw new IllegalArgumentException("returnToOld must be true or false");
}
returnToOld = Boolean.parseBoolean(returnSetting);
targetRegistration = context.servers().register(new ServerDefinition("new",
URI.create("tcp://127.0.0.1:" + port), Map.of(), 10,
Map.of("source", "uranium-smoke-plugin")));
@@ -49,16 +55,25 @@ public final class UraniumTransferPlugin implements Plugin {
while (System.nanoTime() < deadline) {
PlayerView player = context.players().find(identity).orElse(null);
if (player != null && player.currentServer().filter("old"::equals).isPresent()) {
context.players().transfer(identity, "new").whenComplete((result, failure) -> {
if (failure != null) {
context.logger().error("SMOKE_PLUGIN_TRANSFER_FAILED", failure);
} else if (result.status() == TransferStatus.NETWORK_READY) {
context.logger().info("SMOKE_PLUGIN_TRANSFER_PASS status=NETWORK_READY");
} else {
context.logger().error("SMOKE_PLUGIN_TRANSFER_FAILED status={} detail={}",
result.status(), result.detail().orElse(""));
var result = context.players().transfer(identity, "new").toCompletableFuture()
.get(30, TimeUnit.SECONDS);
if (result.status() != TransferStatus.NETWORK_READY) {
context.logger().error("SMOKE_PLUGIN_TRANSFER_FAILED status={} detail={}",
result.status(), result.detail().orElse(""));
return;
}
context.logger().info("SMOKE_PLUGIN_TRANSFER_PASS status=NETWORK_READY");
if (returnToOld) {
Thread.sleep(2000);
var returned = context.players().transfer(identity, "old").toCompletableFuture()
.get(30, TimeUnit.SECONDS);
if (returned.status() != TransferStatus.NETWORK_READY) {
context.logger().error("SMOKE_PLUGIN_RETURN_FAILED status={} detail={}",
returned.status(), returned.detail().orElse(""));
return;
}
});
context.logger().info("SMOKE_PLUGIN_RETURN_PASS status=NETWORK_READY");
}
return;
}
Thread.sleep(20);
@@ -66,7 +81,7 @@ public final class UraniumTransferPlugin implements Plugin {
context.logger().error("SMOKE_PLUGIN_TRANSFER_FAILED player was not connected to old backend");
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (RuntimeException failure) {
} catch (Exception failure) {
context.logger().error("SMOKE_PLUGIN_TRANSFER_FAILED", failure);
}
}
+43 -7
View File
@@ -29,8 +29,10 @@ public final class UraniumTransferProbe {
private UraniumTransferProbe() { }
public static void main(String[] arguments) throws Exception {
if (arguments.length == 2 && arguments[0].equals("--external")) {
runClient(Integer.parseInt(arguments[1]), null);
if (arguments.length == 2 && (arguments[0].equals("--external")
|| arguments[0].equals("--external-roundtrip"))) {
runClient(Integer.parseInt(arguments[1]), null,
arguments[0].equals("--external-roundtrip"));
return;
}
if (arguments.length != 2) throw new IllegalArgumentException("Expected old and new Uranium ports");
@@ -47,13 +49,13 @@ public final class UraniumTransferProbe {
try {
var channel = proxy.start().toCompletableFuture().get(10, TimeUnit.SECONDS);
int proxyPort = ((InetSocketAddress) channel.localAddress()).getPort();
runClient(proxyPort, proxy);
runClient(proxyPort, proxy, false);
} finally {
proxy.close().toCompletableFuture().get(15, TimeUnit.SECONDS);
}
}
private static void runClient(int proxyPort, ProxySessionListener proxy) throws Exception {
private static void runClient(int proxyPort, ProxySessionListener proxy, boolean roundTrip) throws Exception {
try (Socket client = new Socket(InetAddress.getLoopbackAddress(), proxyPort)) {
client.setSoTimeout(30000);
send(client, payload(out -> {
@@ -100,7 +102,8 @@ public final class UraniumTransferProbe {
if (message == ForgeMessage.SERVER_HELLO) secondHellos++;
}
if (id == 7) respawns++;
if ((transfer == null || transfer.isDone()) && respawns >= 2 && keepAlivesAfterReady >= 2) break;
if ((transfer == null || transfer.isDone()) && respawns >= 2
&& (roundTrip || keepAlivesAfterReady >= 2)) break;
}
if (transfer != null) {
TransferResult result = transfer.get(2, TimeUnit.SECONDS);
@@ -110,8 +113,10 @@ public final class UraniumTransferProbe {
require(resets == 1, "expected one Forge reset; got " + resets);
require(secondHellos == 1, "expected one replacement Forge ServerHello; got " + secondHellos);
require(respawns >= 2, "expected two world transition Respawns; got " + respawns);
require(keepAlivesAfterReady >= 2,
"replacement session did not sustain two Keep Alives; got " + keepAlivesAfterReady);
if (!roundTrip) {
require(keepAlivesAfterReady >= 2,
"replacement session did not sustain two Keep Alives; got " + keepAlivesAfterReady);
}
if (proxy != null) {
require(proxy.find(player.identity()).flatMap(PlayerView::currentServer).orElse("").equals("new"),
"player did not move to new backend");
@@ -119,6 +124,37 @@ public final class UraniumTransferProbe {
System.out.printf("REAL_URANIUM_TRANSFER_PASS mode=%s reset=%d serverHellos=%d respawns=%d "
+ "keepAlivesAfterReady=%d%n",
proxy == null ? "plugin" : "direct", resets, secondHellos, respawns, keepAlivesAfterReady);
if (roundTrip) {
int returnResets = 0;
int returnHellos = 0;
int returnRespawns = 0;
int returnKeepAlives = 0;
long returnDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
while (System.nanoTime() < returnDeadline) {
byte[] frame = read(client);
int id = packetId(frame);
if (id == 0x40) throw new AssertionError("disconnected during return transfer");
if (id == 0) {
send(client, frame);
if (returnRespawns >= 2) returnKeepAlives++;
}
if (id == 0x3f) {
ForgeMessage message = respondToForge(client, frame);
if (message == ForgeMessage.RESET) returnResets++;
if (message == ForgeMessage.SERVER_HELLO) returnHellos++;
}
if (id == 7) returnRespawns++;
if (returnRespawns >= 2 && returnKeepAlives >= 2) break;
}
require(returnResets == 1, "expected one return Forge reset; got " + returnResets);
require(returnHellos == 1, "expected one return Forge ServerHello; got " + returnHellos);
require(returnRespawns >= 2, "expected two return Respawns; got " + returnRespawns);
require(returnKeepAlives >= 2,
"return session did not sustain two Keep Alives; got " + returnKeepAlives);
System.out.printf("REAL_URANIUM_ROUNDTRIP_PASS resets=%d serverHellos=%d respawns=%d "
+ "keepAlivesAfterReady=%d%n",
returnResets, returnHellos, returnRespawns, returnKeepAlives);
}
}
}
+62 -10
View File
@@ -4,6 +4,7 @@ param(
[string]$Java8Path = 'C:\Program Files\Zulu\zulu-8\bin\java.exe',
[switch]$InstalledPlugin,
[switch]$PrismClient,
[switch]$ReturnToOld,
[string]$PrismInstance = '1.7.10',
[string]$PrismInstanceFolder,
[string]$PrismPath = (Join-Path $env:LOCALAPPDATA 'Programs\PrismLauncher\prismlauncher.exe')
@@ -11,6 +12,9 @@ param(
$ErrorActionPreference = 'Stop'
if ($PrismClient -and -not $InstalledPlugin) { throw '-PrismClient requires -InstalledPlugin' }
if ($ReturnToOld -and -not $InstalledPlugin) {
throw '-ReturnToOld requires -InstalledPlugin'
}
if (-not $PrismInstanceFolder) { $PrismInstanceFolder = $PrismInstance }
$repoRoot = Split-Path -Parent $PSScriptRoot
$bundle = (Resolve-Path -LiteralPath $BundlePath).Path
@@ -123,6 +127,7 @@ $newPort = FreePort
while ($newPort -eq $oldPort) { $newPort = FreePort }
$servers = [System.Collections.Generic.List[object]]::new()
$proxy = $null
$prismLauncher = $null
$initialJavawIds = @()
$clientLaunchAttempted = $false
try {
@@ -155,6 +160,7 @@ try {
while ($proxyPort -eq $oldPort -or $proxyPort -eq $newPort) { $proxyPort = FreePort }
$config = Join-Path $runDir 'strataproxy.yml'
$pluginDirYaml = $pluginDir.Replace('\', '/')
$returnToOldSetting = if ($ReturnToOld) { 'true' } else { 'false' }
@"
listen: "127.0.0.1:$proxyPort"
authentication: OFFLINE
@@ -163,6 +169,7 @@ plugins:
enabled:
dev.strataproxy.smoke.UraniumTransferPlugin:
newPort: "$newPort"
returnToOld: "$returnToOldSetting"
backends:
- name: old
address: "127.0.0.1:$oldPort"
@@ -178,12 +185,17 @@ backends:
WaitForPort $proxyPort $proxy
if ($PrismClient) {
if (-not (Test-Path -LiteralPath $PrismPath)) { throw "Prism Launcher not found: $PrismPath" }
$existingSmokeLauncher = @(Get-CimInstance Win32_Process -Filter "Name = 'prismlauncher.exe'" |
Where-Object { $_.CommandLine -match '--offline\s+PrismSmoke' })
if ($existingSmokeLauncher.Count -ne 0) {
throw 'A PrismSmoke launcher from another run is still active'
}
$initialJavawIds = @(Get-CimInstance Win32_Process -Filter "Name = 'javaw.exe'" |
Select-Object -ExpandProperty ProcessId)
Start-Process -FilePath $PrismPath -ArgumentList @(
$prismLauncher = Start-Process -FilePath $PrismPath -ArgumentList @(
'--launch', $PrismInstance, '--offline', 'PrismSmoke',
'--server', "127.0.0.1:$proxyPort"
) -WindowStyle Hidden | Out-Null
) -WindowStyle Hidden -PassThru
$clientLaunchAttempted = $true
$deadline = [DateTime]::UtcNow.AddSeconds(120)
$oldLog = Join-Path $old.Directory 'logs\latest.log'
@@ -193,22 +205,41 @@ backends:
$newJoined = Select-String -LiteralPath $newLog -Pattern 'PrismSmoke.*logged in' -Quiet
$transferred = Select-String -LiteralPath $proxyLog `
-Pattern 'SMOKE_PLUGIN_TRANSFER_PASS status=NETWORK_READY' -Quiet
if ($oldJoined -and $newJoined -and $transferred) { break }
$returned = $false
$secondOldLogin = $false
$newDisconnected = $false
if ($ReturnToOld) {
$returned = Select-String -LiteralPath $proxyLog `
-Pattern 'SMOKE_PLUGIN_RETURN_PASS status=NETWORK_READY' -Quiet
$secondOldLogin = @(Select-String -LiteralPath $oldLog -Pattern 'PrismSmoke.*logged in').Count -ge 2
$newDisconnected = Select-String -LiteralPath $newLog -Pattern 'PrismSmoke lost connection' -Quiet
}
if ($oldJoined -and $newJoined -and $transferred -and
(-not $ReturnToOld -or ($returned -and $secondOldLogin -and $newDisconnected))) { break }
if (Select-String -LiteralPath $proxyLog -Pattern 'SMOKE_PLUGIN_TRANSFER_FAILED' -Quiet) {
throw 'Proxy rejected the Prism Forge transfer; inspect proxy.stdout.log and the target server log'
}
if (Select-String -LiteralPath $newLog -Pattern 'PrismSmoke lost connection' -Quiet) {
if (Select-String -LiteralPath $proxyLog -Pattern 'SMOKE_PLUGIN_RETURN_FAILED' -Quiet) {
throw 'Proxy rejected the return Forge transfer; inspect proxy.stdout.log and the old server log'
}
if (-not $ReturnToOld -and
(Select-String -LiteralPath $newLog -Pattern 'PrismSmoke lost connection' -Quiet)) {
throw 'Target Uranium disconnected the Prism Forge client during transfer'
}
if ($proxy.HasExited) { throw "Proxy exited while Prism was transferring: $($proxy.ExitCode)" }
if ($old.Process.HasExited -or $new.Process.HasExited) { throw 'Uranium exited during Prism transfer' }
Start-Sleep -Milliseconds 500
}
if (-not $oldJoined -or -not $newJoined -or -not $transferred) {
throw 'Prism Forge client did not finish Uranium-to-Uranium transfer within 120 seconds'
if (-not $oldJoined -or -not $newJoined -or -not $transferred -or
($ReturnToOld -and (-not $returned -or -not $secondOldLogin -or -not $newDisconnected))) {
throw 'Prism Forge client did not finish Uranium transfer sequence within 120 seconds'
}
Start-Sleep -Seconds 10
if (Select-String -LiteralPath $newLog -Pattern 'PrismSmoke lost connection' -Quiet) {
if ($ReturnToOld) {
if (@(Select-String -LiteralPath $oldLog -Pattern 'PrismSmoke lost connection').Count -ge 2) {
throw 'Prism Forge client disconnected after returning to old during the 10-second hold'
}
} elseif (Select-String -LiteralPath $newLog -Pattern 'PrismSmoke lost connection' -Quiet) {
throw 'Prism Forge client disconnected from target during the 10-second hold'
}
$newClient = @(Get-CimInstance Win32_Process -Filter "Name = 'javaw.exe'" |
@@ -216,14 +247,24 @@ backends:
if ($newClient.Count -ne 1) {
throw "Expected one new Prism Java client, found $($newClient.Count)"
}
Write-Output 'REAL_PRISM_URANIUM_TRANSFER_PASS dynamicRegistration=true status=NETWORK_READY holdSeconds=10'
if ($ReturnToOld) {
Write-Output 'REAL_PRISM_URANIUM_ROUNDTRIP_PASS dynamicRegistration=true transfers=2 status=NETWORK_READY holdSeconds=10'
} else {
Write-Output 'REAL_PRISM_URANIUM_TRANSFER_PASS dynamicRegistration=true status=NETWORK_READY holdSeconds=10'
}
} else {
& java -cp "$classes;$classpath" dev.strataproxy.smoke.UraniumTransferProbe --external $proxyPort
$probeMode = if ($ReturnToOld) { '--external-roundtrip' } else { '--external' }
& java -cp "$classes;$classpath" dev.strataproxy.smoke.UraniumTransferProbe $probeMode $proxyPort
if ($LASTEXITCODE -ne 0) { throw "Installed-plugin transfer probe failed: $LASTEXITCODE" }
Write-Output 'REAL_URANIUM_PLUGIN_TRANSFER_PASS dynamicRegistration=true status=NETWORK_READY'
if ($ReturnToOld) {
Write-Output 'REAL_URANIUM_PLUGIN_ROUNDTRIP_PASS dynamicRegistration=true transfers=2 status=NETWORK_READY'
} else {
Write-Output 'REAL_URANIUM_PLUGIN_TRANSFER_PASS dynamicRegistration=true status=NETWORK_READY'
}
}
WaitForProxyLog $proxyLog 'SMOKE_PLUGIN_REGISTER_PASS name=new'
WaitForProxyLog $proxyLog 'SMOKE_PLUGIN_TRANSFER_PASS status=NETWORK_READY'
if ($ReturnToOld) { WaitForProxyLog $proxyLog 'SMOKE_PLUGIN_RETURN_PASS status=NETWORK_READY' }
} else {
& java -cp "$classes;$classpath" dev.strataproxy.smoke.UraniumTransferProbe $oldPort $newPort
if ($LASTEXITCODE -ne 0) { throw "Uranium transfer probe failed: $LASTEXITCODE" }
@@ -233,12 +274,23 @@ backends:
WaitForLog $new.Directory "$playerName.*logged in"
WaitForLog $old.Directory "$playerName lost connection"
Write-Output 'REAL_URANIUM_BACKEND_LOGS_PASS oldLogin=true newLogin=true oldDisconnected=true'
if ($ReturnToOld) {
if (@(Select-String -LiteralPath (Join-Path $old.Directory 'logs\latest.log') `
-Pattern "$playerName.*logged in").Count -lt 2) {
throw 'Old Uranium did not log the returning player'
}
WaitForLog $new.Directory "$playerName lost connection"
Write-Output 'REAL_URANIUM_ROUNDTRIP_LOGS_PASS oldLogins=2 newLogin=true newDisconnected=true'
}
} finally {
if ($clientLaunchAttempted) {
Get-CimInstance Win32_Process -Filter "Name = 'javaw.exe'" |
Where-Object { $_.ProcessId -notin $initialJavawIds -and (IsPrismInstanceProcess $_) } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
}
if ($prismLauncher -and -not $prismLauncher.HasExited) {
Stop-Process -Id $prismLauncher.Id -Force -ErrorAction SilentlyContinue
}
if ($proxy -and -not $proxy.HasExited) {
Stop-Process -Id $proxy.Id -Force
Wait-Process -Id $proxy.Id -ErrorAction SilentlyContinue
@@ -55,3 +55,17 @@ On 2026-09-26 (Asia/Shanghai), commit `c909f6a0e2b2151dd36be765802427b03e540ab7`
The script reported `REAL_PRISM_URANIUM_TRANSFER_PASS dynamicRegistration=true status=NETWORK_READY holdSeconds=10` and `REAL_URANIUM_BACKEND_LOGS_PASS oldLogin=true newLogin=true oldDisconnected=true`. The Uranium JAR SHA-256 was `B16747D08BAD4B7C67DB8F1F41C9C31066FEAAAC22B527775F604BDFA885BAD2`. The proxy log recorded `SMOKE_PLUGIN_REGISTER_PASS` at 01:15:57 and `SMOKE_PLUGIN_TRANSFER_PASS status=NETWORK_READY` at 01:16:08. The old backend logged `PrismSmoke` in at 01:16:07 and disconnected at 01:16:08; the replacement logged the player in at 01:16:08, recorded `Server side modded connection established` in its FML log, and remained connected until the smoke script stopped the client at 01:16:19. No matching Prism game process remained after cleanup. Raw logs are in ignored local directory `build/local-uranium-transfer-a1560de67b4c477f8fd5bdd25399bdfe`.
This checks the current branch with one real minimal Forge client and two local Uranium backends. It does not cover the target modpack, real Mojang authentication, cross-host operation, or target traffic performance.
## Two-hop protocol probe; Prism launcher limitation
On 2026-09-26 (Asia/Shanghai), the installed distribution was exercised with a disposable plugin and the protocol probe:
```powershell
.\smoke\local-uranium-transfer.ps1 -BundlePath 'C:\Users\layue\Documents\ChatGPT\tdlm 2\Uranium\rfg\build\rfg-netty-compat-bundle' -InstalledPlugin -ReturnToOld
```
The run reported `REAL_URANIUM_ROUNDTRIP_PASS resets=1 serverHellos=1 respawns=2 keepAlivesAfterReady=2`, `REAL_URANIUM_PLUGIN_ROUNDTRIP_PASS dynamicRegistration=true transfers=2 status=NETWORK_READY`, and `REAL_URANIUM_ROUNDTRIP_LOGS_PASS oldLogins=2 newLogin=true newDisconnected=true`. The proxy logged both `SMOKE_PLUGIN_TRANSFER_PASS` and `SMOKE_PLUGIN_RETURN_PASS`. The old backend logged two `NettyProbe` logins; both backends logged a completed FML handshake. Raw logs are in ignored local directory `build/local-uranium-transfer-354d12ea24e54f83bead8ee47f3f7e6e`.
The single-transfer installed-plugin probe also passed after these smoke-plugin changes, with `REAL_URANIUM_PLUGIN_TRANSFER_PASS` and backend login/disconnect markers. Its raw logs are in `build/local-uranium-transfer-0e15153ae1f74cfd8870fce0d58087af`.
Two attempts to run `-InstalledPlugin -PrismClient -ReturnToOld` did not start the Minecraft client. Prism Launcher reported `部分组件元数据加载失败` during component resolution, and neither proxy nor Uranium logged a client login. Those attempts do not establish whether a real Forge client can complete a round trip. Their raw proxy/backend logs are in `build/local-uranium-transfer-6f41df45b2ac499cb7e59d64e4bff96e` and `build/local-uranium-transfer-6a09156996a7448e8133b38d8e863dc2`. The smoke script now stops the Prism launcher process it starts, including after a failed launch.