Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/capture-lab.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ bun run lab down

The private session has a raw `flows.mitm`, redacted `events.jsonl`, screenshots, and a `report.md`. `bun run capture compare <first-id> <second-id>` compares endpoint sets from two captures. A redacted report shows method, host, path shape, status, and JSON field shapes. It does not prove that a server response is valid or that a feature works. Keep raw flows and screenshots private because they may contain account or player data.

Replay sessions save `transport-artifacts.json` for the verified ViaProxy JAR and staged proxy recorder before launch.
Java sessions also save `client-artifacts.json` after staging all client mods and before launching Prism.
These private receipts retain JAR filenames, sizes, and SHA-256 hashes after dependency cleanup, including failed runs.
Compare their staged JAR hashes with build manifests when attributing a capture to a build.
Use runtime observations to verify that the expected mods loaded.

## Inspect the Java client

Use `bun run lab jvm list` to find a running Java process. `bun run lab jvm threads <pid>` and `bun run lab jvm properties <pid>` save private diagnostics. Use `jfr-start`, `jfr-dump`, and `jfr-stop` to record a Java Flight Recorder trace. For deeper analysis, compile your own Java agent JAR or JVMTI library and attach it with `bun run lab jvm agent <pid> <jar>` or `bun run lab jvm native-agent <pid> <library>`.
Expand Down Expand Up @@ -164,6 +170,7 @@ The recorder retains offline game authentication and validates the SDP answer.

A server spawn packet alone does not prove a successful join.
Recordings require local-player initialization and movement acknowledgments.
Direct and ViaProxy Java recordings start the requested scene duration after player initialization and the first subsequent input packet.
They also reject an early client exit, cancellation, or a logged disconnect or packet failure.
Inspect the private screenshot and server logs before reporting a live playable session.
Gameplay acknowledgments can arrive after a transport timeout while the client drains queued packets.
Expand Down
4 changes: 4 additions & 0 deletions patches/viaproxy/series.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
"reason": "Land the shared HTTP signaling and self-signed identity API in ViaBedrock and update the upstream ViaProxy dependency first."
}
]
},
{
"file": "0002-update-native-binding-to-avoid-exit-deadlocks.patch",
"title": "fix(nethernet): update native binding to avoid exit deadlocks"
}
],
"deferred": []
Expand Down
17 changes: 11 additions & 6 deletions patches/viaproxy/setup/0001-stackanvil-branding.patch
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
From 9b16dd02e20ebe30ea04a2d59530be95f48f4a02 Mon Sep 17 00:00:00 2001
From 4347dd2dfb616fdf828b12614e64391bbbe0d50a Mon Sep 17 00:00:00 2001
From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com>
Date: Sat, 26 Sep 2026 23:42:21 +0200
Subject: [PATCH] chore(branding): identify StackAnvil ViaProxy builds and
Expand All @@ -15,6 +15,8 @@ Keep the RakNet transport in StackAnvil distributions for the native
capture relay and controlled local fixtures. Upstream ViaProxy now uses
NetherNet directly and no longer bundles this dependency; the tooling
still accepts native RakNet clients before forwarding their packets.

Place the capture-only RakNet dependency with the other Netty dependencies. Keep its context separate from upstream native binding updates so those patches apply independently of setup.
---
build.gradle | 39 ++++++-------
gradle.properties | 4 +-
Expand All @@ -25,7 +27,7 @@ still accepts native RakNet clients before forwarding their packets.
delete mode 100644 src/main/java/net/raphimc/viaproxy/injection/TransformerDowngrader.java

diff --git a/build.gradle b/build.gradle
index 365523b..ce13ccd 100644
index 365523b..d6eba05 100644
--- a/build.gradle
+++ b/build.gradle
@@ -5,14 +5,20 @@ plugins {
Expand Down Expand Up @@ -62,14 +64,17 @@ index 365523b..ce13ccd 100644

includeInJar "com.google.guava:guava:33.7.2-jre"
includeInJar "com.google.code.gson:gson:2.15.0"
@@ -110,12 +116,13 @@ dependencies {
exclude group: "org.bouncycastle"
exclude group: "dev.opencollab:libdatachannel-java"
@@ -102,6 +108,9 @@ dependencies {
includeInJar("io.netty:netty-codec-socks:4.2.19.Final") {
transitive = false
}
+ includeInJar("org.cloudburstmc.netty:netty-transport-raknet:2.0.0.CR4-20260921.144528-1") {
+ exclude group: "io.netty"
+ }
includeInJar "dev.opencollab:libdatachannel-java-arch-detect:0.24.6.0-20260930.214316-2"
includeInJar("net.raphimc:MinecraftAuth:5.0.3-20260923.192703-2") {
exclude group: "com.google.code.gson", module: "gson"
}
@@ -114,8 +123,6 @@ dependencies {
includeInJar "gs.mclo:api:6.2.1"
includeInJar "net.lenni0451:optconfig:1.1.1"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
From bf9951aa321644c9cf8d4dff8f1975df8b92da50 Mon Sep 17 00:00:00 2001
From 6cd408ee24a2fb9e1eeb7fd6d6119fd0e926bdf5 Mon Sep 17 00:00:00 2001
From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com>
Date: Tue, 6 Oct 2026 01:02:37 +0200
Subject: [PATCH] fix(bedrock): use shared HTTP signaling for BDS connections
Expand Down Expand Up @@ -33,7 +33,7 @@ SDP with numeric37; initialized proxy gameplay remains a separate gate.
create mode 100644 src/test/java/net/raphimc/viaproxy/proxy/client2proxy/OfflineHttpIdentityLoginTest.java

diff --git a/build.gradle b/build.gradle
index ce13ccd..b3a1780 100644
index d6eba05..43f58fd 100644
--- a/build.gradle
+++ b/build.gradle
@@ -65,6 +65,8 @@ repositories {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
From 17d2f70292d506882755f3799a1d1fa9e8ac9527 Mon Sep 17 00:00:00 2001
From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com>
Date: Mon, 2 Nov 2026 23:40:42 +0100
Subject: [PATCH] fix(nethernet): update native binding to avoid exit deadlocks

The September libdatachannel bundle can deadlock when the JVM exits during a data-channel callback. C++ static registry destruction waits for a callback mutex after Java callbacks have stopped.

Use the October 29 bundle already consumed by the Bedrock add-on. Its C API registries have process lifetime and retain explicit rtcCleanup support, so process exit does not destroy active channels on the halted VM thread.

The published Java and native artifacts match their advertised SHA-256 checksums. Their source chain is libdatachannel-java 6be4ad71e1f10dc56a09af6daeac0e1122e23e7a, libdatachannel 3db103e248d7aeb2d7c48da29f0c0f82efd2cadd, and libjuice 67bfe8903b8d6a5dcf8d731271aec68257cb2872.

A real loopback callback-overlap probe hangs with the previous bundle and exits normally with the new bundle on Linux with JDK 17 and 25. Explicit peer-close and awaited-close controls still pass. Other native platforms require their own runtime verification.

Pin the Java facade and native bundle to the same immutable Maven publication. Exclude the facade from NetherNet using its group and module separately, and from the bundle before adding the explicit matching facade. The previous colon-delimited exclusion did not remove NetherNet's older facade, which could be packaged beside newer JNI binaries.
---
build.gradle | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/build.gradle b/build.gradle
index 43f58fd..c8caaf0 100644
--- a/build.gradle
+++ b/build.gradle
@@ -119,9 +119,13 @@ dependencies {
includeInJar("org.cloudburstmc.netty:netty-transport-nethernet:2.0.0.CR4-20261001.235918-13") {
exclude group: "io.netty"
exclude group: "org.bouncycastle"
- exclude group: "dev.opencollab:libdatachannel-java"
+ exclude group: "dev.opencollab", module: "libdatachannel-java"
+ }
+ def libDataChannelVersion = "0.24.6.0-20261029.091642-10"
+ includeInJar "dev.opencollab:libdatachannel-java:$libDataChannelVersion"
+ includeInJar("dev.opencollab:libdatachannel-java-arch-detect:$libDataChannelVersion") {
+ exclude group: "dev.opencollab", module: "libdatachannel-java"
}
- includeInJar "dev.opencollab:libdatachannel-java-arch-detect:0.24.6.0-20260930.214316-2"
includeInJar "gs.mclo:api:6.2.1"
includeInJar "net.lenni0451:optconfig:1.1.1"

--
2.56.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Update the libdatachannel bundle to the version already used by the Bedrock add-on.
The owning patch records the native registry lifetime change and exact source revisions.

The published Java API and native bundle match their advertised Maven SHA-256 checksums.
Callback-overlap, explicit-close, and awaited-close controls pass on Linux with JDK 17 and 25.
The previous bundle hangs during the same callback-overlap control.

The [process regression](../../../src/replay/java/net/pistonmaster/stackanvil/replay/NativeShutdownSelfTest.java) opens a real loopback data channel and exits its JVM while the native callback remains active.
The parent requires a normal exit and verifies that callback overlap occurred.
The full proxy stack and standalone upstream patch build pass.
Transport self-tests and the native process regression pass with the rebuilt proxy.
Its Java API classes and all eight native binaries match the verified Maven artifacts.

An installed Lifeboat attempt stopped before gameplay after an HTTP 503 response.
Cancellation stopped all owned processes normally and retained the artifact receipts.
A retry recorded 20.65 seconds after gameplay readiness with matching staged artifact hashes.
The journal contains 3,755 complete frames, and normal cleanup retained both artifact receipts.
Cancellation before gameplay readiness also stopped all owned processes after the native backend connected.

The saved Lifeboat view remains obscured by large geometry.
These checks establish recording and shutdown behavior; visual spawn compatibility remains open.

These checks establish selected Linux lifecycle behavior.
Windows, macOS, and broader networking and native gameplay parity require separate validation.
43 changes: 43 additions & 0 deletions src/replay/artifact-evidence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { link, mkdtemp, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";

interface ReplayArtifact {
role: "client-mod" | "transport" | "proxy-recorder";
path: string;
}

/** Retain the staged bytes' identity before launch and private dependency cleanup. */
export async function writeReplayArtifactEvidence(
output: string,
files: readonly ReplayArtifact[],
): Promise<void> {
const artifacts = [];
for (const file of files) {
const hash = createHash("sha256");
let bytes = 0;
for await (const chunk of createReadStream(file.path)) {
hash.update(chunk);
bytes += chunk.length;
}
artifacts.push({
role: file.role,
file: basename(file.path),
bytes,
sha256: hash.digest("hex"),
});
}
const staging = await mkdtemp(join(dirname(output), ".artifact-evidence-"));
try {
const temporary = join(staging, "receipt.json");
await writeFile(temporary, `${JSON.stringify({ schema: 1, artifacts }, null, 2)}\n`, {
mode: 0o600,
flag: "wx",
});
// Publish complete bytes without replacing evidence from another attempt.
await link(temporary, output);
} finally {
await rm(staging, { recursive: true, force: true });
}
}
40 changes: 26 additions & 14 deletions src/replay/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { promisify } from "node:util";
import { Effect } from "effect";
import { root } from "../model.ts";
import { artifact } from "../client-artifacts.ts";
import { writeReplayArtifactEvidence } from "./artifact-evidence.ts";
import { installModpack } from "../integration/modpack.ts";
import {
configureShaders,
Expand Down Expand Up @@ -53,7 +54,6 @@ import {
} from "./session-cleanup.ts";
import { hiveTarget, publicTarget, viaProxyTarget } from "./targets.ts";
import {
hasGameplayAcknowledgments,
clientSessionFailed,
verifyRendering,
type SceneFeatures,
Expand Down Expand Up @@ -226,6 +226,7 @@ async function buildFabricRecorder(jar: string, build: string): Promise<string>
join(root, "src/replay/java/net/pistonmaster/stackanvil/replay/PacketJournal.java"),
join(root, "src/replay/java/net/pistonmaster/stackanvil/replay/CameraPresetAudit.java"),
join(root, "src/replay/java/net/pistonmaster/stackanvil/replay/RecordingConnections.java"),
join(root, "src/replay/java/net/pistonmaster/stackanvil/replay/RecordingReadiness.java"),
...files,
]);
await writeFile(
Expand Down Expand Up @@ -427,6 +428,13 @@ async function launchJava(
{ mode: 0o600 },
);
await cp(recorder, join(mods, "stackanvil-recorder.jar"));
await writeReplayArtifactEvidence(
join(directory, "client-artifacts.json"),
(await readdir(mods))
.filter((file) => file.endsWith(".jar"))
.toSorted()
.map((file) => ({ role: "client-mod", path: join(mods, file) })),
);
await writeFile(join(game, "stackanvil-replay-directory.txt"), directory, { mode: 0o600 });
const launcherLog = join(directory, "launcher.log");
service(
Expand Down Expand Up @@ -654,8 +662,10 @@ Live native recordings accept --native-ice-address <local-ip> for a specific hos
for (const test of [
"ReplaySelfTest",
"NativeCaptureSelfTest",
"NativeShutdownSelfTest",
"PrivateFilesSelfTest",
"RecordingConnectionsSelfTest",
"RecordingReadinessSelfTest",
]) {
const { stdout } = await execute("java", [
"-cp",
Expand Down Expand Up @@ -779,6 +789,10 @@ Live native recordings accept --native-ice-address <local-ip> for a specific hos
);
await mkdir(join(proxyHome, "plugins"), { mode: 0o700 });
await writeFile(join(proxyHome, "plugins/recorder.jar"), await readFile(plugin), { mode: 0o600 });
await writeReplayArtifactEvidence(join(directory, "transport-artifacts.json"), [
{ role: "transport", path: jar },
{ role: "proxy-recorder", path: join(proxyHome, "plugins/recorder.jar") },
]);
if (mode === "record") {
if (client === "proxy" && (input !== "local" || option("--account"))) {
const authPath = resolve(
Expand Down Expand Up @@ -937,21 +951,19 @@ Live native recordings accept --native-ice-address <local-ip> for a specific hos
console.log(`Private ${mode} session: ${directory}`);
// Keep connection setup separate from the requested gameplay scene.
let waitingForNativeConnection = client === "native";
let waitingForAddonSpawn = client === "addon";
let until =
Date.now() +
(waitingForNativeConnection ? 300_000 : waitingForAddonSpawn ? 1_200_000 : seconds * 1000);
let waitingForJavaSpawn = client !== "native";
let until = Date.now() + (waitingForNativeConnection ? 300_000 : 1_200_000);
while (!stopped && Date.now() < until && alive(gamePid) && (!child || alive(child.pid))) {
javaCleanup?.captureDescendants();
if (waitingForNativeConnection && existsSync(join(directory, "packets.sbr"))) {
waitingForNativeConnection = false;
until = Date.now() + seconds * 1000;
console.log(`Native connection observed; capturing the full ${seconds}-second scene.`);
}
if (waitingForAddonSpawn && existsSync(join(directory, "gameplay-ready"))) {
waitingForAddonSpawn = false;
if (waitingForJavaSpawn && existsSync(join(directory, "gameplay-ready"))) {
waitingForJavaSpawn = false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
until = Date.now() + seconds * 1000;
console.log(`Add-on gameplay observed; capturing the full ${seconds}-second scene.`);
console.log(`Java gameplay observed; capturing the full ${seconds}-second scene.`);
}
const log = await logText(game.log);
if (
Expand All @@ -975,6 +987,10 @@ Live native recordings accept --native-ice-address <local-ip> for a specific hos
);
}
unexpectedClientExit = !alive(gamePid);
if (waitingForJavaSpawn && !stopped && !unexpectedClientExit)
throw new Error(
"The Java client never reached initialized gameplay; the requested scene timer did not start. Read its private logs and screenshot.",
);
} finally {
try {
await cleanupReplaySession({
Expand Down Expand Up @@ -1077,11 +1093,7 @@ Live native recordings accept --native-ice-address <local-ip> for a specific hos
.toSorted((left, right) => Number(left.slice(11)) - Number(right.slice(11)));
for (const name of names) {
const connection = await inspectJournal(join(directory, name, "packets.sbr"));
if (
!connection.reachedStartGame ||
!connection.reachedSpawn ||
!hasGameplayAcknowledgments(connection.serverboundIds)
)
if (!connection.reachedStartGame || !connection.reachedSpawn || !connection.reachedGameplay)
throw new Error(
`Transfer ${name} did not reach initialized gameplay. Read its private journal.`,
);
Expand All @@ -1099,7 +1111,7 @@ Live native recordings accept --native-ice-address <local-ip> for a specific hos
throw new Error(
"The server did not announce initialization and spawn. Read its private logs and screenshot.",
);
if (!hasGameplayAcknowledgments(summary.serverboundIds))
if (!summary.reachedGameplay)
throw new Error(
"The server announced spawn, but the client did not initialize its player and begin gameplay movement. Read its private logs and screenshot.",
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import net.pistonmaster.stackanvil.replay.PacketJournal;
import net.pistonmaster.stackanvil.replay.PrivateFiles;
import net.pistonmaster.stackanvil.replay.RecordingConnections;
import net.pistonmaster.stackanvil.replay.RecordingReadiness;
import com.viaversion.viaversion.api.Via;
import com.viaversion.viaversion.api.connection.UserConnection;
import com.viaversion.viaversion.api.type.Types;
Expand All @@ -15,7 +16,6 @@
import io.netty.util.ReferenceCountUtil;
import net.raphimc.viabedrock.api.resourcepack.ResourcePack;
import net.raphimc.viabedrock.netty.PacketCodec;
import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets;
import net.raphimc.viabedrock.protocol.data.ProtocolConstants;
import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage;

Expand All @@ -41,8 +41,7 @@ public static void install(ChannelHandlerContext context) throws IOException {
PacketJournal journal = new PacketJournal(directory.resolve("packets.sbr"), ProtocolConstants.BEDROCK_PROTOCOL_VERSION);
context.pipeline().addAfter(PacketCodec.NAME, "stackanvil-recording", new ChannelDuplexHandler() {
private boolean packsSaved;
private boolean playerInitialized;
private boolean gameplayReady;
private final RecordingReadiness readiness = new RecordingReadiness(directory);
private byte[] pendingPresets;
private io.netty.util.concurrent.ScheduledFuture<?> export;

Expand Down Expand Up @@ -83,16 +82,7 @@ private void record(boolean clientbound, Object message) throws IOException {
byte[] bytes = new byte[buffer.readableBytes()];
buffer.getBytes(buffer.readerIndex(), bytes);
journal.append(clientbound, bytes);
if (!clientbound && !gameplayReady) {
final int packet = PacketJournal.packetId(bytes);
if (packet == ServerboundBedrockPackets.SET_LOCAL_PLAYER_AS_INITIALIZED.getId()) playerInitialized = true;
if (packet == ServerboundBedrockPackets.PLAYER_AUTH_INPUT.getId() && playerInitialized) {
try (var output = PrivateFiles.newOutputStream(directory.resolve("gameplay-ready"))) {
output.write(new byte[] {1});
}
gameplayReady = true;
}
}
readiness.record(clientbound, bytes);
}
}

Expand Down
Loading
Loading