From 709893d7ca7194962c57fad83559da14c0071a07 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:33 +0200 Subject: [PATCH] perf(resource-packs): cache converted packs by content Converting a resource-pack stack on every join repeats expensive ZIP work and can advertise stale output when the pack content changes. Cache conversions by the effective pack stack and content hash, and verify cached ZIPs before reuse. Write converted packs and their index atomically, give identical content a stable advertised hash and ID, and make ZIP entry order deterministic. Add focused cache, load-state, and resource negotiation tests. Build on the resource loading changes merged in upstream PR #457. The current cache proposal is https://github.com/ViaVersionAddons/ViaBedrock/pull/430. Store typed converter metadata inside the cached ZIP and restore it for each connection on disk, memory and shared in-flight cache hits. Entity scales, attachable readiness and unresolved model diagnostics otherwise disappear when conversion is skipped. Tests compare miss and hit metadata and isolate the restored map from other connections. Refresh the conversion fingerprint when block particle bindings change so previously cached models are not reused without their particle sprite. Refresh the fingerprint for composed block bones and expanded carrier definitions so cached packs retain the correct geometry and state selectors. Refresh the fingerprint when the converter omits elements without rendered faces. Existing cached packs retain the invalid empty face objects and must rebuild with valid invisible Java elements and remapped groups. Include a resource-format revision independently of the selected conversion profile. Native caption translations add a separate resource archive; older converted packs must not survive this emitted-resource change merely because their source files and converter profile are unchanged. Advance this revision when shared output formats change, including for profiles selected by later features in the patch stack. Advance the resource-format revision for the new native particle archive. Cached conversions produced before particle transport must rebuild even when the source pack contents and selected converter profile are unchanged. Advance the emitted-resource epoch for the native actor archive. Cached packs from older converters lack graphs and pack provenance required by proxy client rendering, even when their input resource hashes still match. Keep converter metadata from the completed conversion instead of inflating the entire ZIP to recover it. Reuse the calculated hash and descriptor after atomic disk writes, while still verifying persisted cache hits. Rebuild truncated ZIPs or malformed cached metadata from source so cache corruption cannot repeatedly reject joins. Preserve archive bytes and normalized immutable metadata across memory and disk results. Stream disk-cache misses into a temporary ZIP while computing its hash. Publish the completed archive atomically before its index, and remove partial staging files on failure. Avoid allocating the complete ZIP in heap memory while retaining identical archive bytes and descriptors. Restore entity scales as finite floats, matching the converter producer and entity renderer. Reject numbers outside the float domain so persisted metadata cannot disconnect a connection during custom actor spawning. Compress large converted ZIPs with Apache Commons Compress scatter/gather workers. Cap each archive at four workers and use temporary disk storage instead of buffering every compressed entry on the heap. Preserve sorted entry order, UTF-8 names, timestamps, file bytes, and deterministic output across worker counts. Small archives retain the existing sequential writer. Wait for all workers before closing backing stores and removing the owned temporary directory, including after interruption and failed writes. Keep interrupt status and surface compression failures to the existing cache retry path. Regression tests exercise ordering, content, cancellation, cleanup, failed output, retry, and output ownership. Cached older archives remain valid because their resources and metadata have the same meanings. No resource-format revision is needed for this packaging change. The parallel path consumes temporary disk space and has more allocation than the sequential writer; it does not replace RakNet liveness while acquisition, prompts, downloads, or client reloads are slow. Expose the same bounded ZIP writer for ordered resource archives. Keep caller ordering so embedded library headers precede indexed resources, while ordinary converted packs retain sorted paths. Reuse failure cleanup and worker ownership instead of maintaining another parallel compressor. Make shared language parsing atomic for concurrent pack preparation. Bundled Content instances are reused across connections; HashMap computeIfAbsent races during concurrent cold definition loads and can throw ConcurrentModificationException. Retain one immutable parsed table per path without serializing unrelated language paths. Use a segmented Commons IO buffer for memory archives, avoiding copies of all preceding bytes each time the output grows. Keep disk streaming and deterministic archive bytes unchanged. Verify equivalence across buffer growth and the existing cache failure, retry and worker cases. Allow producers to mark already compressed payloads for stored ZIP output. Preserve that choice across content merges, reset it after ordinary writes, and retain the previous choice when a replacement fails. Keep payload writes behind one Content method so storage implementations cannot leave stale compression metadata. Content subclasses now implement putBytes. Sequential output calculates the required stored-entry size and CRC. Scatter/gather output uses the same explicit method without another deflation pass. Keep ordinary entries compressed, sorted and deterministic. Older cached packs remain valid because only ZIP methods change. Read converted entity scales through Number.floatValue(). Pack metadata can contain boxed doubles, and casting them to Float disconnects clients while spawning custom actors. The numeric conversion accepts both representations. 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 in-flight deduplication from completed conversion retention. Keep at most four completed packs under a 64 MiB archive and serialized metadata budget, evicting least recently used results. Remove completed futures on success and failure; active HTTP clients retain their own pack reference, and disk archives remain available after eviction. Clear both maps on shutdown. Rejoining servers with distinct pack fingerprints must not retain every converted ZIP and native metadata object indefinitely. Regression tests cover LRU reuse, eviction, oversized archives and retry. Move completed download verification, ZIP decoding and resource definition preparation onto bounded workers. Keep publication on the connection event loop with current-session guards. Complete outstanding work during shutdown and close ZIP inflater resources immediately. Preserve the four-entry, 64 MiB cache limit from the current stack. Count received download chunks without repeatedly scanning the receipt array. Complete cache retention and eviction before exposing converted packs to callers. Returning the worker future allowed caller continuations to reuse a pending oversized result before cache bookkeeping ran. A gated regression reproduces the ordering failure without sleeps and verifies a new conversion follows when completed packs are not retained. Co-authored-by: Miles Tech Studios <291174896+milesalexg-tech@users.noreply.github.com> --- build.gradle | 12 + .../api/model/entity/CustomEntity.java | 2 +- .../resourcepack/ResourcePackExecutor.java | 69 +++ .../api/resourcepack/content/Content.java | 56 ++- .../content/DirectoryContent.java | 2 +- .../resourcepack/content/InMemoryContent.java | 2 +- .../content/ResourcePackZipWriter.java | 190 +++++++++ .../api/resourcepack/content/ZipContent.java | 11 +- .../http/ConvertedResourcePackCache.java | 316 ++++++++++++++ .../http/ConverterDataManifest.java | 74 ++++ .../http/ResourcePackHttpServer.java | 157 +++---- .../viabedrock/api/util/FileSystemUtil.java | 18 + .../packet/ResourcePackClientResponse.java | 52 +++ .../ResourcePackDownloadCompletion.java | 38 ++ .../protocol/packet/ResourcePackPackets.java | 356 ++++++++++------ .../packet/ResourcePackPreparation.java | 38 ++ .../provider/ResourcePackProvider.java | 8 +- .../impl/DiskResourcePackProvider.java | 47 ++- .../impl/InMemoryResourcePackProvider.java | 34 +- .../impl/NoOpResourcePackProvider.java | 11 +- .../storage/ResourcePackDownloadTracker.java | 106 +++-- .../storage/ResourcePackLoadStateTracker.java | 105 ++++- .../ResourcePackExecutorTest.java | 87 ++++ .../content/ContentLanguageCacheTest.java | 65 +++ .../content/ResourcePackZipWriterTest.java | 241 +++++++++++ .../http/ConvertedResourcePackCacheTest.java | 398 ++++++++++++++++++ .../ResourcePackClientResponseTest.java | 74 ++++ .../ResourcePackDownloadCompletionTest.java | 206 +++++++++ .../packet/ResourcePackPreparationTest.java | 140 ++++++ .../ResourcePackDownloadTrackerTest.java | 116 +++++ .../ResourcePackLoadStateTrackerTest.java | 116 +++++ 31 files changed, 2837 insertions(+), 310 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 index 11733de3d..283f70835 100644 --- a/build.gradle +++ b/build.gradle @@ -88,6 +88,8 @@ dependencies { exclude group: "com.viaversion.mcstructs", module: "snbt" exclude group: "com.viaversion.mcstructs", module: "converter" } + api "org.apache.commons:commons-compress:1.28.0" + implementation "commons-io:commons-io:2.20.0" api "com.vdurmont:semver4j:3.1.0" api "com.mojang:brigadier:1.3.10" api "at.yawk.lz4:lz4-java:1.12.0" @@ -101,6 +103,16 @@ dependencies { toolImplementation "org.jsoup:jsoup:1.23.2" toolImplementation "org.ow2.asm:asm-tree:9.11" toolImplementation "net.lenni0451.commons:asm:1.9.2" + + testImplementation "com.viaversion:viaversion-common:5.12.0" + testImplementation "com.google.guava:guava:33.7.1-jre" + testImplementation "io.netty:netty-handler:4.2.18.Final" + testImplementation "org.junit.jupiter:junit-jupiter:6.1.3" + testRuntimeOnly "org.junit.platform:junit-platform-launcher:6.1.3" +} + +test { + useJUnitPlatform() } def registerToolTask(String name, String toolMainClass, String toolDescription) { diff --git a/src/main/java/net/raphimc/viabedrock/api/model/entity/CustomEntity.java b/src/main/java/net/raphimc/viabedrock/api/model/entity/CustomEntity.java index e353ba8b8..275bcbf1b 100644 --- a/src/main/java/net/raphimc/viabedrock/api/model/entity/CustomEntity.java +++ b/src/main/java/net/raphimc/viabedrock/api/model/entity/CustomEntity.java @@ -179,7 +179,7 @@ private void spawn() { final StructuredItem item = new StructuredItem(BedrockProtocol.MAPPINGS.getJavaItems().get("minecraft:paper"), 1, data); javaEntityData.add(new EntityData(partEntity.getJavaEntityDataIndex(EntityDataFields.ITEM_STACK), VersionedTypes.V26_3.entityDataTypes.itemType, item)); - final float scale = (float) resourcePackStorage.getConverterData().get("ce_" + converterKey + "_scale"); + final float scale = ((Number) resourcePackStorage.getConverterData().get("ce_" + converterKey + "_scale")).floatValue(); 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 +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/Content.java @@ -21,22 +21,25 @@ import com.viaversion.viaversion.util.GsonUtil; import net.raphimc.viabedrock.api.util.JsonUtil; +import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; + import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; -import java.util.zip.Deflater; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; public abstract class Content { - private final Map> langCache = new HashMap<>(); + private final Set storedZipEntries = new HashSet<>(); + + private final Map> langCache = new ConcurrentHashMap<>(); public abstract List getFilesShallow(final String path, final String extension); @@ -59,7 +62,24 @@ public String getFullPath(final String shortNamePath, final String... extensions public abstract byte[] get(final String path); - public abstract boolean put(final String path, final byte[] data); + public final boolean put(final String path, final byte[] data) { + final boolean replaced = this.putBytes(path, data); + this.storedZipEntries.remove(path); + return replaced; + } + + /** Stores an already compressed payload without another deflation pass when exporting the ZIP. */ + public final boolean putStored(final String path, final byte[] data) { + final boolean replaced = this.putBytes(path, data); + this.storedZipEntries.add(path); + return replaced; + } + + public final boolean isStored(final String path) { + return this.storedZipEntries.contains(path); + } + + protected abstract boolean putBytes(final String path, final byte[] data); public String getString(final String path) { final byte[] bytes = this.get(path); @@ -157,22 +177,22 @@ public void putAll(final Content content) { } public void copyFrom(final Content content, final String sourcePath, final String targetPath) { - this.put(targetPath, content.get(sourcePath)); + if (content.isStored(sourcePath)) { + this.putStored(targetPath, content.get(sourcePath)); + } else { + this.put(targetPath, content.get(sourcePath)); + } } public byte[] toZip() throws IOException { - final ByteArrayOutputStream baos = new ByteArrayOutputStream(4 * 1024 * 1024); - final ZipOutputStream zipOutputStream = new ZipOutputStream(baos); - zipOutputStream.setLevel(Deflater.BEST_SPEED); - for (String path : this.getFilesDeep("", "")) { - final ZipEntry entry = new ZipEntry(path); - entry.setTime(0L); - zipOutputStream.putNextEntry(entry); - zipOutputStream.write(this.get(path)); - zipOutputStream.closeEntry(); - } - zipOutputStream.close(); - return baos.toByteArray(); + final var output = UnsynchronizedByteArrayOutputStream.builder().setBufferSize(4 * 1024 * 1024).get(); + this.writeZip(output); + return output.toByteArray(); + } + + /** Writes the deterministic archive and closes the supplied output. */ + public void writeZip(final OutputStream output) throws IOException { + ResourcePackZipWriter.write(this, output); } public static class LazyImage { diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/DirectoryContent.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/DirectoryContent.java index 9725052a1..64ecfcb05 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/DirectoryContent.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/DirectoryContent.java @@ -89,7 +89,7 @@ public byte[] get(final String path) { } @Override - public boolean put(final String path, final byte[] data) { + protected boolean putBytes(final String path, final byte[] data) { final boolean exists = this.contains(path); try { Files.write(this.resolvePath(this.dir, Path.of(path)), data); diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/InMemoryContent.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/InMemoryContent.java index 7717839d7..ffedf0521 100644 --- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/InMemoryContent.java +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/InMemoryContent.java @@ -47,7 +47,7 @@ public byte[] get(final String path) { } @Override - public boolean put(final String path, final byte[] data) { + protected boolean putBytes(final String path, final byte[] data) { return this.content.put(path, data) != null; } diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ResourcePackZipWriter.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ResourcePackZipWriter.java new file mode 100644 index 000000000..e818cc48e --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/ResourcePackZipWriter.java @@ -0,0 +1,190 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.raphimc.viabedrock.api.resourcepack.content; + +import org.apache.commons.compress.archivers.zip.ParallelScatterZipCreator; +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.apache.commons.compress.parallel.FileBasedScatterGatherBackingStore; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.Deflater; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** Compresses large archives in parallel while preserving entry order and bounded working storage. */ +public final class ResourcePackZipWriter { + + private static final long PARALLEL_THRESHOLD = 8 * 1024 * 1024; + + private ResourcePackZipWriter() { + } + + static void write(final Content content, final OutputStream output) throws IOException { + try (output) { + final List entries = new ArrayList<>(); + for (final String path : content.getFilesDeep("", "").stream().sorted().toList()) { + entries.add(new Entry(path, content.get(path), content.isStored(path))); + } + write(entries, output); + } + } + + /** Writes entries in caller order, bounds compression workers and closes the supplied output. */ + public static void write(final List entries, final OutputStream output) throws IOException { + try (output) { + final long bytes = entries.stream().mapToLong(entry -> entry.data().length).sum(); + final int workers = Math.min(4, Runtime.getRuntime().availableProcessors()); + if (workers > 1 && entries.size() > 1 && bytes >= PARALLEL_THRESHOLD) { + writeParallel(entries, output, Path.of(System.getProperty("java.io.tmpdir")), workers); + } else { + try (ZipOutputStream zip = new ZipOutputStream(output)) { + zip.setLevel(Deflater.BEST_SPEED); + for (final Entry file : entries) { + final ZipEntry entry = new ZipEntry(file.path()); + entry.setTime(0L); + if (file.stored()) { + final CRC32 crc = new CRC32(); + crc.update(file.data()); + entry.setMethod(ZipEntry.STORED); + entry.setSize(file.data().length); + entry.setCompressedSize(file.data().length); + entry.setCrc(crc.getValue()); + } + zip.putNextEntry(entry); + zip.write(file.data()); + zip.closeEntry(); + } + } + } + } + } + + static void writeParallel(final List entries, final OutputStream output, + final Path temporaryRoot, final int workers) throws IOException { + try (TemporaryDirectory directory = new TemporaryDirectory(Files.createTempDirectory(temporaryRoot, "viabedrock-zip-"), new ConcurrentLinkedQueue<>()); + ZipArchiveOutputStream zip = new ZipArchiveOutputStream(output)) { + final AtomicInteger workerIndex = new AtomicInteger(); + final var executor = Executors.newFixedThreadPool(workers, task -> { + final Thread thread = new Thread(task, "ViaBedrock ZIP Compressor-" + workerIndex.getAndIncrement()); + thread.setDaemon(true); + return thread; + }); + try { + final ParallelScatterZipCreator scatter = new ParallelScatterZipCreator(executor, + directory::createStore, Deflater.BEST_SPEED); + zip.setEncoding("UTF-8"); + for (final Entry file : entries) { + final ZipArchiveEntry entry = new ZipArchiveEntry(file.path()); + entry.setTime(0L); + entry.setMethod(file.stored() ? ZipEntry.STORED : ZipEntry.DEFLATED); + scatter.addArchiveEntry(entry, () -> new ByteArrayInputStream(file.data())); + } + scatter.writeTo(zip); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted resource-pack compression", e); + } catch (final ExecutionException e) { + throw new IOException("Failed to compress resource-pack entries", e.getCause()); + } finally { + executor.shutdownNow(); + boolean interrupted = Thread.interrupted(); + while (!executor.isTerminated()) { + try { + executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); + } catch (final InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + } + + /** Entry bytes remain owned by the caller and must not change during the write. */ + public record Entry(String path, byte[] data, boolean stored) { + public Entry(final String path, final byte[] data) { + this(path, data, false); + } + } + + /** Removes late-created worker files after the executor has terminated, including on failed writes. */ + private record TemporaryDirectory(Path path, ConcurrentLinkedQueue stores) implements AutoCloseable { + FileBasedScatterGatherBackingStore createStore() throws IOException { + final var store = new FileBasedScatterGatherBackingStore(Files.createTempFile(this.path, "compressed-", ".tmp")); + this.stores.add(store); + return store; + } + + @Override + public void close() throws IOException { + IOException failure = null; + for (final var store : this.stores) { + try { + store.close(); + } catch (final IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + try (var files = Files.list(this.path)) { + for (final Path file : files.toList()) { + try { + Files.deleteIfExists(file); + } catch (final IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + } + try { + Files.delete(this.path); + } catch (final IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + if (failure != null) { + throw failure; + } + } + } + +} 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 @@ 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..d54945712 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java @@ -0,0 +1,316 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.raphimc.viabedrock.api.resourcepack.http; + +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; +import net.raphimc.viabedrock.platform.ViaBedrockConfig; +import net.raphimc.viabedrock.protocol.rewriter.ResourcePackRewriter; +import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; + +import java.io.BufferedOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +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.logging.Level; + +public class ConvertedResourcePackCache { + + private static final String CONVERSION_VERSION = "resolved-model-textures-and-appearance-6"; + // Advance when the set or format of emitted resources changes independently of converter profiles. + private static final int RESOURCE_FORMAT_VERSION = 6; + + private final Path directory; + private final ViaBedrockConfig.PackCacheMode mode; + private final Function converter; + private static final int MAX_COMPLETED_PACKS = 4; + private static final long MAX_COMPLETED_BYTES = 64L * 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 -> { + try { + final long start = System.nanoTime(); + final Content content = ResourcePackRewriter.bedrockToJava(storage); + ViaBedrock.getPlatform().getLogger().log(Level.INFO, "Converted resource packs in " + ((System.nanoTime() - start) / 1_000_000L) + "ms"); + return content; + } catch (final Exception e) { + throw new CompletionException("Failed to convert resource packs", e); + } + }); + } + + 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 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 = new CompletableFuture<>(); + this.pending.put(key, future); + this.executor.submit(() -> this.mode == ViaBedrockConfig.PackCacheMode.DISK ? this.loadOrConvert(key, storage) : this.convert(storage)).whenComplete((pack, error) -> { + synchronized (this) { + this.pending.remove(key, future); + if (error == null && !this.stopped) { + this.retain(key, pack); + } + } + // Callers must observe retention and eviction before requesting another conversion. + if (error == null) { + future.complete(pack); + } else { + future.completeExceptionally(error); + } + }); + 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() { + synchronized (this) { + this.stopped = true; + this.completed.clear(); + this.completedBytes = 0; + } + this.executor.stop(); + } + + private Pack loadOrConvert(final String key, final ResourcePackStorage storage) { + try { + Files.createDirectories(this.directory); + final Path index = this.directory.resolve(key + ".sha1"); + if (Files.isRegularFile(index)) { + final String expectedSha1 = Files.readString(index); + if (expectedSha1.matches("[0-9a-f]{40}")) { + final Path cachedPath = this.directory.resolve(expectedSha1 + ".zip"); + if (Files.isRegularFile(cachedPath)) { + try { + final Pack cached = describe(cachedPath); + if (cached.sha1().equals(expectedSha1)) { + return cached; + } + } catch (final IOException | RuntimeException invalid) { + // Rebuild incomplete ZIPs and obsolete or malformed converter metadata. + } + } + } + } + + final Content content = this.convertContent(storage); + final Map metadata = ConverterDataManifest.decode(content.getString(ConverterDataManifest.PATH)); + final Path temporary = Files.createTempFile(this.directory, "conversion-", ".tmp"); + try { + final MessageDigest digest = digest("SHA-1"); + try (var output = new BufferedOutputStream(new DigestOutputStream(Files.newOutputStream(temporary), digest), 64 * 1024)) { + content.writeZip(output); + } + final String sha1 = HexFormat.of().formatHex(digest.digest()); + final UUID id = UUID.nameUUIDFromBytes(("ViaBedrock:" + sha1).getBytes(StandardCharsets.UTF_8)); + final long size = Files.size(temporary); + final Path path = this.directory.resolve(sha1 + ".zip"); + FileSystemUtil.moveAtomically(temporary, path); + FileSystemUtil.writeAtomically(index, sha1.getBytes(StandardCharsets.US_ASCII)); + return new Pack(path, null, size, sha1, id, metadata); + } finally { + Files.deleteIfExists(temporary); + } + } catch (final Exception e) { + throw new CompletionException("Failed to prepare converted resource pack", e); + } + } + + private Pack convert(final ResourcePackStorage storage) { + try { + final Content content = this.convertContent(storage); + return describe(content.toZip(), ConverterDataManifest.decode(content.getString(ConverterDataManifest.PATH))); + } catch (final Exception e) { + throw new CompletionException("Failed to convert resource packs", e); + } + } + + private Content convertContent(final ResourcePackStorage storage) { + final Content content = this.converter.apply(storage); + content.putJson(ConverterDataManifest.PATH, ConverterDataManifest.encode(storage.getConverterData())); + return content; + } + + static Pack describe(final Path path) throws IOException { + final MessageDigest digest = digest("SHA-1"); + try (var input = Files.newInputStream(path)) { + final byte[] buffer = new byte[64 * 1024]; + int length; + while ((length = input.read(buffer)) != -1) { + digest.update(buffer, 0, length); + } + } + final String sha1 = HexFormat.of().formatHex(digest.digest()); + final UUID id = UUID.nameUUIDFromBytes(("ViaBedrock:" + sha1).getBytes(StandardCharsets.UTF_8)); + final Map converterData; + try (ZipFile zip = new ZipFile(path.toFile())) { + final var entry = zip.getEntry(ConverterDataManifest.PATH); + converterData = entry == null ? Map.of() : ConverterDataManifest.decode(new String(zip.getInputStream(entry).readAllBytes(), StandardCharsets.UTF_8)); + } + return new Pack(path, null, Files.size(path), sha1, id, converterData); + } + + private static Pack describe(final byte[] bytes, final Map converterData) { + final String sha1 = HexFormat.of().formatHex(digest("SHA-1").digest(bytes)); + final UUID id = UUID.nameUUIDFromBytes(("ViaBedrock:" + sha1).getBytes(StandardCharsets.UTF_8)); + return new Pack(null, bytes, bytes.length, sha1, id, converterData); + } + + static Pack describe(final byte[] bytes) { + final String sha1 = HexFormat.of().formatHex(digest("SHA-1").digest(bytes)); + final UUID id = UUID.nameUUIDFromBytes(("ViaBedrock:" + sha1).getBytes(StandardCharsets.UTF_8)); + try { + final Content content = new ZipContent(bytes); + final Map converterData = content.contains(ConverterDataManifest.PATH) + ? ConverterDataManifest.decode(content.getString(ConverterDataManifest.PATH)) : Map.of(); + return new Pack(null, bytes, bytes.length, sha1, id, converterData); + } catch (final IOException e) { + throw new CompletionException("Invalid converted resource pack", e); + } + } + + static String fingerprint(final Collection packs) { + final MessageDigest digest = digest("SHA-256"); + try (var output = new DataOutputStream(new DigestOutputStream(OutputStream.nullOutputStream(), digest))) { + writeString(output, ViaBedrock.IMPL_VERSION); + output.writeInt(RESOURCE_FORMAT_VERSION); + writeString(output, CONVERSION_VERSION); + for (ResourcePack pack : packs) { + writeString(output, pack.key().toString()); + final List paths = new ArrayList<>(pack.content().getFilesDeep("", "")); + paths.sort(String::compareTo); + output.writeInt(paths.size()); + for (String path : paths) { + writeString(output, path); + final byte[] bytes = pack.content().get(path); + output.writeInt(bytes.length); + output.write(bytes); + } + } + } catch (final IOException e) { + throw new CompletionException(e); + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static void writeString(final DataOutputStream output, final String value) throws IOException { + final byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + output.writeInt(bytes.length); + output.write(bytes); + } + + private static MessageDigest digest(final String algorithm) { + try { + return MessageDigest.getInstance(algorithm); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException(algorithm + " is not available", e); + } + } + + public record Pack(Path path, byte[] bytes, long size, String sha1, UUID id, Map converterData) { + + public Pack { + converterData = Map.copyOf(converterData); + } + } + +} diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConverterDataManifest.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConverterDataManifest.java new file mode 100644 index 000000000..fc384c9b5 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConverterDataManifest.java @@ -0,0 +1,74 @@ +/* + * 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.http; + +import com.viaversion.viaversion.libs.gson.JsonElement; +import com.viaversion.viaversion.libs.gson.JsonObject; +import com.viaversion.viaversion.libs.gson.JsonParser; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Explicit cache schema for converted model scales, readiness, and unresolved assets. */ +final class ConverterDataManifest { + + static final String PATH = "viabedrock.converter.json"; + + private ConverterDataManifest() { + } + + static JsonObject encode(final Map data) { + final JsonObject manifest = new JsonObject(); + manifest.addProperty("version", 1); + final JsonObject scales = new JsonObject(); + final JsonObject attachables = new JsonObject(); + final JsonObject missing = new JsonObject(); + data.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> { + if (entry.getKey().startsWith("ce_") && entry.getKey().endsWith("_scale") && entry.getValue() instanceof Number scale && Float.isFinite(scale.floatValue())) { + scales.addProperty(entry.getKey(), scale.doubleValue()); + } else if (entry.getKey().startsWith("ca_") && entry.getValue() instanceof Boolean ready) { + attachables.addProperty(entry.getKey(), ready); + } else if (entry.getKey().startsWith("model_missing_") && entry.getValue() instanceof String reason) { + missing.addProperty(entry.getKey(), reason); + } else { + throw new IllegalArgumentException("Unsupported converter metadata " + entry.getKey()); + } + }); + manifest.add("entity_scales", scales); + manifest.add("attachable_models", attachables); + manifest.add("unresolved_models", missing); + return manifest; + } + + static Map decode(final String source) { + final JsonObject manifest = JsonParser.parseString(source).getAsJsonObject(); + if (!manifest.has("version") || manifest.get("version").getAsInt() != 1) { + throw new IllegalArgumentException("Unsupported converter metadata version"); + } + final Map data = new LinkedHashMap<>(); + for (Map.Entry entry : manifest.getAsJsonObject("entity_scales").entrySet()) { + final float scale = entry.getValue().getAsFloat(); + if (!entry.getKey().startsWith("ce_") || !entry.getKey().endsWith("_scale") || !Float.isFinite(scale)) { + throw new IllegalArgumentException("Invalid converted entity scale"); + } + data.put(entry.getKey(), scale); + } + for (Map.Entry entry : manifest.getAsJsonObject("attachable_models").entrySet()) { + if (!entry.getKey().startsWith("ca_") || !entry.getValue().getAsJsonPrimitive().isBoolean()) { + throw new IllegalArgumentException("Invalid converted attachable readiness"); + } + data.put(entry.getKey(), entry.getValue().getAsBoolean()); + } + for (Map.Entry entry : manifest.getAsJsonObject("unresolved_models").entrySet()) { + if (!entry.getKey().startsWith("model_missing_") || !entry.getValue().getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("Invalid unresolved model asset"); + } + data.put(entry.getKey(), entry.getValue().getAsString()); + } + return Map.copyOf(data); + } + +} 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..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,114 @@ import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.http.*; +import io.netty.handler.stream.ChunkedFile; import io.netty.handler.stream.ChunkedStream; import io.netty.handler.stream.ChunkedWriteHandler; 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; +import java.io.RandomAccessFile; import java.net.InetSocketAddress; -import java.util.HashMap; -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; public class ResourcePackHttpServer { private final InetSocketAddress bindAddress; 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) { this.bindAddress = bindAddress; this.channelFuture = new ServerBootstrap() - .group(new NioEventLoopGroup(0)) - .channel(NioServerSocketChannel.class) - .option(ChannelOption.SO_BACKLOG, 128) - .childOption(ChannelOption.TCP_NODELAY, true) - .childOption(ChannelOption.SO_KEEPALIVE, true) - .childHandler(new ChannelInitializer<>() { - @Override - protected void initChannel(final Channel channel) { - channel.pipeline().addLast("http_codec", new HttpServerCodec()); - channel.pipeline().addLast("chunked_writer", new ChunkedWriteHandler()); - channel.pipeline().addLast("http_handler", new SimpleChannelInboundHandler<>() { - @Override - protected void channelRead0(final ChannelHandlerContext ctx, final Object msg) throws InterruptedException { - if (msg instanceof HttpRequest request) { - if (!request.method().equals(HttpMethod.GET)) { - ctx.close(); - return; - } - - final QueryStringDecoder queryStringDecoder = new QueryStringDecoder(request.uri()); - if (!queryStringDecoder.parameters().containsKey("token")) { - ctx.close(); - return; - } - final UUID uuid = UUID.fromString(queryStringDecoder.parameters().get("token").get(0)); - final UserConnection user = ResourcePackHttpServer.this.connections.get(uuid); - if (user == null) { - ctx.close(); - return; + .group(new NioEventLoopGroup(0)) + .channel(NioServerSocketChannel.class) + .option(ChannelOption.SO_BACKLOG, 128) + .childOption(ChannelOption.TCP_NODELAY, true) + .childOption(ChannelOption.SO_KEEPALIVE, true) + .childHandler(new ChannelInitializer<>() { + @Override + protected void initChannel(final Channel channel) { + channel.pipeline().addLast("http_codec", new HttpServerCodec()); + channel.pipeline().addLast("chunked_writer", new ChunkedWriteHandler()); + channel.pipeline().addLast("http_handler", new SimpleChannelInboundHandler<>() { + @Override + protected void channelRead0(final ChannelHandlerContext ctx, final Object msg) { + if (msg instanceof HttpRequest request) { + if (!request.method().equals(HttpMethod.GET)) { + ctx.close(); + return; + } + + final QueryStringDecoder queryStringDecoder = new QueryStringDecoder(request.uri()); + if (!queryStringDecoder.parameters().containsKey("token")) { + ctx.close(); + return; + } + final UUID uuid = UUID.fromString(queryStringDecoder.parameters().get("token").get(0)); + final ConvertedResourcePackCache.Pack pack = ResourcePackHttpServer.this.connections.get(uuid); + if (pack == null) { + ctx.close(); + return; + } + + try { + final DefaultHttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/octet-stream"); + response.headers().set(HttpHeaderNames.CONTENT_LENGTH, pack.size()); + response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); + ctx.write(response); + final HttpChunkedInput content = pack.bytes() != null + ? new HttpChunkedInput(new ChunkedStream(new ByteArrayInputStream(pack.bytes()), 65535)) + : new HttpChunkedInput(new ChunkedFile(new RandomAccessFile(pack.path().toFile(), "r"), 0, pack.size(), 65535)); + ctx.writeAndFlush(content).addListener(ChannelFutureListener.CLOSE); + } catch (final Throwable e) { + ViaBedrock.getPlatform().getLogger().log(Level.SEVERE, "Failed to serve converted resource pack", e); + 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(); - } - }); - } - }) - .bind(bindAddress) - .syncUninterruptibly(); + }); + } + }) + .bind(bindAddress) + .syncUninterruptibly(); } - public void addConnection(final UUID uuid, final UserConnection connection) { - synchronized (this.connections) { - this.connections.put(uuid, connection); - } - + public void addConnection(final UUID uuid, final UserConnection connection, final ConvertedResourcePackCache.Pack pack) { + this.connections.put(uuid, pack); connection.getChannel().closeFuture().addListener(future -> { - synchronized (this.connections) { - this.connections.remove(uuid); - } + this.connections.remove(uuid); }); } + 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(); } diff --git a/src/main/java/net/raphimc/viabedrock/api/util/FileSystemUtil.java b/src/main/java/net/raphimc/viabedrock/api/util/FileSystemUtil.java index ac3b469c8..34fa74686 100644 --- a/src/main/java/net/raphimc/viabedrock/api/util/FileSystemUtil.java +++ b/src/main/java/net/raphimc/viabedrock/api/util/FileSystemUtil.java @@ -31,6 +31,24 @@ public final class FileSystemUtil { + public static void writeAtomically(final Path path, final byte[] bytes) throws IOException { + final Path temporary = Files.createTempFile(path.getParent(), path.getFileName().toString(), ".tmp"); + try { + Files.write(temporary, bytes); + moveAtomically(temporary, path); + } finally { + Files.deleteIfExists(temporary); + } + } + + public static void moveAtomically(final Path source, final Path destination) throws IOException { + try { + Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (final AtomicMoveNotSupportedException ignored) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + public static Map getFilesInDirectory(final String assetPath) throws IOException, URISyntaxException { final Path path = getPath(FileSystemUtil.class.getClassLoader().getResource(assetPath).toURI()); return getFilesInPath(path); diff --git a/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackClientResponse.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackClientResponse.java new file mode 100644 index 000000000..d6ef914b6 --- /dev/null +++ b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackClientResponse.java @@ -0,0 +1,52 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.raphimc.viabedrock.protocol.packet; + +import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ResourcePackResponse; +import net.raphimc.viabedrock.protocol.types.BedrockTypes; + +public final class ResourcePackClientResponse { + + private ResourcePackClientResponse() { + } + + public static void write(final PacketWrapper wrapper, final ResourcePackResponse status) { + if (status == ResourcePackResponse.Downloading) { + throw new IllegalArgumentException("Downloading requires a pack list"); + } + writeStatus(wrapper, status); + } + + public static void writeDownloading(final PacketWrapper wrapper, final String[] packIds) { + writeStatus(wrapper, ResourcePackResponse.Downloading); + wrapper.write(BedrockTypes.STRING_ARRAY, packIds); + } + + private static void writeStatus(final PacketWrapper wrapper, final ResourcePackResponse status) { + final String name = switch (status) { + case Cancel -> "cancel"; + case Downloading -> "downloading"; + case DownloadingFinished -> "downloadingfinished"; + case ResourcePackStackFinished -> "resourcepackstackfinished"; + }; + wrapper.write(BedrockTypes.UNSIGNED_VAR_INT, status.getValue()); + wrapper.write(BedrockTypes.STRING, name); + } + +} 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 dca2f9e71..507e1f895 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 @@ import com.viaversion.viaversion.api.Via; import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; -import com.viaversion.viaversion.api.protocol.packet.State; -import com.viaversion.viaversion.api.protocol.remapper.PacketHandler; +import com.viaversion.viaversion.api.connection.UserConnection; import com.viaversion.viaversion.api.type.Types; import com.viaversion.viaversion.protocols.v1_21_7to1_21_9.packet.ServerboundConfigurationPackets1_21_9; import com.viaversion.viaversion.protocols.v26_2to26_3.packet.ClientboundConfigurationPackets26_3; import net.raphimc.viabedrock.ViaBedrock; import net.raphimc.viabedrock.api.resourcepack.ResourcePack; +import net.raphimc.viabedrock.api.resourcepack.http.ConvertedResourcePackCache; import net.raphimc.viabedrock.api.util.TextUtil; import net.raphimc.viabedrock.protocol.BedrockProtocol; import net.raphimc.viabedrock.protocol.ClientboundBedrockPackets; @@ -47,117 +47,126 @@ 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 { + private ResourcePackPackets() { + } + public static void register(final BedrockProtocol protocol) { - protocol.registerClientboundTransition(ClientboundBedrockPackets.RESOURCE_PACKS_INFO, - ClientboundConfigurationPackets26_3.RESOURCE_PACK_PUSH, (PacketHandler) wrapper -> { - if (wrapper.user().has(ResourcePackLoadStateTracker.class) || wrapper.user().has(ResourcePackStorage.class)) { - ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Received RESOURCE_PACKS_INFO after resource pack negotiation was already started/finished"); - wrapper.cancel(); - return; - } - wrapper.read(Types.BOOLEAN); // resource pack required - wrapper.read(Types.BOOLEAN); // has addon packs + protocol.registerClientbound(ClientboundBedrockPackets.RESOURCE_PACKS_INFO, null, wrapper -> { + wrapper.cancel(); + if (wrapper.user().has(ResourcePackLoadStateTracker.class) || wrapper.user().has(ResourcePackStorage.class)) { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Received RESOURCE_PACKS_INFO after resource pack negotiation was already started/finished"); + return; + } + wrapper.read(Types.BOOLEAN); // resource pack required + wrapper.read(Types.BOOLEAN); // has addon packs + wrapper.read(Types.BOOLEAN); // has scripts + wrapper.read(Types.BOOLEAN); // force disable vibrant visuals + wrapper.read(BedrockTypes.UUID); // world template uuid + wrapper.read(BedrockTypes.STRING); // world template version + final ResourcePackLoadStateTracker.Info[] infos = new ResourcePackLoadStateTracker.Info[wrapper.read(BedrockTypes.UNSIGNED_VAR_INT)]; // resource packs size + for (int i = 0; i < infos.length; i++) { + final UUID id = wrapper.read(BedrockTypes.UUID); // pack id + 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 + wrapper.read(BedrockTypes.STRING); // subpack names + final String contentId = wrapper.read(BedrockTypes.STRING); // content identity wrapper.read(Types.BOOLEAN); // has scripts - wrapper.read(Types.BOOLEAN); // force disable vibrant visuals - wrapper.read(BedrockTypes.UUID); // world template uuid - wrapper.read(BedrockTypes.STRING); // world template version - final ResourcePackLoadStateTracker.Info[] infos = new ResourcePackLoadStateTracker.Info[wrapper.read(BedrockTypes.UNSIGNED_VAR_INT)]; // resource packs size - for (int i = 0; i < infos.length; i++) { - final UUID id = wrapper.read(BedrockTypes.UUID); // pack id - 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 - wrapper.read(BedrockTypes.STRING); // subpack names - final String contentId = wrapper.read(BedrockTypes.STRING); // content identity - wrapper.read(Types.BOOLEAN); // has scripts - wrapper.read(Types.BOOLEAN); // is addon pack - wrapper.read(Types.BOOLEAN); // is ray tracing capable - URL cdnUrl = null; - try { - final String cdnUrlString = wrapper.read(BedrockTypes.STRING); // cdn url - if (!cdnUrlString.isEmpty()) { - cdnUrl = new URL(cdnUrlString); - } - } catch (final MalformedURLException ignored) { + wrapper.read(Types.BOOLEAN); // is addon pack + wrapper.read(Types.BOOLEAN); // is ray tracing capable + URL cdnUrl = null; + try { + final String cdnUrlString = wrapper.read(BedrockTypes.STRING); // cdn url + if (!cdnUrlString.isEmpty()) { + cdnUrl = new URL(cdnUrlString); } - infos[i] = new ResourcePackLoadStateTracker.Info(new ResourcePack.Key(id, version), contentKey, contentId, cdnUrl); + } catch (final MalformedURLException ignored) { } - wrapper.user().put(new ResourcePackLoadStateTracker(wrapper.user(), infos)); - - if (ViaBedrock.getConfig().shouldTranslateResourcePacks() && wrapper.user().getProtocolInfo().protocolVersion().newerThanOrEqualTo(ProtocolConstants.JAVA_VERSION)) { - final UUID httpToken = UUID.randomUUID(); - ViaBedrock.getResourcePackServer().addConnection(httpToken, wrapper.user()); + infos[i] = new ResourcePackLoadStateTracker.Info(new ResourcePack.Key(id, version), contentKey, contentId, cdnUrl); + } + final UserConnection user = wrapper.user(); + final ResourcePackLoadStateTracker loadStateTracker = new ResourcePackLoadStateTracker(user, infos); + user.put(loadStateTracker); - wrapper.write(Types.UUID, UUID.randomUUID()); // id - wrapper.write(Types.STRING, ViaBedrock.getResourcePackServer().getUrl() + "?token=" + httpToken); // url - wrapper.write(Types.STRING, ""); // hash - wrapper.write(Types.BOOLEAN, false); // required - wrapper.write(Types.OPTIONAL_TAG, TextUtil.stringToNbt( - "\n§aIf you press 'Yes', the resource packs will be downloaded and converted to the Java Edition format. " - + "This may take a while, depending on your internet connection and the size of the packs. " - + "If you press 'No', you can join without loading the resource packs but you will have a worse gameplay experience.") - ); // prompt - } else { - wrapper.cancel(); - final PacketWrapper resourcePack = PacketWrapper.create(ServerboundConfigurationPackets1_21_9.RESOURCE_PACK, wrapper.user()); - resourcePack.write(Types.UUID, UUID.randomUUID()); // id - resourcePack.write(Types.VAR_INT, ResourcePackAction.DECLINED.ordinal()); // action - resourcePack.sendToServer(BedrockProtocol.class, false); - } - }, State.PLAY, (PacketHandler) PacketWrapper::cancel // Bedrock client ignores resource packs after the initial info packet - ); + if (shouldTranslate(user)) { + loadStateTracker.loadRequestedResourcePacks().thenRun(() -> { + user.getChannel().eventLoop().execute(() -> { + if (!user.getChannel().isActive() || user.get(ResourcePackLoadStateTracker.class) != loadStateTracker || loadStateTracker.hasReceivedStack()) { + return; + } + final PacketWrapper response = PacketWrapper.create(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE, user); + ResourcePackClientResponse.write(response, ResourcePackResponse.DownloadingFinished); + response.sendToServer(BedrockProtocol.class); + }); + }).exceptionally(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 { + final PacketWrapper response = PacketWrapper.create(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE, wrapper.user()); + ResourcePackClientResponse.write(response, ResourcePackResponse.DownloadingFinished); + response.sendToServer(BedrockProtocol.class); + } + }); protocol.registerClientbound(ClientboundBedrockPackets.RESOURCE_PACK_STACK, null, wrapper -> { wrapper.cancel(); - final ResourcePackLoadStateTracker loadStateTracker = wrapper.user().remove(ResourcePackLoadStateTracker.class); - if (loadStateTracker != null) { - wrapper.read(Types.BOOLEAN); // resource pack required - final ResourcePack.Key[] keys = new ResourcePack.Key[wrapper.read(BedrockTypes.UNSIGNED_VAR_INT)]; // resource packs size - for (int i = 0; i < keys.length; i++) { - final UUID id = UUID.fromString(wrapper.read(BedrockTypes.STRING)); // id - final String version = wrapper.read(BedrockTypes.STRING); // version - wrapper.read(BedrockTypes.STRING); // subpack name - keys[i] = new ResourcePack.Key(id, version); - } - wrapper.read(BedrockTypes.STRING); // base game version - final Experiment[] experiments = wrapper.read(BedrockTypes.EXPERIMENT_ARRAY); // experiments - wrapper.read(Types.BOOLEAN); // experiments previously toggled - wrapper.read(Types.BOOLEAN); // include editor packs - for (Experiment experiment : experiments) { - if (experiment.enabled()) { - ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "This server uses an experimental resource pack: " + experiment.name()); - } - } + final UserConnection user = wrapper.user(); + final ResourcePackLoadStateTracker loadStateTracker = user.get(ResourcePackLoadStateTracker.class); + if (loadStateTracker == null) { + sendStackFinished(user); + return; + } - 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()) { - resourcePack.decryptContent(info.contentKey(), info.contentId()); - try { - Via.getManager().getProviders().get(ResourcePackProvider.class).save(resourcePack); - } catch (final Throwable e) { - ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to save resource pack: " + resourcePack.key(), e); - } - } - resourcePacks.add(resourcePack); - } else { - ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Missing resource pack: " + key); - } + wrapper.read(Types.BOOLEAN); // resource pack required + final ResourcePack.Key[] keys = new ResourcePack.Key[wrapper.read(BedrockTypes.UNSIGNED_VAR_INT)]; // resource packs size + for (int i = 0; i < keys.length; i++) { + final UUID id = UUID.fromString(wrapper.read(BedrockTypes.STRING)); // id + final String version = wrapper.read(BedrockTypes.STRING); // version + wrapper.read(BedrockTypes.STRING); // subpack name + keys[i] = new ResourcePack.Key(id, version); + } + wrapper.read(BedrockTypes.STRING); // base game version + final Experiment[] experiments = wrapper.read(BedrockTypes.EXPERIMENT_ARRAY); // experiments + wrapper.read(Types.BOOLEAN); // experiments previously toggled + wrapper.read(Types.BOOLEAN); // include editor packs + for (Experiment experiment : experiments) { + if (experiment.enabled()) { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "This server uses an experimental resource pack: " + experiment.name()); } - wrapper.user().put(new ResourcePackStorage(resourcePacks)); } - if (loadStateTracker == null || !loadStateTracker.hasJavaClientAccepted()) { - final PacketWrapper resourcePackClientResponse = wrapper.create(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE); - resourcePackClientResponse.write(BedrockTypes.UNSIGNED_VAR_INT, ResourcePackResponse.ResourcePackStackFinished.getValue()); // status - resourcePackClientResponse.write(BedrockTypes.STRING, "resourcepackstackfinished"); // #blameMojang - resourcePackClientResponse.sendToServer(BedrockProtocol.class); + if (loadStateTracker.hasReceivedStack()) { + ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Received duplicate RESOURCE_PACK_STACK"); + return; + } + 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 { + try { + finishStack(user, loadStateTracker, keys); + } catch (final Throwable e) { + BedrockProtocol.kickForIllegalState(user, "Failed to prepare the server resource packs.", e); + } + } + }, user.getChannel().eventLoop()); + } else { + finishStack(user, loadStateTracker, keys); } }); protocol.registerClientbound(ClientboundBedrockPackets.RESOURCE_PACK_DATA_INFO, null, wrapper -> { @@ -187,21 +196,15 @@ public static void register(final BedrockProtocol protocol) { final ResourcePackDownloadTracker downloadTracker = wrapper.user().get(ResourcePackDownloadTracker.class); final ResourcePackDownloadTracker.Download download = downloadTracker.get(key); - if (download != null) { + if (download != null && !download.isComplete()) { ViaBedrock.getPlatform().getLogger().log(Level.INFO, "Downloading resource pack chunk " + chunk + "/" + download.chunkSize() + " for " + key); - 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.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 -> { @@ -213,44 +216,127 @@ public static void register(final BedrockProtocol protocol) { if (resourcePackStorage != null) { resourcePackStorage.setLoadedOnJavaClient(); } - wrapper.write(BedrockTypes.UNSIGNED_VAR_INT, ResourcePackResponse.ResourcePackStackFinished.getValue()); // status - wrapper.write(BedrockTypes.STRING, "resourcepackstackfinished"); // #blameMojang + ResourcePackClientResponse.write(wrapper, ResourcePackResponse.ResourcePackStackFinished); } case FAILED_DOWNLOAD, FAILED_RELOAD, DISCARDED -> { ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Client resource pack download/load failed"); - wrapper.write(BedrockTypes.UNSIGNED_VAR_INT, ResourcePackResponse.ResourcePackStackFinished.getValue()); // status - wrapper.write(BedrockTypes.STRING, "resourcepackstackfinished"); // #blameMojang + ResourcePackClientResponse.write(wrapper, ResourcePackResponse.ResourcePackStackFinished); } 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) { - wrapper.cancel(); - loadStateTracker.setJavaClientAccepted(); - loadStateTracker.loadRequestedResourcePacks().thenAccept(v -> { - final PacketWrapper resourcePackClientResponse = PacketWrapper.create(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE, wrapper.user()); - resourcePackClientResponse.write(BedrockTypes.UNSIGNED_VAR_INT, ResourcePackResponse.DownloadingFinished.getValue()); // status - resourcePackClientResponse.write(BedrockTypes.STRING, "downloadingfinished"); // #blameMojang - resourcePackClientResponse.scheduleSendToServer(BedrockProtocol.class); - }).exceptionally(e -> { - BedrockProtocol.kickForIllegalState(wrapper.user(), "One of the server resource packs failed to load. Try again later or decline the resource packs.", e); - return null; - }); + case ACCEPTED, DOWNLOADED -> wrapper.cancel(); + 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"); + } + 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); + 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 != 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); } - }); + } + 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); + response.scheduleSendToServer(BedrockProtocol.class); + } + + private static boolean shouldTranslate(final UserConnection user) { + return ViaBedrock.getConfig().shouldTranslateResourcePacks() && user.getProtocolInfo().protocolVersion().newerThanOrEqualTo(ProtocolConstants.JAVA_VERSION); + } + + private static void sendJavaResourcePack(final UserConnection user, final ConvertedResourcePackCache.Pack pack, final UUID httpToken) { + final PacketWrapper resourcePack = PacketWrapper.create(ClientboundConfigurationPackets26_3.RESOURCE_PACK_PUSH, user); + resourcePack.write(Types.UUID, pack.id()); // id + resourcePack.write(Types.STRING, ViaBedrock.getResourcePackServer().getUrl() + "?token=" + httpToken); // url + resourcePack.write(Types.STRING, pack.sha1()); // hash of the exact ZIP served by the HTTP server + resourcePack.write(Types.BOOLEAN, false); // required + resourcePack.write(Types.OPTIONAL_TAG, TextUtil.stringToNbt( + "\n§aThis server uses Bedrock resource packs. Apply their Java Edition conversion? " + + "If you decline, some textures and models may be missing.") + ); // prompt + resourcePack.scheduleSend(BedrockProtocol.class); } } 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 +++ b/src/main/java/net/raphimc/viabedrock/protocol/provider/ResourcePackProvider.java @@ -22,10 +22,12 @@ public abstract class ResourcePackProvider implements Provider { - public abstract boolean has(final ResourcePack.Key key); + public abstract boolean has(final ResourcePack.Key key, final String contentIdentity); - public abstract ResourcePack load(final ResourcePack.Key key) throws Exception; + public abstract ResourcePack load(final ResourcePack.Key key, final String contentIdentity) throws Exception; - public abstract void save(final ResourcePack resourcePack) throws Exception; + public abstract ResourcePack loadAny(final ResourcePack.Key key) throws Exception; + + public abstract void save(final ResourcePack resourcePack, final String contentIdentity) throws Exception; } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/DiskResourcePackProvider.java b/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/DiskResourcePackProvider.java index b220b2ffa..d4bb86320 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/DiskResourcePackProvider.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/DiskResourcePackProvider.java @@ -20,33 +20,64 @@ import net.raphimc.viabedrock.ViaBedrock; import net.raphimc.viabedrock.api.resourcepack.ResourcePack; import net.raphimc.viabedrock.api.resourcepack.content.ZipContent; +import net.raphimc.viabedrock.api.util.FileSystemUtil; import net.raphimc.viabedrock.protocol.provider.ResourcePackProvider; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; public class DiskResourcePackProvider extends ResourcePackProvider { @Override - public boolean has(final ResourcePack.Key key) { - return Files.isRegularFile(this.getPath(key)); + public boolean has(final ResourcePack.Key key, final String contentIdentity) { + return contentIdentity != null && Files.isRegularFile(this.getPath(key, contentIdentity)); } @Override - public ResourcePack load(final ResourcePack.Key key) throws IOException { - if (!this.has(key)) { + public ResourcePack load(final ResourcePack.Key key, final String contentIdentity) throws IOException { + if (!this.has(key, contentIdentity)) { throw new IOException("Resource pack not found"); } - return new ResourcePack(new ZipContent(Files.readAllBytes(this.getPath(key)))); + return new ResourcePack(new ZipContent(Files.readAllBytes(this.getPath(key, contentIdentity)))); } @Override - public void save(final ResourcePack resourcePack) throws IOException { - Files.write(this.getPath(resourcePack.key()), resourcePack.content().toZip()); + public ResourcePack loadAny(final ResourcePack.Key key) throws IOException { + return new ResourcePack(new ZipContent(Files.readAllBytes(this.getLegacyPath(key)))); } - private Path getPath(final ResourcePack.Key key) { + @Override + public void save(final ResourcePack resourcePack, final String contentIdentity) throws IOException { + final byte[] bytes = resourcePack.content().toZip(); + if (contentIdentity != null) { + Files.createDirectories(this.getSourcePath()); + FileSystemUtil.writeAtomically(this.getPath(resourcePack.key(), contentIdentity), bytes); + } + FileSystemUtil.writeAtomically(this.getLegacyPath(resourcePack.key()), bytes); + } + + private Path getPath(final ResourcePack.Key key, final String contentIdentity) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(key.toString().getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(contentIdentity.getBytes(StandardCharsets.UTF_8)); + return this.getSourcePath().resolve(HexFormat.of().formatHex(digest.digest()) + ".mcpack"); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available", e); + } + } + + private Path getSourcePath() { + return ViaBedrock.getPlatform().getServerPacksFolder().toPath().resolve("source"); + } + + private Path getLegacyPath(final ResourcePack.Key key) { final Path basePath = ViaBedrock.getPlatform().getServerPacksFolder().toPath(); final Path resolvedPath = basePath.resolve(key.toString() + ".mcpack").normalize(); if (!resolvedPath.startsWith(basePath)) { diff --git a/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/InMemoryResourcePackProvider.java b/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/InMemoryResourcePackProvider.java index af36010c2..df1f4a972 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/InMemoryResourcePackProvider.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/InMemoryResourcePackProvider.java @@ -27,24 +27,42 @@ public class InMemoryResourcePackProvider extends ResourcePackProvider { - private final Map resourcePacks = new ConcurrentHashMap<>(); + private final Map resourcePacks = new ConcurrentHashMap<>(); + private final Map latestPacks = new ConcurrentHashMap<>(); @Override - public boolean has(final ResourcePack.Key key) { - return this.resourcePacks.containsKey(key.toString()); + public boolean has(final ResourcePack.Key key, final String contentIdentity) { + return contentIdentity != null && this.resourcePacks.containsKey(new CacheKey(key, contentIdentity)); } @Override - public ResourcePack load(final ResourcePack.Key key) throws IOException { - if (!this.has(key)) { + public ResourcePack load(final ResourcePack.Key key, final String contentIdentity) throws IOException { + final byte[] bytes = this.resourcePacks.get(new CacheKey(key, contentIdentity)); + if (bytes == null) { throw new IOException("Pack not found"); } - return new ResourcePack(new ZipContent(this.resourcePacks.get(key.toString()))); + return new ResourcePack(new ZipContent(bytes)); } @Override - public void save(final ResourcePack resourcePack) throws IOException { - this.resourcePacks.put(resourcePack.key().toString(), resourcePack.content().toZip()); + public ResourcePack loadAny(final ResourcePack.Key key) throws IOException { + final byte[] bytes = this.latestPacks.get(key); + if (bytes == null) { + throw new IOException("Pack not found"); + } + return new ResourcePack(new ZipContent(bytes)); + } + + @Override + public void save(final ResourcePack resourcePack, final String contentIdentity) throws IOException { + final byte[] bytes = resourcePack.content().toZip(); + if (contentIdentity != null) { + this.resourcePacks.put(new CacheKey(resourcePack.key(), contentIdentity), bytes); + } + this.latestPacks.put(resourcePack.key(), bytes); + } + + private record CacheKey(ResourcePack.Key key, String contentIdentity) { } } diff --git a/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/NoOpResourcePackProvider.java b/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/NoOpResourcePackProvider.java index d24a41544..e466df71e 100644 --- a/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/NoOpResourcePackProvider.java +++ b/src/main/java/net/raphimc/viabedrock/protocol/provider/impl/NoOpResourcePackProvider.java @@ -23,17 +23,22 @@ public class NoOpResourcePackProvider extends ResourcePackProvider { @Override - public boolean has(final ResourcePack.Key key) { + public boolean has(final ResourcePack.Key key, final String contentIdentity) { return false; } @Override - public ResourcePack load(final ResourcePack.Key key) { + public ResourcePack load(final ResourcePack.Key key, final String contentIdentity) { throw new UnsupportedOperationException("NoOpResourcePackProvider cannot load packs"); } @Override - public void save(final ResourcePack resourcePack) { + public ResourcePack loadAny(final ResourcePack.Key key) { + throw new UnsupportedOperationException("NoOpResourcePackProvider cannot load packs"); + } + + @Override + public void save(final ResourcePack resourcePack, final String contentIdentity) { } } 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 void remove(final String key) { 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 b0357f63f..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.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; import net.raphimc.viabedrock.protocol.provider.ResourcePackProvider; -import net.raphimc.viabedrock.protocol.types.BedrockTypes; import java.net.URL; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; 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); @@ -63,14 +69,30 @@ public Info getRequest(final ResourcePack.Key key) { } public void addRemoteResourcePack(final ResourcePack resourcePack) { - try { - Via.getManager().getProviders().get(ResourcePackProvider.class).save(resourcePack); - } 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 ResourcePack getResourcePack(final ResourcePack.Key key) { } public CompletableFuture loadRequestedResourcePacks() { + if (this.requests.isEmpty()) { + this.loadFuture.complete(null); + return this.loadFuture; + } final List> asyncTasks = new ArrayList<>(); final List downloadList = Collections.synchronizedList(new ArrayList<>()); for (Info info : this.requests.values()) { if (BedrockProtocol.MAPPINGS.getBedrockResourcePacks().containsKey(info.key())) { this.addLocalResourcePack(BedrockProtocol.MAPPINGS.getBedrockResourcePacks().get(info.key())); - } else if (Via.getManager().getProviders().get(ResourcePackProvider.class).has(info.key())) { + } else if (Via.getManager().getProviders().get(ResourcePackProvider.class).has(info.key(), info.cacheIdentity())) { asyncTasks.add(() -> { try { - this.addLocalResourcePack(Via.getManager().getProviders().get(ResourcePackProvider.class).load(info.key())); + this.addLocalResourcePack(Via.getManager().getProviders().get(ResourcePackProvider.class).load(info.key(), info.cacheIdentity())); } 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 +158,7 @@ public CompletableFuture loadRequestedResourcePacks() { 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()); - resourcePackClientResponse.write(BedrockTypes.UNSIGNED_VAR_INT, ResourcePackResponse.Downloading.getValue()); // status - resourcePackClientResponse.write(BedrockTypes.STRING, "downloading"); // #blameMojang - resourcePackClientResponse.write(BedrockTypes.STRING_ARRAY, downloadList.stream().map(ResourcePack.Key::toString).toArray(String[]::new)); // downloading packs + ResourcePackClientResponse.writeDownloading(resourcePackClientResponse, downloadList.stream().map(ResourcePack.Key::toString).toArray(String[]::new)); resourcePackClientResponse.scheduleSendToServer(BedrockProtocol.class); } else { this.loadFuture.complete(null); @@ -148,32 +172,67 @@ public CompletableFuture loadRequestedResourcePacks() { public void loadUnrequestedResourcePacks(final ResourcePack.Key[] keys) { for (ResourcePack.Key key : keys) { + if (this.resourcePacks.containsKey(key)) { + continue; + } if (BedrockProtocol.MAPPINGS.getBedrockResourcePacks().containsKey(key)) { this.resourcePacks.put(key, BedrockProtocol.MAPPINGS.getBedrockResourcePacks().get(key)); - } else if (Via.getManager().getProviders().get(ResourcePackProvider.class).has(key)) { + } else { try { - this.resourcePacks.put(key, Via.getManager().getProviders().get(ResourcePackProvider.class).load(key)); + this.resourcePacks.put(key, Via.getManager().getProviders().get(ResourcePackProvider.class).loadAny(key)); } catch (final Throwable e) { - ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to load resource pack: " + key, e); + ViaBedrock.getPlatform().getLogger().log(Level.FINE, "No cached resource pack for: " + key, e); } } } } - @Override - public void onRemove() { - this.executor.shutdownNow(); + public void failLoading(final Throwable error) { + this.loadFuture.completeExceptionally(error); } - public boolean hasJavaClientAccepted() { - return this.javaClientAccepted; + 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) { + + public String cacheIdentity() { + if (this.contentId.isEmpty()) { + return null; + } + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return this.contentId + ':' + HexFormat.of().formatHex(digest.digest(this.contentKey)); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available", e); + } + } + } } 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 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ContentLanguageCacheTest.java @@ -0,0 +1,65 @@ +/* + * 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.content; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class ContentLanguageCacheTest { + + @Test + void concurrentConnectionsShareOneImmutableParsedTable() throws Exception { + final int connections = 8; + final CountDownLatch started = new CountDownLatch(connections); + final AtomicInteger reads = new AtomicInteger(); + final InMemoryContent content = new InMemoryContent() { + @Override + public List getLines(final String path) { + reads.incrementAndGet(); + try { + if (!started.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException(); + } + } catch (final InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + return List.of("a=first", "a=last", "b=value ## comment", "## ignored"); + } + }; + final var executor = Executors.newFixedThreadPool(connections); + try { + final List>> tables = new ArrayList<>(); + for (int index = 0; index < connections; index++) { + tables.add(executor.submit(() -> { + started.countDown(); + return content.getLang("texts/en_US.lang"); + })); + } + final Map first = tables.get(0).get(5, TimeUnit.SECONDS); + assertEquals(Map.of("a", "last", "b", "value"), first); + for (final var table : tables) { + assertSame(first, table.get(5, TimeUnit.SECONDS)); + } + assertEquals(1, reads.get()); + assertThrows(UnsupportedOperationException.class, () -> first.clear()); + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + +} diff --git a/src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ResourcePackZipWriterTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ResourcePackZipWriterTest.java new file mode 100644 index 000000000..4e177dceb --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/content/ResourcePackZipWriterTest.java @@ -0,0 +1,241 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.raphimc.viabedrock.api.resourcepack.content; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.zip.ZipFile; +import java.util.zip.ZipEntry; +import java.util.zip.CRC32; +import java.util.zip.ZipInputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class ResourcePackZipWriterTest { + + @TempDir + Path directory; + + private static List entries() { + final byte[] random = new byte[3 * 1024 * 1024]; + new Random(2193).nextBytes(random); + return List.of(new ResourcePackZipWriter.Entry("assets/pack/a", random, true), + new ResourcePackZipWriter.Entry("assets/pack/b", new byte[random.length]), + new ResourcePackZipWriter.Entry("assets/pack/c", Arrays.copyOf(random, random.length)), + new ResourcePackZipWriter.Entry("assets/pack/é", new byte[0])); + } + + @Test + void parallelWorkersPreserveBytesOrderTimestampsAndDeterminism() throws Exception { + final var entries = entries(); + final ByteArrayOutputStream one = new ByteArrayOutputStream(); + final ByteArrayOutputStream four = new ByteArrayOutputStream(); + ResourcePackZipWriter.writeParallel(entries, one, this.directory, 1); + ResourcePackZipWriter.writeParallel(entries, four, this.directory, 4); + assertArrayEquals(one.toByteArray(), four.toByteArray()); + try (var files = Files.list(this.directory)) { + assertEquals(0, files.count()); + } + final Path archive = this.directory.resolve("result.zip"); + Files.write(archive, four.toByteArray()); + try (ZipFile zip = new ZipFile(archive.toFile())) { + final var actual = zip.stream().toList(); + assertEquals(entries.stream().map(ResourcePackZipWriter.Entry::path).toList(), actual.stream().map(java.util.zip.ZipEntry::getName).toList()); + for (int i = 0; i < entries.size(); i++) { + assertEquals(0L, actual.get(i).getTime()); + try (var input = zip.getInputStream(actual.get(i))) { + assertArrayEquals(entries.get(i).data(), input.readAllBytes()); + } + } + } + } + + @Test + void orderedArchivesKeepTheHeaderBeforeIndexedEntries() throws Exception { + final var entries = new ArrayList(); + entries.add(new ResourcePackZipWriter.Entry("header", new byte[]{1})); + entries.addAll(entries()); + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + ResourcePackZipWriter.write(entries, output); + try (var zip = new ZipInputStream(new ByteArrayInputStream(output.toByteArray()))) { + for (final var entry : entries) { + assertEquals(entry.path(), zip.getNextEntry().getName()); + assertArrayEquals(entry.data(), zip.readAllBytes()); + } + assertNull(zip.getNextEntry()); + } + } + + @Test + void memoryAndStreamedArchivesRemainIdenticalAcrossBufferGrowth() throws Exception { + final Content content = new InMemoryContent(); + for (final var entry : entries()) { + content.put(entry.path(), entry.data()); + } + final ByteArrayOutputStream streamed = new ByteArrayOutputStream(); + content.writeZip(streamed); + assertTrue(streamed.size() > 4 * 1024 * 1024); + assertArrayEquals(streamed.toByteArray(), content.toZip()); + } + + @Test + void mixedStoredAndDeflatedEntriesWorkInBothBackends() throws Exception { + final var payloads = entries(); + final var mixed = List.of(new ResourcePackZipWriter.Entry(payloads.get(0).path(), payloads.get(0).data(), true), + payloads.get(1), new ResourcePackZipWriter.Entry(payloads.get(3).path(), payloads.get(3).data(), true)); + final ByteArrayOutputStream sequential = new ByteArrayOutputStream(); + final ByteArrayOutputStream parallel = new ByteArrayOutputStream(); + // A single entry forces the ordinary writer regardless of processor count or payload size. + ResourcePackZipWriter.write(List.of(mixed.get(0)), sequential); + ResourcePackZipWriter.writeParallel(mixed, parallel, this.directory, 4); + for (final var result : List.of(java.util.Map.entry(sequential.toByteArray(), 1), java.util.Map.entry(parallel.toByteArray(), mixed.size()))) { + try (var zip = new ZipInputStream(new ByteArrayInputStream(result.getKey()))) { + for (int index = 0; index < result.getValue(); index++) { + final var entry = zip.getNextEntry(); + assertNotNull(entry); + final var expected = mixed.get(index); + assertEquals(expected.path(), entry.getName()); + assertEquals(expected.stored() ? ZipEntry.STORED : ZipEntry.DEFLATED, entry.getMethod()); + assertArrayEquals(expected.data(), zip.readAllBytes()); + final CRC32 crc = new CRC32(); + crc.update(expected.data()); + assertEquals(crc.getValue(), entry.getCrc()); + if (expected.stored()) { + assertEquals(expected.data().length, entry.getSize()); + assertEquals(entry.getSize(), entry.getCompressedSize()); + } + } + assertNull(zip.getNextEntry()); + } + } + } + + @Test + void storedOutputHintsSurviveMergingAndResetAfterReplacement() throws Exception { + final String path = entries().get(0).path(); + final byte[] data = entries().get(0).data(); + Files.createDirectories(this.directory.resolve(path).getParent()); + for (final Content source : List.of(new InMemoryContent(), new DirectoryContent(this.directory))) { + source.putStored(path, data); + final Content merged = new InMemoryContent(); + merged.putAll(source); + assertTrue(merged.isStored(path)); + try (var zip = new ZipInputStream(new ByteArrayInputStream(merged.toZip()))) { + assertEquals(ZipEntry.STORED, zip.getNextEntry().getMethod()); + assertArrayEquals(data, zip.readAllBytes()); + } + merged.put(path, new byte[data.length]); + assertFalse(merged.isStored(path)); + try (var zip = new ZipInputStream(new ByteArrayInputStream(merged.toZip()))) { + assertEquals(ZipEntry.DEFLATED, zip.getNextEntry().getMethod()); + assertArrayEquals(new byte[data.length], zip.readAllBytes()); + } + } + } + + @Test + void failedReplacementRetainsTheExistingCompressionHint() { + final String path = entries().get(0).path(); + final InMemoryContent content = new InMemoryContent() { + @Override + protected boolean putBytes(final String entryPath, final byte[] data) { + if (data.length == 0) { + throw new IllegalStateException(); + } + return super.putBytes(entryPath, data); + } + }; + content.putStored(path, new byte[1]); + assertThrows(IllegalStateException.class, () -> content.put(path, new byte[0])); + assertTrue(content.isStored(path)); + assertArrayEquals(new byte[1], content.get(path)); + } + + @Test + void outputFailureRemovesAllWorkerFilesAndAllowsRetry() throws Exception { + final var entries = entries(); + final OutputStream failing = new OutputStream() { + private int remaining = 1024; + + @Override + public void write(final int value) throws IOException { + if (--this.remaining < 0) { + throw new IOException(); + } + } + }; + assertThrows(IOException.class, () -> ResourcePackZipWriter.writeParallel(entries, failing, this.directory, 4)); + try (var files = Files.list(this.directory)) { + assertEquals(0, files.count()); + } + final ByteArrayOutputStream retry = new ByteArrayOutputStream(); + ResourcePackZipWriter.writeParallel(entries, retry, this.directory, 4); + assertTrue(retry.size() > 0); + try (var files = Files.list(this.directory)) { + assertEquals(0, files.count()); + } + } + + @Test + void interruptionPreservesTheSignalAndRemovesWorkerStorage() throws Exception { + final var entries = entries(); + try { + Thread.currentThread().interrupt(); + final IOException error = assertThrows(IOException.class, () -> ResourcePackZipWriter.writeParallel(entries, OutputStream.nullOutputStream(), this.directory, 4)); + assertInstanceOf(InterruptedException.class, error.getCause()); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + try (var files = Files.list(this.directory)) { + assertEquals(0, files.count()); + } + } + + @Test + void contentClosesItsOutputWhenReadingFails() { + final boolean[] closed = {false}; + final InMemoryContent content = new InMemoryContent() { + @Override + public byte[] get(final String path) { + throw new IllegalStateException(); + } + }; + content.put("payload", new byte[1]); + final ByteArrayOutputStream output = new ByteArrayOutputStream() { + @Override + public void close() { + closed[0] = true; + } + }; + assertThrows(IllegalStateException.class, () -> content.writeZip(output)); + assertTrue(closed[0]); + } + +} 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..e340c5bd4 --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java @@ -0,0 +1,398 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.raphimc.viabedrock.api.resourcepack.http; + +import net.raphimc.viabedrock.api.resourcepack.ResourcePack; +import net.raphimc.viabedrock.api.resourcepack.content.InMemoryContent; +import net.raphimc.viabedrock.api.resourcepack.content.Content; +import net.raphimc.viabedrock.api.resourcepack.content.ZipContent; +import net.raphimc.viabedrock.protocol.provider.impl.InMemoryResourcePackProvider; +import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; +import net.raphimc.viabedrock.platform.ViaBedrockConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +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; + +import static org.junit.jupiter.api.Assertions.*; + +class ConvertedResourcePackCacheTest { + + @TempDir + Path directory; + + @Test + void streamedDiskOutputMatchesMemoryArchiveAndMetadata() throws Exception { + final byte[] payload = new byte[2 * 1024 * 1024]; + new Random(2193).nextBytes(payload); + final Map metadata = Map.of("ce_example_scale", 2.5F); + final Function converter = storage -> { + storage.getConverterData().putAll(metadata); + final InMemoryContent content = new InMemoryContent(); + content.put("assets/example/payload", payload); + return content; + }; + final ConvertedResourcePackCache memory = new ConvertedResourcePackCache(this.directory, ViaBedrockConfig.PackCacheMode.MEMORY, converter); + final ConvertedResourcePackCache disk = new ConvertedResourcePackCache(this.directory, ViaBedrockConfig.PackCacheMode.DISK, converter); + try { + final var expected = memory.prepare(new ResourcePackStorage(List.of())).join(); + final var streamed = disk.prepare(new ResourcePackStorage(List.of())).join(); + assertArrayEquals(expected.bytes(), Files.readAllBytes(streamed.path())); + assertEquals(expected.sha1(), streamed.sha1()); + assertEquals(expected.id(), streamed.id()); + assertEquals(expected.size(), streamed.size()); + assertEquals(expected.converterData(), streamed.converterData()); + assertNull(streamed.bytes()); + assertEquals(streamed.sha1(), ConvertedResourcePackCache.describe(streamed.path()).sha1()); + } finally { + memory.stop(); + disk.stop(); + } + } + + @Test + void failedStreamingRemovesTemporaryFilesAndAllowsRetry() throws Exception { + final AtomicInteger conversions = new AtomicInteger(); + final ConvertedResourcePackCache cache = new ConvertedResourcePackCache(this.directory, ViaBedrockConfig.PackCacheMode.DISK, storage -> { + if (conversions.getAndIncrement() == 0) { + return new InMemoryContent() { + @Override + public void writeZip(final OutputStream output) throws IOException { + output.write(new byte[]{1, 2, 3}); + throw new IOException("Interrupted archive write"); + } + }; + } + final InMemoryContent content = new InMemoryContent(); + content.put("assets/example/payload", new byte[]{4, 5, 6}); + return content; + }); + try { + assertThrows(CompletionException.class, () -> cache.prepare(new ResourcePackStorage(List.of())).join()); + try (var files = Files.list(this.directory)) { + assertEquals(0, files.count()); + } + final var complete = cache.prepare(new ResourcePackStorage(List.of())).join(); + assertEquals(2, conversions.get()); + assertArrayEquals(new byte[]{4, 5, 6}, new ZipContent(Files.readAllBytes(complete.path())).get("assets/example/payload")); + try (var files = Files.list(this.directory)) { + assertEquals(2, files.count()); + } + } finally { + cache.stop(); + } + } + + @Test + void damagedDiskArchivesRebuildAndRestoreTheSameMetadata() throws Exception { + final AtomicInteger conversions = new AtomicInteger(); + final Map metadata = Map.of("ce_example_scale", 2.5F); + final Function converter = storage -> { + conversions.incrementAndGet(); + storage.getConverterData().putAll(metadata); + final InMemoryContent content = new InMemoryContent(); + content.put("assets/example/texture", new byte[]{1, 2, 3}); + return content; + }; + final ConvertedResourcePackCache first = new ConvertedResourcePackCache(this.directory, ViaBedrockConfig.PackCacheMode.DISK, converter); + final ConvertedResourcePackCache.Pack original; + try { + original = first.prepare(new ResourcePackStorage(List.of())).join(); + } finally { + first.stop(); + } + final byte[] complete = Files.readAllBytes(original.path()); + Files.write(original.path(), Arrays.copyOf(complete, complete.length / 2)); + final ConvertedResourcePackCache replacement = new ConvertedResourcePackCache(this.directory, ViaBedrockConfig.PackCacheMode.DISK, converter); + try { + final ResourcePackStorage connection = new ResourcePackStorage(List.of()); + final var repaired = replacement.prepare(connection).join(); + assertEquals(2, conversions.get()); + assertEquals(original.sha1(), repaired.sha1()); + assertEquals(original.id(), repaired.id()); + assertEquals(metadata, connection.getConverterData()); + assertEquals(metadata, repaired.converterData()); + assertArrayEquals(complete, Files.readAllBytes(repaired.path())); + assertEquals(repaired.sha1(), ConvertedResourcePackCache.describe(repaired.path()).sha1()); + } finally { + replacement.stop(); + } + } + + @Test + void effectivePackOrderChangesCacheIdentity() { + final ResourcePack first = pack(UUID.randomUUID(), new byte[]{1}); + final ResourcePack second = pack(UUID.randomUUID(), new byte[]{2}); + + assertNotEquals(ConvertedResourcePackCache.fingerprint(List.of(first, second)), ConvertedResourcePackCache.fingerprint(List.of(second, first))); + } + + @Test + void changedSourceBytesChangeCacheIdentityWithoutChangingPackId() { + final UUID id = UUID.randomUUID(); + final ResourcePack original = pack(id, new byte[]{1}); + final ResourcePack updated = pack(id, new byte[]{2}); + + assertNotEquals(ConvertedResourcePackCache.fingerprint(List.of(original)), ConvertedResourcePackCache.fingerprint(List.of(updated))); + } + + @Test + void sourceCacheDoesNotReuseAnotherAdvertisedContentIdentity() throws Exception { + final UUID id = UUID.randomUUID(); + final InMemoryResourcePackProvider provider = new InMemoryResourcePackProvider(); + provider.save(pack(id, new byte[]{1}), "first"); + + assertTrue(provider.has(new ResourcePack.Key(id, "1.0.0"), "first")); + assertFalse(provider.has(new ResourcePack.Key(id, "1.0.0"), "second")); + assertArrayEquals(new byte[]{1}, provider.load(new ResourcePack.Key(id, "1.0.0"), "first").content().get("assets/example")); + } + + @Test + void zipEntryInsertionOrderDoesNotChangeTheAdvertisedHash() throws Exception { + final InMemoryContent first = new InMemoryContent(); + first.put("assets/b", new byte[]{2}); + first.put("assets/a", new byte[]{1}); + final InMemoryContent second = new InMemoryContent(); + second.put("assets/a", new byte[]{1}); + second.put("assets/b", new byte[]{2}); + + final Path firstPath = this.directory.resolve("first.zip"); + final Path secondPath = this.directory.resolve("second.zip"); + Files.write(firstPath, first.toZip()); + Files.write(secondPath, second.toZip()); + + assertArrayEquals(Files.readAllBytes(firstPath), Files.readAllBytes(secondPath)); + assertEquals(ConvertedResourcePackCache.describe(firstPath).sha1(), ConvertedResourcePackCache.describe(secondPath).sha1()); + assertEquals(ConvertedResourcePackCache.describe(firstPath).id(), ConvertedResourcePackCache.describe(secondPath).id()); + assertEquals(ConvertedResourcePackCache.describe(firstPath).id(), ConvertedResourcePackCache.describe(first.toZip()).id()); + } + + @Test + void restoresConverterMetadataAcrossMissesAndSharedMemoryAndDiskHits() { + final Map expected = Map.of("ce_example:actor_default_scale", 2.5F, "ca_example:armor_default", true, + "model_missing_entities/example:actor/default_unused", "missing texture textures/unused"); + for (ViaBedrockConfig.PackCacheMode mode : List.of(ViaBedrockConfig.PackCacheMode.MEMORY, ViaBedrockConfig.PackCacheMode.DISK)) { + final AtomicInteger conversions = new AtomicInteger(); + final Path cachePath = this.directory.resolve(mode.name()); + final ConvertedResourcePackCache cache = new ConvertedResourcePackCache(cachePath, mode, storage -> { + conversions.incrementAndGet(); + storage.getConverterData().putAll(expected); + return new InMemoryContent(); + }); + try { + final ResourcePackStorage first = new ResourcePackStorage(List.of()); + final var converted = cache.prepare(first).join(); + final ResourcePackStorage second = new ResourcePackStorage(List.of()); + final var cached = cache.prepare(second).join(); + assertEquals(converted.sha1(), cached.sha1()); + assertEquals(expected, first.getConverterData()); + assertEquals(expected, second.getConverterData()); + assertEquals(1, conversions.get()); + assertThrows(UnsupportedOperationException.class, () -> cached.converterData().clear()); + } finally { + cache.stop(); + } + if (mode == ViaBedrockConfig.PackCacheMode.DISK) { + final ConvertedResourcePackCache reopened = new ConvertedResourcePackCache(cachePath, mode, ignored -> { + fail("A disk hit must not invoke conversion"); + return new InMemoryContent(); + }); + try { + final ResourcePackStorage fresh = new ResourcePackStorage(List.of()); + reopened.prepare(fresh).join(); + assertEquals(expected, fresh.getConverterData()); + } finally { + reopened.stop(); + } + } + } + } + + @Test + void rejectsUnsupportedObjectsAndInvalidScalesInConverterMetadata() { + assertThrows(IllegalArgumentException.class, () -> ConverterDataManifest.encode(Map.of("ce_example_scale", new Object()))); + assertThrows(IllegalArgumentException.class, () -> ConverterDataManifest.encode(Map.of("ce_example_scale", Double.NaN))); + assertThrows(IllegalArgumentException.class, () -> ConverterDataManifest.encode(Map.of("ce_example_scale", Double.MAX_VALUE))); + final var manifest = ConverterDataManifest.encode(Map.of("ce_example_scale", 1F)); + manifest.getAsJsonObject("entity_scales").addProperty("ce_example_scale", Double.MAX_VALUE); + assertThrows(IllegalArgumentException.class, () -> ConverterDataManifest.decode(manifest.toString())); + assertThrows(IllegalArgumentException.class, () -> ConverterDataManifest.encode(Map.of("arbitrary_key", true))); + } + + @Test + void evictsLeastRecentlyUsedCompletedConversionsWithoutInvalidatingReturnedPacks() { + final AtomicInteger conversions = new AtomicInteger(); + final ConvertedResourcePackCache cache = new ConvertedResourcePackCache(this.directory, + ViaBedrockConfig.PackCacheMode.MEMORY, storage -> { + conversions.incrementAndGet(); + return new InMemoryContent(); + }); + try { + final var sources = java.util.stream.IntStream.range(0, 5) + .mapToObj(index -> new ResourcePackStorage(List.of(pack(UUID.randomUUID(), new byte[]{(byte) index})))) + .toList(); + final var first = cache.prepare(sources.get(0)).join(); + for (int index = 1; index < 4; index++) { + cache.prepare(sources.get(index)).join(); + } + assertSame(first, cache.prepare(sources.get(0)).join()); + cache.prepare(sources.get(sources.size() - 1)).join(); + assertSame(first, cache.prepare(sources.get(0)).join()); + assertEquals(5, conversions.get()); + cache.prepare(sources.get(1)).join(); + assertEquals(6, conversions.get()); + assertTrue(first.bytes().length > 0); + } finally { + cache.stop(); + } + } + + @Test + void completesCacheBookkeepingBeforeCallerContinuations() throws Exception { + final CountDownLatch finishConversion = new CountDownLatch(1); + final AtomicInteger conversions = new AtomicInteger(); + final ConvertedResourcePackCache cache = new ConvertedResourcePackCache(this.directory, + ViaBedrockConfig.PackCacheMode.MEMORY, storage -> { + if (conversions.incrementAndGet() == 1) { + try { + if (!finishConversion.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Conversion was not released"); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CompletionException(e); + } + } + return new InMemoryContent(); + }, 0, 0); + try { + final ResourcePackStorage storage = new ResourcePackStorage(List.of()); + final var first = cache.prepare("uncached", storage); + final var retry = first.thenApply(pack -> cache.prepare("uncached", storage)); + finishConversion.countDown(); + final var next = retry.get(5, TimeUnit.SECONDS); + assertNotSame(first, next); + next.get(5, TimeUnit.SECONDS); + assertEquals(2, conversions.get()); + } finally { + finishConversion.countDown(); + 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\"}}"); + content.put("assets/example", data); + return new ResourcePack(content); + } + +} diff --git a/src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackClientResponseTest.java b/src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackClientResponseTest.java new file mode 100644 index 000000000..f3351181a --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/packet/ResourcePackClientResponseTest.java @@ -0,0 +1,74 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.raphimc.viabedrock.protocol.packet; + +import com.viaversion.viaversion.api.protocol.packet.PacketWrapper; +import com.viaversion.viaversion.protocol.packet.PacketWrapperImpl; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import net.raphimc.viabedrock.protocol.ServerboundBedrockPackets; +import net.raphimc.viabedrock.protocol.data.enums.bedrock.generated.ResourcePackResponse; +import net.raphimc.viabedrock.protocol.types.BedrockTypes; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ResourcePackClientResponseTest { + + @Test + void downloadingUsesTheStringDiscriminatorAndVarintPackCount() throws Exception { + final String[] packIds = {"97e3f100-930a-4b47-b7ae-b8bbd38b1c19_1.0.0"}; + final PacketWrapper wrapper = new PacketWrapperImpl(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE, null, null); + ResourcePackClientResponse.writeDownloading(wrapper, packIds); + + final ByteBuf buffer = Unpooled.buffer(); + try { + wrapper.writeToBuffer(buffer); + assertEquals(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE.getId(), BedrockTypes.UNSIGNED_VAR_INT.read(buffer)); + assertEquals(ResourcePackResponse.Downloading.getValue(), BedrockTypes.UNSIGNED_VAR_INT.read(buffer)); + assertEquals("downloading", BedrockTypes.STRING.read(buffer)); + assertArrayEquals(packIds, BedrockTypes.STRING_ARRAY.read(buffer)); + assertEquals(0, buffer.readableBytes()); + } finally { + buffer.release(); + } + } + + @Test + void finishedResponsesContainTheStatusAndStringDiscriminator() throws Exception { + for (final ResourcePackResponse status : new ResourcePackResponse[]{ + ResourcePackResponse.DownloadingFinished, ResourcePackResponse.ResourcePackStackFinished}) { + final PacketWrapper wrapper = new PacketWrapperImpl(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE, null, null); + ResourcePackClientResponse.write(wrapper, status); + + final ByteBuf buffer = Unpooled.buffer(); + try { + wrapper.writeToBuffer(buffer); + assertEquals(ServerboundBedrockPackets.RESOURCE_PACK_CLIENT_RESPONSE.getId(), BedrockTypes.UNSIGNED_VAR_INT.read(buffer)); + assertEquals(status.getValue(), BedrockTypes.UNSIGNED_VAR_INT.read(buffer)); + assertEquals(status == ResourcePackResponse.DownloadingFinished + ? "downloadingfinished" : "resourcepackstackfinished", BedrockTypes.STRING.read(buffer)); + assertEquals(0, buffer.readableBytes()); + } finally { + buffer.release(); + } + } + } + +} 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..87f0e716c --- /dev/null +++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTrackerTest.java @@ -0,0 +1,116 @@ +/* + * This file is part of ViaBedrock - https://github.com/RaphiMC/ViaBedrock + * Copyright (C) 2023-2026 RK_01/RaphiMC and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +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.*; + +class ResourcePackLoadStateTrackerTest { + + @Test + void sourceCacheIdentityTracksContentAndDecryptionKey() { + final ResourcePack.Key key = new ResourcePack.Key(UUID.randomUUID(), "1.0.0"); + final ResourcePackLoadStateTracker.Info original = new ResourcePackLoadStateTracker.Info(key, new byte[]{1}, "first", null); + final ResourcePackLoadStateTracker.Info same = new ResourcePackLoadStateTracker.Info(key, new byte[]{1}, "first", null); + final ResourcePackLoadStateTracker.Info changedContent = new ResourcePackLoadStateTracker.Info(key, new byte[]{1}, "second", null); + final ResourcePackLoadStateTracker.Info changedKey = new ResourcePackLoadStateTracker.Info(key, new byte[]{2}, "first", null); + + assertEquals(original.cacheIdentity(), same.cacheIdentity()); + assertNotEquals(original.cacheIdentity(), changedContent.cacheIdentity()); + assertNotEquals(original.cacheIdentity(), changedKey.cacheIdentity()); + 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); + } + +}