diff --git a/build.gradle b/build.gradle
index 7da08481a..e2778982d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -101,6 +101,16 @@ dependencies {
toolImplementation "org.jsoup:jsoup:1.23.2"
toolImplementation "org.ow2.asm:asm-tree:9.10.1"
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/resourcepack/content/Content.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/content/Content.java
index 622e99d2e..0f5b02b54 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
@@ -164,7 +164,7 @@ 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("", "")) {
+ for (String path : this.getFilesDeep("", "").stream().sorted().toList()) {
final ZipEntry entry = new ZipEntry(path);
entry.setTime(0L);
zipOutputStream.putNextEntry(entry);
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..2ce52fb7e
--- /dev/null
+++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCache.java
@@ -0,0 +1,180 @@
+/*
+ * 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.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.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.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.logging.Level;
+
+public class ConvertedResourcePackCache {
+
+ private final Path directory;
+ private final ViaBedrockConfig.PackCacheMode mode;
+ private final ExecutorService executor = Executors.newFixedThreadPool(Math.max(1, Math.min(2, Runtime.getRuntime().availableProcessors())), task -> {
+ final Thread thread = new Thread(task, "ViaBedrock Resource Pack Converter");
+ thread.setDaemon(true);
+ return thread;
+ });
+ private final ConcurrentHashMap> pending = new ConcurrentHashMap<>();
+
+ public ConvertedResourcePackCache(final Path directory, final ViaBedrockConfig.PackCacheMode mode) {
+ this.directory = directory;
+ this.mode = mode;
+ }
+
+ public CompletableFuture prepare(final ResourcePackStorage storage) {
+ if (this.mode == ViaBedrockConfig.PackCacheMode.DISABLED) {
+ return CompletableFuture.supplyAsync(() -> convert(storage), this.executor);
+ }
+ return CompletableFuture.supplyAsync(() -> fingerprint(storage.getPackStackTopToBottom()), this.executor).thenCompose(key -> {
+ final CompletableFuture future = this.pending.computeIfAbsent(key, ignored -> CompletableFuture.supplyAsync(() ->
+ this.mode == ViaBedrockConfig.PackCacheMode.DISK ? this.loadOrConvert(key, storage) : convert(storage), this.executor));
+ return future.whenComplete((pack, error) -> {
+ if (error != null) {
+ this.pending.remove(key, future);
+ }
+ });
+ });
+ }
+
+ public void stop() {
+ this.executor.shutdownNow();
+ }
+
+ 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)) {
+ final Pack cached = describe(cachedPath);
+ if (cached.sha1().equals(expectedSha1)) {
+ return cached;
+ }
+ }
+ }
+ }
+
+ final Pack converted = convert(storage);
+ final Path path = this.directory.resolve(converted.sha1() + ".zip");
+ FileSystemUtil.writeAtomically(path, converted.bytes());
+ FileSystemUtil.writeAtomically(index, converted.sha1().getBytes(StandardCharsets.US_ASCII));
+ return describe(path);
+ } catch (final Exception e) {
+ throw new CompletionException("Failed to prepare converted resource pack", e);
+ }
+ }
+
+ private static Pack convert(final ResourcePackStorage storage) {
+ try {
+ final long start = System.nanoTime();
+ final byte[] bytes = ResourcePackRewriter.bedrockToJava(storage).toZip();
+ ViaBedrock.getPlatform().getLogger().log(Level.INFO, "Converted resource packs in " + ((System.nanoTime() - start) / 1_000_000L) + "ms");
+ return describe(bytes);
+ } catch (final Exception e) {
+ throw new CompletionException("Failed to convert resource packs", e);
+ }
+ }
+
+ 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));
+ return new Pack(path, null, Files.size(path), sha1, id);
+ }
+
+ 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));
+ return new Pack(null, bytes, bytes.length, sha1, id);
+ }
+
+ 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);
+ 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) {
+ }
+
+}
diff --git a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ResourcePackHttpServer.java b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ResourcePackHttpServer.java
index c769f6781..3763790a4 100644
--- a/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ResourcePackHttpServer.java
+++ b/src/main/java/net/raphimc/viabedrock/api/resourcepack/http/ResourcePackHttpServer.java
@@ -23,111 +23,102 @@
import io.netty.channel.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.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.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 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;
- }
-
- while (!user.has(ResourcePackStorage.class)) {
- Thread.sleep(100);
- }
- final ResourcePackStorage resourcePackStorage = user.get(ResourcePackStorage.class);
-
- try {
- final long start = System.nanoTime();
- final Content javaContent = ResourcePackRewriter.bedrockToJava(resourcePackStorage);
- final byte[] data = javaContent.toZip();
- final long end = System.nanoTime();
- ViaBedrock.getPlatform().getLogger().log(Level.INFO, "Converted resource packs in " + ((end - start) / 1_000_000L) + "ms");
- System.gc(); // Resource pack conversion is very memory intensive, so we trigger a GC after conversion to free up memory as soon as possible
-
- final DefaultHttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
- response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED);
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/octet-stream");
- response.headers().set(HttpHeaderNames.CONTENT_LENGTH, data.length);
- response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
- ctx.write(response);
- ctx.writeAndFlush(new HttpChunkedInput(new ChunkedStream(new ByteArrayInputStream(data), 65535))).addListener(ChannelFutureListener.CLOSE);
- } catch (final Throwable e) {
- ViaBedrock.getPlatform().getLogger().log(Level.SEVERE, "Failed to convert resource packs", e);
- ctx.close();
+ .group(new NioEventLoopGroup(0))
+ .channel(NioServerSocketChannel.class)
+ .option(ChannelOption.SO_BACKLOG, 128)
+ .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();
+ }
}
}
- }
- @Override
- public void exceptionCaught(final ChannelHandlerContext ctx, final Throwable cause) {
- ctx.close();
- }
- });
- }
- })
- .bind(bindAddress)
- .syncUninterruptibly();
+ @Override
+ public void exceptionCaught(final ChannelHandlerContext ctx, final Throwable cause) {
+ ctx.close();
+ }
+ });
+ }
+ })
+ .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 void 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..27dcc68ff 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,20 @@
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);
+ try {
+ Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (final AtomicMoveNotSupportedException ignored) {
+ Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING);
+ }
+ } finally {
+ Files.deleteIfExists(temporary);
+ }
+ }
+
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/ResourcePackPackets.java b/src/main/java/net/raphimc/viabedrock/protocol/packet/ResourcePackPackets.java
index 1d51a6165..983fa9381 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;
@@ -50,114 +50,113 @@
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());
-
- 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
- );
+ 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);
+
+ if (shouldTranslate(user)) {
+ loadStateTracker.loadRequestedResourcePacks().thenRun(() -> {
+ user.getChannel().eventLoop().execute(() -> {
+ if (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 -> {
+ 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 (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 -> {
@@ -212,44 +211,86 @@ 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
- }
- 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;
- });
- } else {
- wrapper.write(BedrockTypes.UNSIGNED_VAR_INT, ResourcePackResponse.DownloadingFinished.getValue()); // status
- wrapper.write(BedrockTypes.STRING, "downloadingfinished"); // #blameMojang
- }
+ ResourcePackClientResponse.write(wrapper, ResourcePackResponse.ResourcePackStackFinished);
}
- case DOWNLOADED -> wrapper.cancel();
+ case ACCEPTED, DOWNLOADED -> wrapper.cancel();
default -> throw new IllegalStateException("Unhandled ResourcePackAction: " + action);
}
});
}
- private ResourcePackPackets() {
+ private static void finishStack(final UserConnection user, final ResourcePackLoadStateTracker loadStateTracker, final ResourcePack.Key[] keys) {
+ if (user.get(ResourcePackLoadStateTracker.class) != loadStateTracker) {
+ return;
+ }
+ user.remove(ResourcePackLoadStateTracker.class);
+ 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, info.cacheIdentity());
+ } 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);
+ }
+ }
+ final ResourcePackStorage storage = new ResourcePackStorage(resourcePacks);
+ user.put(storage);
+
+ if (shouldTranslate(user)) {
+ ViaBedrock.getResourcePackServer().prepare(storage).thenAccept(pack -> {
+ if (!user.getChannel().isActive()) {
+ return;
+ }
+ final UUID httpToken = UUID.randomUUID();
+ ViaBedrock.getResourcePackServer().addConnection(httpToken, user, pack);
+ sendJavaResourcePack(user, pack, httpToken);
+ }).exceptionally(e -> {
+ BedrockProtocol.kickForIllegalState(user, "Failed to convert the server resource packs.", e);
+ return null;
+ });
+ } else {
+ sendStackFinished(user);
+ }
+ }
+
+ 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/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/ResourcePackLoadStateTracker.java b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java
index f7f550941..47cf78aef 100644
--- a/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java
+++ b/src/main/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTracker.java
@@ -27,11 +27,12 @@
import net.raphimc.viabedrock.api.resourcepack.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;
@@ -48,7 +49,7 @@ public class ResourcePackLoadStateTracker extends StoredObject {
return thread;
}, null, true);
private final CompletableFuture loadFuture = new CompletableFuture<>();
- private boolean javaClientAccepted;
+ private volatile boolean stackReceived;
public ResourcePackLoadStateTracker(final UserConnection user, final ResourcePackLoadStateTracker.Info[] infos) {
super(user);
@@ -64,7 +65,8 @@ public Info getRequest(final ResourcePack.Key key) {
public void addRemoteResourcePack(final ResourcePack resourcePack) {
try {
- Via.getManager().getProviders().get(ResourcePackProvider.class).save(resourcePack);
+ final Info info = this.requests.get(resourcePack.key());
+ Via.getManager().getProviders().get(ResourcePackProvider.class).save(resourcePack, info != null ? info.cacheIdentity() : null);
} catch (final Throwable e) {
ViaBedrock.getPlatform().getLogger().log(Level.WARNING, "Failed to save resource pack: " + resourcePack.key(), e);
}
@@ -84,15 +86,19 @@ public 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 +138,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.SHORT_LE_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 +152,52 @@ 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 CompletableFuture loadedFuture() {
+ return this.loadFuture;
}
- public boolean hasJavaClientAccepted() {
- return this.javaClientAccepted;
+ public void markStackReceived() {
+ this.stackReceived = true;
}
- public void setJavaClientAccepted() {
- this.javaClientAccepted = true;
+ public boolean hasReceivedStack() {
+ return this.stackReceived;
+ }
+
+ @Override
+ public void onRemove() {
+ this.executor.shutdownNow();
}
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/http/ConvertedResourcePackCacheTest.java b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java
new file mode 100644
index 000000000..1d6e40909
--- /dev/null
+++ b/src/test/java/net/raphimc/viabedrock/api/resourcepack/http/ConvertedResourcePackCacheTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.protocol.provider.impl.InMemoryResourcePackProvider;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ConvertedResourcePackCacheTest {
+
+ @TempDir
+ Path directory;
+
+ @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());
+ }
+
+ 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/storage/ResourcePackLoadStateTrackerTest.java b/src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTrackerTest.java
new file mode 100644
index 000000000..ecaede49c
--- /dev/null
+++ b/src/test/java/net/raphimc/viabedrock/protocol/storage/ResourcePackLoadStateTrackerTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import java.util.UUID;
+
+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());
+ }
+
+}