From fcb23c59079ec716ee06ba5dd912e1c3f493b9b5 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:41:19 +0100 Subject: [PATCH 1/5] fix(replay): retain staged artifact hashes before cleanup Private replay cleanup removes installed mods, so hashing their former paths after a capture cannot establish which JARs were staged. Record filenames, byte counts, and SHA-256 hashes before launching clients and transport processes. Keep the receipts private and refuse replacement of existing evidence. Cover source replacement, dependency cleanup, missing inputs, and evidence overwrite refusal. Preserve normal dependency cleanup and capture manifest compatibility. --- docs/capture-lab.md | 6 +++ src/replay/artifact-evidence.ts | 35 ++++++++++++++ src/replay/cli.ts | 12 +++++ test/replay-artifact-evidence.test.ts | 68 +++++++++++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 src/replay/artifact-evidence.ts create mode 100644 test/replay-artifact-evidence.test.ts diff --git a/docs/capture-lab.md b/docs/capture-lab.md index 4e1eecae..8bd872bc 100644 --- a/docs/capture-lab.md +++ b/docs/capture-lab.md @@ -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 ` 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 ` and `bun run lab jvm properties ` 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 ` or `bun run lab jvm native-agent `. diff --git a/src/replay/artifact-evidence.ts b/src/replay/artifact-evidence.ts new file mode 100644 index 00000000..d1eb9403 --- /dev/null +++ b/src/replay/artifact-evidence.ts @@ -0,0 +1,35 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { basename } 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 { + 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"), + }); + } + await writeFile(output, `${JSON.stringify({ schema: 1, artifacts }, null, 2)}\n`, { + mode: 0o600, + flag: "wx", + }); +} diff --git a/src/replay/cli.ts b/src/replay/cli.ts index 75489183..2321489d 100644 --- a/src/replay/cli.ts +++ b/src/replay/cli.ts @@ -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, @@ -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( @@ -779,6 +787,10 @@ Live native recordings accept --native-ice-address 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( diff --git a/test/replay-artifact-evidence.test.ts b/test/replay-artifact-evidence.test.ts new file mode 100644 index 00000000..f68d2faf --- /dev/null +++ b/test/replay-artifact-evidence.test.ts @@ -0,0 +1,68 @@ +import { afterEach, expect, test } from "bun:test"; +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeReplayArtifactEvidence } from "../src/replay/artifact-evidence.ts"; +import { prepareReplayInstanceCleanup } from "../src/replay/java-profile.ts"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "stackanvil-artifact-evidence-")); + roots.push(root); + return root; +} + +test("retains the installed mod hash after its source changes and private mods are removed", async () => { + const root = await fixture(); + const prism = join(root, "prism"); + const game = join(prism, "instances/run/minecraft"); + await mkdir(join(game, "mods"), { recursive: true }); + const release = await prepareReplayInstanceCleanup(prism, game); + const source = join(root, "built.jar"); + const installed = join(game, "mods/addon.jar"); + await writeFile(source, "abc"); + await cp(source, installed); + await writeFile(source, "different build"); + const output = join(root, "client-artifacts.json"); + await writeReplayArtifactEvidence(output, [{ role: "client-mod", path: installed }]); + await release(); + await expect(lstat(installed)).rejects.toThrow(); + expect(JSON.parse(await readFile(output, "utf8"))).toEqual({ + schema: 1, + artifacts: [{ + role: "client-mod", + file: "addon.jar", + bytes: 3, + sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + }], + }); + if (process.platform !== "win32") expect((await lstat(output)).mode & 0o777).toBe(0o600); +}); + +test("does not publish partial artifact evidence when a staged dependency is missing", async () => { + const root = await fixture(); + const installed = join(root, "proxy.jar"); + const output = join(root, "transport-artifacts.json"); + await writeFile(installed, "hello"); + await expect(writeReplayArtifactEvidence(output, [ + { role: "transport", path: installed }, + { role: "proxy-recorder", path: join(root, "missing.jar") }, + ])).rejects.toThrow(); + await expect(lstat(output)).rejects.toThrow(); +}); + +test("refuses to replace an existing recording's artifact evidence", async () => { + const root = await fixture(); + const installed = join(root, "proxy.jar"); + const output = join(root, "transport-artifacts.json"); + await writeFile(installed, "hello"); + await writeReplayArtifactEvidence(output, [{ role: "transport", path: installed }]); + const original = await readFile(output, "utf8"); + await writeFile(installed, "replacement"); + await expect(writeReplayArtifactEvidence(output, [{ role: "transport", path: installed }])).rejects.toThrow(); + expect(await readFile(output, "utf8")).toBe(original); +}); From 509896d3836918606aa35f321b97242dd8561691 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:02:24 +0100 Subject: [PATCH 2/5] fix(replay): start proxy scene timing after initialized input Proxy recordings previously counted resource loading toward the requested gameplay duration. Share the connection-owned readiness observer with the direct recorder and start both Java scene timers only after initialization and subsequent player input. Cover wrong-direction and premature input, repeated readiness, and separate connection ownership. Installed Lifeboat captures retain over twenty seconds of admitted gameplay on both routes and exit normally. The earlier native-library shutdown race remains unresolved. --- docs/capture-lab.md | 1 + src/replay/cli.ts | 14 +++---- .../replay/fabric/FabricRecorder.java | 16 ++----- .../stackanvil/replay/RecordingPlugin.java | 2 + .../stackanvil/replay/RecordingReadiness.java | 29 +++++++++++++ .../replay/RecordingReadinessSelfTest.java | 42 +++++++++++++++++++ 6 files changed, 84 insertions(+), 20 deletions(-) create mode 100644 src/replay/java/net/pistonmaster/stackanvil/replay/RecordingReadiness.java create mode 100644 src/replay/java/net/pistonmaster/stackanvil/replay/RecordingReadinessSelfTest.java diff --git a/docs/capture-lab.md b/docs/capture-lab.md index 8bd872bc..b42034ea 100644 --- a/docs/capture-lab.md +++ b/docs/capture-lab.md @@ -170,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. diff --git a/src/replay/cli.ts b/src/replay/cli.ts index 2321489d..e7143f31 100644 --- a/src/replay/cli.ts +++ b/src/replay/cli.ts @@ -227,6 +227,7 @@ async function buildFabricRecorder(jar: string, build: string): Promise 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( @@ -664,6 +665,7 @@ Live native recordings accept --native-ice-address for a specific hos "NativeCaptureSelfTest", "PrivateFilesSelfTest", "RecordingConnectionsSelfTest", + "RecordingReadinessSelfTest", ]) { const { stdout } = await execute("java", [ "-cp", @@ -949,10 +951,8 @@ Live native recordings accept --native-ice-address 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"))) { @@ -960,10 +960,10 @@ Live native recordings accept --native-ice-address for a specific hos 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; 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 ( diff --git a/src/replay/fabric/net/pistonmaster/stackanvil/replay/fabric/FabricRecorder.java b/src/replay/fabric/net/pistonmaster/stackanvil/replay/fabric/FabricRecorder.java index 3f1224ee..e31beb32 100644 --- a/src/replay/fabric/net/pistonmaster/stackanvil/replay/fabric/FabricRecorder.java +++ b/src/replay/fabric/net/pistonmaster/stackanvil/replay/fabric/FabricRecorder.java @@ -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; @@ -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; @@ -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; @@ -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); } } diff --git a/src/replay/java/net/pistonmaster/stackanvil/replay/RecordingPlugin.java b/src/replay/java/net/pistonmaster/stackanvil/replay/RecordingPlugin.java index c42e251b..d88e625f 100644 --- a/src/replay/java/net/pistonmaster/stackanvil/replay/RecordingPlugin.java +++ b/src/replay/java/net/pistonmaster/stackanvil/replay/RecordingPlugin.java @@ -32,6 +32,7 @@ public void channel(Proxy2ServerChannelInitializeEvent event) throws IOException if (!used.compareAndSet(false, true)) { event.setCancelled(true); return; } Path directory = Path.of(System.getProperty("stackanvil.recording")); PacketJournal journal = new PacketJournal(directory.resolve("packets.sbr"), ProtocolConstants.BEDROCK_PROTOCOL_VERSION); + RecordingReadiness readiness = new RecordingReadiness(directory); ProxyConnection connection = ProxyConnection.fromChannel(event.getChannel()); event.getChannel().pipeline().addAfter(PacketCodec.NAME, "stackanvil-recording", new ChannelDuplexHandler() { private boolean packsSaved; @@ -63,6 +64,7 @@ private void record(boolean clientbound, Object message) throws IOException { byte[] payload = new byte[buffer.readableBytes()]; buffer.getBytes(buffer.readerIndex(), payload); journal.append(clientbound, payload); + readiness.record(clientbound, payload); } savePacks(); } diff --git a/src/replay/java/net/pistonmaster/stackanvil/replay/RecordingReadiness.java b/src/replay/java/net/pistonmaster/stackanvil/replay/RecordingReadiness.java new file mode 100644 index 00000000..ae1da6db --- /dev/null +++ b/src/replay/java/net/pistonmaster/stackanvil/replay/RecordingReadiness.java @@ -0,0 +1,29 @@ +package net.pistonmaster.stackanvil.replay; + +import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; + +import java.io.IOException; +import java.nio.file.Path; + +/** Admit a gameplay scene only after this connection initializes its player and sends input. */ +public final class RecordingReadiness { + private final Path directory; + private boolean initialized; + private boolean ready; + + public RecordingReadiness(Path directory) { + this.directory = directory; + } + + public void record(boolean clientbound, byte[] payload) throws IOException { + if (clientbound || ready) return; + int packet = PacketJournal.packetId(payload); + if (packet == ServerboundBedrockPackets.SET_LOCAL_PLAYER_AS_INITIALIZED.getId()) initialized = true; + if (packet == ServerboundBedrockPackets.PLAYER_AUTH_INPUT.getId() && initialized) { + try (var output = PrivateFiles.newOutputStream(directory.resolve("gameplay-ready"))) { + output.write(new byte[] {1}); + } + ready = true; + } + } +} diff --git a/src/replay/java/net/pistonmaster/stackanvil/replay/RecordingReadinessSelfTest.java b/src/replay/java/net/pistonmaster/stackanvil/replay/RecordingReadinessSelfTest.java new file mode 100644 index 00000000..4fb42efa --- /dev/null +++ b/src/replay/java/net/pistonmaster/stackanvil/replay/RecordingReadinessSelfTest.java @@ -0,0 +1,42 @@ +package net.pistonmaster.stackanvil.replay; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; + +public final class RecordingReadinessSelfTest { + public static void main(String[] args) throws Exception { + Path root = Files.createTempDirectory("stackanvil-readiness-test"); + try { + // Protocol 2225 headers: SetLocalPlayerAsInitialized=113, PlayerAuthInput=144. + byte[] initialized = {113}; + byte[] input = {(byte) 0x90, 1}; + Path marker = root.resolve("gameplay-ready"); + var readiness = new RecordingReadiness(root); + readiness.record(true, initialized); + readiness.record(false, input); + require(!Files.exists(marker), "Input without serverbound initialization admitted gameplay"); + readiness.record(false, initialized); + readiness.record(true, input); + require(!Files.exists(marker), "Initialization without subsequent serverbound input admitted gameplay"); + readiness.record(false, input); + require(Files.readAllBytes(marker)[0] == 1, "Initialized input did not admit gameplay"); + readiness.record(false, input); + require(Files.readAllBytes(marker).length == 1, "Repeated input replaced readiness evidence"); + + Path next = root.resolve("next"); + PrivateFiles.createDirectory(next); + new RecordingReadiness(next).record(false, input); + require(!Files.exists(next.resolve("gameplay-ready")), "Another connection inherited initialized state"); + System.out.println("PASS connection-owned gameplay admission after initialized player input"); + } finally { + try (var paths = Files.walk(root)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); + } + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} From 283f094e10be29cb197f923f99e73278f40667e6 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:06:07 +0100 Subject: [PATCH 3/5] fix(viaproxy): prevent native callback deadlocks at JVM exit Pin the Java facade and native bundle to the same verified October publication. Correct the transitive facade exclusion so the proxy cannot package older Java classes beside newer JNI binaries. Fold the capture dependency relocation into its setup patch so the native update applies independently. Add a forked-JVM loopback regression that exits during an active native callback. It reproduces the old bundle hang and passes with the matching update. Full and standalone builds, transport self-tests, and Linux JDK 17 and 25 controls pass. The installed join hit HTTP 503 before gameplay; cancellation stopped every owned process normally. --- patches/viaproxy/series.json | 4 + .../setup/0001-stackanvil-branding.patch | 17 ++-- ...d-http-signaling-for-bds-connections.patch | 4 +- ...tive-binding-to-avoid-exit-deadlocks.patch | 40 ++++++++ ...tive-binding-to-avoid-exit-deadlocks.pr.md | 19 ++++ src/replay/cli.ts | 1 + .../replay/NativeShutdownSelfTest.java | 96 +++++++++++++++++++ 7 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.patch create mode 100644 patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.pr.md create mode 100644 src/replay/java/net/pistonmaster/stackanvil/replay/NativeShutdownSelfTest.java diff --git a/patches/viaproxy/series.json b/patches/viaproxy/series.json index 0a58fc2e..7fecebc6 100644 --- a/patches/viaproxy/series.json +++ b/patches/viaproxy/series.json @@ -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": [] diff --git a/patches/viaproxy/setup/0001-stackanvil-branding.patch b/patches/viaproxy/setup/0001-stackanvil-branding.patch index a9918306..56f0a608 100644 --- a/patches/viaproxy/setup/0001-stackanvil-branding.patch +++ b/patches/viaproxy/setup/0001-stackanvil-branding.patch @@ -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 @@ -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 +- @@ -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 { @@ -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" diff --git a/patches/viaproxy/upstreamable/0001-use-shared-http-signaling-for-bds-connections.patch b/patches/viaproxy/upstreamable/0001-use-shared-http-signaling-for-bds-connections.patch index 7c2470b3..7a5ea3c1 100644 --- a/patches/viaproxy/upstreamable/0001-use-shared-http-signaling-for-bds-connections.patch +++ b/patches/viaproxy/upstreamable/0001-use-shared-http-signaling-for-bds-connections.patch @@ -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 @@ -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 { diff --git a/patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.patch b/patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.patch new file mode 100644 index 00000000..c9b0d61a --- /dev/null +++ b/patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.patch @@ -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 diff --git a/patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.pr.md b/patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.pr.md new file mode 100644 index 00000000..8fd0ee91 --- /dev/null +++ b/patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.pr.md @@ -0,0 +1,19 @@ +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. +This attempt does not establish successful gameplay with the updated bundle. + +These checks establish selected Linux lifecycle behavior. +Windows, macOS, and broader networking and native gameplay parity require separate validation. diff --git a/src/replay/cli.ts b/src/replay/cli.ts index e7143f31..00cf51bf 100644 --- a/src/replay/cli.ts +++ b/src/replay/cli.ts @@ -663,6 +663,7 @@ Live native recordings accept --native-ice-address for a specific hos for (const test of [ "ReplaySelfTest", "NativeCaptureSelfTest", + "NativeShutdownSelfTest", "PrivateFilesSelfTest", "RecordingConnectionsSelfTest", "RecordingReadinessSelfTest", diff --git a/src/replay/java/net/pistonmaster/stackanvil/replay/NativeShutdownSelfTest.java b/src/replay/java/net/pistonmaster/stackanvil/replay/NativeShutdownSelfTest.java new file mode 100644 index 00000000..2ede3a6e --- /dev/null +++ b/src/replay/java/net/pistonmaster/stackanvil/replay/NativeShutdownSelfTest.java @@ -0,0 +1,96 @@ +package net.pistonmaster.stackanvil.replay; + +import tel.schich.libdatachannel.DataChannel; +import tel.schich.libdatachannel.GatheringState; +import tel.schich.libdatachannel.PeerConnection; +import tel.schich.libdatachannel.PeerConnectionConfiguration; +import tel.schich.libdatachannel.SessionDescriptionType; + +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Comparator; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** Real native callbacks must not prevent the owning JVM from exiting. */ +public final class NativeShutdownSelfTest { + // Retain both endpoints so GC cannot replace JVM teardown with ordinary cleanup. + private static PeerConnection offerer, answerer; + private static DataChannel outgoing, incoming; + + public static void main(String[] args) throws Exception { + if (args.length == 1 && args[0].equals("child")) { + exitDuringCallback(); + return; + } + Path directory = Files.createTempDirectory("stackanvil-native-shutdown-"); + PrivateFiles.protectDirectory(directory); + Path log = directory.resolve("child.log"); + try { + PrivateFiles.write(log, new byte[0]); + String java = ProcessHandle.current().info().command().orElseThrow(); + Process child = new ProcessBuilder(java, "-Xcheck:jni", "-Djava.awt.headless=true", + "-XX:-CreateCoredumpOnCrash", "-XX:ErrorFile=" + directory.resolve("hs_err_pid%p.log"), + "-cp", System.getProperty("java.class.path"), NativeShutdownSelfTest.class.getName(), "child") + .redirectErrorStream(true).redirectOutput(log.toFile()).start(); + boolean exited; + try { + exited = child.waitFor(25, TimeUnit.SECONDS); + } finally { + if (child.isAlive()) child.destroyForcibly(); + if (!child.waitFor(5, TimeUnit.SECONDS)) throw new AssertionError("Native test process did not terminate"); + } + String output = Files.readString(log); + require(exited, "Native JVM exit stalled with an active callback.\n" + output); + require(child.exitValue() == 0, "Native exit fixture failed.\n" + output); + require(output.contains("Exiting with an active native callback"), "No callback overlap was established.\n" + output); + System.out.println("PASS native JVM exit while a loopback data-channel callback is active"); + } finally { + try (var paths = Files.walk(directory)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); + } + } + } + + private static void exitDuringCallback() throws Exception { + var configuration = PeerConnectionConfiguration.DEFAULT.withIceServers(List.of()) + .withBindAddress(InetAddress.getByName("127.0.0.1")).withDisableAutoNegotiation(true); + offerer = PeerConnection.createPeer(configuration); + answerer = PeerConnection.createPeer(configuration); + var offerGathered = new CountDownLatch(1); + var answerGathered = new CountDownLatch(1); + var entered = new CountDownLatch(1); + var returned = new CountDownLatch(1); + offerer.onGatheringStateChange.register((peer, state) -> { + if (state == GatheringState.RTC_GATHERING_COMPLETE) offerGathered.countDown(); + }); + answerer.onGatheringStateChange.register((peer, state) -> { + if (state == GatheringState.RTC_GATHERING_COMPLETE) answerGathered.countDown(); + }); + answerer.onDataChannel.register((peer, channel) -> incoming = channel); + outgoing = offerer.createDataChannel("stackanvil-exit-probe"); + outgoing.onOpen.register(channel -> { + entered.countDown(); + try { new CountDownLatch(1).await(10, TimeUnit.SECONDS); } + catch (InterruptedException error) { Thread.currentThread().interrupt(); } + finally { returned.countDown(); } + }); + offerer.setLocalDescription("offer"); + require(offerGathered.await(5, TimeUnit.SECONDS), "Offer gathering timed out"); + answerer.setRemoteDescription(offerer.localDescription(), SessionDescriptionType.OFFER); + answerer.setLocalDescription("answer"); + require(answerGathered.await(5, TimeUnit.SECONDS), "Answer gathering timed out"); + offerer.setRemoteDescription(answerer.localDescription(), SessionDescriptionType.ANSWER); + require(entered.await(5, TimeUnit.SECONDS), "Native channel did not open"); + require(returned.getCount() == 1, "Native callback returned before the exit control"); + System.out.println("Exiting with an active native callback"); + System.out.flush(); + System.exit(0); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} From 447546d4f2b7498c262a89675255439e44b31238 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:06:13 +0100 Subject: [PATCH 4/5] fix(replay): publish complete artifact receipts exclusively Write each private receipt in a staging directory and hard-link the completed file into place. A failed write cannot leave partial final evidence, and concurrent publishers cannot replace the winner. Remove staging files after publication or failure. Verify concurrent publication, existing receipt preservation, private modes, missing dependencies, and retained staged hashes. All 289 tooling tests and bun run check pass. --- src/replay/artifact-evidence.ts | 20 ++++++++++++++------ test/replay-artifact-evidence.test.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/replay/artifact-evidence.ts b/src/replay/artifact-evidence.ts index d1eb9403..1f1b2f07 100644 --- a/src/replay/artifact-evidence.ts +++ b/src/replay/artifact-evidence.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; -import { writeFile } from "node:fs/promises"; -import { basename } from "node:path"; +import { link, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; interface ReplayArtifact { role: "client-mod" | "transport" | "proxy-recorder"; @@ -28,8 +28,16 @@ export async function writeReplayArtifactEvidence( sha256: hash.digest("hex"), }); } - await writeFile(output, `${JSON.stringify({ schema: 1, artifacts }, null, 2)}\n`, { - mode: 0o600, - flag: "wx", - }); + 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 }); + } } diff --git a/test/replay-artifact-evidence.test.ts b/test/replay-artifact-evidence.test.ts index f68d2faf..ccf31179 100644 --- a/test/replay-artifact-evidence.test.ts +++ b/test/replay-artifact-evidence.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { writeReplayArtifactEvidence } from "../src/replay/artifact-evidence.ts"; @@ -65,4 +65,29 @@ test("refuses to replace an existing recording's artifact evidence", async () => await writeFile(installed, "replacement"); await expect(writeReplayArtifactEvidence(output, [{ role: "transport", path: installed }])).rejects.toThrow(); expect(await readFile(output, "utf8")).toBe(original); + expect((await readdir(root)).sort()).toEqual(["proxy.jar", "transport-artifacts.json"]); +}); + +test("concurrent receipt publication keeps one complete artifact record", async () => { + const root = await fixture(); + const first = join(root, "first.jar"); + const second = join(root, "second.jar"); + const output = join(root, "transport-artifacts.json"); + await writeFile(first, "first"); + await writeFile(second, "second"); + const results = await Promise.allSettled([ + writeReplayArtifactEvidence(output, [{ role: "transport", path: first }]), + writeReplayArtifactEvidence(output, [{ role: "transport", path: second }]), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const receipt = JSON.parse(await readFile(output, "utf8")); + const winner = receipt.artifacts[0].file; + expect(["first.jar", "second.jar"]).toContain(winner); + const reference = join(root, "reference.json"); + await writeReplayArtifactEvidence(reference, [{ role: "transport", path: join(root, winner) }]); + expect(await readFile(output, "utf8")).toBe(await readFile(reference, "utf8")); + expect((await readdir(root)).sort()).toEqual([ + "first.jar", "reference.json", "second.jar", "transport-artifacts.json", + ]); }); From a1c5b7760c2f52bdc81b219b0d2cf8d770fb531e Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:16:50 +0100 Subject: [PATCH 5/5] fix(replay): reject captures without admitted gameplay Reject Java setup timeouts when the scene clock never starts, after saving diagnostic logs and a screenshot. The existing finally block still stops owned processes and releases private dependencies. Derive gameplay acknowledgment from ordered serverbound initialization and subsequent movement, including transfer journals. Packet counts alone admitted input sent before initialization. Regression fixtures cover packet order, direction, both movement protocols, and incomplete sequences; recorded direct and proxy controls remain accepted. --- ...ate-native-binding-to-avoid-exit-deadlocks.pr.md | 7 ++++++- src/replay/cli.ts | 13 ++++++------- src/replay/journal.ts | 5 +++++ src/replay/verification.ts | 4 ---- test/replay-journal.test.ts | 12 ++++++++++++ test/replay-rendering.test.ts | 11 +---------- 6 files changed, 30 insertions(+), 22 deletions(-) diff --git a/patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.pr.md b/patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.pr.md index 8fd0ee91..95e9ab74 100644 --- a/patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.pr.md +++ b/patches/viaproxy/upstreamable/0002-update-native-binding-to-avoid-exit-deadlocks.pr.md @@ -13,7 +13,12 @@ Its Java API classes and all eight native binaries match the verified Maven arti An installed Lifeboat attempt stopped before gameplay after an HTTP 503 response. Cancellation stopped all owned processes normally and retained the artifact receipts. -This attempt does not establish successful gameplay with the updated bundle. +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. diff --git a/src/replay/cli.ts b/src/replay/cli.ts index 00cf51bf..8ef38b90 100644 --- a/src/replay/cli.ts +++ b/src/replay/cli.ts @@ -54,7 +54,6 @@ import { } from "./session-cleanup.ts"; import { hiveTarget, publicTarget, viaProxyTarget } from "./targets.ts"; import { - hasGameplayAcknowledgments, clientSessionFailed, verifyRendering, type SceneFeatures, @@ -988,6 +987,10 @@ Live native recordings accept --native-ice-address 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({ @@ -1090,11 +1093,7 @@ Live native recordings accept --native-ice-address 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.`, ); @@ -1112,7 +1111,7 @@ Live native recordings accept --native-ice-address 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.", ); diff --git a/src/replay/journal.ts b/src/replay/journal.ts index 1b707f71..6bb6506e 100644 --- a/src/replay/journal.ts +++ b/src/replay/journal.ts @@ -12,6 +12,7 @@ export interface PacketSummary { serverboundIds: Record; reachedStartGame: boolean; reachedSpawn: boolean; + reachedGameplay: boolean; sceneSha256: string; } @@ -28,12 +29,14 @@ export function summarizeJournal(bytes: Buffer): PacketSummary { serverboundIds: {}, reachedStartGame: false, reachedSpawn: false, + reachedGameplay: false, sceneSha256: "", }; const scene = createHash("sha256"); let offset = 8; let previous = -1n; let count = 0; + let initialized = false; while (offset < bytes.length) { if (offset + 13 > bytes.length) throw new Error("Truncated packet journal entry."); const direction = bytes[offset]!; @@ -74,6 +77,8 @@ export function summarizeJournal(bytes: Buffer): PacketSummary { } else { result.serverbound++; result.serverboundIds[id] = (result.serverboundIds[id] ?? 0) + 1; + initialized ||= id === 113; + result.reachedGameplay ||= initialized && (id === 144 || id === 19); } previous = nanos; result.elapsedSeconds = Number(nanos) / 1e9; diff --git a/src/replay/verification.ts b/src/replay/verification.ts index 3d4b9b6b..f50d03dc 100644 --- a/src/replay/verification.ts +++ b/src/replay/verification.ts @@ -158,7 +158,3 @@ export function verifyRendering( failures.push("The replay client disconnected or failed to handle a packet."); return failures; } -/** Protocol 2193: client initialization plus movement proves more than a server spawn notification. */ -export function hasGameplayAcknowledgments(ids: Record): boolean { - return (ids[113] ?? 0) > 0 && ((ids[144] ?? 0) > 0 || (ids[19] ?? 0) > 0); -} diff --git a/test/replay-journal.test.ts b/test/replay-journal.test.ts index 14e65791..a155530c 100644 --- a/test/replay-journal.test.ts +++ b/test/replay-journal.test.ts @@ -73,6 +73,18 @@ describe("private Bedrock packet journals", () => { const serverOnly = summarizeJournal(journal([{ direction: 1, nanos: 0n, payload: [113] }])); expect(serverOnly.serverboundIds).toEqual({}); }); + test("admits gameplay only after client initialization and subsequent movement", () => { + for (const movement of [144, 19]) { + const input = { direction: 0, nanos: 0n, payload: packetHeader(movement, 0) }; + const initialized = { direction: 0, nanos: 0n, payload: [113] }; + expect(summarizeJournal(journal([input, initialized])).reachedGameplay).toBe(false); + expect(summarizeJournal(journal([input, initialized, input])).reachedGameplay).toBe(true); + expect(summarizeJournal(journal([{ ...initialized, direction: 1 }, input])).reachedGameplay).toBe(false); + expect(summarizeJournal(journal([initialized, { ...input, direction: 1 }])).reachedGameplay).toBe(false); + expect(summarizeJournal(journal([initialized])).reachedGameplay).toBe(false); + expect(summarizeJournal(journal([input])).reachedGameplay).toBe(false); + } + }); test("rejects every partial entry instead of silently accepting a broken scene", () => { const bytes = journal([{ direction: 1, nanos: 0n, payload: [11, 1, 2, 3] }]); for (let length = 9; length < bytes.length; length++) expect(() => summarizeJournal(bytes.subarray(0, length))).toThrow(); diff --git a/test/replay-rendering.test.ts b/test/replay-rendering.test.ts index 90203de7..9972983b 100644 --- a/test/replay-rendering.test.ts +++ b/test/replay-rendering.test.ts @@ -1,14 +1,5 @@ import { expect, test } from "bun:test"; -import { clientSessionFailed, hasGameplayAcknowledgments, verifyRendering, type RenderAudit, type SceneFeatures } from "../src/replay/verification.ts"; - -test("gameplay requires local initialization and either movement protocol", () => { - const incomplete: Record[] = [{}, { 113: 1 }, { 144: 20 }, { 19: 20 }, { 113: 0, 144: 20 }]; - for (const ids of incomplete) { - expect(hasGameplayAcknowledgments(ids)).toBeFalse(); - } - expect(hasGameplayAcknowledgments({ 113: 1, 144: 20 })).toBeTrue(); - expect(hasGameplayAcknowledgments({ 113: 1, 19: 20 })).toBeTrue(); -}); +import { clientSessionFailed, verifyRendering, type RenderAudit, type SceneFeatures } from "../src/replay/verification.ts"; const expected: SceneFeatures = { skinUpdates: 3, geometrySkinUpdates: 3, localGeometrySkinUpdates: 1, skinTextures: ["64x64:abc", "128x128:def"], fullSkinRecords: { abc: 2, def: 1 }, customActorIdentifiers: ["probe:dragon"] }; const actual: RenderAudit = { installedSkins: 3, installedGeometrySkins: 3, rejectedSkins: 0, nativePlayerRendererSelections: 2, nativePlayerRenderFrames: 4, nativeOtherPlayerRenderFrames: 5, thirdPersonScene: true, skinTextures: [...expected.skinTextures], fullSkinRecords: { ...expected.fullSkinRecords }, actorIdentifiers: ["probe:dragon"], evaluatedModels: ["geometry.dragon:dragon"], nativeCustomActorResolvedModels: 2, nativeCustomActorRenderFrames: 3 };