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); + } + +}