Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String, CompletableFuture<Pack>> pending = new ConcurrentHashMap<>();

public ConvertedResourcePackCache(final Path directory, final ViaBedrockConfig.PackCacheMode mode) {
this.directory = directory;
this.mode = mode;
}

public CompletableFuture<Pack> 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<Pack> 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<ResourcePack> 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<String> 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) {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID, UserConnection> connections = new HashMap<>();
private final ConcurrentHashMap<UUID, ConvertedResourcePackCache.Pack> 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<ConvertedResourcePackCache.Pack> prepare(final ResourcePackStorage storage) {
return this.convertedPacks.prepare(storage);
}

public void stop() {
this.convertedPacks.stop();
if (this.channelFuture != null) {
this.channelFuture.channel().close();
}
Expand Down
Loading