diff --git a/docs/capture-lab.md b/docs/capture-lab.md index 2b6a1af..c1374a7 100644 --- a/docs/capture-lab.md +++ b/docs/capture-lab.md @@ -225,6 +225,13 @@ Both global and instance Java selections use the copied Prism runtime. External Java paths, including system runtimes, require a separate sandbox contract and are rejected by isolated replay. Escaping source links and write-through links in profile configuration cause preparation to stop. +Private preparation resolves ancestor aliases such as macOS `/var` to `/private/var` before checking ownership and copying data. +The owned directory itself must still be a real directory, and writable configuration files cannot be symlinks. +Native installation copies use the filesystem copy API with a full-copy fallback when cloning is unavailable. +On macOS, native idle checks require Python 3 and inspect exact environment entries through Darwin's process API. +They recognize path aliases, ignore command-line lookalikes, and never print process environments. +Unavailable inspection stops preparation; the replay launcher's virtual display and network namespace requirements still apply. + Java cleanup records process IDs and start times, including launcher descendants. It verifies these identities immediately before each signal. A reused or unrecorded process ID stops cleanup without signaling that process. diff --git a/patches/viabedrock/upstreamable/0001-cache-converted-resource-packs.patch b/patches/viabedrock/upstreamable/0001-cache-converted-resource-packs.patch index e517694..d996486 100644 --- a/patches/viabedrock/upstreamable/0001-cache-converted-resource-packs.patch +++ b/patches/viabedrock/upstreamable/0001-cache-converted-resource-packs.patch @@ -1,4 +1,4 @@ -From 0816b184908e6d6208cedc1e19e6c27e0bc1cc6d Mon Sep 17 00:00:00 2001 +From 786bc023b12d348feeabd8d2d0b2c988feaad3a6 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:52:24 +0200 Subject: [PATCH] perf(resource-packs): cache converted packs by content @@ -107,38 +107,70 @@ BREAKING CHANGE: Content subclasses implement protected putBytes instead of overriding put. The public put method now maintains ZIP metadata. Advance the emitted-resource format identity when sidebar image metadata gains source dimensions and its aspect-ratio setting. Rebuild cached packs so the add-on does not receive stale metadata. + +Separate active conversions from completed archive retention. Limit the completed cache to 32 least-recently-used entries and 128 MiB of archive bytes plus estimated metadata, while allowing oversized results to serve their current connections. Evicted disk descriptors reload the verified archive without repeating conversion. + +Keep source ZIP loading, decryption, persistence and resource-definition construction off the connection event loop. Save downloaded packs once during preparation after decryption, retaining nonfatal cache-write failures. Check tracker and channel ownership before publication, and check storage identity before advertising converted resources. + +Bound converter and preparation queues without caller-thread fallback. Complete queued and running futures on shutdown. Preparation workers belong to the HTTP server lifecycle; translation-disabled connections own a small fallback pool until tracker removal. Tests cover cache eviction, oversized in-flight sharing, metadata restoration, saturation, shutdown, event-loop responsiveness and stale publication. + +Persist every downloaded INFO pack on the preparation worker before selecting and decrypting STACK resources. Unused offers otherwise bypass the source cache and download again on later joins. Save selected resources again only after actual decryption; individual cache-write failures remain nonfatal and do not prevent other downloaded packs from being saved. + +Route exceptions thrown while publishing prepared storage to the existing connection failure path, and catch resource advertisement failures on the event loop. Reject stale or disconnected load-completion errors before they can kick another session. Tests cover unused download persistence, continuing after an unwritable cache entry, and throwing publication callbacks. + +Keep resource-pack chunk assembly on the connection event loop, but verify the completed encoded archive hash and inflate its ZIP on existing bounded preparation workers. Track remaining chunks in constant time instead of scanning the receipt prefix after every packet. Preserve the synchronous decoder method, constructor and accessors for existing integrations. + +Retain the completed download entry until event-loop publication. Check the channel, load tracker, download tracker and exact entry identity before publishing success or failure so replaced transfers cannot affect a new session. Fail negotiation and report decoder, rejection and shutdown errors on the event loop. Guard acquisition failure callbacks with the same current-session checks. Release ZIP inflater resources immediately after successful or failed reads. + +Tests cover ordered, reversed and shuffled chunks; duplicate, range and failed-copy receipt accounting; hash mismatch; truncated ZIP and missing manifest errors; synchronous compatibility; event-loop responsiveness; stale transfer and session identities; worker rejection and cancellation; and exceptional negotiation completion. --- build.gradle | 12 + .../api/model/entity/CustomEntity.java | 2 +- - .../api/resourcepack/content/Content.java | 56 ++-- + .../resourcepack/ResourcePackExecutor.java | 69 ++++ + .../api/resourcepack/content/Content.java | 56 ++- .../content/DirectoryContent.java | 2 +- .../resourcepack/content/InMemoryContent.java | 2 +- - .../content/ResourcePackZipWriter.java | 190 +++++++++++ - .../http/ConvertedResourcePackCache.java | 253 +++++++++++++++ - .../http/ConverterDataManifest.java | 74 +++++ - .../http/ResourcePackHttpServer.java | 145 ++++----- - .../viabedrock/api/util/FileSystemUtil.java | 18 ++ + .../content/ResourcePackZipWriter.java | 190 +++++++++ + .../api/resourcepack/content/ZipContent.java | 11 +- + .../http/ConvertedResourcePackCache.java | 311 +++++++++++++++ + .../http/ConverterDataManifest.java | 74 ++++ + .../http/ResourcePackHttpServer.java | 157 ++++---- + .../viabedrock/api/util/FileSystemUtil.java | 18 + .../packet/ResourcePackClientResponse.java | 52 +++ - .../protocol/packet/ResourcePackPackets.java | 295 ++++++++++-------- + .../ResourcePackDownloadCompletion.java | 38 ++ + .../protocol/packet/ResourcePackPackets.java | 358 ++++++++++------- + .../packet/ResourcePackPreparation.java | 38 ++ .../provider/ResourcePackProvider.java | 8 +- .../impl/DiskResourcePackProvider.java | 47 ++- .../impl/InMemoryResourcePackProvider.java | 34 +- .../impl/NoOpResourcePackProvider.java | 11 +- - .../storage/ResourcePackLoadStateTracker.java | 62 ++-- + .../storage/ResourcePackDownloadTracker.java | 106 +++-- + .../storage/ResourcePackLoadStateTracker.java | 105 +++-- + .../ResourcePackExecutorTest.java | 87 +++++ .../content/ContentLanguageCacheTest.java | 65 ++++ - .../content/ResourcePackZipWriterTest.java | 241 ++++++++++++++ - .../http/ConvertedResourcePackCacheTest.java | 255 +++++++++++++++ - .../ResourcePackClientResponseTest.java | 74 +++++ - .../ResourcePackLoadStateTrackerTest.java | 43 +++ - 22 files changed, 1675 insertions(+), 266 deletions(-) + .../content/ResourcePackZipWriterTest.java | 241 ++++++++++++ + .../http/ConvertedResourcePackCacheTest.java | 364 ++++++++++++++++++ + .../ResourcePackClientResponseTest.java | 74 ++++ + .../ResourcePackDownloadCompletionTest.java | 206 ++++++++++ + .../packet/ResourcePackPreparationTest.java | 140 +++++++ + .../ResourcePackDownloadTrackerTest.java | 116 ++++++ + .../ResourcePackLoadStateTrackerTest.java | 116 ++++++ + 31 files changed, 2799 insertions(+), 311 deletions(-) + create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackExecutor.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ResourcePackZipWriter.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConverterDataManifest.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackClientResponse.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackDownloadCompletion.java + create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPreparation.java + create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackExecutorTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ContentLanguageCacheTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ResourcePackZipWriterTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackClientResponseTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackDownloadCompletionTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPreparationTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackDownloadTrackerTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTrackerTest.java diff --git a/build.gradle b/build.gradle @@ -184,6 +216,81 @@ index e353ba8b8..275bcbf1b 100644 javaEntityData.add(new EntityData(partEntity.getJavaEntityDataIndex(EntityDataFields.SCALE), VersionedTypes.V26_3.entityDataTypes.vector3FType, new Vector3f(scale, scale, scale))); javaEntityData.add(new EntityData(partEntity.getJavaEntityDataIndex(EntityDataFields.TRANSLATION), VersionedTypes.V26_3.entityDataTypes.vector3FType, new Vector3f(0F, scale * 0.5F, 0F))); +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackExecutor.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackExecutor.java +new file mode 100644 +index 000000000..abc19f6cb +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackExecutor.java +@@ -0,0 +1,69 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.resourcepack; ++ ++import java.util.HashSet; ++import java.util.Set; ++import java.util.concurrent.ArrayBlockingQueue; ++import java.util.concurrent.CancellationException; ++import java.util.concurrent.CompletableFuture; ++import java.util.concurrent.RejectedExecutionException; ++import java.util.concurrent.ThreadPoolExecutor; ++import java.util.concurrent.TimeUnit; ++import java.util.function.Supplier; ++ ++/** Bounded background work that never falls back to the connection's event loop. */ ++public final class ResourcePackExecutor { ++ ++ private final ThreadPoolExecutor executor; ++ private final Set> tasks = new HashSet<>(); ++ ++ public ResourcePackExecutor(final int workers, final int queuedTasks, final String threadName) { ++ final int threads = Math.max(1, Math.min(workers, Runtime.getRuntime().availableProcessors())); ++ this.executor = new ThreadPoolExecutor(threads, threads, 0, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(queuedTasks), task -> { ++ final Thread thread = new Thread(task, threadName); ++ thread.setDaemon(true); ++ return thread; ++ }, new ThreadPoolExecutor.AbortPolicy()); ++ } ++ ++ public synchronized CompletableFuture submit(final Supplier work) { ++ final CompletableFuture result = new CompletableFuture<>(); ++ this.tasks.add(result); ++ result.whenComplete((value, error) -> { ++ synchronized (this) { ++ this.tasks.remove(result); ++ } ++ }); ++ try { ++ this.executor.execute(() -> { ++ if (result.isDone()) { ++ return; ++ } ++ try { ++ result.complete(work.get()); ++ } catch (final Throwable error) { ++ result.completeExceptionally(error); ++ } ++ }); ++ } catch (final RejectedExecutionException error) { ++ result.completeExceptionally(error); ++ } ++ return result; ++ } ++ ++ public void stop() { ++ final Set> waiting; ++ synchronized (this) { ++ this.executor.shutdownNow(); ++ waiting = Set.copyOf(this.tasks); ++ } ++ for (final CompletableFuture task : waiting) { ++ task.completeExceptionally(new CancellationException("Resource pack executor stopped")); ++ } ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/Content.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/Content.java index 622e99d2e..21dd17dde 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/Content.java @@ -502,12 +609,34 @@ index 000000000..e818cc48e + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ZipContent.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ZipContent.java +index 4264b66bc..d16e08e63 100644 +--- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ZipContent.java ++++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ZipContent.java +@@ -25,11 +25,12 @@ import java.util.zip.ZipInputStream; + public class ZipContent extends InMemoryContent { + + public ZipContent(final byte[] zipData) throws IOException { +- final ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(zipData)); +- ZipEntry zipEntry; +- while ((zipEntry = zipInputStream.getNextEntry()) != null) { +- if (!zipEntry.isDirectory()) { +- this.content.put(zipEntry.getName(), zipInputStream.readAllBytes()); ++ try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(zipData))) { ++ ZipEntry zipEntry; ++ while ((zipEntry = zipInputStream.getNextEntry()) != null) { ++ if (!zipEntry.isDirectory()) { ++ this.content.put(zipEntry.getName(), zipInputStream.readAllBytes()); ++ } + } + } + } diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java new file mode 100644 -index 000000000..07d8bd362 +index 000000000..a6857de88 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java -@@ -0,0 +1,253 @@ +@@ -0,0 +1,311 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors @@ -529,6 +658,7 @@ index 000000000..07d8bd362 + +import net.raphimc.viabedrock.ViaBedrock; +import net.raphimc.viabedrock.api.resourcepack.ResourcePack; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePackExecutor; +import net.raphimc.viabedrock.api.resourcepack.content.Content; +import net.raphimc.viabedrock.api.resourcepack.content.ZipContent; +import net.raphimc.viabedrock.api.util.FileSystemUtil; @@ -550,15 +680,14 @@ index 000000000..07d8bd362 +import java.util.Collection; +import java.util.HexFormat; +import java.util.List; ++import java.util.LinkedHashMap; ++import java.util.HashMap; +import java.util.UUID; +import java.util.Map; +import java.util.function.Function; +import java.util.zip.ZipFile; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; -+import java.util.concurrent.ConcurrentHashMap; -+import java.util.concurrent.ExecutorService; -+import java.util.concurrent.Executors; +import java.util.logging.Level; + +public class ConvertedResourcePackCache { @@ -570,12 +699,16 @@ index 000000000..07d8bd362 + private final Path directory; + private final ViaBedrockConfig.PackCacheMode mode; + private final Function converter; -+ private final ExecutorService executor = Executors.newFixedThreadPool(Math.max(1, Math.min(2, Runtime.getRuntime().availableProcessors())), task -> { -+ final Thread thread = new Thread(task, "ViaBedrock Resource Pack Converter"); -+ thread.setDaemon(true); -+ return thread; -+ }); -+ private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); ++ private static final int MAX_COMPLETED_PACKS = 32; ++ private static final long MAX_COMPLETED_BYTES = 128L * 1024 * 1024; ++ ++ private final ResourcePackExecutor executor = new ResourcePackExecutor(2, 64, "ViaBedrock Resource Pack Converter"); ++ private final Map> pending = new HashMap<>(); ++ private final LinkedHashMap completed = new LinkedHashMap<>(16, 0.75F, true); ++ private final int maxCompletedPacks; ++ private final long maxCompletedBytes; ++ private long completedBytes; ++ private boolean stopped; + + public ConvertedResourcePackCache(final Path directory, final ViaBedrockConfig.PackCacheMode mode) { + this(directory, mode, storage -> { @@ -591,31 +724,85 @@ index 000000000..07d8bd362 + } + + ConvertedResourcePackCache(final Path directory, final ViaBedrockConfig.PackCacheMode mode, final Function converter) { ++ this(directory, mode, converter, MAX_COMPLETED_PACKS, MAX_COMPLETED_BYTES); ++ } ++ ++ ConvertedResourcePackCache(final Path directory, final ViaBedrockConfig.PackCacheMode mode, final Function converter, ++ final int maxCompletedPacks, final long maxCompletedBytes) { + this.directory = directory; + this.mode = mode; + this.converter = converter; ++ this.maxCompletedPacks = maxCompletedPacks; ++ this.maxCompletedBytes = maxCompletedBytes; + } + + public CompletableFuture prepare(final ResourcePackStorage storage) { + if (this.mode == ViaBedrockConfig.PackCacheMode.DISABLED) { -+ return CompletableFuture.supplyAsync(() -> this.convert(storage), this.executor); -+ } -+ return CompletableFuture.supplyAsync(() -> fingerprint(storage.getPackStackTopToBottom()), this.executor).thenCompose(key -> { -+ final CompletableFuture future = this.pending.computeIfAbsent(key, ignored -> CompletableFuture.supplyAsync(() -> -+ this.mode == ViaBedrockConfig.PackCacheMode.DISK ? this.loadOrConvert(key, storage) : this.convert(storage), this.executor)); -+ return future.thenApply(pack -> { -+ storage.getConverterData().putAll(pack.converterData()); -+ return pack; -+ }).whenComplete((pack, error) -> { -+ if (error != null) { -+ this.pending.remove(key, future); ++ return this.executor.submit(() -> this.convert(storage)); ++ } ++ return this.executor.submit(() -> fingerprint(storage.getPackStackTopToBottom())).thenCompose(key -> ++ this.prepare(key, storage).thenApply(pack -> { ++ storage.getConverterData().putAll(pack.converterData()); ++ return pack; ++ })); ++ } ++ ++ synchronized CompletableFuture prepare(final String key, final ResourcePackStorage storage) { ++ final Pack cached = this.completed.get(key); ++ if (cached != null) { ++ return CompletableFuture.completedFuture(cached); ++ } ++ final CompletableFuture existing = this.pending.get(key); ++ if (existing != null) { ++ return existing; ++ } ++ final CompletableFuture future = this.executor.submit(() -> ++ this.mode == ViaBedrockConfig.PackCacheMode.DISK ? this.loadOrConvert(key, storage) : this.convert(storage)); ++ this.pending.put(key, future); ++ future.whenComplete((pack, error) -> { ++ synchronized (this) { ++ this.pending.remove(key, future); ++ if (error == null && !this.stopped) { ++ this.retain(key, pack); + } -+ }); ++ } + }); ++ return future; ++ } ++ ++ private void retain(final String key, final Pack pack) { ++ final long bytes = retainedBytes(pack); ++ if (this.maxCompletedPacks <= 0 || bytes > this.maxCompletedBytes) { ++ return; ++ } ++ this.completed.put(key, pack); ++ this.completedBytes += bytes; ++ final var entries = this.completed.entrySet().iterator(); ++ while (this.completed.size() > this.maxCompletedPacks || this.completedBytes > this.maxCompletedBytes) { ++ this.completedBytes -= retainedBytes(entries.next().getValue()); ++ entries.remove(); ++ } ++ } ++ ++ // Include metadata in the budget even when the archive itself remains on disk. ++ private static long retainedBytes(final Pack pack) { ++ long bytes = 256L + (pack.bytes() == null ? 0 : pack.bytes().length); ++ for (final Map.Entry entry : pack.converterData().entrySet()) { ++ bytes += 128L + 2L * entry.getKey().length(); ++ if (entry.getValue() instanceof String text) { ++ bytes += 2L * text.length(); ++ } ++ } ++ return bytes; + } + + public void stop() { -+ this.executor.shutdownNow(); ++ synchronized (this) { ++ this.stopped = true; ++ this.completed.clear(); ++ this.completedBytes = 0; ++ } ++ this.executor.stop(); + } + + private Pack loadOrConvert(final String key, final ResourcePackStorage storage) { @@ -842,10 +1029,10 @@ index 000000000..fc384c9b5 + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ResourcePackHttpServer.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ResourcePackHttpServer.java -index c769f6781..3763790a4 100644 +index c769f6781..7d7e895fc 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ResourcePackHttpServer.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ResourcePackHttpServer.java -@@ -23,111 +23,102 @@ import io.netty.channel.*; +@@ -23,111 +23,114 @@ import io.netty.channel.*; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.http.*; @@ -855,6 +1042,7 @@ index c769f6781..3763790a4 100644 import net.raphimc.viabedrock.ViaBedrock; -import net.raphimc.viabedrock.api.resourcepack.content.Content; -import net.raphimc.viabedrock.protocol.rewriter.ResourcePackRewriter; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePackExecutor; import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; import java.io.ByteArrayInputStream; @@ -864,6 +1052,7 @@ index c769f6781..3763790a4 100644 -import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; ++import java.util.function.Supplier; +import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; @@ -873,6 +1062,7 @@ index c769f6781..3763790a4 100644 private final ChannelFuture channelFuture; - private final Map connections = new HashMap<>(); + private final ConcurrentHashMap connections = new ConcurrentHashMap<>(); ++ private final ResourcePackExecutor preparations = new ResourcePackExecutor(2, 32, "ViaBedrock Resource Pack Preparation"); + private final ConvertedResourcePackCache convertedPacks = new ConvertedResourcePackCache(ViaBedrock.getPlatform().getServerPacksFolder().toPath().resolve("converted"), ViaBedrock.getConfig().getPackCacheMode()); public ResourcePackHttpServer(final InetSocketAddress bindAddress) { @@ -907,31 +1097,6 @@ index c769f6781..3763790a4 100644 - if (user == null) { - ctx.close(); - return; -- } -- -- while (!user.has(ResourcePackStorage.class)) { -- Thread.sleep(100); -- } -- final ResourcePackStorage resourcePackStorage = user.get(ResourcePackStorage.class); -- -- try { -- final long start = System.nanoTime(); -- final Content javaContent = ResourcePackRewriter.bedrockToJava(resourcePackStorage); -- final byte[] data = javaContent.toZip(); -- final long end = System.nanoTime(); -- ViaBedrock.getPlatform().getLogger().log(Level.INFO, "Converted resource packs in " + ((end - start) / 1_000_000L) + "ms"); -- System.gc(); // Resource pack conversion is very memory intensive, so we trigger a GC after conversion to free up memory as soon as possible -- -- final DefaultHttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); -- response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED); -- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/octet-stream"); -- response.headers().set(HttpHeaderNames.CONTENT_LENGTH, data.length); -- response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); -- ctx.write(response); -- ctx.writeAndFlush(new HttpChunkedInput(new ChunkedStream(new ByteArrayInputStream(data), 65535))).addListener(ChannelFutureListener.CLOSE); -- } catch (final Throwable e) { -- ViaBedrock.getPlatform().getLogger().log(Level.SEVERE, "Failed to convert resource packs", e); -- ctx.close(); + .group(new NioEventLoopGroup(0)) + .channel(NioServerSocketChannel.class) + .option(ChannelOption.SO_BACKLOG, 128) @@ -978,9 +1143,38 @@ index c769f6781..3763790a4 100644 + ctx.close(); + } } ++ } + +- while (!user.has(ResourcePackStorage.class)) { +- Thread.sleep(100); +- } +- final ResourcePackStorage resourcePackStorage = user.get(ResourcePackStorage.class); +- +- try { +- final long start = System.nanoTime(); +- final Content javaContent = ResourcePackRewriter.bedrockToJava(resourcePackStorage); +- final byte[] data = javaContent.toZip(); +- final long end = System.nanoTime(); +- ViaBedrock.getPlatform().getLogger().log(Level.INFO, "Converted resource packs in " + ((end - start) / 1_000_000L) + "ms"); +- System.gc(); // Resource pack conversion is very memory intensive, so we trigger a GC after conversion to free up memory as soon as possible +- +- final DefaultHttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); +- response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED); +- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/octet-stream"); +- response.headers().set(HttpHeaderNames.CONTENT_LENGTH, data.length); +- response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); +- ctx.write(response); +- ctx.writeAndFlush(new HttpChunkedInput(new ChunkedStream(new ByteArrayInputStream(data), 65535))).addListener(ChannelFutureListener.CLOSE); +- } catch (final Throwable e) { +- ViaBedrock.getPlatform().getLogger().log(Level.SEVERE, "Failed to convert resource packs", e); +- ctx.close(); +- } ++ @Override ++ public void exceptionCaught(final ChannelHandlerContext ctx, final Throwable cause) { ++ ctx.close(); } - } - +- - @Override - public void exceptionCaught(final ChannelHandlerContext ctx, final Throwable cause) { - ctx.close(); @@ -990,10 +1184,6 @@ index c769f6781..3763790a4 100644 - }) - .bind(bindAddress) - .syncUninterruptibly(); -+ @Override -+ public void exceptionCaught(final ChannelHandlerContext ctx, final Throwable cause) { -+ ctx.close(); -+ } + }); + } + }) @@ -1019,8 +1209,17 @@ index c769f6781..3763790a4 100644 + public CompletableFuture prepare(final ResourcePackStorage storage) { + return this.convertedPacks.prepare(storage); + } ++ ++ public CompletableFuture prepareResources(final Supplier work) { ++ return this.preparations.submit(work); ++ } ++ ++ public CompletableFuture prepareStorage(final Supplier work) { ++ return this.prepareResources(work); ++ } + public void stop() { ++ this.preparations.stop(); + this.convertedPacks.stop(); if (this.channelFuture != null) { this.channelFuture.channel().close(); @@ -1112,8 +1311,52 @@ index 000000000..d6ef914b6 + } + +} +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackDownloadCompletion.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackDownloadCompletion.java +new file mode 100644 +index 000000000..a6f6e14a2 +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackDownloadCompletion.java +@@ -0,0 +1,38 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.protocol.packet; ++ ++import net.raphimc.viabedrock.api.resourcepack.ResourcePack; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackDownloadTracker; ++ ++import java.util.concurrent.CompletableFuture; ++import java.util.concurrent.Executor; ++import java.util.function.BooleanSupplier; ++import java.util.function.Consumer; ++import java.util.function.Function; ++import java.util.function.Supplier; ++ ++/** Verifies completed downloads in workers and publishes only the receiving session's current entry. */ ++final class ResourcePackDownloadCompletion { ++ ++ private ResourcePackDownloadCompletion() { ++ } ++ ++ static void finish(final Function, CompletableFuture> workers, final Executor eventLoop, ++ final BooleanSupplier sessionCurrent, final ResourcePackDownloadTracker tracker, final String key, ++ final ResourcePackDownloadTracker.Download download, final Consumer loaded, final Consumer failed) { ++ ResourcePackPreparation.prepare(workers, eventLoop, () -> sessionCurrent.getAsBoolean() && tracker.get(key) == download, ++ download::verifyAndParse, (pack, error) -> { ++ if (error != null) { ++ failed.accept(error); ++ return; ++ } ++ tracker.remove(key); ++ loaded.accept(pack); ++ }, failed); ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java -index 1d51a6165..983fa9381 100644 +index 1d51a6165..1d836bedc 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java @@ -19,13 +19,13 @@ package net.raphimc.viabedrock.protocol.packet; @@ -1132,7 +1375,13 @@ index 1d51a6165..983fa9381 100644 import net.raphimc.viabedrock.api.util.TextUtil; import net.raphimc.viabedrock.protocol.BedrockProtocol; import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; -@@ -50,114 +50,113 @@ import java.util.logging.Level; +@@ -47,117 +47,126 @@ import java.util.ArrayList; + import java.util.List; + import java.util.UUID; + import java.util.logging.Level; ++import java.util.concurrent.CompletableFuture; ++import java.util.function.Function; ++import java.util.function.Supplier; public final class ResourcePackPackets { @@ -1235,7 +1484,7 @@ index 1d51a6165..983fa9381 100644 + if (shouldTranslate(user)) { + loadStateTracker.loadRequestedResourcePacks().thenRun(() -> { + user.getChannel().eventLoop().execute(() -> { -+ if (user.get(ResourcePackLoadStateTracker.class) != loadStateTracker || loadStateTracker.hasReceivedStack()) { ++ if (!user.getChannel().isActive() || user.get(ResourcePackLoadStateTracker.class) != loadStateTracker || loadStateTracker.hasReceivedStack()) { + return; + } + final PacketWrapper response = PacketWrapper.create(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE, user); @@ -1243,7 +1492,11 @@ index 1d51a6165..983fa9381 100644 + response.sendToServer(BedrockProtocol.class); + }); + }).exceptionally(e -> { -+ BedrockProtocol.kickForIllegalState(user, "One of the server resource packs failed to load. Try again later.", e); ++ user.getChannel().eventLoop().execute(() -> { ++ if (user.getChannel().isActive() && user.get(ResourcePackLoadStateTracker.class) == loadStateTracker) { ++ BedrockProtocol.kickForIllegalState(user, "One of the server resource packs failed to load. Try again later.", e); ++ } ++ }); + return null; + }); + } else { @@ -1329,6 +1582,9 @@ index 1d51a6165..983fa9381 100644 + loadStateTracker.markStackReceived(); + if (shouldTranslate(user)) { + loadStateTracker.loadedFuture().whenCompleteAsync((ignored, error) -> { ++ if (!user.getChannel().isActive() || user.get(ResourcePackLoadStateTracker.class) != loadStateTracker) { ++ return; ++ } + if (error != null) { + BedrockProtocol.kickForIllegalState(user, "One of the server resource packs failed to load. Try again later.", error); + } else { @@ -1344,7 +1600,32 @@ index 1d51a6165..983fa9381 100644 } }); protocol.registerClientbound(ClientboundBedrockPackets.RESOURCE_PACK_DATA_INFO, null, wrapper -> { -@@ -212,44 +211,86 @@ public final class ResourcePackPackets { +@@ -187,20 +196,14 @@ public final class ResourcePackPackets { + + final ResourcePackDownloadTracker downloadTracker = wrapper.user().get(ResourcePackDownloadTracker.class); + final ResourcePackDownloadTracker.Download download = downloadTracker.get(key); +- if (download != null) { +- final ResourcePack resourcePack = download.processDataChunk(chunk, data); +- if (resourcePack != null) { +- downloadTracker.remove(key); +- if (download.type() == PackType.Resources) { +- final ResourcePackLoadStateTracker loadStateTracker = wrapper.user().get(ResourcePackLoadStateTracker.class); +- if (loadStateTracker != null) { +- loadStateTracker.addRemoteResourcePack(resourcePack); +- } +- } ++ if (download != null && !download.isComplete()) { ++ if (download.acceptDataChunk(chunk, data)) { ++ finishDownload(wrapper.user(), downloadTracker, key, download); + } + } else { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Received RESOURCE_PACK_CHUNK_DATA for unknown pack: " + key); + } ++ + }); + + protocol.registerServerboundTransition(ServerboundConfigurationPackets1_21_9.RESOURCE_PACK, ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE, wrapper -> { +@@ -212,44 +215,127 @@ public final class ResourcePackPackets { if (resourcePackStorage != null) { resourcePackStorage.setLoadedOnJavaClient(); } @@ -1361,7 +1642,8 @@ index 1d51a6165..983fa9381 100644 case DECLINED, INVALID_URL -> { - wrapper.write(BedrockTypes.UNSIGNED_VAR_INT, ResourcePackResponse.DownloadingFinished.getValue()); // status - wrapper.write(BedrockTypes.STRING, "downloadingfinished"); // #blameMojang -- } ++ ResourcePackClientResponse.write(wrapper, ResourcePackResponse.ResourcePackStackFinished); + } - case ACCEPTED -> { - final ResourcePackLoadStateTracker loadStateTracker = wrapper.user().get(ResourcePackLoadStateTracker.class); - if (loadStateTracker != null) { @@ -1376,64 +1658,103 @@ index 1d51a6165..983fa9381 100644 - BedrockProtocol.kickForIllegalState(wrapper.user(), "One of the server resource packs failed to load. Try again later or decline the resource packs.", e); - return null; - }); -- } else { -- wrapper.write(BedrockTypes.UNSIGNED_VAR_INT, ResourcePackResponse.DownloadingFinished.getValue()); // status -- wrapper.write(BedrockTypes.STRING, "downloadingfinished"); // #blameMojang -- } -+ ResourcePackClientResponse.write(wrapper, ResourcePackResponse.ResourcePackStackFinished); - } -- case DOWNLOADED -> wrapper.cancel(); + case ACCEPTED, DOWNLOADED -> wrapper.cancel(); - default -> throw new IllegalStateException("Unhandled ResourcePackAction: " + action); - } - }); - } - -- private ResourcePackPackets() { -+ private static void finishStack(final UserConnection user, final ResourcePackLoadStateTracker loadStateTracker, final ResourcePack.Key[] keys) { -+ if (user.get(ResourcePackLoadStateTracker.class) != loadStateTracker) { -+ return; ++ default -> throw new IllegalStateException("Unhandled ResourcePackAction: " + action); ++ } ++ }); ++ } ++ ++ private static void finishDownload(final UserConnection user, final ResourcePackDownloadTracker downloadTracker, ++ final String key, final ResourcePackDownloadTracker.Download download) { ++ final ResourcePackLoadStateTracker loadStateTracker = user.get(ResourcePackLoadStateTracker.class); ++ final Function, CompletableFuture> preparation = ViaBedrock.getResourcePackServer() != null ++ ? ViaBedrock.getResourcePackServer()::prepareResources : loadStateTracker != null ? loadStateTracker::prepareStack : null; ++ if (preparation == null) { ++ throw new IllegalStateException("Received resource pack data without a load tracker"); + } -+ user.remove(ResourcePackLoadStateTracker.class); ++ ResourcePackDownloadCompletion.finish(preparation, user.getChannel().eventLoop(), ++ () -> user.getChannel().isActive() && user.get(ResourcePackDownloadTracker.class) == downloadTracker ++ && user.get(ResourcePackLoadStateTracker.class) == loadStateTracker, ++ downloadTracker, key, download, resourcePack -> { ++ if (download.type() == PackType.Resources && loadStateTracker != null) { ++ loadStateTracker.addRemoteResourcePack(resourcePack); ++ } ++ }, error -> failDownload(user, loadStateTracker, error)); ++ } ++ ++ private static void failDownload(final UserConnection user, final ResourcePackLoadStateTracker loadStateTracker, final Throwable error) { ++ if (loadStateTracker != null) { ++ loadStateTracker.failLoading(error); ++ } ++ BedrockProtocol.kickForIllegalState(user, "One of the server resource packs failed to load. Try again later.", error); ++ } ++ ++ private static void finishStack(final UserConnection user, final ResourcePackLoadStateTracker loadStateTracker, final ResourcePack.Key[] keys) { ++ final Function, CompletableFuture> preparation = ViaBedrock.getResourcePackServer() != null ++ ? ViaBedrock.getResourcePackServer()::prepareStorage : loadStateTracker::prepareStack; ++ ResourcePackPreparation.prepare(preparation, user.getChannel().eventLoop(), ++ () -> user.getChannel().isActive() && user.get(ResourcePackLoadStateTracker.class) == loadStateTracker, ++ () -> prepareStack(loadStateTracker, keys), (storage, error) -> { ++ if (error != null) { ++ BedrockProtocol.kickForIllegalState(user, "Failed to prepare the server resource packs.", error); ++ return; ++ } ++ user.remove(ResourcePackLoadStateTracker.class); ++ user.put(storage); ++ if (shouldTranslate(user)) { ++ ViaBedrock.getResourcePackServer().prepare(storage).whenCompleteAsync((pack, conversionError) -> { ++ if (!user.getChannel().isActive() || user.get(ResourcePackStorage.class) != storage) { ++ return; ++ } ++ if (conversionError != null) { ++ BedrockProtocol.kickForIllegalState(user, "Failed to convert the server resource packs.", conversionError); ++ return; ++ } ++ try { ++ final UUID httpToken = UUID.randomUUID(); ++ ViaBedrock.getResourcePackServer().addConnection(httpToken, user, pack); ++ sendJavaResourcePack(user, pack, httpToken); ++ } catch (final Throwable publicationError) { ++ BedrockProtocol.kickForIllegalState(user, "Failed to advertise the server resource packs.", publicationError); ++ } ++ }, user.getChannel().eventLoop()); + } else { +- wrapper.write(BedrockTypes.UNSIGNED_VAR_INT, ResourcePackResponse.DownloadingFinished.getValue()); // status +- wrapper.write(BedrockTypes.STRING, "downloadingfinished"); // #blameMojang ++ sendStackFinished(user); ++ } ++ }, error -> BedrockProtocol.kickForIllegalState(user, "Failed to publish the server resource packs.", error)); ++ } ++ ++ private static ResourcePackStorage prepareStack(final ResourcePackLoadStateTracker loadStateTracker, final ResourcePack.Key[] keys) { ++ loadStateTracker.saveRemoteResourcePacks(); + loadStateTracker.loadUnrequestedResourcePacks(keys); + final List resourcePacks = new ArrayList<>(); + for (ResourcePack.Key key : keys) { + final ResourcePack resourcePack = loadStateTracker.getResourcePack(key); + if (resourcePack != null) { + final ResourcePackLoadStateTracker.Info info = loadStateTracker.getRequest(key); -+ if (info != null && info.contentKey().length > 0 && resourcePack.isContentEncrypted()) { ++ final boolean decrypt = info != null && info.contentKey().length > 0 && resourcePack.isContentEncrypted(); ++ if (decrypt) { + resourcePack.decryptContent(info.contentKey(), info.contentId()); + try { -+ Via.getManager().getProviders().get(ResourcePackProvider.class).save(resourcePack, info.cacheIdentity()); ++ Via.getManager().getProviders().get(ResourcePackProvider.class).save(resourcePack, info != null ? info.cacheIdentity() : null); + } catch (final Throwable e) { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to save resource pack: " + resourcePack.key(), e); -+ } -+ } + } + } +- case DOWNLOADED -> wrapper.cancel(); +- default -> throw new IllegalStateException("Unhandled ResourcePackAction: " + action); + resourcePacks.add(resourcePack); + } else { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Missing resource pack: " + key); -+ } -+ } -+ final ResourcePackStorage storage = new ResourcePackStorage(resourcePacks); -+ user.put(storage); -+ -+ if (shouldTranslate(user)) { -+ ViaBedrock.getResourcePackServer().prepare(storage).thenAccept(pack -> { -+ if (!user.getChannel().isActive()) { -+ return; -+ } -+ final UUID httpToken = UUID.randomUUID(); -+ ViaBedrock.getResourcePackServer().addConnection(httpToken, user, pack); -+ sendJavaResourcePack(user, pack, httpToken); -+ }).exceptionally(e -> { -+ BedrockProtocol.kickForIllegalState(user, "Failed to convert the server resource packs.", e); -+ return null; -+ }); -+ } else { -+ sendStackFinished(user); + } +- }); + } -+ } -+ ++ return new ResourcePackStorage(resourcePacks); + } + +- private ResourcePackPackets() { + private static void sendStackFinished(final UserConnection user) { + final PacketWrapper response = PacketWrapper.create(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE, user); + ResourcePackClientResponse.write(response, ResourcePackResponse.ResourcePackStackFinished); @@ -1458,6 +1779,50 @@ index 1d51a6165..983fa9381 100644 } } +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPreparation.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPreparation.java +new file mode 100644 +index 000000000..eef573c8c +--- /dev/null ++++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPreparation.java +@@ -0,0 +1,38 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.protocol.packet; ++ ++import java.util.concurrent.Executor; ++import java.util.concurrent.CompletableFuture; ++import java.util.function.BiConsumer; ++import java.util.function.BooleanSupplier; ++import java.util.function.Supplier; ++import java.util.function.Function; ++import java.util.function.Consumer; ++ ++/** Keeps connection ownership checks and publication on the event loop while preparing packs in workers. */ ++final class ResourcePackPreparation { ++ ++ private ResourcePackPreparation() { ++ } ++ ++ static void prepare(final Function, CompletableFuture> workers, final Executor eventLoop, final BooleanSupplier current, ++ final Supplier work, final BiConsumer complete, final Consumer failed) { ++ if (!current.getAsBoolean()) { ++ return; ++ } ++ workers.apply(work).whenComplete((value, error) -> eventLoop.execute(() -> { ++ if (current.getAsBoolean()) { ++ try { ++ complete.accept(value, error); ++ } catch (final Throwable publicationError) { ++ failed.accept(publicationError); ++ } ++ } ++ })); ++ } ++ ++} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/provider/ResourcePackProvider.java b/src/main/java/net/raphimc/viabedrock/protocol/provider/ResourcePackProvider.java index 8bd1ae7b1..dfcf0d64a 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/provider/ResourcePackProvider.java @@ -1640,13 +2005,147 @@ index d24a41544..e466df71e 100644 } } -diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java -index f7f550941..47cf78aef 100644 ---- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java -+++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java -@@ -27,11 +27,12 @@ import net.raphimc.viabedrock.api.resourcepack.content.ZipContent; - import net.raphimc.viabedrock.api.resourcepack.http.BedrockPackDownloader; - import net.raphimc.viabedrock.protocol.BedrockProtocol; +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackDownloadTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackDownloadTracker.java +index 199d10fd2..62030195f 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackDownloadTracker.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackDownloadTracker.java +@@ -45,45 +45,97 @@ public class ResourcePackDownloadTracker implements StorableObject { + this.downloads.remove(key); + } + +- public record Download(byte[] hash, boolean premium, PackType type, long chunkSize, boolean[] receivedChunks, byte[] data) { ++ /** Chunk assembly is owned by the event loop; a completed buffer is read only by its decoder. */ ++ public static final class Download { + +- public ResourcePack processDataChunk(final long chunk, final byte[] data) { ++ private final byte[] hash; ++ private final boolean premium; ++ private final PackType type; ++ private final long chunkSize; ++ private final boolean[] receivedChunks; ++ private final byte[] data; ++ private int remainingChunks; ++ ++ public Download(final byte[] hash, final boolean premium, final PackType type, final long chunkSize, final boolean[] receivedChunks, final byte[] data) { ++ this.hash = hash; ++ this.premium = premium; ++ this.type = type; ++ this.chunkSize = chunkSize; ++ this.receivedChunks = receivedChunks; ++ this.data = data; ++ for (final boolean received : receivedChunks) { ++ if (!received) { ++ this.remainingChunks++; ++ } ++ } ++ } ++ ++ /** Copies one chunk without hashing or inflating the complete archive. */ ++ public boolean acceptDataChunk(final long chunk, final byte[] data) { + if (chunk < 0 || chunk >= this.receivedChunks.length) { + throw new IllegalStateException("Received out of bounds chunk"); + } +- if (this.receivedChunks[Math.toIntExact(chunk)]) { ++ final int index = Math.toIntExact(chunk); ++ if (this.receivedChunks[index]) { + throw new IllegalStateException("Received duplicate chunk"); + } + + System.arraycopy(data, 0, this.data, Math.toIntExact(chunk * this.chunkSize), data.length); +- this.receivedChunks[Math.toIntExact(chunk)] = true; +- if (this.hasReceivedAllChunks()) { +- try { +- final MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); +- final byte[] hash = sha256.digest(this.data); +- if (!Arrays.equals(hash, this.hash)) { +- throw new IllegalStateException("Hash mismatch"); +- } +- } catch (final Throwable e) { +- throw new RuntimeException("Failed to verify data hash", e); +- } +- try { +- return new ResourcePack(new ZipContent(this.data)); +- } catch (final Throwable e) { +- throw new RuntimeException("Failed to parse resource pack data", e); +- } +- } else { +- return null; +- } ++ this.receivedChunks[index] = true; ++ return --this.remainingChunks == 0; + } + +- private boolean hasReceivedAllChunks() { +- for (boolean receivedChunk : this.receivedChunks) { +- if (!receivedChunk) { +- return false; ++ /** Retained for integrations using synchronous decoding; packet handlers use acceptDataChunk. */ ++ public ResourcePack processDataChunk(final long chunk, final byte[] data) { ++ return this.acceptDataChunk(chunk, data) ? this.verifyAndParse() : null; ++ } ++ ++ public ResourcePack verifyAndParse() { ++ if (!this.isComplete()) { ++ throw new IllegalStateException("Resource pack data is incomplete"); ++ } ++ try { ++ final MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); ++ final byte[] hash = sha256.digest(this.data); ++ if (!Arrays.equals(hash, this.hash)) { ++ throw new IllegalStateException("Hash mismatch"); + } ++ } catch (final Throwable e) { ++ throw new RuntimeException("Failed to verify data hash", e); ++ } ++ try { ++ return new ResourcePack(new ZipContent(this.data)); ++ } catch (final Throwable e) { ++ throw new RuntimeException("Failed to parse resource pack data", e); + } +- return true; ++ } ++ ++ public boolean isComplete() { ++ return this.remainingChunks == 0; ++ } ++ ++ public byte[] hash() { ++ return this.hash; ++ } ++ ++ public boolean premium() { ++ return this.premium; ++ } ++ ++ public PackType type() { ++ return this.type; ++ } ++ ++ public long chunkSize() { ++ return this.chunkSize; ++ } ++ ++ public boolean[] receivedChunks() { ++ return this.receivedChunks; ++ } ++ ++ public byte[] data() { ++ return this.data; + } + + } +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java +index f7f550941..5bc485dc8 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java +@@ -23,24 +23,29 @@ import com.viaversion.viaversion.api.connection.UserConnection; + import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; + import net.raphimc.viabedrock.ViaBedrock; + import net.raphimc.viabedrock.api.resourcepack.ResourcePack; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePackExecutor; + import net.raphimc.viabedrock.api.resourcepack.content.ZipContent; + import net.raphimc.viabedrock.api.resourcepack.http.BedrockPackDownloader; + import net.raphimc.viabedrock.protocol.BedrockProtocol; import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; -import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ResourcePackResponse; +import net.raphimc.viabedrock.protocol.packet.ResourcePackClientResponse; @@ -1659,26 +2158,65 @@ index f7f550941..47cf78aef 100644 import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; -@@ -48,7 +49,7 @@ public class ResourcePackLoadStateTracker extends StoredObject { + import java.util.logging.Level; ++import java.util.function.Supplier; ++import java.util.function.BiConsumer; + + public class ResourcePackLoadStateTracker extends StoredObject { + + private final Map requests = new HashMap<>(); + private final Map resourcePacks = new ConcurrentHashMap<>(); ++ private final Set remoteResourcePacks = ConcurrentHashMap.newKeySet(); + private final AtomicInteger remainingResourcePackCount = new AtomicInteger(); + private final ExecutorService executor = new ForkJoinPool(Runtime.getRuntime().availableProcessors(), pool -> { + final ForkJoinWorkerThread thread = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool); +@@ -48,7 +53,8 @@ public class ResourcePackLoadStateTracker extends StoredObject { return thread; }, null, true); private final CompletableFuture loadFuture = new CompletableFuture<>(); - private boolean javaClientAccepted; + private volatile boolean stackReceived; ++ private ResourcePackExecutor preparationExecutor; public ResourcePackLoadStateTracker(final UserConnection user, final ResourcePackLoadStateTracker.Info[] infos) { super(user); -@@ -64,7 +65,8 @@ public class ResourcePackLoadStateTracker extends StoredObject { +@@ -63,14 +69,30 @@ public class ResourcePackLoadStateTracker extends StoredObject { + } public void addRemoteResourcePack(final ResourcePack resourcePack) { - try { +- try { - Via.getManager().getProviders().get(ResourcePackProvider.class).save(resourcePack); -+ final Info info = this.requests.get(resourcePack.key()); -+ Via.getManager().getProviders().get(ResourcePackProvider.class).save(resourcePack, info != null ? info.cacheIdentity() : null); - } catch (final Throwable e) { - ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to save resource pack: " + resourcePack.key(), e); - } -@@ -84,15 +86,19 @@ public class ResourcePackLoadStateTracker extends StoredObject { +- } catch (final Throwable e) { +- ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to save resource pack: " + resourcePack.key(), e); +- } ++ // Persist all downloads during stack preparation, away from the event loop. ++ this.remoteResourcePacks.add(resourcePack.key()); + this.addLocalResourcePack(resourcePack); + } + ++ /** Preserve source-cache coverage for downloaded INFO packs even when STACK does not select them. */ ++ public void saveRemoteResourcePacks() { ++ final List downloaded = this.remoteResourcePacks.stream().map(this.resourcePacks::get).filter(Objects::nonNull).toList(); ++ saveRemoteResourcePacks(downloaded, this.requests, Via.getManager().getProviders().get(ResourcePackProvider.class), ++ (pack, error) -> ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to save resource pack: " + pack.key(), error)); ++ } ++ ++ static void saveRemoteResourcePacks(final Collection downloaded, final Map requests, ++ final ResourcePackProvider provider, final BiConsumer failed) { ++ for (final ResourcePack pack : downloaded) { ++ try { ++ final Info info = requests.get(pack.key()); ++ provider.save(pack, info != null ? info.cacheIdentity() : null); ++ } catch (final Throwable error) { ++ failed.accept(pack, error); ++ } ++ } ++ } ++ + public void addLocalResourcePack(final ResourcePack resourcePack) { + this.resourcePacks.put(resourcePack.key(), resourcePack); + if (this.remainingResourcePackCount.decrementAndGet() == 0) { +@@ -84,15 +106,19 @@ public class ResourcePackLoadStateTracker extends StoredObject { } public CompletableFuture loadRequestedResourcePacks() { @@ -1700,7 +2238,7 @@ index f7f550941..47cf78aef 100644 } catch (final Throwable e) { if (!(e.getCause() instanceof InterruptedException)) { ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to load resource pack: " + info.key(), e); -@@ -132,9 +138,7 @@ public class ResourcePackLoadStateTracker extends StoredObject { +@@ -132,9 +158,7 @@ public class ResourcePackLoadStateTracker extends StoredObject { if (!downloadList.isEmpty()) { ViaBedrock.getPlatform().getLogger().log(Level.INFO, "Downloading " + downloadList.size() + " resource packs over the game protocol"); final PacketWrapper resourcePackClientResponse = PacketWrapper.create(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE, this.user()); @@ -1711,7 +2249,7 @@ index f7f550941..47cf78aef 100644 resourcePackClientResponse.scheduleSendToServer(BedrockProtocol.class); } else { this.loadFuture.complete(null); -@@ -148,32 +152,52 @@ public class ResourcePackLoadStateTracker extends StoredObject { +@@ -148,32 +172,67 @@ public class ResourcePackLoadStateTracker extends StoredObject { public void loadUnrequestedResourcePacks(final ResourcePack.Key[] keys) { for (ResourcePack.Key key : keys) { @@ -1736,25 +2274,40 @@ index f7f550941..47cf78aef 100644 - @Override - public void onRemove() { - this.executor.shutdownNow(); -+ public CompletableFuture loadedFuture() { -+ return this.loadFuture; ++ public void failLoading(final Throwable error) { ++ this.loadFuture.completeExceptionally(error); } - public boolean hasJavaClientAccepted() { - return this.javaClientAccepted; -+ public void markStackReceived() { -+ this.stackReceived = true; ++ public CompletableFuture loadedFuture() { ++ return this.loadFuture; } - public void setJavaClientAccepted() { - this.javaClientAccepted = true; ++ public void markStackReceived() { ++ this.stackReceived = true; ++ } ++ + public boolean hasReceivedStack() { + return this.stackReceived; + } + ++ /** Used when resource-pack translation has no HTTP server to own preparation workers. */ ++ public CompletableFuture prepareStack(final Supplier work) { ++ if (this.preparationExecutor == null) { ++ this.preparationExecutor = new ResourcePackExecutor(1, 1, "ViaBedrock Resource Pack Preparation"); ++ } ++ return this.preparationExecutor.submit(work); ++ } ++ + @Override + public void onRemove() { + this.executor.shutdownNow(); ++ if (this.preparationExecutor != null) { ++ this.preparationExecutor.stop(); ++ } } public record Info(ResourcePack.Key key, byte[] contentKey, String contentId, URL httpUrl) { @@ -1774,6 +2327,99 @@ index f7f550941..47cf78aef 100644 } } +diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackExecutorTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackExecutorTest.java +new file mode 100644 +index 000000000..09eed6373 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackExecutorTest.java +@@ -0,0 +1,87 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.api.resourcepack; ++ ++import org.junit.jupiter.api.Test; ++ ++import java.util.concurrent.CancellationException; ++import java.util.concurrent.CountDownLatch; ++import java.util.concurrent.ExecutionException; ++import java.util.concurrent.RejectedExecutionException; ++import java.util.concurrent.TimeUnit; ++import java.util.concurrent.atomic.AtomicBoolean; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class ResourcePackExecutorTest { ++ ++ @Test ++ void saturationRejectsWithoutRunningOnTheCaller() throws Exception { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 1, "pack-test"); ++ final CountDownLatch started = new CountDownLatch(1); ++ final CountDownLatch release = new CountDownLatch(1); ++ final AtomicBoolean rejectedWorkRan = new AtomicBoolean(); ++ try { ++ final var running = workers.submit(() -> await(started, release)); ++ assertTrue(started.await(5, TimeUnit.SECONDS)); ++ final var queued = workers.submit(() -> "queued"); ++ final var rejected = workers.submit(() -> { ++ rejectedWorkRan.set(true); ++ return "rejected"; ++ }); ++ assertInstanceOf(RejectedExecutionException.class, assertThrows(ExecutionException.class, ++ () -> rejected.get(5, TimeUnit.SECONDS)).getCause()); ++ assertFalse(rejectedWorkRan.get()); ++ release.countDown(); ++ assertEquals("running", running.get(5, TimeUnit.SECONDS)); ++ assertEquals("queued", queued.get(5, TimeUnit.SECONDS)); ++ } finally { ++ release.countDown(); ++ workers.stop(); ++ } ++ } ++ ++ @Test ++ void shutdownCompletesQueuedAndRunningFuturesAndRejectsNewWork() throws Exception { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 1, "pack-test"); ++ final CountDownLatch started = new CountDownLatch(1); ++ final CountDownLatch release = new CountDownLatch(1); ++ final AtomicBoolean queuedWorkRan = new AtomicBoolean(); ++ final var running = workers.submit(() -> await(started, release)); ++ assertTrue(started.await(5, TimeUnit.SECONDS)); ++ final var queued = workers.submit(() -> { ++ queuedWorkRan.set(true); ++ return "queued"; ++ }); ++ workers.stop(); ++ try { ++ assertThrows(CancellationException.class, () -> running.get(5, TimeUnit.SECONDS)); ++ assertThrows(CancellationException.class, () -> queued.get(5, TimeUnit.SECONDS)); ++ assertFalse(queuedWorkRan.get()); ++ assertInstanceOf(RejectedExecutionException.class, assertThrows(ExecutionException.class, ++ () -> workers.submit(() -> "late").get(5, TimeUnit.SECONDS)).getCause()); ++ } finally { ++ release.countDown(); ++ } ++ } ++ ++ private static String await(final CountDownLatch started, final CountDownLatch release) { ++ started.countDown(); ++ boolean interrupted = false; ++ while (release.getCount() != 0) { ++ try { ++ release.await(); ++ } catch (final InterruptedException ignored) { ++ interrupted = true; ++ } ++ } ++ if (interrupted) { ++ Thread.currentThread().interrupt(); ++ } ++ return "running"; ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ContentLanguageCacheTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ContentLanguageCacheTest.java new file mode 100644 index 000000000..c0f072f08 @@ -2094,10 +2740,10 @@ index 000000000..4e177dceb +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java new file mode 100644 -index 000000000..53ea1ce25 +index 000000000..dd08f175e --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java -@@ -0,0 +1,255 @@ +@@ -0,0 +1,364 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors @@ -2136,6 +2782,8 @@ index 000000000..53ea1ce25 +import java.util.Map; +import java.util.Random; +import java.util.concurrent.CompletionException; ++import java.util.concurrent.CountDownLatch; ++import java.util.concurrent.TimeUnit; +import java.util.Arrays; +import java.util.function.Function; +import java.util.concurrent.atomic.AtomicInteger; @@ -2345,6 +2993,113 @@ index 000000000..53ea1ce25 + assertThrows(IllegalArgumentException.class, () -> ConverterDataManifest.encode(Map.of("arbitrary_key", true))); + } + ++ @Test ++ void evictsLeastRecentlyUsedCompletedPacksWithoutChangingExistingArchives() { ++ final AtomicInteger conversions = new AtomicInteger(); ++ final ConvertedResourcePackCache cache = new ConvertedResourcePackCache(this.directory, ViaBedrockConfig.PackCacheMode.MEMORY, storage -> { ++ conversions.incrementAndGet(); ++ storage.getConverterData().put("ce_actor_scale", 1.5F); ++ return new InMemoryContent(); ++ }, 2, 1024 * 1024); ++ final ResourcePack first = pack(UUID.randomUUID(), new byte[]{1}); ++ final ResourcePack second = pack(UUID.randomUUID(), new byte[]{2}); ++ final ResourcePack third = pack(UUID.randomUUID(), new byte[]{3}); ++ try { ++ final var original = cache.prepare(new ResourcePackStorage(List.of(first))).join(); ++ cache.prepare(new ResourcePackStorage(List.of(second))).join(); ++ final ResourcePackStorage touched = new ResourcePackStorage(List.of(first)); ++ assertSame(original, cache.prepare(touched).join()); ++ assertEquals(1.5F, touched.getConverterData().get("ce_actor_scale")); ++ cache.prepare(new ResourcePackStorage(List.of(third))).join(); ++ assertEquals(3, conversions.get()); ++ cache.prepare(new ResourcePackStorage(List.of(second))).join(); ++ assertEquals(4, conversions.get()); ++ assertEquals(original.sha1(), ConvertedResourcePackCache.describe(original.bytes()).sha1()); ++ } finally { ++ cache.stop(); ++ } ++ } ++ ++ @Test ++ void memoryBudgetEvictsArchivesAndSkipsOversizedResults() { ++ final AtomicInteger conversions = new AtomicInteger(); ++ final Function converter = storage -> { ++ conversions.incrementAndGet(); ++ final InMemoryContent content = new InMemoryContent(); ++ content.putStored("assets/payload", new byte[2048]); ++ return content; ++ }; ++ final ResourcePack first = pack(UUID.randomUUID(), new byte[]{1}); ++ final ResourcePack second = pack(UUID.randomUUID(), new byte[]{2}); ++ final ConvertedResourcePackCache bounded = new ConvertedResourcePackCache(this.directory, ViaBedrockConfig.PackCacheMode.MEMORY, converter, 32, 3000); ++ final ConvertedResourcePackCache oversized = new ConvertedResourcePackCache(this.directory, ViaBedrockConfig.PackCacheMode.MEMORY, converter, 32, 1024); ++ try { ++ bounded.prepare(new ResourcePackStorage(List.of(first))).join(); ++ bounded.prepare(new ResourcePackStorage(List.of(second))).join(); ++ bounded.prepare(new ResourcePackStorage(List.of(first))).join(); ++ assertEquals(3, conversions.get()); ++ final var served = oversized.prepare(new ResourcePackStorage(List.of(first))).join(); ++ assertTrue(served.bytes().length > 1024); ++ oversized.prepare(new ResourcePackStorage(List.of(first))).join(); ++ assertEquals(5, conversions.get()); ++ } finally { ++ bounded.stop(); ++ oversized.stop(); ++ } ++ } ++ ++ @Test ++ void evictedDiskDescriptorsReloadWithoutReconversion() { ++ final AtomicInteger conversions = new AtomicInteger(); ++ final ConvertedResourcePackCache cache = new ConvertedResourcePackCache(this.directory, ViaBedrockConfig.PackCacheMode.DISK, storage -> { ++ conversions.incrementAndGet(); ++ storage.getConverterData().put("model_missing_large", "x".repeat(2048)); ++ return new InMemoryContent(); ++ }, 1, 1024); ++ try { ++ final ResourcePackStorage first = new ResourcePackStorage(List.of()); ++ final var original = cache.prepare(first).join(); ++ final ResourcePackStorage second = new ResourcePackStorage(List.of()); ++ final var restored = cache.prepare(second).join(); ++ assertNotSame(original, restored); ++ assertEquals(1, conversions.get()); ++ assertEquals(original.sha1(), restored.sha1()); ++ assertEquals(first.getConverterData(), second.getConverterData()); ++ } finally { ++ cache.stop(); ++ } ++ } ++ ++ @Test ++ void concurrentRequestsShareAnOversizedInFlightConversion() throws Exception { ++ final CountDownLatch started = new CountDownLatch(1); ++ final CountDownLatch release = new CountDownLatch(1); ++ final AtomicInteger conversions = new AtomicInteger(); ++ final ConvertedResourcePackCache cache = new ConvertedResourcePackCache(this.directory, ViaBedrockConfig.PackCacheMode.MEMORY, storage -> { ++ conversions.incrementAndGet(); ++ started.countDown(); ++ try { ++ assertTrue(release.await(5, TimeUnit.SECONDS)); ++ } catch (final InterruptedException interrupted) { ++ Thread.currentThread().interrupt(); ++ throw new IllegalStateException(interrupted); ++ } ++ return new InMemoryContent(); ++ }, 1, 1); ++ try { ++ final var first = cache.prepare("shared", new ResourcePackStorage(List.of())); ++ assertTrue(started.await(5, TimeUnit.SECONDS)); ++ final var second = cache.prepare("shared", new ResourcePackStorage(List.of())); ++ assertSame(first, second); ++ release.countDown(); ++ assertSame(first.get(5, TimeUnit.SECONDS), second.get(5, TimeUnit.SECONDS)); ++ assertEquals(1, conversions.get()); ++ } finally { ++ release.countDown(); ++ cache.stop(); ++ } ++ } ++ + private static ResourcePack pack(final UUID id, final byte[] data) { + final InMemoryContent content = new InMemoryContent(); + content.putString("manifest.json", "{\"format_version\":3,\"header\":{\"uuid\":\"" + id + "\",\"version\":\"1.0.0\",\"name\":\"test\"}}"); @@ -2433,12 +3188,492 @@ index 000000000..f3351181a + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackDownloadCompletionTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackDownloadCompletionTest.java +new file mode 100644 +index 000000000..2c2d2a3d7 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackDownloadCompletionTest.java +@@ -0,0 +1,206 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.protocol.packet; ++ ++import io.netty.channel.DefaultEventLoop; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePack; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePackExecutor; ++import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.PackType; ++import net.raphimc.viabedrock.protocol.storage.ResourcePackDownloadTracker; ++import org.junit.jupiter.api.Test; ++ ++import java.security.MessageDigest; ++import java.util.UUID; ++import java.util.concurrent.CancellationException; ++import java.util.concurrent.CompletableFuture; ++import java.util.concurrent.CountDownLatch; ++import java.util.concurrent.RejectedExecutionException; ++import java.util.concurrent.TimeUnit; ++import java.util.concurrent.atomic.AtomicBoolean; ++import java.util.concurrent.atomic.AtomicInteger; ++import java.util.concurrent.atomic.AtomicReference; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class ResourcePackDownloadCompletionTest { ++ ++ @Test ++ void decodingWaitsForWorkersWithoutBlockingTheEventLoopOrPublishingEarly() throws Exception { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 4, "download-test"); ++ final DefaultEventLoop loop = new DefaultEventLoop(); ++ final CountDownLatch started = new CountDownLatch(1); ++ final CountDownLatch release = new CountDownLatch(1); ++ final ResourcePackDownloadTracker tracker = new ResourcePackDownloadTracker(); ++ final var download = completed(tracker, false); ++ final CompletableFuture loaded = new CompletableFuture<>(); ++ try { ++ workers.submit(() -> block(started, release)); ++ assertTrue(started.await(5, TimeUnit.SECONDS)); ++ loop.submit(() -> ResourcePackDownloadCompletion.finish(workers::submit, loop, () -> { ++ assertTrue(loop.inEventLoop()); ++ return true; ++ }, tracker, "pack", download, pack -> { ++ assertTrue(loop.inEventLoop()); ++ assertNull(tracker.get("pack")); ++ loaded.complete(pack); ++ }, loaded::completeExceptionally)).get(5, TimeUnit.SECONDS); ++ assertEquals("heartbeat", loop.submit(() -> "heartbeat").get(5, TimeUnit.SECONDS)); ++ assertFalse(loaded.isDone()); ++ assertSame(download, tracker.get("pack")); ++ release.countDown(); ++ assertArrayEquals(new byte[]{1, 2, 3}, loaded.get(5, TimeUnit.SECONDS).content().get("payload")); ++ } finally { ++ release.countDown(); ++ workers.stop(); ++ loop.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync(); ++ } ++ } ++ ++ @Test ++ void replacedDownloadTrackerLoadOwnerAndDisconnectedChannelDiscardCompletion() throws Exception { ++ for (int stale = 0; stale < 4; stale++) { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 4, "download-test"); ++ final DefaultEventLoop loop = new DefaultEventLoop(); ++ final CountDownLatch started = new CountDownLatch(1); ++ final CountDownLatch release = new CountDownLatch(1); ++ final ResourcePackDownloadTracker tracker = new ResourcePackDownloadTracker(); ++ final var download = completed(tracker, stale % 2 != 0); ++ final AtomicReference currentTracker = new AtomicReference<>(tracker); ++ final Object expectedOwner = new Object(); ++ final AtomicReference currentOwner = new AtomicReference<>(expectedOwner); ++ final AtomicBoolean active = new AtomicBoolean(true); ++ final AtomicInteger publications = new AtomicInteger(); ++ final AtomicInteger failures = new AtomicInteger(); ++ try { ++ workers.submit(() -> block(started, release)); ++ assertTrue(started.await(5, TimeUnit.SECONDS)); ++ loop.submit(() -> ResourcePackDownloadCompletion.finish(workers::submit, loop, ++ () -> active.get() && currentTracker.get() == tracker && currentOwner.get() == expectedOwner, ++ tracker, "pack", download, pack -> publications.incrementAndGet(), error -> failures.incrementAndGet())).get(5, TimeUnit.SECONDS); ++ final int replacement = stale; ++ loop.submit(() -> { ++ switch (replacement) { ++ case 0 -> tracker.add("pack", 2, 2, new byte[32], false, PackType.Resources); ++ case 1 -> currentTracker.set(new ResourcePackDownloadTracker()); ++ case 2 -> currentOwner.set(new Object()); ++ case 3 -> active.set(false); ++ default -> throw new AssertionError(); ++ } ++ }).get(5, TimeUnit.SECONDS); ++ release.countDown(); ++ workers.submit(() -> "worker-barrier").get(5, TimeUnit.SECONDS); ++ loop.submit(() -> "completion-barrier").get(5, TimeUnit.SECONDS); ++ assertEquals(0, publications.get()); ++ assertEquals(0, failures.get()); ++ assertNotNull(tracker.get("pack")); ++ if (stale == 0) { ++ assertNotSame(download, tracker.get("pack")); ++ } ++ } finally { ++ release.countDown(); ++ workers.stop(); ++ loop.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync(); ++ } ++ } ++ } ++ ++ @Test ++ void verificationFailureReportsOnTheEventLoopWithoutPublishing() throws Exception { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 4, "download-test"); ++ final DefaultEventLoop loop = new DefaultEventLoop(); ++ final ResourcePackDownloadTracker tracker = new ResourcePackDownloadTracker(); ++ final var download = completed(tracker, true); ++ final CompletableFuture failed = new CompletableFuture<>(); ++ final AtomicInteger publications = new AtomicInteger(); ++ try { ++ loop.submit(() -> ResourcePackDownloadCompletion.finish(workers::submit, loop, () -> true, tracker, "pack", download, ++ pack -> publications.incrementAndGet(), error -> { ++ assertTrue(loop.inEventLoop()); ++ failed.complete(error); ++ })).get(5, TimeUnit.SECONDS); ++ assertEquals("Failed to verify data hash", failed.get(5, TimeUnit.SECONDS).getMessage()); ++ assertEquals(0, publications.get()); ++ assertSame(download, tracker.get("pack")); ++ } finally { ++ workers.stop(); ++ loop.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync(); ++ } ++ } ++ ++ @Test ++ void rejectedDecoderReportsFailureWithoutRunningOnTheCaller() throws Exception { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 4, "download-test"); ++ final DefaultEventLoop loop = new DefaultEventLoop(); ++ final ResourcePackDownloadTracker tracker = new ResourcePackDownloadTracker(); ++ final var download = completed(tracker, true); ++ final CompletableFuture failed = new CompletableFuture<>(); ++ workers.stop(); ++ try { ++ loop.submit(() -> ResourcePackDownloadCompletion.finish(workers::submit, loop, () -> true, tracker, "pack", download, ++ pack -> fail("A rejected decoder cannot publish"), error -> { ++ assertTrue(loop.inEventLoop()); ++ failed.complete(error); ++ })).get(5, TimeUnit.SECONDS); ++ assertInstanceOf(RejectedExecutionException.class, failed.get(5, TimeUnit.SECONDS)); ++ assertSame(download, tracker.get("pack")); ++ } finally { ++ loop.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync(); ++ } ++ } ++ ++ @Test ++ void shutdownSettlesQueuedDecoderInsteadOfHangingNegotiation() throws Exception { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 4, "download-test"); ++ final DefaultEventLoop loop = new DefaultEventLoop(); ++ final CountDownLatch started = new CountDownLatch(1); ++ final CountDownLatch release = new CountDownLatch(1); ++ final ResourcePackDownloadTracker tracker = new ResourcePackDownloadTracker(); ++ final var download = completed(tracker, false); ++ final CompletableFuture failed = new CompletableFuture<>(); ++ try { ++ workers.submit(() -> block(started, release)); ++ assertTrue(started.await(5, TimeUnit.SECONDS)); ++ loop.submit(() -> ResourcePackDownloadCompletion.finish(workers::submit, loop, () -> true, tracker, "pack", download, ++ pack -> fail("A cancelled decoder cannot publish"), failed::complete)).get(5, TimeUnit.SECONDS); ++ workers.stop(); ++ assertInstanceOf(CancellationException.class, failed.get(5, TimeUnit.SECONDS)); ++ } finally { ++ release.countDown(); ++ workers.stop(); ++ loop.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync(); ++ } ++ } ++ ++ private static ResourcePackDownloadTracker.Download completed(final ResourcePackDownloadTracker tracker, final boolean mismatch) throws Exception { ++ final InMemoryContent content = new InMemoryContent(); ++ content.putString("manifest.json", "{\"format_version\":3,\"header\":{\"uuid\":\"" + UUID.randomUUID() ++ + "\",\"version\":\"1.0.0\",\"name\":\"test\"}}"); ++ content.put("payload", new byte[]{1, 2, 3}); ++ final byte[] archive = content.toZip(); ++ final byte[] hash = mismatch ? new byte[32] : MessageDigest.getInstance("SHA-256").digest(archive); ++ final var download = tracker.add("pack", archive.length, archive.length, hash, false, PackType.Resources); ++ assertTrue(download.acceptDataChunk(0, archive)); ++ return download; ++ } ++ ++ private static String block(final CountDownLatch started, final CountDownLatch release) { ++ started.countDown(); ++ boolean interrupted = false; ++ while (release.getCount() != 0) { ++ try { ++ release.await(); ++ } catch (final InterruptedException ignored) { ++ interrupted = true; ++ } ++ } ++ if (interrupted) { ++ Thread.currentThread().interrupt(); ++ } ++ return "released"; ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPreparationTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPreparationTest.java +new file mode 100644 +index 000000000..69ccd109e +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPreparationTest.java +@@ -0,0 +1,140 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.protocol.packet; ++ ++import io.netty.channel.DefaultEventLoop; ++import net.raphimc.viabedrock.api.resourcepack.ResourcePackExecutor; ++import org.junit.jupiter.api.Test; ++ ++import java.util.concurrent.CompletableFuture; ++import java.util.concurrent.CountDownLatch; ++import java.util.concurrent.TimeUnit; ++import java.util.concurrent.atomic.AtomicBoolean; ++import java.util.concurrent.atomic.AtomicInteger; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class ResourcePackPreparationTest { ++ ++ @Test ++ void expensivePreparationDoesNotBlockTheEventLoopAndPublishesOnIt() throws Exception { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 1, "prepare-test"); ++ final DefaultEventLoop loop = new DefaultEventLoop(); ++ final CountDownLatch started = new CountDownLatch(1); ++ final CountDownLatch release = new CountDownLatch(1); ++ final CompletableFuture published = new CompletableFuture<>(); ++ try { ++ loop.submit(() -> ResourcePackPreparation.prepare(workers::submit, loop, () -> { ++ assertTrue(loop.inEventLoop()); ++ return true; ++ }, () -> { ++ assertFalse(loop.inEventLoop()); ++ started.countDown(); ++ try { ++ assertTrue(release.await(5, TimeUnit.SECONDS)); ++ } catch (final InterruptedException interrupted) { ++ throw new IllegalStateException(interrupted); ++ } ++ return "prepared"; ++ }, (value, error) -> { ++ assertTrue(loop.inEventLoop()); ++ if (error == null) { ++ published.complete(value); ++ } else { ++ published.completeExceptionally(error); ++ } ++ }, published::completeExceptionally)).get(5, TimeUnit.SECONDS); ++ assertTrue(started.await(5, TimeUnit.SECONDS)); ++ assertEquals("heartbeat", loop.submit(() -> "heartbeat").get(5, TimeUnit.SECONDS)); ++ assertFalse(published.isDone()); ++ release.countDown(); ++ assertEquals("prepared", published.get(5, TimeUnit.SECONDS)); ++ } finally { ++ release.countDown(); ++ workers.stop(); ++ loop.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync(); ++ } ++ } ++ ++ @Test ++ void replacedOrDisconnectedOwnershipDiscardsWorkerCompletion() throws Exception { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 1, "prepare-test"); ++ final DefaultEventLoop loop = new DefaultEventLoop(); ++ final AtomicBoolean current = new AtomicBoolean(true); ++ final CountDownLatch started = new CountDownLatch(1); ++ final CountDownLatch release = new CountDownLatch(1); ++ final AtomicInteger publications = new AtomicInteger(); ++ try { ++ loop.submit(() -> ResourcePackPreparation.prepare(workers::submit, loop, current::get, () -> { ++ started.countDown(); ++ try { ++ assertTrue(release.await(5, TimeUnit.SECONDS)); ++ } catch (final InterruptedException interrupted) { ++ throw new IllegalStateException(interrupted); ++ } ++ return "stale"; ++ }, (value, error) -> publications.incrementAndGet(), error -> fail("Stale preparation must discard failure"))).get(5, TimeUnit.SECONDS); ++ assertTrue(started.await(5, TimeUnit.SECONDS)); ++ loop.submit(() -> current.set(false)).get(5, TimeUnit.SECONDS); ++ release.countDown(); ++ workers.submit(() -> "worker-barrier").get(5, TimeUnit.SECONDS); ++ loop.submit(() -> "publication-barrier").get(5, TimeUnit.SECONDS); ++ assertEquals(0, publications.get()); ++ } finally { ++ release.countDown(); ++ workers.stop(); ++ loop.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync(); ++ } ++ } ++ ++ @Test ++ void rejectedWorkReportsFailureOnTheEventLoop() throws Exception { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 1, "prepare-test"); ++ final DefaultEventLoop loop = new DefaultEventLoop(); ++ final CompletableFuture failed = new CompletableFuture<>(); ++ workers.stop(); ++ try { ++ loop.submit(() -> ResourcePackPreparation.prepare(workers::submit, loop, () -> true, () -> { ++ fail("Rejected work must not run"); ++ return "unexpected"; ++ }, (value, error) -> { ++ assertTrue(loop.inEventLoop()); ++ failed.complete(error); ++ }, failed::complete)).get(5, TimeUnit.SECONDS); ++ assertInstanceOf(java.util.concurrent.RejectedExecutionException.class, failed.get(5, TimeUnit.SECONDS)); ++ } finally { ++ loop.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync(); ++ } ++ } ++ ++ @Test ++ void throwingPublicationReportsFailureOnTheEventLoop() throws Exception { ++ final ResourcePackExecutor workers = new ResourcePackExecutor(1, 1, "prepare-test"); ++ final DefaultEventLoop loop = new DefaultEventLoop(); ++ final CompletableFuture failed = new CompletableFuture<>(); ++ final AtomicInteger successfulPublications = new AtomicInteger(); ++ final IllegalStateException publicationError = new IllegalStateException("Publishing failed"); ++ try { ++ loop.submit(() -> ResourcePackPreparation.prepare(workers::submit, loop, () -> true, () -> "prepared", (value, error) -> { ++ assertTrue(loop.inEventLoop()); ++ assertNull(error); ++ if (value.equals("prepared")) { ++ throw publicationError; ++ } ++ successfulPublications.incrementAndGet(); ++ }, error -> { ++ assertTrue(loop.inEventLoop()); ++ failed.complete(error); ++ })).get(5, TimeUnit.SECONDS); ++ assertSame(publicationError, failed.get(5, TimeUnit.SECONDS)); ++ assertEquals(0, successfulPublications.get()); ++ } finally { ++ workers.stop(); ++ loop.shutdownGracefully(0, 5, TimeUnit.SECONDS).sync(); ++ } ++ } ++ ++} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackDownloadTrackerTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackDownloadTrackerTest.java +new file mode 100644 +index 000000000..bd7fd6bb2 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackDownloadTrackerTest.java +@@ -0,0 +1,116 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.protocol.storage; ++ ++import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; ++import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.PackType; ++import org.junit.jupiter.api.Test; ++ ++import java.security.MessageDigest; ++import java.io.IOException; ++import java.util.ArrayList; ++import java.util.Arrays; ++import java.util.Collections; ++import java.util.Random; ++import java.util.UUID; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++class ResourcePackDownloadTrackerTest { ++ ++ @Test ++ void assemblesOrderedReversedAndShuffledChunksAndOnlyCompletesOnce() throws Exception { ++ final byte[] archive = archive(); ++ for (int ordering = 0; ordering < 3; ordering++) { ++ final var download = new ResourcePackDownloadTracker().add("pack", archive.length, 17, digest(archive), false, PackType.Resources); ++ final var chunks = new ArrayList(); ++ for (int chunk = 0; chunk < download.receivedChunks().length; chunk++) { ++ chunks.add(chunk); ++ } ++ if (ordering == 1) { ++ Collections.reverse(chunks); ++ } else if (ordering == 2) { ++ Collections.shuffle(chunks, new Random(2193)); ++ } ++ assertFalse(download.isComplete()); ++ assertThrows(IllegalStateException.class, download::verifyAndParse); ++ for (int index = 0; index < chunks.size(); index++) { ++ final int chunk = chunks.get(index); ++ final int offset = chunk * 17; ++ final boolean complete = download.acceptDataChunk(chunk, Arrays.copyOfRange(archive, offset, Math.min(offset + 17, archive.length))); ++ assertEquals(index == chunks.size() - 1, complete); ++ assertEquals(complete, download.isComplete()); ++ } ++ assertArrayEquals(archive, download.data()); ++ assertArrayEquals(new byte[]{1, 2, 3}, download.verifyAndParse().content().get("payload")); ++ assertThrows(IllegalStateException.class, () -> download.acceptDataChunk(chunks.get(0), new byte[0])); ++ } ++ } ++ ++ @Test ++ void duplicateOutOfBoundsAndFailedCopiesDoNotAdvanceReceiptCount() { ++ final var download = new ResourcePackDownloadTracker().add("pack", 4, 2, new byte[32], false, PackType.Resources); ++ assertFalse(download.acceptDataChunk(1, new byte[]{3, 4})); ++ assertThrows(IllegalStateException.class, () -> download.acceptDataChunk(1, new byte[]{3, 4})); ++ assertThrows(IllegalStateException.class, () -> download.acceptDataChunk(-1, new byte[0])); ++ assertThrows(IllegalStateException.class, () -> download.acceptDataChunk(2, new byte[0])); ++ assertThrows(IndexOutOfBoundsException.class, () -> download.acceptDataChunk(0, new byte[5])); ++ assertFalse(download.isComplete()); ++ assertTrue(download.acceptDataChunk(0, new byte[]{1, 2})); ++ assertArrayEquals(new byte[]{1, 2, 3, 4}, download.data()); ++ } ++ ++ @Test ++ void rejectsHashMismatchBeforeParsing() throws Exception { ++ final byte[] archive = archive(); ++ final var download = new ResourcePackDownloadTracker().add("pack", archive.length, archive.length, new byte[32], false, PackType.Resources); ++ assertTrue(download.acceptDataChunk(0, archive)); ++ final RuntimeException error = assertThrows(RuntimeException.class, download::verifyAndParse); ++ assertEquals("Failed to verify data hash", error.getMessage()); ++ assertEquals("Hash mismatch", error.getCause().getMessage()); ++ } ++ ++ @Test ++ void rejectsTruncatedZipAndMissingManifestWithTheExistingParseError() throws Exception { ++ final InMemoryContent missingManifest = new InMemoryContent(); ++ missingManifest.put("payload", new byte[]{1}); ++ final InMemoryContent truncatedContent = new InMemoryContent(); ++ truncatedContent.putStored("payload", new byte[1024]); ++ final byte[] truncated = Arrays.copyOf(truncatedContent.toZip(), 100); ++ for (final byte[] invalid : new byte[][]{truncated, missingManifest.toZip()}) { ++ final var download = new ResourcePackDownloadTracker().add("pack", invalid.length, invalid.length, digest(invalid), false, PackType.Resources); ++ assertTrue(download.acceptDataChunk(0, invalid)); ++ final RuntimeException error = assertThrows(RuntimeException.class, download::verifyAndParse); ++ assertEquals("Failed to parse resource pack data", error.getMessage()); ++ if (invalid == truncated) { ++ assertInstanceOf(IOException.class, error.getCause()); ++ } else { ++ assertInstanceOf(IllegalStateException.class, error.getCause().getCause()); ++ assertEquals("Missing manifest.json", error.getCause().getCause().getMessage()); ++ } ++ } ++ } ++ ++ @Test ++ void synchronousCompatibilityMethodStillReturnsTheDecodedPack() throws Exception { ++ final byte[] archive = archive(); ++ final var download = new ResourcePackDownloadTracker().add("pack", archive.length, archive.length, digest(archive), false, PackType.Resources); ++ assertArrayEquals(new byte[]{1, 2, 3}, download.processDataChunk(0, archive).content().get("payload")); ++ } ++ ++ private static byte[] archive() throws Exception { ++ final InMemoryContent content = new InMemoryContent(); ++ content.putString("manifest.json", "{\"format_version\":3,\"header\":{\"uuid\":\"" + UUID.randomUUID() ++ + "\",\"version\":\"1.0.0\",\"name\":\"test\"}}"); ++ content.put("payload", new byte[]{1, 2, 3}); ++ return content.toZip(); ++ } ++ ++ private static byte[] digest(final byte[] data) throws Exception { ++ return MessageDigest.getInstance("SHA-256").digest(data); ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTrackerTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTrackerTest.java new file mode 100644 -index 000000000..ecaede49c +index 000000000..87f0e716c --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTrackerTest.java -@@ -0,0 +1,43 @@ +@@ -0,0 +1,116 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors @@ -2459,9 +3694,17 @@ index 000000000..ecaede49c +package net.raphimc.viabedrock.protocol.storage; + +import net.raphimc.viabedrock.api.resourcepack.ResourcePack; ++import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; ++import net.raphimc.viabedrock.protocol.provider.impl.InMemoryResourcePackProvider; +import org.junit.jupiter.api.Test; + +import java.util.UUID; ++import java.util.List; ++import java.util.Map; ++import java.util.ArrayList; ++import java.io.IOException; ++import java.util.concurrent.ExecutionException; ++import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + @@ -2481,6 +3724,71 @@ index 000000000..ecaede49c + assertNull(new ResourcePackLoadStateTracker.Info(key, new byte[0], "", null).cacheIdentity()); + } + ++ @Test ++ void persistsDownloadedPacksAbsentFromTheSelectedStack() throws Exception { ++ final ResourcePack selected = pack(new byte[]{1}); ++ final ResourcePack unused = pack(new byte[]{2}); ++ final var selectedInfo = new ResourcePackLoadStateTracker.Info(selected.key(), new byte[0], "selected", null); ++ final var unusedInfo = new ResourcePackLoadStateTracker.Info(unused.key(), new byte[0], "unused", null); ++ final InMemoryResourcePackProvider provider = new InMemoryResourcePackProvider(); ++ ++ ResourcePackLoadStateTracker.saveRemoteResourcePacks(List.of(selected, unused), ++ Map.of(selected.key(), selectedInfo, unused.key(), unusedInfo), provider, ++ (pack, error) -> fail("Saving a valid downloaded pack must succeed", error)); ++ ++ assertTrue(provider.has(selected.key(), selectedInfo.cacheIdentity())); ++ assertTrue(provider.has(unused.key(), unusedInfo.cacheIdentity())); ++ assertArrayEquals(new byte[]{2}, provider.load(unused.key(), unusedInfo.cacheIdentity()).content().get("payload")); ++ assertArrayEquals(new byte[]{2}, unused.content().get("payload")); ++ } ++ ++ @Test ++ void failedSourceWriteDoesNotStopSavingOtherDownloadedPacks() throws Exception { ++ final ResourcePack failed = pack(new byte[]{1}); ++ final ResourcePack unused = pack(new byte[]{2}); ++ final var failedInfo = new ResourcePackLoadStateTracker.Info(failed.key(), new byte[0], "failed", null); ++ final var unusedInfo = new ResourcePackLoadStateTracker.Info(unused.key(), new byte[0], "unused", null); ++ final List failures = new ArrayList<>(); ++ final InMemoryResourcePackProvider provider = new InMemoryResourcePackProvider() { ++ @Override ++ public void save(final ResourcePack pack, final String identity) throws IOException { ++ if (pack.key().equals(failed.key())) { ++ throw new IOException("Source cache is not writable"); ++ } ++ super.save(pack, identity); ++ } ++ }; ++ ++ assertDoesNotThrow(() -> ResourcePackLoadStateTracker.saveRemoteResourcePacks(List.of(failed, unused), ++ Map.of(failed.key(), failedInfo, unused.key(), unusedInfo), provider, (pack, error) -> { ++ assertInstanceOf(IOException.class, error); ++ failures.add(pack.key()); ++ })); ++ ++ assertEquals(List.of(failed.key()), failures); ++ assertFalse(provider.has(failed.key(), failedInfo.cacheIdentity())); ++ assertTrue(provider.has(unused.key(), unusedInfo.cacheIdentity())); ++ assertArrayEquals(new byte[]{2}, provider.load(unused.key(), unusedInfo.cacheIdentity()).content().get("payload")); ++ } ++ ++ @Test ++ void failedDecodeCompletesTheNegotiationFutureExceptionally() { ++ final ResourcePackLoadStateTracker tracker = new ResourcePackLoadStateTracker(null, new ResourcePackLoadStateTracker.Info[0]); ++ final var negotiation = tracker.loadedFuture(); ++ final IllegalStateException failure = new IllegalStateException("Bad downloaded pack"); ++ tracker.failLoading(failure); ++ assertSame(failure, assertThrows(ExecutionException.class, () -> negotiation.get(5, TimeUnit.SECONDS)).getCause()); ++ tracker.onRemove(); ++ } ++ ++ private static ResourcePack pack(final byte[] payload) { ++ final InMemoryContent content = new InMemoryContent(); ++ content.putString("manifest.json", "{\"format_version\":3,\"header\":{\"uuid\":\"" + UUID.randomUUID() ++ + "\",\"version\":\"1.0.0\",\"name\":\"test\"}}"); ++ content.put("payload", payload); ++ return new ResourcePack(content); ++ } ++ +} -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viabedrock/upstreamable/0001-cache-converted-resource-packs.pr.md b/patches/viabedrock/upstreamable/0001-cache-converted-resource-packs.pr.md index 4817420..774f77d 100644 --- a/patches/viabedrock/upstreamable/0001-cache-converted-resource-packs.pr.md +++ b/patches/viabedrock/upstreamable/0001-cache-converted-resource-packs.pr.md @@ -2,6 +2,14 @@ Please check that a changed source pack cannot reuse an older conversion, and that reconnecting with unchanged packs avoids another conversion. +## Download completion on bounded workers + +Receiving the last pack chunk no longer hashes and inflates the entire archive on the connection event loop. Chunk assembly stays on that loop, with a constant-time remaining count; completed archives use the existing bounded preparation workers. Duplicate chunks cannot alter a completed buffer or enqueue another decode. ZIP input streams release their native inflater after success or failure. + +Decoded packs remain associated with their exact download entry until publication on the event loop. Channel, load tracker, download tracker, and entry identity checks reject obsolete results. Decode failures, worker rejection, and shutdown settle negotiation and use the existing disconnect handling. The synchronous decoder API remains available for integrations. + +The standalone owning patch passes 45 tests and both Checkstyle tasks. New regressions cover out-of-order chunks, failed-copy accounting, digest and archive errors, event-loop responsiveness, stale owners, rejection, shutdown, and failed negotiation completion. The complete core build passes 1,155 tests with 30 optional skips and no failures or errors. These are correctness and responsiveness checks; no new live-server frame-time or joining benchmark was performed. + ## Testing - [x] Run `./gradlew test checkstyleMain checkstyleTest` on the feature-only checkout. @@ -228,3 +236,13 @@ The cache schema still restores finite scales as floats. The feature-only tests and both Checkstyle tasks pass. The cache patch applies alone to the pinned upstream base and passes all 22 tests and both Checkstyle tasks. The complete core and add-on builds also pass after replaying the stacks. + +## Bound retention and keep preparation off connection threads + +Completed conversions now use a 32-entry LRU with a 128 MiB retention budget for archive bytes and estimated metadata. In-flight sharing is separate, so oversized results can serve waiting connections without remaining cached. Verified disk entries can reload after eviction. + +Source ZIP loading, decryption, persistence, and definition construction run on bounded workers. Conversion has two workers and 64 queued tasks; HTTP-server preparation has two workers and 32 queued tasks. Hosts with fewer processors use fewer workers. Translation-disabled connections lazily own one preparation worker with one queued task. Saturation fails through the existing connection error path without running heavy work on the caller. Shutdown completes pending futures. + +Preserve all downloaded INFO packs before selecting STACK resources, including unused offers. Publish storage and advertise resources only while the owning tracker, storage, and channel remain current. Selected subpacks, builtin layers, and conversion-profile fingerprints survive full-stack replay. + +Validation: the standalone owning patch passes 34 tests and both Checkstyle tasks. The complete core build passes with 1,144 tests passed and 30 skipped; the add-on and ViaProxy also build against the updated library. Regressions cover eviction, oversized sharing, queue rejection, shutdown, nonfatal source-cache failures, stale publication, and event-loop responsiveness. No new live joining-time or frame-time measurements were made. diff --git a/patches/viabedrock/upstreamable/0010-render-neighbor-dependent-block-shapes.patch b/patches/viabedrock/upstreamable/0010-render-neighbor-dependent-block-shapes.patch index 5dcb5eb..da41af1 100644 --- a/patches/viabedrock/upstreamable/0010-render-neighbor-dependent-block-shapes.patch +++ b/patches/viabedrock/upstreamable/0010-render-neighbor-dependent-block-shapes.patch @@ -1,4 +1,4 @@ -From 81c9e7e14d98233d835ebb273fbd66e71e02281b Mon Sep 17 00:00:00 2001 +From 18425ce892be7529cf0154f3036181cc33bb2016 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:22:23 +0200 Subject: [PATCH] fix(world): render neighbor-dependent block shapes @@ -40,20 +40,40 @@ rejects the first case. These fixtures supply packed maps, component IDs, allocator and region lookup, and stop before callback registration. Connect redstone wire to piston and sticky piston power targets, except their front face, matching the target block behavior. Directional tests cover both piston kinds. + +Classify palette entries before resolving connected shapes or refreshing +neighbor borders. Sections containing no fences, stairs, or wire now skip +all coordinate lookups and BlockPosition allocations. Mixed sections only +resolve positions whose original palette entries require connected shapes. +Use each palette's coordinate order and retain the existing native world +lookup, including secondary-layer waterlogging, for final shape resolution. + +The previous border path examined up to 24,576 positions per column even +when all four neighboring columns contained only ordinary blocks. Palette +classification also replaces 4,096 inverse state lookups per remapped +section with one lookup per palette entry. + +Differential tests compare complete candidate sets in both Bedrock and +Java palette layouts, all four borders, and complete remapped arrays while +shape updates append palette entries. Work-count assertions show ordinary +uniform and mixed sections perform no coordinate reads. The 16 focused +shape and redstone tests pass; changed test Checkstyle passes. --- - .../api/model/BedrockFenceGeometry.java | 57 +++++++ + .../api/model/BedrockFenceGeometry.java | 57 ++++++ .../protocol/packet/WorldPackets.java | 17 ++ - .../protocol/storage/ChunkTracker.java | 105 ++++++++++++- - .../storage/ConnectedBlockShapes.java | 145 ++++++++++++++++++ - .../storage/RedstoneWireConnections.java | 134 ++++++++++++++++ - .../api/model/BedrockFenceGeometryTest.java | 50 ++++++ - .../storage/ConnectedBlockShapesTest.java | 120 +++++++++++++++ - .../storage/RedstoneWireConnectionsTest.java | 87 +++++++++++ - 8 files changed, 712 insertions(+), 3 deletions(-) + .../protocol/storage/ChunkTracker.java | 105 ++++++++++- + .../storage/ConnectedBlockShapes.java | 175 ++++++++++++++++++ + .../storage/RedstoneWireConnections.java | 134 ++++++++++++++ + .../api/model/BedrockFenceGeometryTest.java | 50 +++++ + .../ConnectedBlockShapeCandidatesTest.java | 163 ++++++++++++++++ + .../storage/ConnectedBlockShapesTest.java | 120 ++++++++++++ + .../storage/RedstoneWireConnectionsTest.java | 87 +++++++++ + 9 files changed, 905 insertions(+), 3 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/model/BedrockFenceGeometry.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapes.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/storage/RedstoneWireConnections.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/model/BedrockFenceGeometryTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapeCandidatesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapesTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/storage/RedstoneWireConnectionsTest.java @@ -176,7 +196,7 @@ index a87286ba0..3a98a6995 100644 protocol.registerClientbound(ClientboundBedrockPackets.BLOCK_ENTITY_DATA, ClientboundPackets26_3.BLOCK_ENTITY_DATA, new PacketHandlers() { @Override diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java -index 10b5d1890..0680a9fdd 100644 +index 10b5d1890..2037f97a3 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -50,6 +50,7 @@ import net.raphimc.viabedrock.api.chunk.light.LightEngine; @@ -257,7 +277,7 @@ index 10b5d1890..0680a9fdd 100644 if (!Objects.equals(prevTag, tag)) { this.getChunk(blockPosition.x() >> 4, blockPosition.z() >> 4).removeBlockEntityAt(blockPosition); entityTracker.removeItemFrame(blockPosition); -@@ -487,6 +516,62 @@ public class ChunkTracker extends StoredObject { +@@ -487,6 +516,65 @@ public class ChunkTracker extends StoredObject { // Initial lighting uses this chunk alone. Refresh its borders with any loaded neighbors. this.lightDirtyChunks.add(chunkKey); this.invalidateNearbyLight(chunkX, chunkZ); @@ -283,28 +303,31 @@ index 10b5d1890..0680a9fdd 100644 + } + + private void refreshNeighboringConnectedBorders(final int chunkX, final int chunkZ) { -+ final boolean westLoaded = this.chunkLight.containsKey(ChunkPosition.chunkKey(chunkX - 1, chunkZ)); -+ final boolean eastLoaded = this.chunkLight.containsKey(ChunkPosition.chunkKey(chunkX + 1, chunkZ)); -+ final boolean northLoaded = this.chunkLight.containsKey(ChunkPosition.chunkKey(chunkX, chunkZ - 1)); -+ final boolean southLoaded = this.chunkLight.containsKey(ChunkPosition.chunkKey(chunkX, chunkZ + 1)); -+ if (!westLoaded && !eastLoaded && !northLoaded && !southLoaded) { ++ this.refreshConnectedBorder(chunkX - 1, chunkZ, 15, 16, 0, 16); ++ this.refreshConnectedBorder(chunkX + 1, chunkZ, 0, 1, 0, 16); ++ this.refreshConnectedBorder(chunkX, chunkZ - 1, 0, 16, 15, 16); ++ this.refreshConnectedBorder(chunkX, chunkZ + 1, 0, 16, 0, 1); ++ } ++ ++ private void refreshConnectedBorder(final int chunkX, final int chunkZ, final int minX, final int maxX, ++ final int minZ, final int maxZ) { ++ final long key = ChunkPosition.chunkKey(chunkX, chunkZ); ++ final BedrockChunk chunk = this.chunks.get(key); ++ if (chunk == null || !this.chunkLight.containsKey(key)) { + return; + } -+ for (int offset = 0; offset < 16; offset++) { -+ for (int y = this.minY; y < this.minY + this.worldHeight; y++) { -+ if (westLoaded) { -+ this.sendConnectedUpdate(new BlockPosition((chunkX << 4) - 1, y, (chunkZ << 4) + offset)); -+ } -+ if (eastLoaded) { -+ this.sendConnectedUpdate(new BlockPosition((chunkX << 4) + 16, y, (chunkZ << 4) + offset)); -+ } -+ if (northLoaded) { -+ this.sendConnectedUpdate(new BlockPosition((chunkX << 4) + offset, y, (chunkZ << 4) - 1)); -+ } -+ if (southLoaded) { -+ this.sendConnectedUpdate(new BlockPosition((chunkX << 4) + offset, y, (chunkZ << 4) + 16)); -+ } ++ final BlockStateRewriter rewriter = this.user().get(BlockStateRewriter.class); ++ for (int sectionIndex = 0; sectionIndex < chunk.getSections().length; sectionIndex++) { ++ final List palettes = chunk.getSections()[sectionIndex].palettes(PaletteType.BLOCKS); ++ if (palettes.isEmpty()) { ++ continue; + } ++ final int sectionY = this.minY + (sectionIndex << 4); ++ // Waterlogging changes properties, preserving the connected block's identifier. ++ ConnectedBlockShapes.forEachCandidate(palettes.get(0), id -> ConnectedBlockShapes.isConnected( ++ BedrockProtocol.MAPPINGS.getJavaBlockStates().inverse().get(rewriter.javaId(id))), ++ minX, maxX, minZ, maxZ, local -> this.sendConnectedUpdate(new BlockPosition( ++ (chunkX << 4) + (local & 15), sectionY + (local >> 8), (chunkZ << 4) + ((local >> 4) & 15)))); + } + } + @@ -320,33 +343,30 @@ index 10b5d1890..0680a9fdd 100644 } private static LightPacketData buildFullLightPacketData(final ChunkLight light) { -@@ -952,6 +1037,20 @@ public class ChunkTracker extends StoredObject { +@@ -952,6 +1040,17 @@ public class ChunkTracker extends StoredObject { } } -+ for (int y = 0; y < 16; y++) { -+ for (int z = 0; z < 16; z++) { -+ for (int x = 0; x < 16; x++) { -+ final int javaId = remappedBlockPalette.idAt(x, y, z); -+ final BlockState state = BedrockProtocol.MAPPINGS.getJavaBlockStates().inverse().get(javaId); -+ if (ConnectedBlockShapes.isConnected(state)) { -+ final BlockPosition position = new BlockPosition((chunk.getX() << 4) + x, -+ this.minY + (idx << 4) + y, (chunk.getZ() << 4) + z); -+ remappedBlockPalette.setIdAt(x, y, z, this.getJavaBlockState(position)); -+ } -+ } -+ } -+ } ++ final int sectionY = this.minY + (idx << 4); ++ ConnectedBlockShapes.forEachCandidate(remappedBlockPalette, id -> ConnectedBlockShapes.isConnected( ++ BedrockProtocol.MAPPINGS.getJavaBlockStates().inverse().get(id)), 0, 16, 0, 16, local -> { ++ final int x = local & 15; ++ final int y = local >> 8; ++ final int z = (local >> 4) & 15; ++ final BlockPosition position = new BlockPosition((chunk.getX() << 4) + x, ++ sectionY + y, (chunk.getZ() << 4) + z); ++ remappedBlockPalette.setIdAt(x, y, z, this.getJavaBlockState(position)); ++ }); + int nonAirBlockCount = 0; int fluidCount = 0; for (int i = 0; i < ChunkSection.SIZE; i++) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapes.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapes.java new file mode 100644 -index 000000000..7c96fab6d +index 000000000..646565d63 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapes.java -@@ -0,0 +1,145 @@ +@@ -0,0 +1,175 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2026 RK_01/RaphiMC and contributors @@ -359,10 +379,14 @@ index 000000000..7c96fab6d +package net.raphimc.viabedrock.protocol.storage; + +import com.viaversion.viaversion.api.minecraft.BlockPosition; ++import com.viaversion.viaversion.api.minecraft.chunks.DataPalette; +import net.raphimc.viabedrock.api.model.BlockState; + ++import java.util.BitSet; +import java.util.Map; +import java.util.function.Function; ++import java.util.function.IntConsumer; ++import java.util.function.IntPredicate; +import java.util.function.Predicate; + +final class ConnectedBlockShapes { @@ -374,6 +398,32 @@ index 000000000..7c96fab6d + return RedstoneWireConnections.isWire(state) || isFence(state) || isStairs(state); + } + ++ /** Visits relevant positions as {@code y << 8 | z << 4 | x}, using the palette's own coordinate order. */ ++ static void forEachCandidate(final DataPalette palette, final IntPredicate connected, ++ final int minX, final int maxX, final int minZ, final int maxZ, ++ final IntConsumer update) { ++ final BitSet entries = new BitSet(palette.size()); ++ for (int entry = 0; entry < palette.size(); entry++) { ++ if (connected.test(palette.idByIndex(entry))) { ++ entries.set(entry); ++ } ++ } ++ if (entries.isEmpty()) { ++ return; ++ } ++ // Shape updates may append palette entries. Existing positions keep their original ++ // indices, so the predicate only needs to run for the entries present before this pass. ++ for (int y = 0; y < 16; y++) { ++ for (int z = minZ; z < maxZ; z++) { ++ for (int x = minX; x < maxX; x++) { ++ if (entries.get(palette.paletteIndexAt(palette.index(x, y, z)))) { ++ update.accept((y << 8) | (z << 4) | x); ++ } ++ } ++ } ++ } ++ } ++ + static BlockState shape(final BlockPosition position, final BlockState state, + final Function blockAt, final Predicate fullBlock) { + if (RedstoneWireConnections.isWire(state)) { @@ -688,6 +738,175 @@ index 000000000..6d5c118b4 + } + +} +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapeCandidatesTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapeCandidatesTest.java +new file mode 100644 +index 000000000..f3c852edf +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapeCandidatesTest.java +@@ -0,0 +1,163 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.protocol.storage; ++ ++import com.viaversion.viaversion.api.minecraft.BlockPosition; ++import com.viaversion.viaversion.api.minecraft.chunks.ChunkSection; ++import com.viaversion.viaversion.api.minecraft.chunks.DataPalette; ++import com.viaversion.viaversion.api.minecraft.chunks.DataPaletteImpl; ++import net.raphimc.viabedrock.api.chunk.datapalette.BedrockDataPalette; ++import net.raphimc.viabedrock.api.model.BlockState; ++import org.junit.jupiter.api.Test; ++ ++import java.util.ArrayList; ++import java.util.HashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.Random; ++import java.util.concurrent.atomic.AtomicInteger; ++ ++import static org.junit.jupiter.api.Assertions.assertArrayEquals; ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++ ++class ConnectedBlockShapeCandidatesTest { ++ ++ private static final List STATES = List.of( ++ BlockState.fromString("minecraft:air"), ++ BlockState.fromString("minecraft:stone"), ++ BlockState.fromString("minecraft:oak_fence[east=false,north=false,south=false,waterlogged=false,west=false]"), ++ BlockState.fromString("minecraft:oak_fence[east=false,north=false,south=false,waterlogged=true,west=false]"), ++ BlockState.fromString("minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]"), ++ BlockState.fromString("minecraft:oak_stairs[facing=west,half=bottom,shape=straight,waterlogged=true]"), ++ BlockState.fromString("minecraft:redstone_wire[east=none,north=none,power=0,south=none,west=none]")); ++ ++ @Test ++ void candidatePassMatchesExhaustivePositionsForBothPaletteOrdersAndEveryBorder() { ++ final Random random = new Random(90210); ++ for (int sample = 0; sample < 12; sample++) { ++ for (final DataPalette palette : List.of(new BedrockDataPalette(), new DataPaletteImpl(ChunkSection.SIZE))) { ++ palette.addId(0); ++ // Retain unused and repeated palette entries; neither should add candidates. ++ palette.addId(6); ++ palette.addId(0); ++ for (int y = 0; y < 16; y++) { ++ for (int z = 0; z < 16; z++) { ++ for (int x = 0; x < 16; x++) { ++ palette.setIdAt(x, y, z, random.nextInt(16) == 0 ? random.nextInt(STATES.size()) : 1); ++ } ++ } ++ } ++ for (final int[] bounds : new int[][]{{0, 16, 0, 16}, {15, 16, 0, 16}, {0, 1, 0, 16}, {0, 16, 15, 16}, {0, 16, 0, 1}}) { ++ final List expected = new ArrayList<>(); ++ for (int y = 0; y < 16; y++) { ++ for (int z = bounds[2]; z < bounds[3]; z++) { ++ for (int x = bounds[0]; x < bounds[1]; x++) { ++ if (ConnectedBlockShapes.isConnected(STATES.get(palette.idAt(x, y, z)))) { ++ expected.add((y << 8) | (z << 4) | x); ++ } ++ } ++ } ++ } ++ final List actual = new ArrayList<>(); ++ ConnectedBlockShapes.forEachCandidate(palette, ++ id -> ConnectedBlockShapes.isConnected(STATES.get(id)), ++ bounds[0], bounds[1], bounds[2], bounds[3], actual::add); ++ assertEquals(expected, actual); ++ } ++ } ++ } ++ } ++ ++ @Test ++ void remappedOutputsMatchExhaustiveShapesWhileUpdatesAppendPaletteEntries() { ++ final Random random = new Random(8317); ++ for (int sample = 0; sample < 12; sample++) { ++ final DataPalette palette = new DataPaletteImpl(ChunkSection.SIZE); ++ final int[] original = new int[ChunkSection.SIZE]; ++ final List states = new ArrayList<>(STATES); ++ final Map ids = new HashMap<>(); ++ for (int id = 0; id < states.size(); id++) { ++ ids.put(states.get(id), id); ++ } ++ palette.addId(0); ++ for (int local = 0; local < original.length; local++) { ++ original[local] = random.nextInt(STATES.size()); ++ palette.setIdAt(local & 15, local >> 8, (local >> 4) & 15, original[local]); ++ } ++ final int[] expected = original.clone(); ++ for (int local = 0; local < expected.length; local++) { ++ final BlockState state = states.get(original[local]); ++ if (!ConnectedBlockShapes.isConnected(state)) { ++ continue; ++ } ++ final BlockPosition position = new BlockPosition(local & 15, local >> 8, (local >> 4) & 15); ++ final BlockState shaped = ConnectedBlockShapes.shape(position, state, adjacent -> { ++ if (adjacent.x() < 0 || adjacent.x() >= 16 || adjacent.y() < 0 || adjacent.y() >= 16 ++ || adjacent.z() < 0 || adjacent.z() >= 16) { ++ return STATES.get(0); ++ } ++ return STATES.get(original[(adjacent.y() << 8) | (adjacent.z() << 4) | adjacent.x()]); ++ }, adjacent -> "stone".equals(adjacent.identifier())); ++ expected[local] = ids.computeIfAbsent(shaped, key -> { ++ states.add(key); ++ return states.size() - 1; ++ }); ++ } ++ final int originalPaletteSize = palette.size(); ++ final AtomicInteger checks = new AtomicInteger(); ++ ConnectedBlockShapes.forEachCandidate(palette, id -> { ++ checks.incrementAndGet(); ++ return ConnectedBlockShapes.isConnected(states.get(id)); ++ }, 0, 16, 0, 16, local -> palette.setIdAt(local & 15, local >> 8, (local >> 4) & 15, expected[local])); ++ final int[] actual = new int[original.length]; ++ for (int local = 0; local < actual.length; local++) { ++ actual[local] = palette.idAt(local & 15, local >> 8, (local >> 4) & 15); ++ } ++ assertArrayEquals(expected, actual); ++ assertEquals(originalPaletteSize, checks.get()); ++ assertTrue(palette.size() > originalPaletteSize); ++ } ++ } ++ ++ @Test ++ void irrelevantUniformAndMixedSectionsNeverReadBlockCoordinates() { ++ final CountingPalette palette = new CountingPalette(); ++ palette.addId(0); ++ final AtomicInteger checks = new AtomicInteger(); ++ ConnectedBlockShapes.forEachCandidate(palette, id -> { ++ checks.incrementAndGet(); ++ return ConnectedBlockShapes.isConnected(STATES.get(id)); ++ }, 0, 16, 0, 16, _ -> { ++ throw new AssertionError("Ordinary blocks must not be updated"); ++ }); ++ assertEquals(1, checks.get()); ++ assertEquals(0, palette.coordinateReads); ++ palette.setIdAt(3, 7, 11, 1); ++ checks.set(0); ++ ConnectedBlockShapes.forEachCandidate(palette, id -> { ++ checks.incrementAndGet(); ++ return ConnectedBlockShapes.isConnected(STATES.get(id)); ++ }, 15, 16, 0, 16, _ -> { ++ throw new AssertionError("Ordinary border blocks must not be updated"); ++ }); ++ assertEquals(2, checks.get()); ++ assertEquals(0, palette.coordinateReads); ++ } ++ ++ private static final class CountingPalette extends BedrockDataPalette { ++ ++ private int coordinateReads; ++ ++ @Override ++ public int paletteIndexAt(final int coordinate) { ++ this.coordinateReads++; ++ return super.paletteIndexAt(coordinate); ++ } ++ ++ } ++ ++} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapesTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/ConnectedBlockShapesTest.java new file mode 100644 index 000000000..b24440d48 @@ -908,4 +1127,4 @@ index 000000000..304095fae + +} -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viabedrock/upstreamable/0010-render-neighbor-dependent-block-shapes.pr.md b/patches/viabedrock/upstreamable/0010-render-neighbor-dependent-block-shapes.pr.md index 9031609..7742daa 100644 --- a/patches/viabedrock/upstreamable/0010-render-neighbor-dependent-block-shapes.pr.md +++ b/patches/viabedrock/upstreamable/0010-render-neighbor-dependent-block-shapes.pr.md @@ -100,3 +100,9 @@ Core reports 664 passing tests and 19 skips; CubeConverter reports 16 passing te All eight neighbor-shape tests pass, with no failures or errors. Fresh Java comparisons match the 4,096 stair cases and the earlier 4,096 gate, slab, and snow cases. Live joins and complete movement parity were not verified in this run. + +## Skip sections without connected states + +Neighbor refreshes classify palette entries once and visit only cells whose entries need connected-state resolution. Sections without candidates avoid a 4,096-cell scan; border refreshes use the same helper with edge bounds. Preserve Java and Bedrock palette indexing, secondary-layer waterlogging, and the later upper-door predicate. + +Eight connected-shape tests pass, including differential comparisons with full-array scans for both palette types and all four borders. The complete core stack replays and builds with Checkstyle, 1,144 passing tests, and 30 skips. These checks establish equivalent candidate selection, without measuring live chunk frame times. diff --git a/patches/viabedrock/upstreamable/0025-apply-dependencies-and-selected-subpacks.patch b/patches/viabedrock/upstreamable/0025-apply-dependencies-and-selected-subpacks.patch index 90180a1..8893155 100644 --- a/patches/viabedrock/upstreamable/0025-apply-dependencies-and-selected-subpacks.patch +++ b/patches/viabedrock/upstreamable/0025-apply-dependencies-and-selected-subpacks.patch @@ -1,4 +1,4 @@ -From e81d8ea8a5a8ab9574279fd0e56c544ba367cbc8 Mon Sep 17 00:00:00 2001 +From 90186318bd9b5c335276f505787e040f3e2ef57f Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:58:26 +0200 Subject: [PATCH] feat(resource-packs): apply dependencies and selected @@ -10,11 +10,11 @@ Resolve pack order from manifest dependencies while preserving server precedence .../api/resourcepack/ResourcePackStack.java | 75 ++++++++++++ .../api/resourcepack/content/Content.java | 3 +- .../resourcepack/content/SubpackContent.java | 104 ++++++++++++++++ - .../protocol/packet/ResourcePackPackets.java | 20 ++-- + .../protocol/packet/ResourcePackPackets.java | 24 ++-- .../protocol/storage/ResourcePackStorage.java | 8 +- .../resourcepack/ResourcePackStackTest.java | 113 ++++++++++++++++++ .../http/ConvertedResourcePackCacheTest.java | 11 ++ - 8 files changed, 390 insertions(+), 19 deletions(-) + 8 files changed, 392 insertions(+), 21 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackStack.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/content/SubpackContent.java create mode 100644 src/test/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackStackTest.java @@ -348,10 +348,10 @@ index 000000000..26031077e + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java -index 983fa9381..376a1a04b 100644 +index 1d836bedc..19f727a9f 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java -@@ -72,7 +72,7 @@ public final class ResourcePackPackets { +@@ -75,7 +75,7 @@ public final class ResourcePackPackets { final String version = wrapper.read(BedrockTypes.STRING); // pack version wrapper.read(BedrockTypes.UNSIGNED_LONG_LE); // pack size final byte[] contentKey = wrapper.read(BedrockTypes.BYTE_ARRAY); // content key @@ -360,7 +360,7 @@ index 983fa9381..376a1a04b 100644 final String contentId = wrapper.read(BedrockTypes.STRING); // content identity wrapper.read(Types.BOOLEAN); // has scripts wrapper.read(Types.BOOLEAN); // is addon pack -@@ -122,10 +122,11 @@ public final class ResourcePackPackets { +@@ -129,10 +129,11 @@ public final class ResourcePackPackets { wrapper.read(Types.BOOLEAN); // resource pack required final ResourcePack.Key[] keys = new ResourcePack.Key[wrapper.read(BedrockTypes.UNSIGNED_VAR_INT)]; // resource packs size @@ -373,7 +373,7 @@ index 983fa9381..376a1a04b 100644 keys[i] = new ResourcePack.Key(id, version); } wrapper.read(BedrockTypes.STRING); // base game version -@@ -149,14 +150,18 @@ public final class ResourcePackPackets { +@@ -159,14 +160,18 @@ public final class ResourcePackPackets { BedrockProtocol.kickForIllegalState(user, "One of the server resource packs failed to load. Try again later.", error); } else { try { @@ -394,16 +394,28 @@ index 983fa9381..376a1a04b 100644 } }); protocol.registerClientbound(ClientboundBedrockPackets.RESOURCE_PACK_DATA_INFO, null, wrapper -> { -@@ -226,14 +231,15 @@ public final class ResourcePackPackets { - }); +@@ -255,12 +260,12 @@ public final class ResourcePackPackets { + BedrockProtocol.kickForIllegalState(user, "One of the server resource packs failed to load. Try again later.", error); } - private static void finishStack(final UserConnection user, final ResourcePackLoadStateTracker loadStateTracker, final ResourcePack.Key[] keys) { + private static void finishStack(final UserConnection user, final ResourcePackLoadStateTracker loadStateTracker, final ResourcePack.Key[] keys, final String[] subpackNames) { - if (user.get(ResourcePackLoadStateTracker.class) != loadStateTracker) { - return; - } - user.remove(ResourcePackLoadStateTracker.class); + final Function, CompletableFuture> preparation = ViaBedrock.getResourcePackServer() != null + ? ViaBedrock.getResourcePackServer()::prepareStorage : loadStateTracker::prepareStack; + ResourcePackPreparation.prepare(preparation, user.getChannel().eventLoop(), + () -> user.getChannel().isActive() && user.get(ResourcePackLoadStateTracker.class) == loadStateTracker, +- () -> prepareStack(loadStateTracker, keys), (storage, error) -> { ++ () -> prepareStack(loadStateTracker, keys, subpackNames), (storage, error) -> { + if (error != null) { + BedrockProtocol.kickForIllegalState(user, "Failed to prepare the server resource packs.", error); + return; +@@ -290,11 +295,12 @@ public final class ResourcePackPackets { + }, error -> BedrockProtocol.kickForIllegalState(user, "Failed to publish the server resource packs.", error)); + } + +- private static ResourcePackStorage prepareStack(final ResourcePackLoadStateTracker loadStateTracker, final ResourcePack.Key[] keys) { ++ private static ResourcePackStorage prepareStack(final ResourcePackLoadStateTracker loadStateTracker, final ResourcePack.Key[] keys, final String[] subpackNames) { + loadStateTracker.saveRemoteResourcePacks(); loadStateTracker.loadUnrequestedResourcePacks(keys); final List resourcePacks = new ArrayList<>(); - for (ResourcePack.Key key : keys) { @@ -412,7 +424,7 @@ index 983fa9381..376a1a04b 100644 final ResourcePack resourcePack = loadStateTracker.getResourcePack(key); if (resourcePack != null) { final ResourcePackLoadStateTracker.Info info = loadStateTracker.getRequest(key); -@@ -245,7 +251,7 @@ public final class ResourcePackPackets { +@@ -307,7 +313,7 @@ public final class ResourcePackPackets { ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to save resource pack: " + resourcePack.key(), e); } } @@ -571,10 +583,10 @@ index 000000000..1abb141dd + +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java -index 53ea1ce25..4ea0f89f7 100644 +index dd08f175e..d88fe5cef 100644 --- a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java -@@ -162,6 +162,17 @@ class ConvertedResourcePackCacheTest { +@@ -164,6 +164,17 @@ class ConvertedResourcePackCacheTest { assertNotEquals(ConvertedResourcePackCache.fingerprint(List.of(original)), ConvertedResourcePackCache.fingerprint(List.of(updated))); } @@ -593,4 +605,4 @@ index 53ea1ce25..4ea0f89f7 100644 void sourceCacheDoesNotReuseAnotherAdvertisedContentIdentity() throws Exception { final UUID id = UUID.randomUUID(); -- -2.55.0 +2.50.1 (Apple Git-155) diff --git a/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.patch b/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.patch index 80b54c8..06c5c72 100644 --- a/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.patch +++ b/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.patch @@ -1,4 +1,4 @@ -From 4c8ea67ee3ec6914a96beab16b0b6019b2e406fb Mon Sep 17 00:00:00 2001 +From 4ae13b75885f70312fe638609f32c31fe5612c01 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:59:09 +0200 Subject: [PATCH] perf(lighting): propagate increased block light incrementally @@ -55,18 +55,39 @@ assets and probe output remain private. Rendered image parity remains open. Bound the lifetime of background lighting jobs through result application or discard, including slow connection event loops, rejected executors and failures. Releasing worker capacity before completion delivery allowed queued callbacks to retain unlimited region snapshots. Keep one permit per job and release it exactly once. Keep uniform lighting snapshots as one state instead of 4,096 integers. Reuse the emission workspace for block light, and avoid allocating arrays for uniform zero/full light sections. Preserve mixed-section, secondary-water, border and incremental propagation semantics. Stress tests exercise 32,000 submissions with blocked result delivery, abandoned trackers and exception cleanup. Full-height and dense/compact parity tests check the storage bound and output. A private 24-section benchmark produces identical light data with approximately 27 percent less allocation per update; its mostly uniform snapshot uses approximately 96 percent less state-array storage. These measurements do not establish game-wide heap or frame-rate improvement. + +Keep at most one queued or running chunk-tracker callback per connection. +A slow event loop previously retained every timer callback and processed +catch-up bursts after chunk conversion or other blocking work completed. +Dirty chunks and light work already remain in their tracker queues, so +repeated timer callbacks need no separate replay. + +Release the connection reservation in finally when the callback runs or +is discarded. Recover rejected submissions during channel shutdown and +clean up other submission failures. Identity-specific reservation tokens +prevent an old failed submission from clearing a newer callback's slot. +Keep the existing callback boundary so subsequent dimension-tracker +identity checks still reject stale work. + +Five manual-executor regression tests cover 32 bursts of 1,000 redundant +timer submissions, independent connections, a running callback, discarded +work, failed callbacks, rejected executors, and replacement reservations +across inline executor failure. All pass; changed test Checkstyle passes. --- .../api/chunk/light/LightEngine.java | 228 ++++++++++-- .../protocol/data/BedrockMappingData.java | 32 +- .../protocol/storage/ChunkTracker.java | 332 +++++++++++++++--- + .../protocol/task/ChunkTrackerTickTask.java | 33 +- .../api/chunk/light/LightEngineTest.java | 211 +++++++++++ .../protocol/data/BlockLightEmissionTest.java | 91 +++++ .../data/BlockLightFilteringTest.java | 97 +++++ .../protocol/storage/ChunkTrackerTest.java | 168 +++++++++ - 7 files changed, 1071 insertions(+), 88 deletions(-) + .../task/ChunkTrackerTickTaskTest.java | 148 ++++++++ + 9 files changed, 1251 insertions(+), 89 deletions(-) create mode 100644 src/test/java/net/raphimc/viabedrock/api/chunk/light/LightEngineTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/data/BlockLightEmissionTest.java create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/data/BlockLightFilteringTest.java + create mode 100644 src/test/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTaskTest.java diff --git a/src/main/java/net/raphimc/viabedrock/api/chunk/light/LightEngine.java b/src/main/java/net/raphimc/viabedrock/api/chunk/light/LightEngine.java index 7083d9305..d101d0b5a 100644 @@ -474,7 +495,7 @@ index 98d435ad8..ae1e0fe72 100644 return Integer.parseInt(blockState.properties().getOrDefault(property, String.valueOf(fallback))); } catch (final NumberFormatException e) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java -index 12898b47f..07b236527 100644 +index 67c2261f0..132a4c34e 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -67,12 +67,17 @@ import net.raphimc.viabedrock.api.model.NativeBlockIdentifiers; @@ -645,7 +666,7 @@ index 12898b47f..07b236527 100644 public BedrockChunkSection handleBlockPalette(final BedrockChunkSection section) { this.replaceLegacyBlocks(section); this.resolvePersistentIds(section); -@@ -800,17 +893,21 @@ public class ChunkTracker extends StoredObject { +@@ -803,17 +896,21 @@ public class ChunkTracker extends StoredObject { private ChunkLight computeChunkLight(final int chunkX, final int chunkZ) { final int[][][] regionStates = new int[9][][]; @@ -671,7 +692,7 @@ index 12898b47f..07b236527 100644 for (int dz = -1; dz <= 1; dz++) { for (int dx = -1; dx <= 1; dx++) { final BedrockChunk neighbor = this.chunks.get(ChunkPosition.chunkKey(chunkX + dx, chunkZ + dz)); -@@ -820,9 +917,10 @@ public class ChunkTracker extends StoredObject { +@@ -823,9 +920,10 @@ public class ChunkTracker extends StoredObject { final int chunkIndex = (dz + 1) * 3 + (dx + 1); regionStates[chunkIndex] = this.snapshotChunkStates(neighbor); @@ -683,7 +704,7 @@ index 12898b47f..07b236527 100644 } private int[][] snapshotChunkStates(final BedrockChunk chunk) { -@@ -833,27 +931,53 @@ public class ChunkTracker extends StoredObject { +@@ -836,27 +934,53 @@ public class ChunkTracker extends StoredObject { return states.clone(); } @@ -754,7 +775,7 @@ index 12898b47f..07b236527 100644 this.pendingLightComputations.remove(chunkKey); if (!this.user().getChannel().isActive()) { return; -@@ -883,7 +1007,6 @@ public class ChunkTracker extends StoredObject { +@@ -886,7 +1010,6 @@ public class ChunkTracker extends StoredObject { this.writeLightData(lightUpdate, lightData); lightUpdate.send(BedrockProtocol.class); }); @@ -762,7 +783,7 @@ index 12898b47f..07b236527 100644 } private int[][] getJavaBlockStates(final BedrockChunk chunk) { -@@ -901,13 +1024,12 @@ public class ChunkTracker extends StoredObject { +@@ -904,13 +1027,12 @@ public class ChunkTracker extends StoredObject { /** * Converts the bedrock block states of a chunk section into java block states, indexed with @@ -778,7 +799,7 @@ index 12898b47f..07b236527 100644 } final BlockStateRewriter blockStateRewriter = this.user().get(BlockStateRewriter.class); -@@ -917,6 +1039,10 @@ public class ChunkTracker extends StoredObject { +@@ -920,6 +1042,10 @@ public class ChunkTracker extends StoredObject { final int javaId = blockStateRewriter.javaId(layer0.idByIndex(i)); entryJavaIds[i] = javaId != -1 ? javaId : ProtocolConstants.JAVA_AIR_ID; } @@ -789,7 +810,7 @@ index 12898b47f..07b236527 100644 if (layer0.size() == 1) { if (entryJavaIds[0] != ProtocolConstants.JAVA_AIR_ID) { Arrays.fill(states, entryJavaIds[0]); -@@ -930,7 +1056,37 @@ public class ChunkTracker extends StoredObject { +@@ -933,7 +1059,37 @@ public class ChunkTracker extends StoredObject { } } } @@ -828,7 +849,7 @@ index 12898b47f..07b236527 100644 } private void invalidateJavaBlockStates(final int chunkX, final int subChunkY, final int chunkZ) { -@@ -942,6 +1098,10 @@ public class ChunkTracker extends StoredObject { +@@ -945,6 +1101,10 @@ public class ChunkTracker extends StoredObject { final int sectionIndex = subChunkY + Math.abs(this.minY >> 4); if (sectionIndex >= 0 && sectionIndex < chunkStates.length) { chunkStates[sectionIndex] = null; @@ -839,7 +860,7 @@ index 12898b47f..07b236527 100644 } } -@@ -1021,23 +1181,50 @@ public class ChunkTracker extends StoredObject { +@@ -1024,21 +1184,48 @@ public class ChunkTracker extends StoredObject { this.sendChunk(chunkPos.chunkX(), chunkPos.chunkZ()); } @@ -872,10 +893,10 @@ index 12898b47f..07b236527 100644 + this.pendingLightComputations.remove(chunkKey); + task.close(); + throw failure; - } - } - } - ++ } ++ } ++ } ++ + static OptionalLong takeLightUpdate(final LongSet dirty, final LongSet pending, final LongPredicate loaded) { + final var iterator = dirty.iterator(); + while (iterator.hasNext()) { @@ -888,16 +909,14 @@ index 12898b47f..07b236527 100644 + // Keep the newer column's refresh queued until that worker releases this key. + if (pending.contains(key)) { + continue; -+ } + } + iterator.remove(); + return OptionalLong.of(key); -+ } + } + return OptionalLong.empty(); -+ } -+ + } + private long distanceToCenterSquared(final int chunkX, final int chunkZ) { - final long dx = (long) chunkX - this.centerX; - final long dz = (long) chunkZ - this.centerZ; @@ -1334,6 +1521,63 @@ public class ChunkTracker extends StoredObject { } } @@ -962,6 +981,62 @@ index 12898b47f..07b236527 100644 private record SubChunkPosition(int chunkX, int subChunkY, int chunkZ) { } +diff --git a/src/main/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTask.java b/src/main/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTask.java +index fe5c0a709..4ff6b7c1a 100644 +--- a/src/main/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTask.java ++++ b/src/main/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTask.java +@@ -22,14 +22,21 @@ import com.viaversion.viaversion.api.connection.UserConnection; + import net.raphimc.viabedrock.protocol.BedrockProtocol; + import net.raphimc.viabedrock.protocol.storage.ChunkTracker; + ++import java.util.concurrent.ConcurrentHashMap; ++import java.util.concurrent.ConcurrentMap; ++import java.util.concurrent.Executor; ++import java.util.concurrent.RejectedExecutionException; ++ + public class ChunkTrackerTickTask implements Runnable { + ++ private final ConcurrentMap pendingTicks = new ConcurrentHashMap<>(); ++ + @Override + public void run() { + for (UserConnection info : Via.getManager().getConnectionManager().getConnections()) { + final ChunkTracker chunkTracker = info.get(ChunkTracker.class); + if (chunkTracker != null) { +- info.getChannel().eventLoop().submit(() -> { ++ submitTick(this.pendingTicks, info, info.getChannel().eventLoop(), () -> { + if (!info.getChannel().isActive()) { + return; + } +@@ -44,4 +51,28 @@ public class ChunkTrackerTickTask implements Runnable { + } + } + ++ static boolean submitTick(final ConcurrentMap pending, final T connection, final Executor executor, final Runnable tick) { ++ final Object reservation = new Object(); ++ if (pending.putIfAbsent(connection, reservation) != null) { ++ return false; ++ } ++ try { ++ executor.execute(() -> { ++ try { ++ tick.run(); ++ } finally { ++ pending.remove(connection, reservation); ++ } ++ }); ++ return true; ++ } catch (final RejectedExecutionException _) { ++ // A channel can shut down between the timer's connection snapshot and submission. ++ pending.remove(connection, reservation); ++ return false; ++ } catch (final RuntimeException | Error failure) { ++ pending.remove(connection, reservation); ++ throw failure; ++ } ++ } ++ + } diff --git a/src/test/java/net/raphimc/viabedrock/api/chunk/light/LightEngineTest.java b/src/test/java/net/raphimc/viabedrock/api/chunk/light/LightEngineTest.java new file mode 100644 index 000000000..695c24ce8 @@ -1581,5 +1656,159 @@ index 130e48f58..17bc55d4d 100644 + } + } +diff --git a/src/test/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTaskTest.java b/src/test/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTaskTest.java +new file mode 100644 +index 000000000..bc141a9a2 +--- /dev/null ++++ b/src/test/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTaskTest.java +@@ -0,0 +1,148 @@ ++/* ++ * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock ++ * Copyright (C) 2026 RK_01/RaphiMC and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++package net.raphimc.viabedrock.protocol.task; ++ ++import org.junit.jupiter.api.Test; ++ ++import java.util.ArrayDeque; ++import java.util.Queue; ++import java.util.Set; ++import java.util.concurrent.ConcurrentHashMap; ++import java.util.concurrent.ConcurrentMap; ++import java.util.concurrent.Executor; ++import java.util.concurrent.RejectedExecutionException; ++import java.util.concurrent.atomic.AtomicBoolean; ++import java.util.concurrent.atomic.AtomicInteger; ++ ++import static org.junit.jupiter.api.Assertions.assertEquals; ++import static org.junit.jupiter.api.Assertions.assertFalse; ++import static org.junit.jupiter.api.Assertions.assertThrows; ++import static org.junit.jupiter.api.Assertions.assertTrue; ++ ++class ChunkTrackerTickTaskTest { ++ ++ @Test ++ void delayedEventLoopCoalescesBurstsAndResumesWithoutCatchUpTicks() { ++ final ConcurrentMap pending = new ConcurrentHashMap<>(); ++ final ManualExecutor eventLoop = new ManualExecutor(); ++ final AtomicInteger ticks = new AtomicInteger(); ++ for (int burst = 0; burst < 32; burst++) { ++ assertTrue(ChunkTrackerTickTask.submitTick(pending, "player", eventLoop, ticks::incrementAndGet)); ++ for (int timerTick = 0; timerTick < 1000; timerTick++) { ++ assertFalse(ChunkTrackerTickTask.submitTick(pending, "player", eventLoop, ticks::incrementAndGet)); ++ } ++ assertEquals(1, eventLoop.tasks.size()); ++ assertEquals(Set.of("player"), pending.keySet()); ++ eventLoop.drain(); ++ assertEquals(burst + 1, ticks.get()); ++ assertTrue(pending.isEmpty()); ++ } ++ } ++ ++ @Test ++ void separateConnectionsProgressAndRunningCallbackRemainsReserved() { ++ final ConcurrentMap pending = new ConcurrentHashMap<>(); ++ final ManualExecutor eventLoop = new ManualExecutor(); ++ final AtomicInteger ticks = new AtomicInteger(); ++ assertTrue(ChunkTrackerTickTask.submitTick(pending, "first", eventLoop, () -> { ++ assertFalse(ChunkTrackerTickTask.submitTick(pending, "first", eventLoop, ticks::incrementAndGet)); ++ ticks.incrementAndGet(); ++ })); ++ assertTrue(ChunkTrackerTickTask.submitTick(pending, "second", eventLoop, ticks::incrementAndGet)); ++ assertEquals(2, eventLoop.tasks.size()); ++ eventLoop.drain(); ++ assertEquals(2, ticks.get()); ++ assertTrue(pending.isEmpty()); ++ } ++ ++ @Test ++ void discardedTrackerOrDisconnectedCallbackReleasesTheConnection() { ++ final ConcurrentMap pending = new ConcurrentHashMap<>(); ++ final ManualExecutor eventLoop = new ManualExecutor(); ++ final AtomicBoolean current = new AtomicBoolean(true); ++ final AtomicInteger ticks = new AtomicInteger(); ++ assertTrue(ChunkTrackerTickTask.submitTick(pending, "player", eventLoop, () -> { ++ if (!current.get()) { ++ return; ++ } ++ ticks.incrementAndGet(); ++ })); ++ current.set(false); ++ eventLoop.drain(); ++ assertEquals(0, ticks.get()); ++ assertTrue(pending.isEmpty()); ++ assertTrue(ChunkTrackerTickTask.submitTick(pending, "player", eventLoop, ticks::incrementAndGet)); ++ eventLoop.drain(); ++ assertEquals(1, ticks.get()); ++ } ++ ++ @Test ++ void rejectedExecutorAndFailedCallbackDoNotPermanentlySuppressTicks() { ++ final ConcurrentMap pending = new ConcurrentHashMap<>(); ++ final ManualExecutor eventLoop = new ManualExecutor(); ++ final Executor rejected = _ -> { ++ throw new RejectedExecutionException(); ++ }; ++ assertFalse(ChunkTrackerTickTask.submitTick(pending, "player", rejected, () -> { })); ++ assertTrue(pending.isEmpty()); ++ assertTrue(ChunkTrackerTickTask.submitTick(pending, "player", eventLoop, () -> { ++ throw new IllegalStateException(); ++ })); ++ assertThrows(IllegalStateException.class, eventLoop::drain); ++ assertTrue(pending.isEmpty()); ++ final Executor failedSubmission = _ -> { ++ throw new IllegalStateException(); ++ }; ++ assertThrows(IllegalStateException.class, ++ () -> ChunkTrackerTickTask.submitTick(pending, "player", failedSubmission, () -> { })); ++ assertTrue(pending.isEmpty()); ++ final AtomicInteger ticks = new AtomicInteger(); ++ assertTrue(ChunkTrackerTickTask.submitTick(pending, "player", eventLoop, ticks::incrementAndGet)); ++ eventLoop.drain(); ++ assertEquals(1, ticks.get()); ++ } ++ ++ @Test ++ void failedInlineExecutionRetainsReplacementReservation() { ++ final ConcurrentMap pending = new ConcurrentHashMap<>(); ++ final ManualExecutor eventLoop = new ManualExecutor(); ++ final AtomicInteger ticks = new AtomicInteger(); ++ final Executor inline = callback -> { ++ try { ++ callback.run(); ++ } catch (final IllegalStateException failure) { ++ assertTrue(ChunkTrackerTickTask.submitTick(pending, "player", eventLoop, ticks::incrementAndGet)); ++ throw failure; ++ } ++ }; ++ assertThrows(IllegalStateException.class, () -> ChunkTrackerTickTask.submitTick(pending, "player", inline, () -> { ++ throw new IllegalStateException(); ++ })); ++ assertFalse(ChunkTrackerTickTask.submitTick(pending, "player", eventLoop, ticks::incrementAndGet)); ++ assertEquals(1, eventLoop.tasks.size()); ++ eventLoop.drain(); ++ assertEquals(1, ticks.get()); ++ assertTrue(pending.isEmpty()); ++ } ++ ++ private static final class ManualExecutor implements Executor { ++ ++ private final Queue tasks = new ArrayDeque<>(); ++ ++ @Override ++ public void execute(final Runnable task) { ++ this.tasks.add(task); ++ } ++ ++ private void drain() { ++ while (!this.tasks.isEmpty()) { ++ this.tasks.remove().run(); ++ } ++ } ++ ++ } ++ ++} -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.pr.md b/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.pr.md index 4c86245..fc68191 100644 --- a/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.pr.md +++ b/patches/viabedrock/upstreamable/0030-propagate-increased-block-light-incrementally.pr.md @@ -65,3 +65,9 @@ The add-on build passes with 605 tests, including 117 fixture skips. Neither sui ViaProxy builds successfully. All 1,250 core files match both downstream bundles, except their manifests. The emission regressions fail with the old rules and pass with the corrected rules. These checks do not include a new live Java/native screenshot comparison. + +## Coalesce queued chunk ticks + +Each connection now has at most one queued or executing chunk tick. A token owns the reservation until its callback finishes, so a busy connection cannot accumulate repeated scheduler callbacks. Rejection and exceptions release the reservation; an older rejected submission cannot remove a newer reservation. Later dimension-replacement guards remain inside the callback. + +Targeted regressions cover pending and running work, independent connections, rejection, exceptions, and reservation ownership. The full core build and Checkstyle pass with 1,144 passing tests and 30 skips. Tick coalescing bounds queued work; it does not establish a live latency improvement for every lighting workload. diff --git a/patches/viabedrock/upstreamable/0033-refresh-connected-door-states.patch b/patches/viabedrock/upstreamable/0033-refresh-connected-door-states.patch index cdf1741..6e41bcf 100644 --- a/patches/viabedrock/upstreamable/0033-refresh-connected-door-states.patch +++ b/patches/viabedrock/upstreamable/0033-refresh-connected-door-states.patch @@ -1,4 +1,4 @@ -From 8e2ef11816ed1119ad7cb126c408759ce4ba71ab Mon Sep 17 00:00:00 2001 +From 9275c684e765648f42986eb0b8511423d73baacc Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:58:08 +0200 Subject: [PATCH] fix(world): refresh connected door states @@ -6,9 +6,9 @@ Subject: [PATCH] fix(world): refresh connected door states Bedrock can update a door lower half without updating its upper half. Derive the upper open state from the lower block, send an upper-half update when the lower half changes, and apply the same connection while converting chunk palettes. --- .../protocol/packet/WorldPackets.java | 2 + - .../protocol/storage/ChunkTracker.java | 44 ++++++++++++++++++- - .../protocol/storage/ChunkTrackerTest.java | 10 +++++ - 3 files changed, 55 insertions(+), 1 deletion(-) + .../protocol/storage/ChunkTracker.java | 62 ++++++++++++++++--- + .../protocol/storage/ChunkTrackerTest.java | 10 +++ + 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/WorldPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/WorldPackets.java index 943565702..f4f3768d1 100644 @@ -31,7 +31,7 @@ index 943565702..f4f3768d1 100644 protocol.registerClientbound(ClientboundBedrockPackets.BLOCK_ENTITY_DATA, ClientboundPackets26_3.BLOCK_ENTITY_DATA, new PacketHandlers() { @Override diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java -index 07b236527..d18fcef2a 100644 +index 132a4c34e..93084f1d6 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -252,6 +252,12 @@ public class ChunkTracker extends StoredObject { @@ -95,17 +95,35 @@ index 07b236527..d18fcef2a 100644 + } + private void refreshNeighboringConnectedBorders(final int chunkX, final int chunkZ) { - final boolean westLoaded = this.chunkLight.containsKey(ChunkPosition.chunkKey(chunkX - 1, chunkZ)); - final boolean eastLoaded = this.chunkLight.containsKey(ChunkPosition.chunkKey(chunkX + 1, chunkZ)); -@@ -1350,7 +1392,7 @@ public class ChunkTracker extends StoredObject { - for (int x = 0; x < 16; x++) { - final int javaId = remappedBlockPalette.idAt(x, y, z); - final BlockState state = BedrockProtocol.MAPPINGS.getJavaBlockStates().inverse().get(javaId); -- if (ConnectedBlockShapes.isConnected(state)) { -+ if (ConnectedBlockShapes.isConnected(state) || isUpperDoor(state)) { - final BlockPosition position = new BlockPosition((chunk.getX() << 4) + x, - this.minY + (idx << 4) + y, (chunk.getZ() << 4) + z); - remappedBlockPalette.setIdAt(x, y, z, this.getJavaBlockState(position)); + this.refreshConnectedBorder(chunkX - 1, chunkZ, 15, 16, 0, 16); + this.refreshConnectedBorder(chunkX + 1, chunkZ, 0, 1, 0, 16); +@@ -1349,15 +1391,17 @@ public class ChunkTracker extends StoredObject { + } + + final int sectionY = this.minY + (idx << 4); +- ConnectedBlockShapes.forEachCandidate(remappedBlockPalette, id -> ConnectedBlockShapes.isConnected( +- BedrockProtocol.MAPPINGS.getJavaBlockStates().inverse().get(id)), 0, 16, 0, 16, local -> { +- final int x = local & 15; +- final int y = local >> 8; +- final int z = (local >> 4) & 15; +- final BlockPosition position = new BlockPosition((chunk.getX() << 4) + x, +- sectionY + y, (chunk.getZ() << 4) + z); +- remappedBlockPalette.setIdAt(x, y, z, this.getJavaBlockState(position)); +- }); ++ ConnectedBlockShapes.forEachCandidate(remappedBlockPalette, id -> { ++ final BlockState state = BedrockProtocol.MAPPINGS.getJavaBlockStates().inverse().get(id); ++ return ConnectedBlockShapes.isConnected(state) || isUpperDoor(state); ++ }, 0, 16, 0, 16, local -> { ++ final int x = local & 15; ++ final int y = local >> 8; ++ final int z = (local >> 4) & 15; ++ final BlockPosition position = new BlockPosition((chunk.getX() << 4) + x, ++ sectionY + y, (chunk.getZ() << 4) + z); ++ remappedBlockPalette.setIdAt(x, y, z, this.getJavaBlockState(position)); ++ }); + + int nonAirBlockCount = 0; + int fluidCount = 0; diff --git a/src/test/java/net/raphimc/viabedrock/protocol/storage/ChunkTrackerTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/ChunkTrackerTest.java index 17bc55d4d..aae5aea28 100644 --- a/src/test/java/net/raphimc/viabedrock/protocol/storage/ChunkTrackerTest.java @@ -135,4 +153,4 @@ index 17bc55d4d..aae5aea28 100644 final BedrockChunkSectionImpl section = new BedrockChunkSectionImpl(); final BedrockDataPalette palette = new BedrockDataPalette(); -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viabedrock/upstreamable/0054-render-textured-bedrock-full-cubes.patch b/patches/viabedrock/upstreamable/0054-render-textured-bedrock-full-cubes.patch index b5aeb7a..28ebbb7 100644 --- a/patches/viabedrock/upstreamable/0054-render-textured-bedrock-full-cubes.patch +++ b/patches/viabedrock/upstreamable/0054-render-textured-bedrock-full-cubes.patch @@ -1,4 +1,4 @@ -From d2baca40f7df778b9b9b944a447f676c863e4ccd Mon Sep 17 00:00:00 2001 +From 0e9aac7af3446375fa327dbdcd8da782cec6770a Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:38:26 +0200 Subject: [PATCH] feat(blocks): render Bedrock state geometry and materials @@ -1326,7 +1326,7 @@ index ba8c2db57..5c6717bea 100644 } diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java -index 07d8bd362..77927fbdb 100644 +index a6857de88..6ed04ed5d 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java @@ -53,7 +53,7 @@ import java.util.logging.Level; @@ -1338,16 +1338,16 @@ index 07d8bd362..77927fbdb 100644 // Advance when the set or format of emitted resources changes independently of converter profiles. private static final int RESOURCE_FORMAT_VERSION = 6; -@@ -90,7 +90,7 @@ public class ConvertedResourcePackCache { +@@ -101,7 +101,7 @@ public class ConvertedResourcePackCache { if (this.mode == ViaBedrockConfig.PackCacheMode.DISABLED) { - return CompletableFuture.supplyAsync(() -> this.convert(storage), this.executor); + return this.executor.submit(() -> this.convert(storage)); } -- return CompletableFuture.supplyAsync(() -> fingerprint(storage.getPackStackTopToBottom()), this.executor).thenCompose(key -> { -+ return CompletableFuture.supplyAsync(() -> fingerprint(storage), this.executor).thenCompose(key -> { - final CompletableFuture future = this.pending.computeIfAbsent(key, ignored -> CompletableFuture.supplyAsync(() -> - this.mode == ViaBedrockConfig.PackCacheMode.DISK ? this.loadOrConvert(key, storage) : this.convert(storage), this.executor)); - return future.thenApply(pack -> { -@@ -229,6 +229,16 @@ public class ConvertedResourcePackCache { +- return this.executor.submit(() -> fingerprint(storage.getPackStackTopToBottom())).thenCompose(key -> ++ return this.executor.submit(() -> fingerprint(storage)).thenCompose(key -> + this.prepare(key, storage).thenApply(pack -> { + storage.getConverterData().putAll(pack.converterData()); + return pack; +@@ -287,6 +287,16 @@ public class ConvertedResourcePackCache { return HexFormat.of().formatHex(digest.digest()); } @@ -1490,7 +1490,7 @@ index 3562ee17a..2bc019200 100644 final JoinGameStorage joinGameStorage = user.get(JoinGameStorage.class); final GameSessionStorage gameSession = user.get(GameSessionStorage.class); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java -index 376a1a04b..5a54fc635 100644 +index 1b3836ee0..5a5017f3c 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java @@ -39,6 +39,7 @@ import net.raphimc.viabedrock.protocol.provider.ResourcePackProvider; @@ -1507,9 +1507,9 @@ index 376a1a04b..5a54fc635 100644 import java.util.UUID; +import java.util.function.Consumer; import java.util.logging.Level; - - public final class ResourcePackPackets { -@@ -208,8 +210,24 @@ public final class ResourcePackPackets { + import java.util.concurrent.CompletableFuture; + import java.util.function.Function; +@@ -214,8 +216,24 @@ public final class ResourcePackPackets { }); protocol.registerServerboundTransition(ServerboundConfigurationPackets1_21_9.RESOURCE_PACK, ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE, wrapper -> { @@ -1535,7 +1535,7 @@ index 376a1a04b..5a54fc635 100644 switch (action) { case SUCCESSFULLY_LOADED -> { final ResourcePackStorage resourcePackStorage = wrapper.user().get(ResourcePackStorage.class); -@@ -282,6 +300,30 @@ public final class ResourcePackPackets { +@@ -304,6 +322,30 @@ public final class ResourcePackPackets { response.scheduleSendToServer(BedrockProtocol.class); } @@ -2900,7 +2900,7 @@ index 000000000..57bfb204d + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java -index 8ad56c394..ecd155033 100644 +index 08325181b..cc98d2d41 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java @@ -52,6 +52,7 @@ import net.raphimc.viabedrock.api.chunk.section.BedrockChunkSection; @@ -2988,7 +2988,7 @@ index 8ad56c394..ecd155033 100644 final int previousEmission = emission[previousState] & 0xFF; final int currentEmission = emission[currentState] & 0xFF; if (opacity[previousState] != opacity[currentState] || currentEmission <= previousEmission) { -@@ -955,7 +976,7 @@ public class ChunkTracker extends StoredObject { +@@ -958,7 +979,7 @@ public class ChunkTracker extends StoredObject { final BitSet[][] secondaryWater = new BitSet[9][]; secondaryWater[4] = this.snapshotSecondaryWater(chunk); return LightEngine.computeCenterLight(regionStates, secondaryWater, this.worldHeight >> 4, this.skyLight, @@ -2997,7 +2997,7 @@ index 8ad56c394..ecd155033 100644 } /** -@@ -1034,7 +1055,7 @@ public class ChunkTracker extends StoredObject { +@@ -1037,7 +1058,7 @@ public class ChunkTracker extends StoredObject { this.pendingLightComputations.add(chunkKey); task.execute(LIGHT_EXECUTOR, this.user().getChannel().eventLoop(), () -> LightEngine.computeCenterLight(snapshot.states(), snapshot.secondaryWater(), this.worldHeight >> 4, this.skyLight, @@ -3765,7 +3765,7 @@ index 000000000..9b0790e12 + +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java -index 4ea0f89f7..3daadbd87 100644 +index d88fe5cef..1d87d0e62 100644 --- a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java @@ -17,7 +17,9 @@ @@ -3778,7 +3778,7 @@ index 4ea0f89f7..3daadbd87 100644 import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; import net.raphimc.viabedrock.api.resourcepack.content.Content; import net.raphimc.viabedrock.api.resourcepack.content.ZipContent; -@@ -145,6 +147,33 @@ class ConvertedResourcePackCacheTest { +@@ -147,6 +149,33 @@ class ConvertedResourcePackCacheTest { } } @@ -5531,4 +5531,4 @@ index 000000000..7359689d9 + +} -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viabedrock/upstreamable/0063-load-archives-with-a-single-pack-directory.patch b/patches/viabedrock/upstreamable/0063-load-archives-with-a-single-pack-directory.patch index 776aba1..ece3f03 100644 --- a/patches/viabedrock/upstreamable/0063-load-archives-with-a-single-pack-directory.patch +++ b/patches/viabedrock/upstreamable/0063-load-archives-with-a-single-pack-directory.patch @@ -1,4 +1,4 @@ -From 1204faceccee677da38f784884d5e4e6a88fa9ee Mon Sep 17 00:00:00 2001 +From b3a99ab2709866c43d296454ec96344b5047722e Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:53:41 +0200 Subject: [PATCH] fix(resource-packs): load archives with a single pack @@ -55,19 +55,19 @@ index ebab0034b..e4632f65f 100644 throw new IllegalStateException("Missing manifest.json"); } diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ZipContent.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ZipContent.java -index 4264b66bc..aaaba4ca2 100644 +index d16e08e63..a16028bbf 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ZipContent.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ZipContent.java @@ -29,7 +29,8 @@ public class ZipContent extends InMemoryContent { - ZipEntry zipEntry; - while ((zipEntry = zipInputStream.getNextEntry()) != null) { - if (!zipEntry.isDirectory()) { -- this.content.put(zipEntry.getName(), zipInputStream.readAllBytes()); -+ final String path = zipEntry.getName().replace('\\', '/').replaceAll("/{2,}", "/"); -+ this.content.put(path, zipInputStream.readAllBytes()); + ZipEntry zipEntry; + while ((zipEntry = zipInputStream.getNextEntry()) != null) { + if (!zipEntry.isDirectory()) { +- this.content.put(zipEntry.getName(), zipInputStream.readAllBytes()); ++ final String path = zipEntry.getName().replace('\\', '/').replaceAll("/{2,}", "/"); ++ this.content.put(path, zipInputStream.readAllBytes()); + } } } - } diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackArchiveTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/ResourcePackArchiveTest.java new file mode 100644 index 000000000..1ffcc9dd1 @@ -164,4 +164,4 @@ index 000000000..1ffcc9dd1 + +} -- -2.55.0 +2.50.1 (Apple Git-155) diff --git a/patches/viabedrock/upstreamable/0065-discard-replaced-dimension-work.patch b/patches/viabedrock/upstreamable/0065-discard-replaced-dimension-work.patch index 6f885fc..5049a4e 100644 --- a/patches/viabedrock/upstreamable/0065-discard-replaced-dimension-work.patch +++ b/patches/viabedrock/upstreamable/0065-discard-replaced-dimension-work.patch @@ -1,4 +1,4 @@ -From 1631b80b7ae0b5ded14d4b3341c5ee561606898c Mon Sep 17 00:00:00 2001 +From d6c96811238fce20848ec25c418a69e611515005 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:22:35 +0200 Subject: [PATCH] fix(chunks): discard work from replaced dimension trackers @@ -14,10 +14,10 @@ is reproducible from the saved scene without another public connection. 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java -index ecd155033..08b0e243c 100644 +index cc98d2d41..0c74c5580 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ChunkTracker.java -@@ -1057,7 +1057,7 @@ public class ChunkTracker extends StoredObject { +@@ -1060,7 +1060,7 @@ public class ChunkTracker extends StoredObject { () -> LightEngine.computeCenterLight(snapshot.states(), snapshot.secondaryWater(), this.worldHeight >> 4, this.skyLight, this.blockLightEmission, this.blockOpacity), (result, error) -> { this.pendingLightComputations.remove(chunkKey); @@ -27,13 +27,13 @@ index ecd155033..08b0e243c 100644 } if (error != null) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTask.java b/src/main/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTask.java -index fe5c0a709..e35cc7104 100644 +index 4ff6b7c1a..799519832 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTask.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/task/ChunkTrackerTickTask.java -@@ -30,7 +30,8 @@ public class ChunkTrackerTickTask implements Runnable { +@@ -37,7 +37,8 @@ public class ChunkTrackerTickTask implements Runnable { final ChunkTracker chunkTracker = info.get(ChunkTracker.class); if (chunkTracker != null) { - info.getChannel().eventLoop().submit(() -> { + submitTick(this.pendingTicks, info, info.getChannel().eventLoop(), () -> { - if (!info.getChannel().isActive()) { + // A dimension change can replace this tracker while its tick is queued. + if (!info.getChannel().isActive() || info.get(ChunkTracker.class) != chunkTracker) { @@ -41,4 +41,4 @@ index fe5c0a709..e35cc7104 100644 } -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viabedrock/upstreamable/0068-resolve-licensed-native-image-fallbacks.patch b/patches/viabedrock/upstreamable/0068-resolve-licensed-native-image-fallbacks.patch index 8e55427..04d67e2 100644 --- a/patches/viabedrock/upstreamable/0068-resolve-licensed-native-image-fallbacks.patch +++ b/patches/viabedrock/upstreamable/0068-resolve-licensed-native-image-fallbacks.patch @@ -1,4 +1,4 @@ -From 5b9e6c09c45b6238b63dfcb8389a7f58e3fa3da7 Mon Sep 17 00:00:00 2001 +From 9c70e4fd22f434c4268b96c2e6a0f6257b87ca4f Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:58:59 +0200 Subject: [PATCH] feat(resource-packs): resolve licensed native image fallbacks @@ -49,18 +49,18 @@ index e7792a28a..4d2f6dacb 100644 providers.register(EmoteProvider.class, new EmoteProvider()); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java -index 5a54fc635..57976037b 100644 +index 5a5017f3c..b0b619bb3 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java -@@ -274,7 +274,7 @@ public final class ResourcePackPackets { +@@ -313,7 +313,7 @@ public final class ResourcePackPackets { ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Missing resource pack: " + key); } } -- final ResourcePackStorage storage = new ResourcePackStorage(resourcePacks); -+ final ResourcePackStorage storage = new ResourcePackStorage(resourcePacks, loadStateTracker.getBuiltinResourcePacks()); - user.put(storage); +- return new ResourcePackStorage(resourcePacks); ++ return new ResourcePackStorage(resourcePacks, loadStateTracker.getBuiltinResourcePacks()); + } - if (shouldTranslate(user)) { + private static void sendStackFinished(final UserConnection user) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/provider/BuiltinResourcePackProvider.java b/src/main/java/net/raphimc/viabedrock/protocol/provider/BuiltinResourcePackProvider.java new file mode 100644 index 000000000..3e2a05127 @@ -94,10 +94,10 @@ index 000000000..3e2a05127 + +} diff --git a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java -index 47cf78aef..329d12723 100644 +index 53a65a908..4ed8a5d2d 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java -@@ -28,6 +28,7 @@ import net.raphimc.viabedrock.api.resourcepack.http.BedrockPackDownloader; +@@ -29,6 +29,7 @@ import net.raphimc.viabedrock.api.resourcepack.http.BedrockPackDownloader; import net.raphimc.viabedrock.protocol.BedrockProtocol; import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; import net.raphimc.viabedrock.protocol.packet.ResourcePackClientResponse; @@ -105,15 +105,15 @@ index 47cf78aef..329d12723 100644 import net.raphimc.viabedrock.protocol.provider.ResourcePackProvider; import java.net.URL; -@@ -49,6 +50,7 @@ public class ResourcePackLoadStateTracker extends StoredObject { +@@ -53,6 +54,7 @@ public class ResourcePackLoadStateTracker extends StoredObject { return thread; }, null, true); private final CompletableFuture loadFuture = new CompletableFuture<>(); + private CompletableFuture> builtinFuture = CompletableFuture.completedFuture(List.of()); private volatile boolean stackReceived; + private ResourcePackExecutor preparationExecutor; - public ResourcePackLoadStateTracker(final UserConnection user, final ResourcePackLoadStateTracker.Info[] infos) { -@@ -86,9 +88,16 @@ public class ResourcePackLoadStateTracker extends StoredObject { +@@ -106,9 +108,16 @@ public class ResourcePackLoadStateTracker extends StoredObject { } public CompletableFuture loadRequestedResourcePacks() { @@ -131,7 +131,7 @@ index 47cf78aef..329d12723 100644 } final List> asyncTasks = new ArrayList<>(); final List downloadList = Collections.synchronizedList(new ArrayList<>()); -@@ -147,7 +156,7 @@ public class ResourcePackLoadStateTracker extends StoredObject { +@@ -167,7 +176,7 @@ public class ResourcePackLoadStateTracker extends StoredObject { this.loadFuture.completeExceptionally(e); return null; }); @@ -140,7 +140,7 @@ index 47cf78aef..329d12723 100644 } public void loadUnrequestedResourcePacks(final ResourcePack.Key[] keys) { -@@ -168,7 +177,11 @@ public class ResourcePackLoadStateTracker extends StoredObject { +@@ -188,7 +197,11 @@ public class ResourcePackLoadStateTracker extends StoredObject { } public CompletableFuture loadedFuture() { @@ -265,7 +265,7 @@ index 000000000..b508d8527 + +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java -index 3daadbd87..a764ca59c 100644 +index 1d87d0e62..4fdf1a0ef 100644 --- a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java @@ -29,6 +29,7 @@ import net.raphimc.viabedrock.platform.ViaBedrockConfig; @@ -276,7 +276,7 @@ index 3daadbd87..a764ca59c 100644 import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; -@@ -191,6 +192,27 @@ class ConvertedResourcePackCacheTest { +@@ -193,6 +194,27 @@ class ConvertedResourcePackCacheTest { assertNotEquals(ConvertedResourcePackCache.fingerprint(List.of(original)), ConvertedResourcePackCache.fingerprint(List.of(updated))); } @@ -394,4 +394,4 @@ index 5d513b681..0e7bbc30f 100644 final Content result = new InMemoryContent(); rewriter.submitTasks(storage, task -> result.putAll(task.get())); -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viabedrock/upstreamable/0072-convert-native-sidebar-title-images-and-styled-scores.patch b/patches/viabedrock/upstreamable/0072-convert-native-sidebar-title-images-and-styled-scores.patch index 45c8165..49c47d4 100644 --- a/patches/viabedrock/upstreamable/0072-convert-native-sidebar-title-images-and-styled-scores.patch +++ b/patches/viabedrock/upstreamable/0072-convert-native-sidebar-title-images-and-styled-scores.patch @@ -1,4 +1,4 @@ -From 84e0e211f101e3ca4d1d16ad4c8d7275248a3570 Mon Sep 17 00:00:00 2001 +From 975acba50f3e83608ab82cc956e5e77dec3d299b Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:06:00 +0200 Subject: [PATCH] feat(scoreboard): convert native sidebar title images and @@ -31,7 +31,7 @@ Tests cover default aspect preservation, explicit stretching, metadata dimension .../model/scoreboard/ScoreboardObjective.java | 22 +- .../definition/ScoreboardUiDefinitions.java | 430 ++++++++++++++++++ .../definition/TextFormattingDefinitions.java | 109 +++++ - .../http/ConvertedResourcePackCache.java | 22 +- + .../http/ConvertedResourcePackCache.java | 29 +- .../raphimc/viabedrock/api/util/TextUtil.java | 30 +- .../protocol/packet/HudPackets.java | 38 +- .../rewriter/ResourcePackRewriter.java | 2 + @@ -43,7 +43,7 @@ Tests cover default aspect preservation, explicit stretching, metadata dimension .../http/ConvertedResourcePackCacheTest.java | 32 ++ .../api/util/TextPaletteFormattingTest.java | 39 ++ .../protocol/packet/HudPacketsTest.java | 39 ++ - 16 files changed, 1427 insertions(+), 20 deletions(-) + 16 files changed, 1433 insertions(+), 21 deletions(-) create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/ScoreboardUiDefinitions.java create mode 100644 src/main/java/net/raphimc/viabedrock/api/resourcepack/definition/TextFormattingDefinitions.java create mode 100644 src/main/java/net/raphimc/viabedrock/protocol/rewriter/resourcepack/ScoreboardUiResourceRewriter.java @@ -676,7 +676,7 @@ index 000000000..72539af5b + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java -index 068fcc97d..fe3125938 100644 +index dc02e950c..460205e38 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java @@ -53,11 +53,12 @@ import java.util.logging.Level; @@ -692,29 +692,40 @@ index 068fcc97d..fe3125938 100644 + private final String conversionVersion; private final ViaBedrockConfig.PackCacheMode mode; private final Function converter; - private final ExecutorService executor = Executors.newFixedThreadPool(Math.max(1, Math.min(2, Runtime.getRuntime().availableProcessors())), task -> { -@@ -81,6 +82,11 @@ public class ConvertedResourcePackCache { + private static final int MAX_COMPLETED_PACKS = 32; +@@ -85,11 +86,21 @@ public class ConvertedResourcePackCache { } ConvertedResourcePackCache(final Path directory, final ViaBedrockConfig.PackCacheMode mode, final Function converter) { -+ this(directory, mode, converter, CONVERSION_VERSION); +- this(directory, mode, converter, MAX_COMPLETED_PACKS, MAX_COMPLETED_BYTES); ++ this(directory, mode, converter, CONVERSION_VERSION, MAX_COMPLETED_PACKS, MAX_COMPLETED_BYTES); + } + + ConvertedResourcePackCache(final Path directory, final ViaBedrockConfig.PackCacheMode mode, final Function converter, final String conversionVersion) { ++ this(directory, mode, converter, conversionVersion, MAX_COMPLETED_PACKS, MAX_COMPLETED_BYTES); + } + + ConvertedResourcePackCache(final Path directory, final ViaBedrockConfig.PackCacheMode mode, final Function converter, + final int maxCompletedPacks, final long maxCompletedBytes) { ++ this(directory, mode, converter, CONVERSION_VERSION, maxCompletedPacks, maxCompletedBytes); ++ } ++ ++ private ConvertedResourcePackCache(final Path directory, final ViaBedrockConfig.PackCacheMode mode, final Function converter, ++ final String conversionVersion, final int maxCompletedPacks, final long maxCompletedBytes) { + this.conversionVersion = conversionVersion; this.directory = directory; this.mode = mode; this.converter = converter; -@@ -90,7 +96,7 @@ public class ConvertedResourcePackCache { +@@ -101,7 +112,7 @@ public class ConvertedResourcePackCache { if (this.mode == ViaBedrockConfig.PackCacheMode.DISABLED) { - return CompletableFuture.supplyAsync(() -> this.convert(storage), this.executor); + return this.executor.submit(() -> this.convert(storage)); } -- return CompletableFuture.supplyAsync(() -> fingerprint(storage), this.executor).thenCompose(key -> { -+ return CompletableFuture.supplyAsync(() -> fingerprint(storage, this.conversionVersion), this.executor).thenCompose(key -> { - final CompletableFuture future = this.pending.computeIfAbsent(key, ignored -> CompletableFuture.supplyAsync(() -> - this.mode == ViaBedrockConfig.PackCacheMode.DISK ? this.loadOrConvert(key, storage) : this.convert(storage), this.executor)); - return future.thenApply(pack -> { -@@ -206,11 +212,15 @@ public class ConvertedResourcePackCache { +- return this.executor.submit(() -> fingerprint(storage)).thenCompose(key -> ++ return this.executor.submit(() -> fingerprint(storage, this.conversionVersion)).thenCompose(key -> + this.prepare(key, storage).thenApply(pack -> { + storage.getConverterData().putAll(pack.converterData()); + return pack; +@@ -264,11 +275,15 @@ public class ConvertedResourcePackCache { } static String fingerprint(final Collection packs) { @@ -731,7 +742,7 @@ index 068fcc97d..fe3125938 100644 for (ResourcePack pack : packs) { writeString(output, pack.key().toString()); final List paths = new ArrayList<>(pack.content().getFilesDeep("", "")); -@@ -230,7 +240,11 @@ public class ConvertedResourcePackCache { +@@ -288,7 +303,11 @@ public class ConvertedResourcePackCache { } static String fingerprint(final ResourcePackStorage storage) { @@ -1649,10 +1660,10 @@ index 000000000..6f0ed29be + +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java -index a764ca59c..8b6981215 100644 +index 4fdf1a0ef..7883b9646 100644 --- a/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java -@@ -175,6 +175,38 @@ class ConvertedResourcePackCacheTest { +@@ -177,6 +177,38 @@ class ConvertedResourcePackCacheTest { assertEquals(original, ConvertedResourcePackCache.fingerprint(storage)); } @@ -1802,4 +1813,4 @@ index dfd42cd14..8199a3e78 100644 private final Map entries = new HashMap<>(); -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viabedrock/upstreamable/0095-resolve-authored-bedrock-server-form-scenes.patch b/patches/viabedrock/upstreamable/0095-resolve-authored-bedrock-server-form-scenes.patch index 59eba9f..6b68c57 100644 --- a/patches/viabedrock/upstreamable/0095-resolve-authored-bedrock-server-form-scenes.patch +++ b/patches/viabedrock/upstreamable/0095-resolve-authored-bedrock-server-form-scenes.patch @@ -1,4 +1,4 @@ -From f956b2fcf9ed54d099b5c9a51a3efc9bd5eb3b00 Mon Sep 17 00:00:00 2001 +From 626217371c505e1a8faa10b556df05200a889961 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:42:36 +0200 Subject: [PATCH] feat(ui): resolve authored Bedrock server UI scenes @@ -1065,7 +1065,7 @@ index 000000000..0e2c78baf + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java -index fe3125938..992f4aff8 100644 +index 460205e38..ce194fe39 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java @@ -17,6 +17,7 @@ @@ -1075,7 +1075,7 @@ index fe3125938..992f4aff8 100644 +import net.raphimc.viabedrock.api.resourcepack.ui.JsonUiProgram; import net.raphimc.viabedrock.ViaBedrock; import net.raphimc.viabedrock.api.resourcepack.ResourcePack; - import net.raphimc.viabedrock.api.resourcepack.content.Content; + import net.raphimc.viabedrock.api.resourcepack.ResourcePackExecutor; @@ -53,7 +54,7 @@ import java.util.logging.Level; public class ConvertedResourcePackCache { @@ -1085,7 +1085,7 @@ index fe3125938..992f4aff8 100644 // Advance when the set or format of emitted resources changes independently of converter profiles. private static final int RESOURCE_FORMAT_VERSION = 6; -@@ -245,6 +246,7 @@ public class ConvertedResourcePackCache { +@@ -308,6 +309,7 @@ public class ConvertedResourcePackCache { private static String fingerprint(final ResourcePackStorage storage, final String conversionVersion) { final StringBuilder identity = new StringBuilder(fingerprint(storage.getPackStackTopToBottom(), conversionVersion)); @@ -11488,4 +11488,4 @@ index 000000000..1c73aac64 + +} -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.patch b/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.patch index 884064c..4db6459 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.patch @@ -1,4 +1,4 @@ -From aa125fd6ba32c3eb5f46a49cf1eafb52f843deb8 Mon Sep 17 00:00:00 2001 +From 98821a8f6b41f2aacd4da4654c41150f925c1f43 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:53:07 +0200 Subject: [PATCH] feat(bedrock): render custom entities and player skins @@ -418,6 +418,8 @@ portraits and animated persona faces remain separate rendering work. Pixel tests cover both geometry dimensions on the same double-size image. Retain the difference between native and Java vehicle yaw when applying rider anchors. Boat Java yaw is offset by ninety degrees, but the native rider seat remains defined in native vehicle coordinates. Directional boat anchor tests cover quarter and half turns. + +Performance: Reuse resolved custom actor draw lists while the authoritative snapshot, resource generation, and Nether lighting are unchanged. Bound the access-order cache to 1,024 actors and invalidate it on replacement, removal, or reload. This removes repeated geometry partitioning, texture lookups, allocations, and missing-asset logging from unchanged frames. Seven JUnit regressions cover invalidation, LRU limits, failure retry, and 144 frames requiring one resolution. --- build.gradle.kts | 6 + .../bedrock/ViaFabricPlusBedrock.java | 17 ++ @@ -447,13 +449,14 @@ Retain the difference between native and Java vehicle yaw when applying rider an .../bedrock/render/BedrockActorResources.java | 43 +++ .../render/BedrockActorUvTransform.java | 18 ++ .../bedrock/render/BedrockEmissiveMask.java | 27 ++ - .../bedrock/render/BedrockEntityRenderer.java | 185 ++++++++++++ + .../bedrock/render/BedrockEntityRenderer.java | 192 ++++++++++++ .../bedrock/render/BedrockGeometry.java | 215 +++++++++++++ .../bedrock/render/BedrockModelCube.java | 35 +++ .../bedrock/render/BedrockNameTagAnchors.java | 75 +++++ .../bedrock/render/BedrockNameTags.java | 76 +++++ .../bedrock/render/BedrockPlayerRenderer.java | 53 ++++ .../bedrock/render/BedrockPlayerSkins.java | 283 ++++++++++++++++++ + .../render/BedrockRenderSnapshotCache.java | 48 +++ .../bedrock/render/BedrockRidingAnchors.java | 73 +++++ .../bedrock/render/BedrockShaderEmission.java | 181 +++++++++++ .../render/BedrockShaderPipelines.java | 28 ++ @@ -477,11 +480,12 @@ Retain the difference between native and Java vehicle yaw when applying rider an .../render/BedrockNameTagAnchorsTest.java | 120 ++++++++ .../bedrock/render/BedrockNameTagsTest.java | 127 ++++++++ .../render/BedrockPlayerMaterialsTest.java | 42 +++ + .../BedrockRenderSnapshotCacheTest.java | 123 ++++++++ .../render/BedrockRidingAnchorsTest.java | 149 +++++++++ .../render/BedrockShaderPipelinesTest.java | 49 +++ .../bedrock/render/BedrockSkinImagesTest.java | 82 +++++ .../render/CustomEntityRenderStoreTest.java | 62 ++++ - 62 files changed, 4015 insertions(+), 1 deletion(-) + 64 files changed, 4193 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockActorRenderType.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/access/IBedrockDynamicTexture.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/compat/iris/MixinIrisPipelines.java @@ -515,6 +519,7 @@ Retain the difference between native and Java vehicle yaw when applying rider an create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockNameTags.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerRenderer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockRenderSnapshotCache.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockRidingAnchors.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderEmission.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelines.java @@ -535,6 +540,7 @@ Retain the difference between native and Java vehicle yaw when applying rider an create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockNameTagAnchorsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockNameTagsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerMaterialsTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockRenderSnapshotCacheTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockRidingAnchorsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockShaderPipelinesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockSkinImagesTest.java @@ -1899,10 +1905,10 @@ index 0000000..8017f31 +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java new file mode 100644 -index 0000000..66bd0e5 +index 0000000..8c62842 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java -@@ -0,0 +1,185 @@ +@@ -0,0 +1,192 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors @@ -1933,6 +1939,7 @@ index 0000000..66bd0e5 +import java.util.LinkedHashMap; +import java.util.Set; +import java.util.Map; ++import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.client.model.Model; +import net.minecraft.client.renderer.SubmitNodeCollector; @@ -1955,6 +1962,7 @@ index 0000000..66bd0e5 +public final class BedrockEntityRenderer extends EntityRenderer { + + private final Map> models = new HashMap<>(); ++ private final BedrockRenderSnapshotCache> resolvedModels = new BedrockRenderSnapshotCache<>(1024); + private final Map renderTypes = new LinkedHashMap<>(16, 0.75F, true) { + @Override + protected boolean removeEldestEntry(final Map.Entry eldest) { @@ -1997,10 +2005,12 @@ index 0000000..66bd0e5 + this.materialDefinitions = null; + this.models.clear(); + this.renderTypes.clear(); ++ this.resolvedModels.clear(); + return; + } + final RenderData renderData = CustomEntityRenderStore.get(entity.getUUID()); + if (renderData == null) { ++ this.resolvedModels.remove(entity.getUUID()); + return; + } + if (this.currentPacks != packs) { @@ -2008,18 +2018,21 @@ index 0000000..66bd0e5 + this.materialDefinitions = BedrockActorMaterialDefinitions.load(packs); + this.models.clear(); + this.renderTypes.clear(); ++ this.resolvedModels.clear(); + } -+ state.models = this.resolveModels(renderData, packs); ++ final boolean netherLighting = BedrockActorRenderTypes.usesNetherLighting(); ++ state.models = this.resolvedModels.resolve(entity.getUUID(), renderData, packs, netherLighting, ++ () -> this.resolveModels(renderData, packs, netherLighting)); + state.scale = renderData.scale(); + state.yaw = entity.getYRot(); + } + + private List resolveModels( + final RenderData renderData, -+ final ResourcePackStorage packs ++ final ResourcePackStorage packs, ++ final boolean netherLighting + ) { + final List resolved = new ArrayList<>(); -+ final boolean netherLighting = BedrockActorRenderTypes.usesNetherLighting(); + for (var evaluated : renderData.models()) { + final BedrockGeometryModel geometry = packs.getModels().entityModels().get(evaluated.geometryValue()); + if (geometry == null) { @@ -2861,6 +2874,60 @@ index 0000000..45f54b4 + } + +} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockRenderSnapshotCache.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockRenderSnapshotCache.java +new file mode 100644 +index 0000000..3bf88f1 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockRenderSnapshotCache.java +@@ -0,0 +1,48 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++import java.util.LinkedHashMap; ++import java.util.Map; ++import java.util.function.Supplier; ++ ++/** Reuses resolved draws only while the authoritative snapshot and rendering inputs are unchanged. */ ++final class BedrockRenderSnapshotCache { ++ private final Map> entries; ++ ++ BedrockRenderSnapshotCache(final int maximumEntries) { ++ if (maximumEntries <= 0) throw new IllegalArgumentException("Invalid render cache capacity"); ++ this.entries = new LinkedHashMap<>(16, 0.75F, true) { ++ @Override ++ protected boolean removeEldestEntry(final Map.Entry> eldest) { ++ return this.size() > maximumEntries; ++ } ++ }; ++ } ++ ++ V resolve(final K owner, final S snapshot, final Object generation, final boolean netherLighting, ++ final Supplier resolver) { ++ final Entry cached = this.entries.get(owner); ++ // Snapshot identity avoids comparing entire model/material lists in the render hot path. ++ if (cached != null && cached.snapshot == snapshot && cached.generation == generation ++ && cached.netherLighting == netherLighting) return cached.value; ++ final V value = resolver.get(); ++ this.entries.put(owner, new Entry<>(snapshot, generation, netherLighting, value)); ++ return value; ++ } ++ ++ void remove(final K owner) { ++ this.entries.remove(owner); ++ } ++ ++ void clear() { ++ this.entries.clear(); ++ } ++ ++ private record Entry(S snapshot, Object generation, boolean netherLighting, V value) { ++ } ++} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockRidingAnchors.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockRidingAnchors.java new file mode 100644 index 0000000..79525c0 @@ -4602,6 +4669,135 @@ index 0000000..195df27 + BedrockActorRenderTypes.selectPipeline(false, true, false, true)); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockRenderSnapshotCacheTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockRenderSnapshotCacheTest.java +new file mode 100644 +index 0000000..4a9a4de +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockRenderSnapshotCacheTest.java +@@ -0,0 +1,123 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Map; ++import java.util.concurrent.atomic.AtomicInteger; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockRenderSnapshotCacheTest { ++ @Test ++ void resolvesRetainedSnapshotsOnceAcrossRepeatedFrames() { ++ final var cache = new BedrockRenderSnapshotCache>(8); ++ final Object snapshot = new Object(); ++ final Object generation = new Object(); ++ final AtomicInteger resolutions = new AtomicInteger(); ++ final var expected = cache.resolve("actor", snapshot, generation, false, () -> { ++ resolutions.incrementAndGet(); ++ return List.of("geometry.body"); ++ }); ++ ++ for (int frame = 0; frame < 144; frame++) { ++ assertSame(expected, cache.resolve("actor", snapshot, generation, false, () -> { ++ throw new AssertionError("Resolved an unchanged actor frame"); ++ })); ++ } ++ assertEquals(1, resolutions.get()); ++ } ++ ++ @Test ++ void replacesEqualSnapshotsAndKeepsActorsIndependent() { ++ final var cache = new BedrockRenderSnapshotCache, Object>(8); ++ final Object generation = new Object(); ++ final List before = new ArrayList<>(List.of("geometry.body")); ++ final List replacement = new ArrayList<>(before); ++ final Object first = new Object(); ++ final Object second = new Object(); ++ final Object updated = new Object(); ++ assertSame(first, cache.resolve("first", before, generation, false, () -> first)); ++ assertSame(second, cache.resolve("second", before, generation, false, () -> second)); ++ assertSame(updated, cache.resolve("first", replacement, generation, false, () -> updated)); ++ assertSame(second, cache.resolve("second", before, generation, false, () -> { ++ throw new AssertionError("Invalidated another actor"); ++ })); ++ } ++ ++ @Test ++ void invalidatesPackGenerationsAndLightingChanges() { ++ final var cache = new BedrockRenderSnapshotCache(8); ++ final Object snapshot = new Object(); ++ final Object before = new Object(); ++ final Object replacement = new Object(); ++ assertEquals("overworld", cache.resolve("actor", snapshot, before, false, () -> "overworld")); ++ assertEquals("nether", cache.resolve("actor", snapshot, before, true, () -> "nether")); ++ assertEquals("replacement", cache.resolve("actor", snapshot, replacement, true, () -> "replacement")); ++ assertEquals("overworld again", cache.resolve("actor", snapshot, replacement, false, () -> "overworld again")); ++ } ++ ++ @Test ++ void retainsMissingAssetsUntilReplacementResourcesAreAvailable() { ++ final var cache = new BedrockRenderSnapshotCache>(8); ++ final Object snapshot = new Object(); ++ final Object missingGeneration = new Object(); ++ final AtomicInteger resourceReads = new AtomicInteger(); ++ final Map assets = new java.util.HashMap<>(); ++ final java.util.function.Supplier> resolve = () -> { ++ resourceReads.incrementAndGet(); ++ final String asset = assets.get("geometry.body"); ++ return asset == null ? List.of() : List.of(asset); ++ }; ++ assertEquals(List.of(), cache.resolve("actor", snapshot, missingGeneration, false, resolve)); ++ assertEquals(List.of(), cache.resolve("actor", snapshot, missingGeneration, false, resolve)); ++ assertEquals(1, resourceReads.get()); ++ ++ assets.put("geometry.body", "replacement texture"); ++ assertEquals(List.of("replacement texture"), cache.resolve("actor", snapshot, new Object(), false, resolve)); ++ assertEquals(2, resourceReads.get()); ++ } ++ ++ @Test ++ void boundsRetainedSnapshotsWithAccessOrderEviction() { ++ final var cache = new BedrockRenderSnapshotCache(2); ++ final Object snapshot = new Object(); ++ final Object generation = new Object(); ++ cache.resolve("first", snapshot, generation, false, () -> "first"); ++ cache.resolve("second", snapshot, generation, false, () -> "second"); ++ assertEquals("first", cache.resolve("first", snapshot, generation, false, () -> "unexpected")); ++ cache.resolve("third", snapshot, generation, false, () -> "third"); ++ assertEquals("first", cache.resolve("first", snapshot, generation, false, () -> "unexpected")); ++ assertEquals("second resolved again", cache.resolve("second", snapshot, generation, false, () -> "second resolved again")); ++ } ++ ++ @Test ++ void removalAndClearDiscardCachedAssets() { ++ final var cache = new BedrockRenderSnapshotCache(8); ++ final Object snapshot = new Object(); ++ final Object generation = new Object(); ++ cache.resolve("first", snapshot, generation, false, () -> "first"); ++ cache.resolve("second", snapshot, generation, false, () -> "second"); ++ cache.remove("first"); ++ assertEquals("new actor", cache.resolve("first", snapshot, generation, false, () -> "new actor")); ++ cache.clear(); ++ assertEquals("reloaded", cache.resolve("second", snapshot, generation, false, () -> "reloaded")); ++ } ++ ++ @Test ++ void failedResolutionRemainsRetryable() { ++ final var cache = new BedrockRenderSnapshotCache(8); ++ final Object snapshot = new Object(); ++ final Object generation = new Object(); ++ assertThrows(IllegalStateException.class, () -> cache.resolve("actor", snapshot, generation, false, () -> { ++ throw new IllegalStateException("Asset load failed"); ++ })); ++ assertEquals("retry", cache.resolve("actor", snapshot, generation, false, () -> "retry")); ++ } ++} diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockRidingAnchorsTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockRidingAnchorsTest.java new file mode 100644 index 0000000..e3eadc3 @@ -4969,4 +5165,4 @@ index 0000000..101df6e + +} -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.pr.md b/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.pr.md index 5a49cdf..cc8c7fc 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0002-native-entities-and-skins.pr.md @@ -295,3 +295,9 @@ The fixture reaches playable spawn and loads its pack. Its general world-rendering audit does not apply because it injects an extra skin and omits world chunks. The reported skin still needs a visual comparison with stock Chat Heads. The add-on build passes 681 tests, with 120 skipped. + +## Reuse unchanged actor render snapshots + +A 1,024-entry LRU retains resolved draws while actor snapshot identity, accepted resource generation, and Nether lighting remain unchanged. Geometry resolution, material partitioning, texture lookup, and warnings no longer repeat on every unchanged frame. UV expressions, animated poses, scale, and yaw still evaluate at their existing per-frame points. Snapshot replacement, pack changes, resource reloads, and level changes invalidate retained state. + +Seven helper regressions pass, including 144 unchanged frames with one resolution, invalidation, missing results, retry after failure, and LRU eviction. The complete add-on build passes with 784 tests passed and 120 skipped, against the updated ViaBedrock library and pinned ViaFabricPlus dependency. These checks measure operation reuse, not live FPS or frame-time percentiles. diff --git a/patches/viafabricplus-bedrock/upstreamable/0014-animate-numeric-looping-bedrock-bones.patch b/patches/viafabricplus-bedrock/upstreamable/0014-animate-numeric-looping-bedrock-bones.patch index 706966f..583c2ff 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0014-animate-numeric-looping-bedrock-bones.patch +++ b/patches/viafabricplus-bedrock/upstreamable/0014-animate-numeric-looping-bedrock-bones.patch @@ -1,4 +1,4 @@ -From a0fbed1b21f9627b48065dcf19b37a5c6b809708 Mon Sep 17 00:00:00 2001 +From 8c92046b9958d637dce8dffe78327976bdf6787a Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:56:50 +0200 Subject: [PATCH] feat(entities): evaluate custom actors with shared animation @@ -87,6 +87,12 @@ hiding it while holding a compass. Live GPU rendering remains a separate verification step. Keep weak resource-cache locks limited to lookup and publication. Parse a missing generation outside the lock so equipment preparation cannot block rendering an already prepared actor generation. + +Defer custom actor effect preparation until an emitted sound or particle requests a named locator. Parse every accepted model document once for all geometries and retain only converted locator maps, instead of reparsing the entire model pack for each rendered geometry. Construct animated effect models only after a requested locator resolves. + +Keep bottom-to-top pack order, first matching modern definitions inside one file, empty-definition overrides, legacy locator rotations, and malformed-resource fallback identical to the former scan. Cache missing lookups and unreadable files until resource replacement. Preserve the locator index across level changes while clearing animated model state. + +Validation: five standalone JUnit tests pass against the production locator parser and its unchanged Vector record, extracted for compilation without Minecraft. Differential tests compare the former scan over overrides, duplicate and malformed definitions and 384 seeded model definitions. A 144-frame two-geometry test verifies one file parse, and replacement-generation tests verify missing locators are retried. --- .../bedrock/ViaFabricPlusBedrock.java | 5 + .../appearance/BedrockActorAnimation.java | 358 ++++++++++++++++-- @@ -95,9 +101,11 @@ Keep weak resource-cache locks limited to lookup and publication. Parse a missin .../bedrock/render/BedrockActorState.java | 16 +- .../render/BedrockActorUvAnimation.java | 21 + .../bedrock/render/BedrockAnimatedModel.java | 24 +- - .../bedrock/render/BedrockEntityRenderer.java | 181 ++++++++- + .../bedrock/render/BedrockEntityRenderer.java | 178 ++++++++- .../render/BedrockFirstPersonInput.java | 2 + .../bedrock/render/BedrockGeometry.java | 22 +- + .../render/BedrockModelLocatorIndex.java | 77 ++++ + .../bedrock/render/BedrockModelLocators.java | 2 +- .../bedrock/render/BedrockPlayerSkins.java | 2 + .../render/NativeAnimationPayload.java | 24 ++ .../BedrockAnimationCommandsTest.java | 180 +++++++++ @@ -105,15 +113,18 @@ Keep weak resource-cache locks limited to lookup and publication. Parse a missin .../BedrockServerEntityAnimationTest.java | 200 ++++++++++ .../render/BedrockActorUvAnimationTest.java | 63 +++ .../render/BedrockCustomActorModelTest.java | 57 +++ - 17 files changed, 1307 insertions(+), 49 deletions(-) + .../render/BedrockModelLocatorIndexTest.java | 207 ++++++++++ + 20 files changed, 1589 insertions(+), 50 deletions(-) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorFrame.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorUvAnimation.java + create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocatorIndex.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/NativeAnimationPayload.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockAnimationCommandsTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockServerActorResourcesTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/appearance/BedrockServerEntityAnimationTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockActorUvAnimationTest.java create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockCustomActorModelTest.java + create mode 100644 src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocatorIndexTest.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java b/src/main/java/com/viaversion/viafabricplus/bedrock/ViaFabricPlusBedrock.java index eeba5f9..b33ea1c 100644 @@ -912,7 +923,7 @@ index 9feeff0..1c1c8c8 100644 final String normalized = name.toLowerCase(Locale.ROOT).replace("_", ""); return normalized.equals("hip") ? "waist" : normalized; diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java -index 66bd0e5..4285662 100644 +index 8c62842..2d5ad78 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java @@ -19,6 +19,16 @@ @@ -932,7 +943,7 @@ index 66bd0e5..4285662 100644 import com.mojang.math.Axis; import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; import com.viaversion.viafabricplus.bedrock.render.CustomEntityRenderStore.RenderData; -@@ -45,18 +55,24 @@ import net.raphimc.viabedrock.api.model.entity.CustomEntity; +@@ -46,19 +56,27 @@ import net.raphimc.viabedrock.api.model.entity.CustomEntity; import net.raphimc.viabedrock.api.util.StringUtil; import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; import org.cube.converter.model.impl.bedrock.BedrockGeometryModel; @@ -942,6 +953,7 @@ index 66bd0e5..4285662 100644 public final class BedrockEntityRenderer extends EntityRenderer { private final Map> models = new HashMap<>(); + private final BedrockRenderSnapshotCache> resolvedModels = new BedrockRenderSnapshotCache<>(1024); + private final Map uvAnimations = new HashMap<>(); private final Map renderTypes = new LinkedHashMap<>(16, 0.75F, true) { @Override @@ -953,12 +965,14 @@ index 66bd0e5..4285662 100644 }; + private final Map> animationGraphs = new HashMap<>(); + private final Map actors = new WeakHashMap<>(); -+ private final Map effectModels = new HashMap<>(); ++ private final Map effectModels = new HashMap<>(); ++ private final BedrockModelLocatorIndex effectLocators = new BedrockModelLocatorIndex(this::effectLocatorDocuments, ++ (file, invalid) -> ViaFabricPlusBedrock.impl().logger().warn("Could not load actor locators from {}", file, invalid)); + private Object currentLevel; private ResourcePackStorage currentPacks; private BedrockActorMaterialDefinitions materialDefinitions; -@@ -86,26 +102,60 @@ public final class BedrockEntityRenderer extends EntityRenderer this.resolveModels(renderData, packs, netherLighting)); state.scale = renderData.scale(); + final BedrockActorAnimation graph = this.animationGraphs.computeIfAbsent(renderData.identifier(), identifier -> { + try { @@ -1020,7 +1042,7 @@ index 66bd0e5..4285662 100644 state.yaw = entity.getYRot(); } -@@ -133,32 +183,123 @@ public final class BedrockEntityRenderer extends EntityRenderer model = this.models.computeIfAbsent(modelKey, _ -> BedrockGeometry.entityModel(geometry, material.getValue())); final var alphaTest = this.materialDefinitions.alphaTest(material.getKey()); final RenderType type; @@ -1039,26 +1061,6 @@ index 66bd0e5..4285662 100644 } - resolved.add(new RenderedModel(model, type)); + resolved.add(new RenderedModel(model, type, key, uv, evaluated.geometryValue())); -+ this.effectModels.computeIfAbsent(evaluated.geometryValue(), name -> { -+ Map locators = Map.of(); -+ for (final var pack : packs.getPackStackBottomToTop()) { -+ for (final String file : pack.content().getFilesDeep("models/", ".json")) { -+ try { -+ final var document = pack.content().getJson(file); -+ boolean defines = document.has(name); -+ if (document.has("minecraft:geometry")) { -+ for (final var entry : document.getAsJsonArray("minecraft:geometry")) { -+ if (name.equals(entry.getAsJsonObject().getAsJsonObject("description").get("identifier").getAsString())) defines = true; -+ } -+ } -+ if (defines) locators = BedrockModelLocators.read(document, name); -+ } catch (RuntimeException invalid) { -+ ViaFabricPlusBedrock.impl().logger().warn("Could not load actor locators from {}", file, invalid); -+ } -+ } -+ } -+ return new EffectModel(BedrockGeometry.actorModel(geometry), locators); -+ }); } } return List.copyOf(resolved); @@ -1130,16 +1132,30 @@ index 66bd0e5..4285662 100644 + private org.joml.Matrix4f effectLocator(final ActorPlayback actor, final String locator, final Vec3 position, final float yaw) { + if (locator == null || locator.isBlank()) return null; + for (final String name : actor.models) { -+ final EffectModel model = this.effectModels.get(name); -+ final var declaration = model == null ? null : model.locators.get(locator); ++ final var declaration = this.effectLocators.get(name, locator); + if (declaration != null) { -+ model.model.pose(actor.pose); -+ return model.model.worldLocator(declaration, position, yaw, actor.scale); ++ final BedrockAnimatedModel model = this.effectModels.computeIfAbsent(name, geometryName -> { ++ final var geometry = this.currentPacks.getModels().entityModels().get(geometryName); ++ return geometry == null ? null : BedrockGeometry.actorModel(geometry); ++ }); ++ if (model == null) continue; ++ model.pose(actor.pose); ++ return model.worldLocator(declaration, position, yaw, actor.scale); + } + } + return null; + } + ++ private List effectLocatorDocuments() { ++ final List documents = new ArrayList<>(); ++ for (final var pack : this.currentPacks.getPackStackBottomToTop()) { ++ for (final String file : pack.content().getFilesDeep("models/", ".json")) { ++ documents.add(new BedrockModelLocatorIndex.Document(file, () -> pack.content().getJson(file))); ++ } ++ } ++ return documents; ++ } ++ + private Vec3 effectPosition(final ActorPlayback actor, final String locator, final Vec3 origin, final float yaw) { + final var matrix = this.effectLocator(actor, locator, origin, yaw); + return matrix == null ? origin : new Vec3(matrix.m30(), matrix.m31(), matrix.m32()); @@ -1148,7 +1164,7 @@ index 66bd0e5..4285662 100644 static void applyActorTransform(final PoseStack poseStack, final float yaw, final float scale) { // Scale around the native actor origin before compensating for the Java model origin. poseStack.scale(scale, scale, scale); -@@ -169,17 +310,41 @@ public final class BedrockEntityRenderer extends EntityRenderer models = List.of(); @@ -1161,8 +1177,8 @@ index 66bd0e5..4285662 100644 private float scale = 1F; + Map pose() { return this.pose; } + BedrockActorAnimation.Visibility visibility() { return this.visibility; } - } - ++ } ++ + private static final class ActorPlayback { + private final BedrockActorAnimation graph; + private final BedrockActorAnimation.Playback playback; @@ -1176,10 +1192,8 @@ index 66bd0e5..4285662 100644 + this.playback = playback; + this.lifetime = lifetime; + } -+ } -+ -+ private record EffectModel(BedrockAnimatedModel model, Map locators) {} -+ + } + private record ModelKey(String geometry, Set bones) { } @@ -1248,6 +1262,102 @@ index 8998b94..6e99eea 100644 private static ModelPart root(final BedrockGeometryModel geometry, final boolean player, final Predicate selectedBones) { return root(geometry, player, Map.of(), null, null, selectedBones); +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocatorIndex.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocatorIndex.java +new file mode 100644 +index 0000000..a52dad0 +--- /dev/null ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocatorIndex.java +@@ -0,0 +1,77 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++import com.viaversion.viaversion.libs.gson.JsonObject; ++import java.util.LinkedHashMap; ++import java.util.List; ++import java.util.Map; ++import java.util.function.BiConsumer; ++import java.util.function.Supplier; ++ ++/** One lazy locator-only index for a resource generation, in accepted pack override order. */ ++final class BedrockModelLocatorIndex { ++ private final Supplier> documents; ++ private final BiConsumer diagnostics; ++ private Map> locators; ++ ++ BedrockModelLocatorIndex(final Supplier> documents, ++ final BiConsumer diagnostics) { ++ this.documents = documents; ++ this.diagnostics = diagnostics; ++ } ++ ++ BedrockModelLocators.Locator get(final String geometry, final String locator) { ++ if (locator == null || locator.isBlank()) return null; ++ if (this.locators == null) this.locators = this.read(); ++ return this.locators.getOrDefault(geometry, Map.of()).get(locator); ++ } ++ ++ void clear() { ++ this.locators = null; ++ } ++ ++ private Map> read() { ++ final Map> result = new LinkedHashMap<>(); ++ for (final Document resource : this.documents.get()) { ++ try { ++ final JsonObject document = resource.content.get(); ++ final boolean modern = document.has("minecraft:geometry"); ++ final Map definitions = new LinkedHashMap<>(); ++ if (modern) { ++ // Validate every identity before publishing this file, just as the former per-model scan did. ++ for (final var entry : document.getAsJsonArray("minecraft:geometry")) { ++ final JsonObject model = entry.getAsJsonObject(); ++ final String name = model.getAsJsonObject("description").get("identifier").getAsString(); ++ // BedrockModelLocators.read selects the first matching definition inside one document. ++ definitions.putIfAbsent(name, model); ++ } ++ // A modern document takes precedence over any legacy keys in that same document. ++ for (final String key : document.keySet()) ++ if (!definitions.containsKey(key)) result.put(key, Map.of()); ++ } else { ++ for (final var entry : document.entrySet()) ++ if (entry.getValue().isJsonObject()) definitions.put(entry.getKey(), entry.getValue().getAsJsonObject()); ++ } ++ for (final var entry : definitions.entrySet()) { ++ try { ++ // An empty winning definition clears lower-pack locators; malformed ones retain them. ++ result.put(entry.getKey(), BedrockModelLocators.model(entry.getValue(), modern)); ++ } catch (RuntimeException invalid) { ++ this.diagnostics.accept(resource.path, invalid); ++ } ++ } ++ } catch (RuntimeException invalid) { ++ this.diagnostics.accept(resource.path, invalid); ++ } ++ } ++ return Map.copyOf(result); ++ } ++ ++ record Document(String path, Supplier content) { ++ } ++} +diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocators.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocators.java +index 73a8827..0e8d0d3 100644 +--- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocators.java ++++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocators.java +@@ -62,7 +62,7 @@ final class BedrockModelLocators { + return model(model, true); + } + +- private static Map model(final JsonObject model, final boolean modern) { ++ static Map model(final JsonObject model, final boolean modern) { + final Map locators = new LinkedHashMap<>(); + if (!model.has("bones")) return Map.of(); + for (final var entry : model.getAsJsonArray("bones")) { diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java index 7176ee7..a46b849 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java @@ -1968,5 +2078,218 @@ index 0000000..4676741 + assertEquals(2F,normal.length(),.00001F); + } +} +diff --git a/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocatorIndexTest.java b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocatorIndexTest.java +new file mode 100644 +index 0000000..dace656 +--- /dev/null ++++ b/src/test/java/com/viaversion/viafabricplus/bedrock/render/BedrockModelLocatorIndexTest.java +@@ -0,0 +1,207 @@ ++/* ++ * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock ++ * Copyright (C) 2026 ViaVersion and contributors ++ * SPDX-License-Identifier: GPL-3.0-or-later ++ */ ++ ++package com.viaversion.viafabricplus.bedrock.render; ++ ++import com.viaversion.viaversion.libs.gson.JsonArray; ++import com.viaversion.viaversion.libs.gson.JsonObject; ++import java.util.ArrayList; ++import java.util.List; ++import java.util.Map; ++import java.util.Random; ++import java.util.concurrent.atomic.AtomicInteger; ++import java.util.concurrent.atomic.AtomicReference; ++import org.junit.jupiter.api.Test; ++ ++import static org.junit.jupiter.api.Assertions.*; ++ ++final class BedrockModelLocatorIndexTest { ++ @Test ++ void waitsForNamedEffectsAndParsesEachFileOnceForAllGeometries() { ++ final AtomicInteger listings = new AtomicInteger(); ++ final AtomicInteger parses = new AtomicInteger(); ++ final JsonObject document = modern(model("geometry.first", 1), model("geometry.second", 2)); ++ final var index = new BedrockModelLocatorIndex(() -> { ++ listings.incrementAndGet(); ++ return List.of(new BedrockModelLocatorIndex.Document("models/two.json", () -> { ++ parses.incrementAndGet(); ++ return document; ++ })); ++ }, (_, _) -> fail("Valid model rejected")); ++ ++ assertNull(index.get("geometry.first", null)); ++ assertNull(index.get("geometry.first", " ")); ++ assertEquals(0, listings.get()); ++ for (int frame = 0; frame < 144; frame++) { ++ assertEquals(-9F, index.get("geometry.first", "touch").x()); ++ assertEquals(-8F, index.get("geometry.second", "touch").x()); ++ assertNull(index.get("geometry.missing", "touch")); ++ assertNull(index.get("geometry.first", "missing")); ++ } ++ assertEquals(1, listings.get()); ++ assertEquals(1, parses.get()); ++ } ++ ++ @Test ++ void clearRetriesMissingLocatorsAndLoadsReplacementGeneration() { ++ final AtomicInteger parses = new AtomicInteger(); ++ final AtomicReference document = new AtomicReference<>(modern()); ++ final var index = new BedrockModelLocatorIndex(() -> List.of( ++ new BedrockModelLocatorIndex.Document("models/replaced.json", () -> { ++ parses.incrementAndGet(); ++ return document.get(); ++ })), (_, _) -> fail("Valid model rejected")); ++ assertNull(index.get("geometry.actor", "touch")); ++ document.set(modern(model("geometry.actor", 4))); ++ assertNull(index.get("geometry.actor", "touch")); ++ assertEquals(1, parses.get()); ++ index.clear(); ++ assertEquals(-6F, index.get("geometry.actor", "touch").x()); ++ assertEquals(2, parses.get()); ++ } ++ ++ @Test ++ void preservesFormerScanResultsAcrossOverridesDuplicatesAndMalformedModels() { ++ final List documents = new ArrayList<>(); ++ documents.add(modern(model("geometry.override", 1), model("geometry.clear", 2), ++ model("geometry.mixed", 3), model("geometry.invalid_identity", 4))); ++ final JsonObject legacy = new JsonObject(); ++ legacy.add("geometry.legacy", model("geometry.legacy", 5)); ++ documents.add(legacy); ++ documents.add(modern(model("geometry.override", 6), model("geometry.duplicate", 7), model("geometry.duplicate", 8))); ++ documents.add(modern(empty("geometry.clear"))); ++ final JsonObject malformed = model("geometry.override", 9); ++ malformed.getAsJsonArray("bones").get(0).getAsJsonObject().getAsJsonObject("locators") ++ .getAsJsonObject("touch").add("offset", vector(1, 2)); ++ documents.add(modern(malformed, model("geometry.sibling", 10))); ++ final JsonObject invalidIdentity = modern(model("geometry.invalid_identity", 11), new JsonObject()); ++ documents.add(invalidIdentity); ++ final JsonObject mixed = modern(model("geometry.unrelated", 12)); ++ mixed.add("geometry.mixed", model("geometry.mixed", 13)); ++ documents.add(mixed); ++ ++ final List diagnostics = new ArrayList<>(); ++ final var index = index(documents, diagnostics); ++ for (final String geometry : List.of("geometry.override", "geometry.clear", "geometry.mixed", "geometry.legacy", ++ "geometry.duplicate", "geometry.sibling", "geometry.invalid_identity", "geometry.unrelated", "geometry.missing")) { ++ for (final String locator : List.of("touch", "missing")) ++ assertEquals(previousScan(documents, geometry).get(locator), index.get(geometry, locator), geometry); ++ } ++ assertEquals(-3F, index.get("geometry.duplicate", "touch").x()); ++ assertEquals(0F, index.get("geometry.legacy", "touch").rotationX()); ++ assertNull(index.get("geometry.clear", "touch")); ++ assertNull(index.get("geometry.mixed", "touch")); ++ assertEquals(2, diagnostics.size()); ++ } ++ ++ @Test ++ void matchesFormerScanAcrossManyOrderedPackDefinitions() { ++ final Random random = new Random(934872); ++ final List documents = new ArrayList<>(); ++ for (int file = 0; file < 24; file++) { ++ final List models = new ArrayList<>(); ++ for (int entry = 0; entry < 16; entry++) { ++ final String identifier = "geometry.actor_" + random.nextInt(40); ++ models.add(random.nextInt(4) == 0 ? empty(identifier) : model(identifier, random.nextInt(80))); ++ } ++ documents.add(modern(models.toArray(JsonObject[]::new))); ++ } ++ final var index = index(documents, new ArrayList<>()); ++ for (int geometry = 0; geometry < 48; geometry++) { ++ final String identifier = "geometry.actor_" + geometry; ++ assertEquals(previousScan(documents, identifier).get("touch"), index.get(identifier, "touch"), identifier); ++ } ++ } ++ ++ @Test ++ void skipsUnreadableFilesOnceWhileRetainingEarlierLocators() { ++ final AtomicInteger failedReads = new AtomicInteger(); ++ final List diagnostics = new ArrayList<>(); ++ final var index = new BedrockModelLocatorIndex(() -> List.of( ++ new BedrockModelLocatorIndex.Document("models/base.json", () -> modern(model("geometry.actor", 1))), ++ new BedrockModelLocatorIndex.Document("models/broken.json", () -> { ++ failedReads.incrementAndGet(); ++ throw new IllegalArgumentException("Malformed JSON"); ++ })), (file, _) -> diagnostics.add(file)); ++ assertEquals(-9F, index.get("geometry.actor", "touch").x()); ++ assertEquals(-9F, index.get("geometry.actor", "touch").x()); ++ assertNull(index.get("geometry.missing", "touch")); ++ assertEquals(1, failedReads.get()); ++ assertEquals(List.of("models/broken.json"), diagnostics); ++ } ++ ++ private static BedrockModelLocatorIndex index(final List documents, final List diagnostics) { ++ return new BedrockModelLocatorIndex(() -> { ++ final List resources = new ArrayList<>(); ++ for (int file = 0; file < documents.size(); file++) { ++ final JsonObject document = documents.get(file); ++ resources.add(new BedrockModelLocatorIndex.Document("models/" + file + ".json", () -> document)); ++ } ++ return resources; ++ }, (file, _) -> diagnostics.add(file)); ++ } ++ ++ /** The pre-index renderer algorithm, retained as the differential behavior reference. */ ++ private static Map previousScan(final List documents, ++ final String geometry) { ++ Map locators = Map.of(); ++ for (final JsonObject document : documents) { ++ try { ++ boolean defines = document.has(geometry); ++ if (document.has("minecraft:geometry")) { ++ for (final var entry : document.getAsJsonArray("minecraft:geometry")) { ++ if (geometry.equals(entry.getAsJsonObject().getAsJsonObject("description").get("identifier").getAsString())) ++ defines = true; ++ } ++ } ++ if (defines) locators = BedrockModelLocators.read(document, geometry); ++ } catch (RuntimeException ignored) { ++ } ++ } ++ return locators; ++ } ++ ++ private static JsonObject modern(final JsonObject... models) { ++ final JsonObject document = new JsonObject(); ++ document.addProperty("format_version", "1.12.0"); ++ final JsonArray geometries = new JsonArray(); ++ for (final JsonObject model : models) geometries.add(model); ++ document.add("minecraft:geometry", geometries); ++ return document; ++ } ++ ++ private static JsonObject empty(final String identifier) { ++ final JsonObject model = new JsonObject(); ++ final JsonObject description = new JsonObject(); ++ description.addProperty("identifier", identifier); ++ model.add("description", description); ++ return model; ++ } ++ ++ private static JsonObject model(final String identifier, final int offsetX) { ++ final JsonObject model = empty(identifier); ++ final JsonObject bone = new JsonObject(); ++ bone.addProperty("name", "root"); ++ bone.add("pivot", vector(10, 20, 30)); ++ final JsonObject locator = new JsonObject(); ++ locator.add("offset", vector(offsetX, 42, -4)); ++ locator.add("rotation", vector(11, 22, 33)); ++ locator.addProperty("ignore_inherited_scale", true); ++ final JsonObject locators = new JsonObject(); ++ locators.add("touch", locator); ++ bone.add("locators", locators); ++ final JsonArray bones = new JsonArray(); ++ bones.add(bone); ++ model.add("bones", bones); ++ return model; ++ } ++ ++ private static JsonArray vector(final int... values) { ++ final JsonArray vector = new JsonArray(); ++ for (final int value : values) vector.add(value); ++ return vector; ++ } ++} -- -2.56.0 +2.50.1 (Apple Git-155) diff --git a/patches/viafabricplus-bedrock/upstreamable/0014-animate-numeric-looping-bedrock-bones.pr.md b/patches/viafabricplus-bedrock/upstreamable/0014-animate-numeric-looping-bedrock-bones.pr.md index 8112a8f..33eb0f6 100644 --- a/patches/viafabricplus-bedrock/upstreamable/0014-animate-numeric-looping-bedrock-bones.pr.md +++ b/patches/viafabricplus-bedrock/upstreamable/0014-animate-numeric-looping-bedrock-bones.pr.md @@ -144,3 +144,9 @@ An accepted CubeCraft pack references native player root controllers and the emp A private production-graph fixture resolves the original pack references and samples right-arm position `(13.5, -10, 12)` and rotation `(95, -45, 115)`. The accepted server visibility script reveals the empty hand and hides it with a compass equipped. Targeted tests cover baseline inheritance, effect lookup, accepted-pack overrides, duplicate controller formats and non-VR hand binding. The complete add-on build passes: 532 tests passed, 117 optional tests skipped, no failures. Access-widener validation passes. The reviewed candidate was installed in the desktop client, which joined CubeCraft successfully. The user confirms the arm is visible. This verifies the reported empty-hand regression; VR, all costumes and complete native visual parity remain separate checks. + +## Index effect locators once per resource generation + +The first nonblank locator request builds a lazy index that parses each model JSON once and retains only locator maps. Effect models are built after the requested locator resolves. Keep bottom-to-top pack overrides, first duplicate geometry definitions, empty-definition overrides, and malformed-file behavior equivalent to the previous scan. Resource generation changes discard the index. + +Five regressions pass, including a seeded differential comparison over 384 definitions and 144 repeated requests across two geometries with one model parse. The full add-on build passes with 784 tests passed, 120 skipped, and no failures or errors. Live particle-heavy frame-time profiling remains separate from these operation-count checks. diff --git a/scripts/replay-process-environment.py b/scripts/replay-process-environment.py new file mode 100644 index 0000000..7801b96 --- /dev/null +++ b/scripts/replay-process-environment.py @@ -0,0 +1,125 @@ +"""Inspect Darwin process environments without publishing arguments or environment bytes. + +KERN_PROCARGS2 is the same bounded sysctl used by Apple's ps implementation: +https://github.com/apple-oss-distributions/adv_cmds/blob/main/ps/print.c +""" +import ctypes +import errno +import os +import struct +import sys +import time + + +def environment(data): + if len(data) < 4: + raise ValueError("Truncated process arguments") + count = struct.unpack_from("=i", data)[0] + if count < 1 or count > len(data): + raise ValueError("Invalid process argument count") + cursor = data.index(b"\0", 4) + 1 # Saved executable path and padding. + while cursor < len(data) and data[cursor] == 0: + cursor += 1 + for _ in range(count): + cursor = data.index(b"\0", cursor) + 1 + while cursor < len(data) and data[cursor] != 0: + end = data.index(b"\0", cursor) + yield data[cursor:end] + cursor = end + 1 + + +def live_environment(read, state, pause=time.sleep): + """Retry stack-copy races; only a verified exited/zombie process can be discarded.""" + for attempt in range(3): + try: + data = read() + return () if data is None else tuple(environment(data)) + except OSError as error: + if error.errno != errno.EIO: + raise + failure = error + except ValueError as error: + failure = error + if state() in (None, 5): # No process, or SZOMB: it cannot use the profile. + return () + if attempt == 2: + raise failure + pause(0.005) + + +def process_state(library, pid): + # PROC_PIDT_SHORTBSDINFO has a 64-byte fixed layout, including its reserved trailing uint32. + # pbsi_status is its fourth uint32. A smaller buffer fails with ENOMEM. + info = ctypes.create_string_buffer(64) + used = library.proc_pidinfo(pid, 13, 0, info, len(info)) + if used == len(info): + return struct.unpack_from("=I", info.raw, 12)[0] + code = ctypes.get_errno() + if used == 0 and code in (errno.ESRCH, errno.ENOENT): + return None + raise OSError(code, "Cannot verify process lifetime") + + +def busy(variable, path, owner_pid): + library = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + library.proc_listpids.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_int] + library.proc_listpids.restype = ctypes.c_int + library.proc_pidinfo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint64, ctypes.c_void_p, ctypes.c_int] + library.proc_pidinfo.restype = ctypes.c_int + library.sysctl.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_uint, ctypes.c_void_p, + ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p, ctypes.c_size_t] + library.sysctl.restype = ctypes.c_int + + # PROC_ALL_PIDS includes owned processes that changed their effective UID. + size = library.proc_listpids(1, 0, None, 0) + if size <= 0: + raise OSError("Cannot enumerate processes") + for _ in range(4): + size += 4096 + if size > 4 * 1024 * 1024: + raise ValueError("Unbounded process list") + pids = (ctypes.c_int * (size // 4))() + used = library.proc_listpids(1, 0, pids, size) + if used <= 0: + raise OSError("Cannot enumerate processes") + if used < size: + break + else: + raise ValueError("Process list keeps growing") + + argmax, length = ctypes.c_int(), ctypes.c_size_t(4) + if library.sysctl((ctypes.c_int * 2)(1, 8), 2, ctypes.byref(argmax), ctypes.byref(length), None, 0): + raise OSError("Cannot inspect argument bounds") + if not 4096 <= argmax.value <= 16 * 1024 * 1024: + raise ValueError("Invalid argument bounds") + data = ctypes.create_string_buffer(argmax.value) + prefix = os.fsencode(variable + "=") + for pid in pids[:used // 4]: + if pid <= 0 or pid in (os.getpid(), owner_pid): + continue + def read(): + length = ctypes.c_size_t(argmax.value) + if library.sysctl((ctypes.c_int * 3)(1, 49, pid), 3, data, ctypes.byref(length), None, 0): + code = ctypes.get_errno() + # Keep Linux /proc's handling of exited, zombie/kernel, and foreign processes. + if code in (errno.ESRCH, errno.EINVAL, errno.EACCES, errno.EPERM): + return None + raise OSError(code, "Cannot inspect process environment") + if length.value > argmax.value: + raise ValueError("Invalid argument size") + return data.raw[:length.value] + + for entry in live_environment(read, lambda: process_state(library, pid)): + if entry.startswith(prefix) and os.path.realpath(os.fsdecode(entry[len(prefix):])) == path: + return True + return False + + +if __name__ == "__main__": + try: + if sys.platform != "darwin" or len(sys.argv) != 4 or sys.argv[1] not in ("BOL_HOME", "WINEPREFIX"): + raise ValueError("Unsupported process inspection") + result = 10 if busy(sys.argv[1], sys.argv[2], int(sys.argv[3])) else 0 + except Exception: + result = 1 # Fail closed without leaking private process data through a traceback. + sys.exit(result) diff --git a/src/replay/form-fixture.ts b/src/replay/form-fixture.ts index faf15b2..8157f50 100644 --- a/src/replay/form-fixture.ts +++ b/src/replay/form-fixture.ts @@ -1,11 +1,12 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { constants } from "node:fs"; -import { lstat, mkdir, open, realpath, rm, writeFile } from "node:fs/promises"; -import { basename, dirname, join, resolve } from "node:path"; +import { lstat, mkdir, open, rm, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; import { promisify } from "node:util"; import { Effect } from "effect"; import { journalMagic, maxJournalBytes, summarizeJournal } from "./journal.ts"; +import { directChild, realDirectory } from "./private-path.ts"; const execute = promisify(execFile); const protocol = 2193; @@ -294,16 +295,16 @@ async function boundedFile(source: string, limit: number): Promise { await file.close(); } } -async function freshOutput(output: string, privateRoot: string): Promise { - const state = await lstat(privateRoot); - if (!state.isDirectory() || state.isSymbolicLink() || await realpath(privateRoot) !== privateRoot - || dirname(output) !== privateRoot || basename(output).startsWith(".")) { +async function freshOutput(output: string, privateRoot: string): Promise { + privateRoot = await realDirectory(privateRoot); + if (basename(output).startsWith(".")) { throw new Error("Fixture output must be a fresh direct child of the owned private replay directory."); } + output = await directChild(privateRoot, output); try { await lstat(output); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return output; throw error; } throw new Error("Fixture output already exists."); @@ -318,7 +319,7 @@ export async function writePrivateFixture(output: string, privateRoot: string, f } names.add(file.name); } - await freshOutput(output, privateRoot); + output = await freshOutput(output, privateRoot); await mkdir(output, { mode: 0o700 }); const owner = await lstat(output); try { @@ -344,7 +345,7 @@ export const prepareFormFixture = (planPath: string, privateRoot: string, review const planBytes = await boundedFile(planPath, 65536); const plan = validatePlan(JSON.parse(planBytes.toString("utf8"))); const base = resolve(privateRoot); - await freshOutput(plan.output, base); + plan.output = await freshOutput(plan.output, base); const inputs: InputFile[] = [{ source: planPath, sha256: sha(planBytes), bytes: planBytes.length }]; const checked = async (source: string, limit: number, expected?: string) => { const data = await boundedFile(source, limit); diff --git a/src/replay/java-profile.ts b/src/replay/java-profile.ts index 3df9e88..7729d7c 100644 --- a/src/replay/java-profile.ts +++ b/src/replay/java-profile.ts @@ -1,6 +1,7 @@ import { constants } from "node:fs"; import { chmod, cp, lstat, readFile, readdir, readlink, realpath, statfs, writeFile } from "node:fs/promises"; -import { isAbsolute, join, relative, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { realDirectory } from "./private-path.ts"; const reserveBytes = 5 * 1024 ** 3; @@ -27,7 +28,7 @@ async function validateTree(directory: string, path = directory): Promise { - if (await realpath(source) !== source) throw new Error("Replay profile source cannot be a symlink."); + source = await realDirectory(source); async function validate(path: string): Promise { const state = await lstat(path); if (state.isSymbolicLink()) throw new Error("Replay profile inputs cannot contain write-through symlinks."); @@ -45,13 +46,20 @@ export async function validateReplayProfile(source: string): Promise { } export async function privateReplayJavaPath(source: string, target: string, configured: string): Promise { - const sourceJava = join(source, "java"); - if (!configured.startsWith(sourceJava + "/") || !inside(sourceJava, await realpath(configured))) { + if (!isAbsolute(configured)) throw new Error("Replay Java executable must use an absolute copied runtime path."); + const [sourceRoot, targetRoot, configuredParent] = await Promise.all([ + realDirectory(source), realDirectory(target), realpath(dirname(configured)), + ]); + const sourceJava = join(sourceRoot, "java"); + const selected = join(configuredParent, basename(configured)); + const sourceExecutable = await realpath(configured); + if (!inside(sourceJava, selected) || !inside(sourceJava, sourceExecutable) || !(await lstat(sourceExecutable)).isFile()) { throw new Error("Replay Java executable must come from the copied Prism runtime."); } - const privateJava = join(target, "java"); - const path = join(privateJava, relative(sourceJava, configured)); - if (!inside(privateJava, await realpath(path))) throw new Error("Copied Java executable escaped its private runtime."); + const privateJava = join(targetRoot, "java"); + const path = join(privateJava, relative(sourceJava, selected)); + const copiedExecutable = await realpath(path); + if (!inside(privateJava, copiedExecutable) || !(await lstat(copiedExecutable)).isFile()) throw new Error("Copied Java executable escaped its private runtime."); return path; } @@ -71,7 +79,8 @@ export function requireReplayCopySpace(bytes: number, available: number): void { /** Prism may rewrite cached files, so every launcher directory has a private writable copy. */ export async function prepareReplayPrismData(source: string, target: string): Promise { - if (await realpath(source) !== source || await realpath(target) !== target || inside(source, target) || inside(target, source)) { + [source, target] = await Promise.all([realDirectory(source), realDirectory(target)]); + if (inside(source, target) || inside(target, source)) { throw new Error("Replay Prism data must use separate real directories."); } const entries = (await readdir(source, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)).filter(entry => diff --git a/src/replay/native-prefix.ts b/src/replay/native-prefix.ts index a1d1277..265901c 100644 --- a/src/replay/native-prefix.ts +++ b/src/replay/native-prefix.ts @@ -1,6 +1,8 @@ import { constants } from "node:fs"; -import { chmod, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; -import { join, relative, resolve, sep } from "node:path"; +import { chmod, cp, lstat, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; +import { directChild, realDirectory } from "./private-path.ts"; +import { requireEnvironmentPathIdle } from "./process-environment.ts"; const ownerFile = ".stackanvil-replay-prefix.json"; const dataPatterns = [ @@ -38,8 +40,9 @@ async function sourceIdentity(source: string, run: string): Promise /** Check the clone marker and filesystem identity before any profile mutation. */ export async function validateNativeReplayPrefix(prefix: string, directory: string, sourcePrefix: string): Promise { - const [run, source] = await Promise.all([realpath(directory), realpath(sourcePrefix)]); - if (resolve(prefix) !== join(run, "native-prefix") || (await lstat(prefix)).isSymbolicLink()) { + const [run, source] = await Promise.all([realDirectory(directory), realDirectory(sourcePrefix)]); + await directChild(run, prefix, "native-prefix"); + if ((await lstat(prefix)).isSymbolicLink()) { throw new Error("The native replay prefix is outside its owned run."); } const actual = await realpath(prefix); @@ -60,26 +63,14 @@ export async function validateNativeReplayPrefix(prefix: string, directory: stri /** Match the launcher's exact NUL-delimited WINEPREFIX entry without logging it. */ export async function requireNativePrefixIdle(prefix: string): Promise { - const expected = `WINEPREFIX=${prefix}`; - for (const name of await readdir("/proc")) { - if (!/^\d+$/.test(name) || Number(name) === process.pid) continue; - let environment: Buffer; - try { environment = await readFile(`/proc/${name}/environ`); } - catch (error) { - if (["ENOENT", "ESRCH", "EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) continue; - throw error; - } - if (environment.toString().split("\0").includes(expected)) { - throw new Error("The native replay prefix is in use. Stop its owned Wine processes before preparing another copy."); - } - } + await requireEnvironmentPathIdle("WINEPREFIX", prefix); } /** Create an exclusive per-run clone and seed it before the launcher can read it. */ export async function prepareNativeReplayPrefix(options: NativePrefixOptions): Promise { const payload = nativeReplayServerLine(options.port, Math.floor(Date.now() / 1000)); const [root, run, source] = await Promise.all([ - realpath(options.privateRoot), realpath(options.directory), realpath(options.sourcePrefix), + realDirectory(options.privateRoot), realDirectory(options.directory), realDirectory(options.sourcePrefix), ]); if (!inside(root, run) || inside(run, source) || inside(source, run) || run === source) { throw new Error("Native replay prefix preparation requires a private run and a separate source template."); diff --git a/src/replay/native-profile.ts b/src/replay/native-profile.ts index a20100f..e63d26f 100644 --- a/src/replay/native-profile.ts +++ b/src/replay/native-profile.ts @@ -1,10 +1,10 @@ -import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; -import { promisify } from "node:util"; +import { constants } from "node:fs"; +import { chmod, cp, lstat, mkdir, readFile, realpath, rename, rm, symlink, writeFile } from "node:fs/promises"; +import { isAbsolute, join, relative, sep } from "node:path"; +import { directChild, realDirectory } from "./private-path.ts"; +import { requireEnvironmentPathIdle } from "./process-environment.ts"; -const execute = promisify(execFile); const ownerFile = ".stackanvil-source"; export interface NativeProfileOptions { @@ -30,25 +30,9 @@ async function plainFile(path: string): Promise { if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("The native profile file is not an owned regular file."); } -async function directory(path: string): Promise { - const metadata = await lstat(path); - if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("The native profile root is not a real directory."); - return realpath(path); -} - /** Refuse profile changes while an owned launcher is using either installation. */ export async function requireNativeProfileIdle(profile: string): Promise { - const expected = `BOL_HOME=${profile}`; - for (const name of await readdir("/proc")) { - if (!/^\d+$/.test(name) || Number(name) === process.pid) continue; - let environment: Buffer; - try { environment = await readFile(`/proc/${name}/environ`); } - catch (error) { - if (["ENOENT", "ESRCH", "EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) continue; - throw error; - } - if (environment.toString().split("\0").includes(expected)) throw new Error("The native profile is in use."); - } + await requireEnvironmentPathIdle("BOL_HOME", profile); } async function atomicSettings(runtime: string, settings: NativeProfile["settings"], validate: () => Promise): Promise { @@ -82,10 +66,10 @@ async function ownedContent(runtime: string, game: string, validate: () => Promi /** Copy launcher state without allowing its absolute content link to escape the copy. */ export async function prepareNativeProfile(options: NativeProfileOptions): Promise { - const source = await directory(resolve(options.source)); - const privateRoot = await directory(resolve(options.privateRoot)); - const runtime = resolve(options.runtime); - if (runtime !== join(privateRoot, "native-client") || source === runtime || inside(source, runtime) || inside(runtime, source)) { + const source = await realDirectory(options.source); + const privateRoot = await realDirectory(options.privateRoot); + const runtime = await directChild(privateRoot, options.runtime, "native-client"); + if (source === runtime || inside(source, runtime) || inside(runtime, source)) { throw new Error("The native profile requires a separate owned installation directory."); } await plainFile(join(source, "settings.json")); @@ -103,7 +87,7 @@ export async function prepareNativeProfile(options: NativeProfileOptions): Promi const idle = options.assertIdle ?? requireNativeProfileIdle; await idle(source); try { - const existing = await directory(runtime); + const existing = await realDirectory(runtime); if (existing !== runtime) throw new Error("The native profile root escapes its owned installation."); await plainFile(join(runtime, ownerFile)); if ((await readFile(join(runtime, ownerFile), "utf8")) !== source) throw new Error("The native profile belongs to a different source installation."); @@ -111,7 +95,7 @@ export async function prepareNativeProfile(options: NativeProfileOptions): Promi if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; // An existing partial copy must never be merged with a second preparation. await mkdir(runtime, { mode: 0o700 }); - await execute("cp", ["-a", "--reflink=auto", `${source}${sep}.`, runtime]); + await cp(source, runtime, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true, mode: constants.COPYFILE_FICLONE }); await chmod(runtime, 0o700); const marker = join(runtime, ownerFile); try { await plainFile(marker); } @@ -120,7 +104,7 @@ export async function prepareNativeProfile(options: NativeProfileOptions): Promi await chmod(marker, 0o600); } const validate = async () => { - if (await directory(runtime) !== runtime) throw new Error("The native profile root escapes its owned installation."); + if (await realDirectory(runtime) !== runtime) throw new Error("The native profile root escapes its owned installation."); await plainFile(join(runtime, ownerFile)); if ((await readFile(join(runtime, ownerFile), "utf8")) !== source) throw new Error("The native profile belongs to a different source installation."); }; diff --git a/src/replay/private-path.ts b/src/replay/private-path.ts new file mode 100644 index 0000000..1760ed6 --- /dev/null +++ b/src/replay/private-path.ts @@ -0,0 +1,18 @@ +import { lstat, realpath } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; + +/** Canonicalize ancestor aliases without accepting a symbolic link as the owned root. */ +export async function realDirectory(path: string): Promise { + const directory = resolve(path), state = await lstat(directory); + if (!state.isDirectory() || state.isSymbolicLink()) throw new Error("The private profile root is not a real directory."); + return realpath(directory); +} + +/** Resolve only the existing parent; the child may not exist and must remain a direct child. */ +export async function directChild(root: string, path: string, name = basename(path)): Promise { + const requested = resolve(path); + if (basename(requested) !== name || await realpath(dirname(requested)) !== root) { + throw new Error("The private output is outside its owned directory."); + } + return join(root, name); +} diff --git a/src/replay/process-environment.ts b/src/replay/process-environment.ts new file mode 100644 index 0000000..efd845b --- /dev/null +++ b/src/replay/process-environment.ts @@ -0,0 +1,46 @@ +import { execFile } from "node:child_process"; +import { readFile, readdir, realpath } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +async function canonical(path: string): Promise { + path = resolve(path); + try { return await realpath(path); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + // Cached runtimes and prefixes may not exist yet; their existing ancestors still have aliases. + const parent = dirname(path); + if (parent === path) throw error; + return join(await canonical(parent), basename(path)); + } +} + +/** Inspect exact environment entries, never command-line text or logged environment data. */ +export async function requireEnvironmentPathIdle(variable: "WINEPREFIX" | "BOL_HOME", path: string): Promise { + const target = await canonical(path); + if (process.platform === "darwin") { + const helper = fileURLToPath(new URL("../../scripts/replay-process-environment.py", import.meta.url)); + await new Promise((accept, reject) => { + execFile("python3", [helper, variable, target, String(process.pid)], { timeout: 15000, maxBuffer: 1024 }, error => { + if (!error) accept(); + else reject(new Error(error.code === 10 ? "The native profile or prefix is in use." : "Cannot inspect native process ownership on macOS.", { cause: error })); + }); + }); + return; + } + if (process.platform !== "linux") throw new Error("Native process ownership inspection is unavailable on this platform."); + for (const name of await readdir("/proc")) { + if (!/^\d+$/.test(name) || Number(name) === process.pid) continue; + let environment: Buffer; + try { environment = await readFile(`/proc/${name}/environ`); } + catch (error) { + if (["ENOENT", "ESRCH", "EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) continue; + throw error; + } + for (const entry of environment.toString().split("\0")) { + if (entry.startsWith(`${variable}=`) && await canonical(entry.slice(variable.length + 1)) === target) { + throw new Error("The native profile or prefix is in use."); + } + } + } +} diff --git a/test/native-prefix.test.ts b/test/native-prefix.test.ts index 9968239..0d83c21 100644 --- a/test/native-prefix.test.ts +++ b/test/native-prefix.test.ts @@ -118,7 +118,7 @@ test("concurrent preparers cannot merge or overwrite the winning clone", async ( } }); -test.skipIf(process.platform !== "linux")("detects a live exact WINEPREFIX without running Wine", async () => { +test.skipIf(!["linux", "darwin"].includes(process.platform))("detects a live exact WINEPREFIX without running Wine", async () => { const input = await fixture(); const child = Bun.spawn([process.execPath, "-e", 'console.log("ready"); setInterval(() => {}, 1000)'], { env: { ...process.env, WINEPREFIX: input.sourcePrefix }, stdout: "pipe", stderr: "ignore", diff --git a/test/native-profile.test.ts b/test/native-profile.test.ts index 1c9af22..32428ac 100644 --- a/test/native-profile.test.ts +++ b/test/native-profile.test.ts @@ -33,7 +33,7 @@ test("copies absolute launcher content into the private installation without cha expect(await readFile(join(input.source, "settings.json"))).toEqual(sourceSettings); expect(await readlink(join(input.source, "content"))).toBe(input.game); expect(result.settings.retain).toBe(input.settings.retain); - expect(await realpath(result.settings.proton)).toBe(join(input.runtime, relative(input.source, input.proton))); + expect(await realpath(result.settings.proton)).toBe(await realpath(join(input.runtime, relative(input.source, input.proton)))); }); test("supports relative content and internal source path aliases", async () => { @@ -42,8 +42,20 @@ test("supports relative content and internal source path aliases", async () => { await symlink(input.game, alias); await writeFile(join(input.source, "settings.json"), JSON.stringify({ ...input.settings, game_dir: alias })); const result = await prepareNativeProfile(input); - expect(await realpath(join(result.runtime, "content"))).toBe(join(input.runtime, relative(input.source, input.game))); - expect(await realpath(alias)).toBe(input.game); + expect(await realpath(join(result.runtime, "content"))).toBe(await realpath(join(input.runtime, relative(input.source, input.game)))); + expect(await realpath(alias)).toBe(await realpath(input.game)); +}); + +test("prepares canonical installations through ancestor aliases without accepting owned root links", async () => { + const input = await fixture(), alias = join(input.root, "ancestor"); + await symlink(input.root, alias); + const result = await prepareNativeProfile({ ...input, source: join(alias, "source"), privateRoot: join(alias, "replay"), runtime: join(alias, "replay", "native-client") }); + expect(result.runtime).toBe(await realpath(input.runtime)); + expect(await readFile(join(result.runtime, "content", "Minecraft.Windows.exe"))).toEqual(input.payload); + const rootLink = join(input.root, "owned-root-link"); + await symlink(input.privateRoot, rootLink); + await expect(prepareNativeProfile({ ...input, privateRoot: rootLink, runtime: join(rootLink, "native-client") })).rejects.toThrow(); + expect(await readFile(join(input.game, "Minecraft.Windows.exe"))).toEqual(input.payload); }); test("repairs a cached owned source-pointing content link and preserves copied executable", async () => { @@ -113,19 +125,21 @@ test("rejects foreign cached copies and root aliases without modifying them", as test("refuses a busy cached profile before repairing launcher pointers", async () => { const input = await fixture(); await prepareNativeProfile(input); + const runtime = await realpath(input.runtime); await rm(join(input.runtime, "content")); await symlink(input.game, join(input.runtime, "content")); await expect(prepareNativeProfile({ ...input, assertIdle: async profile => { - if (profile === input.runtime) throw new Error("Concurrent launcher"); + if (profile === runtime) throw new Error("Concurrent launcher"); } })).rejects.toThrow(); expect(await readlink(join(input.runtime, "content"))).toBe(input.game); }); test("rechecks cached ownership after an idle boundary before replacing content", async () => { const input = await fixture(); await prepareNativeProfile(input); + const runtime = await realpath(input.runtime); await rm(join(input.runtime, "content")); await symlink(input.game, join(input.runtime, "content")); const marker = join(input.runtime, ".stackanvil-source"); await expect(prepareNativeProfile({ ...input, assertIdle: async profile => { - if (profile === input.runtime) await writeFile(marker, input.root); + if (profile === runtime) await writeFile(marker, input.root); } })).rejects.toThrow(); expect(await readlink(join(input.runtime, "content"))).toBe(input.game); expect(await readFile(join(input.game, "Minecraft.Windows.exe"))).toEqual(input.payload); @@ -141,7 +155,7 @@ test("rejects source settings aliases without creating a runtime", async () => { await expect(lstat(input.runtime)).rejects.toThrow(); }); -test.skipIf(process.platform !== "linux")("detects exact live launcher ownership without running Bedrock", async () => { +test.skipIf(!["linux", "darwin"].includes(process.platform))("detects exact live launcher ownership without running Bedrock", async () => { const input = await fixture(); const child = Bun.spawn([process.execPath, "-e", 'console.log("ready"); setInterval(() => {}, 1000)'], { env: { ...process.env, BOL_HOME: input.runtime }, stdout: "pipe", stderr: "ignore", diff --git a/test/replay-java-profile.test.ts b/test/replay-java-profile.test.ts index ebab7df..db148d5 100644 --- a/test/replay-java-profile.test.ts +++ b/test/replay-java-profile.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readFile, readlink, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { prepareReplayPrismData, requireReplayCopySpace, privateReplayJavaPath, rebindReplayJavaConfig, validateReplayProfile } from "../src/replay/java-profile.ts"; @@ -41,7 +41,7 @@ test("copies cached assets, libraries and launcher state privately without chang expect((await lstat(join(target, directory.split("/")[0]!))).isSymbolicLink()).toBe(false); } expect(await readFile(join(target, "prismlauncher.cfg"), "utf8")).toBe("configuration"); - expect(await privateReplayJavaPath(source, target, join(source, "java/bin/runtime"))).toBe(join(target, "java/bin/runtime")); + expect(await privateReplayJavaPath(source, target, join(source, "java/bin/runtime"))).toBe(join(await realpath(target), "java/bin/runtime")); await expect(privateReplayJavaPath(source, target, join(source, "assets/entry"))).rejects.toThrow(); await expect(readFile(join(target, "instances"))).rejects.toThrow(); }); @@ -73,7 +73,7 @@ test("rebinds global and instance Java selection without changing source configu await writeFile(instance, configured); for (const path of [join(target, "prismlauncher.cfg"), instance]) { await rebindReplayJavaConfig(path, source, target); - expect(await readFile(path, "utf8")).toBe(`JavaPath=${join(target, "java/bin/java")}\n`); + expect(await readFile(path, "utf8")).toBe(`JavaPath=${join(await realpath(target), "java/bin/java")}\n`); } expect(await readFile(join(source, "prismlauncher.cfg"), "utf8")).toBe(configured); await writeFile(instance, "JavaPath=/usr/bin/java\n"); @@ -101,3 +101,19 @@ test("reserves disk for full private copies even when reflink support is unavail expect(() => requireReplayCopySpace(-1, reserve)).toThrow(); expect(() => requireReplayCopySpace(1024, Number.NaN)).toThrow(); }); + +test("copies through canonical ancestor aliases but refuses source and destination root symlinks", async () => { + const { source, target, root } = await fixture(), alias = join(root, "ancestor"); + await symlink(root, alias); + const sourceAlias = join(alias, "source"), targetAlias = join(alias, "private"); + await prepareReplayPrismData(sourceAlias, targetAlias); + expect(await privateReplayJavaPath(sourceAlias, targetAlias, join(sourceAlias, "java/bin/java"))).toBe(join(await realpath(target), "java/bin/java")); + await expect(privateReplayJavaPath(sourceAlias, targetAlias, join(sourceAlias, "java"))).rejects.toThrow(); + const sourceLink = join(root, "source-link"), targetLink = join(root, "target-link"); + await symlink(source, sourceLink); await symlink(target, targetLink); + await expect(prepareReplayPrismData(sourceLink, target)).rejects.toThrow(); + await expect(prepareReplayPrismData(source, targetLink)).rejects.toThrow(); + await expect(validateReplayProfile(sourceLink)).rejects.toThrow(); + await writeFile(join(target, "assets/entry"), "private-change"); + expect(await readFile(join(source, "assets/entry"), "utf8")).toBe("original"); +}); diff --git a/test/replay-private-path.test.ts b/test/replay-private-path.test.ts new file mode 100644 index 0000000..9731f24 --- /dev/null +++ b/test/replay-private-path.test.ts @@ -0,0 +1,89 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { directChild, realDirectory } from "../src/replay/private-path.ts"; +import { requireEnvironmentPathIdle } from "../src/replay/process-environment.ts"; +import { writePrivateFixture } from "../src/replay/form-fixture.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 paths $() ")); + roots.push(root); + const directory = join(root, "directory"), alias = join(root, "ancestor-alias"); + await mkdir(join(directory, "owned"), { recursive: true }); + await symlink(directory, alias); + return { root, directory, alias, owned: join(alias, "owned"), actual: await realpath(join(directory, "owned")) }; +} + +test("canonicalizes ancestor aliases but refuses an aliased owned root and unrelated outputs", async () => { + const input = await fixture(); + expect(await realDirectory(input.owned)).toBe(input.actual); + expect(await directChild(input.actual, join(input.owned, "child"))).toBe(join(input.actual, "child")); + await expect(realDirectory(input.alias)).rejects.toThrow(); + await expect(directChild(input.actual, join(input.directory, "child"))).rejects.toThrow(); + await expect(directChild(input.actual, join(input.owned, "child"), "different")).rejects.toThrow(); +}); + +test("writes a direct fixture under a canonical root and still refuses root and output symlinks", async () => { + const input = await fixture(), files = [{ name: "fixture.json", bytes: Buffer.from("private") }]; + await writePrivateFixture(join(input.owned, "fixture"), input.actual, files, async () => {}); + expect(await readFile(join(input.actual, "fixture", "fixture.json"), "utf8")).toBe("private"); + await expect(writePrivateFixture(join(input.alias, "fixture"), input.alias, files, async () => {})).rejects.toThrow(); + await symlink(join(input.actual, "fixture"), join(input.owned, "linked")); + await expect(writePrivateFixture(join(input.owned, "linked"), input.actual, files, async () => {})).rejects.toThrow(); + expect(await readFile(join(input.actual, "fixture", "fixture.json"), "utf8")).toBe("private"); +}); + +test.skipIf(!["linux", "darwin"].includes(process.platform))("matches complete live environment paths across aliases without matching arguments or neighboring paths", async () => { + const input = await fixture(); + for (const variable of ["BOL_HOME", "WINEPREFIX"] as const) { + for (const [environment, target, busy] of [[input.owned, input.actual, true], [`${input.owned}-neighbor`, input.actual, false], + [join(input.owned, "not-created"), join(input.actual, "not-created"), true]] as const) { + const child = Bun.spawn([process.execPath, "-e", 'console.log("ready"); setInterval(() => {}, 1000)', "", `${variable}=${input.actual}`], { + env: { ...process.env, [variable]: environment, [`OTHER_${variable}`]: input.actual }, stdout: "pipe", stderr: "ignore", + }); + try { + const reader = child.stdout.getReader(); await reader.read(); reader.releaseLock(); + if (busy) await expect(requireEnvironmentPathIdle(variable, target)).rejects.toThrow("in use"); + else await requireEnvironmentPathIdle(variable, target); + } finally { child.kill(); await child.exited; } + } + } +}); + +test.skipIf(process.platform !== "darwin")("refuses unavailable Darwin inspection without publishing process data", async () => { + const input = await fixture(); + const module = new URL("../src/replay/process-environment.ts", import.meta.url).href; + const code = `import { requireEnvironmentPathIdle } from ${JSON.stringify(module)}; + try { await requireEnvironmentPathIdle("WINEPREFIX", ${JSON.stringify(input.actual)}); process.exit(1); } + catch (error) { if (error.message !== "Cannot inspect native process ownership on macOS.") process.exit(2); }`; + const child = Bun.spawn([process.execPath, "-e", code], { env: { ...process.env, PATH: input.actual }, stdout: "pipe", stderr: "pipe" }); + expect(await child.exited).toBe(0); + expect(await new Response(child.stdout).text()).toBe(""); + expect(await new Response(child.stderr).text()).toBe(""); +}); + +test.skipIf(process.platform !== "darwin")("retries Darwin stack-copy races while preserving bounded failures for unreadable live processes", async () => { + const script = fileURLToPath(new URL("./replay-process-environment.test.py", import.meta.url)); + const child = Bun.spawn(["python3", script], { stdout: "pipe", stderr: "pipe" }); + const output = await new Response(child.stderr).text(); + expect(await child.exited).toBe(0); + expect(output).toContain("Ran 6 tests"); + expect(await new Response(child.stdout).text()).toBe(""); +}); + +test.skipIf(process.platform !== "darwin")("keeps inspecting exact ownership while unrelated processes repeatedly exit", async () => { + const input = await fixture(); + const code = 'console.log("ready"); while (true) { const child = Bun.spawn([process.execPath, "-e", ""], {stdout:"ignore",stderr:"ignore"}); await child.exited; }'; + const churn = Bun.spawn([process.execPath, "-e", code], { stdout: "pipe", stderr: "ignore" }); + try { + const reader = churn.stdout.getReader(); await reader.read(); reader.releaseLock(); + for (let iteration = 0; iteration < 30; iteration++) { + await requireEnvironmentPathIdle("WINEPREFIX", input.actual); + } + } finally { churn.kill(); await churn.exited; } +}, 15000); diff --git a/test/replay-process-environment.test.py b/test/replay-process-environment.test.py new file mode 100644 index 0000000..173f48a --- /dev/null +++ b/test/replay-process-environment.test.py @@ -0,0 +1,77 @@ +"""Deterministic Darwin stack-copy/lifetime regression cases using synthetic bytes only.""" +import errno +import ctypes +import importlib.util +import os +from pathlib import Path +import struct +import unittest + +source = Path(__file__).resolve().parents[1] / "scripts" / "replay-process-environment.py" +spec = importlib.util.spec_from_file_location("inspection", source) +inspection = importlib.util.module_from_spec(spec) +spec.loader.exec_module(inspection) + + +class StackCopyRaceTest(unittest.TestCase): + def setUp(self): + self.data = struct.pack("=i", 3) + b"/fixture\0\0fixture\0WINEPREFIX=/argument\0\0WINEPREFIX=/owned path\0\0" + + def test_exec_race_retries_then_matches_only_the_complete_environment_entry(self): + for code in (errno.EIO,): + reads = [] + def read(): + reads.append(True) + if len(reads) == 1: + raise OSError(code, "Stack changed") + return self.data + result = inspection.live_environment(read, lambda: 2, lambda _: None) + self.assertTrue(result == (b"WINEPREFIX=/owned path",)) + self.assertEqual(len(reads), 2) + + def test_exited_and_zombie_processes_have_no_environment_to_inspect(self): + for state in (None, 5): + def read(): + raise OSError(errno.EIO, "Exited stack") + self.assertTrue(inspection.live_environment(read, lambda: state, lambda _: None) == ()) + + def test_zero_argument_and_truncated_stacks_retry_instead_of_silently_skipping_live_processes(self): + for malformed in (struct.pack("=i", 0), self.data[:12]): + reads = iter((malformed, self.data)) + self.assertTrue(inspection.live_environment(lambda: next(reads), lambda: 2, lambda _: None) == (b"WINEPREFIX=/owned path",)) + + def test_persistently_unreadable_live_process_fails_closed_after_bounded_retries(self): + for code in (errno.EIO,): + reads = [] + def read(): + reads.append(True) + raise OSError(code, "Unreadable live stack") + with self.assertRaises(OSError) as failure: + inspection.live_environment(read, lambda: 2, lambda _: None) + self.assertEqual(failure.exception.errno, code) + self.assertEqual(len(reads), 3) + with self.assertRaises(ValueError): + inspection.live_environment(lambda: struct.pack("=i", 0), lambda: 2, lambda _: None) + + def test_unavailable_lifetime_inspection_and_nontransient_errors_fail_closed(self): + def read(): + raise OSError(errno.EIO, "Stack changed") + def state(): + raise OSError(errno.EPERM, "Unavailable lifetime") + with self.assertRaises(OSError): + inspection.live_environment(read, state, lambda _: None) + def permanent(): + raise OSError(errno.ENOMEM, "Unavailable inspection") + with self.assertRaises(OSError) as failure: + inspection.live_environment(permanent, lambda: None, lambda _: None) + self.assertEqual(failure.exception.errno, errno.ENOMEM) + + def test_native_lifetime_api_accepts_the_complete_short_info_layout(self): + library = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + library.proc_pidinfo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint64, ctypes.c_void_p, ctypes.c_int] + library.proc_pidinfo.restype = ctypes.c_int + self.assertTrue(inspection.process_state(library, os.getpid()) in (1, 2, 3, 4, 6, 7)) + + +if __name__ == "__main__": + unittest.main()