Skip to content
Merged
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
30 changes: 28 additions & 2 deletions src/main/java/dev/loat/msmp_vision/MSMPVision.java
Original file line number Diff line number Diff line change
@@ -1,30 +1,56 @@
package dev.loat.msmp_vision;

import dev.loat.msmp.MSMPNamespace;
import dev.loat.msmp.MSMPServer;
import dev.loat.msmp_vision.config.Config;
import dev.loat.msmp_vision.logging.Logger;
import dev.loat.msmp_vision.msmp.endpoints.Endpoints;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.minecraft.server.jsonrpc.ManagementServer;

/**
* This class represents the main entry point for the MSMPVision mod.
*/
public class MSMPVision implements ModInitializer {

/**
* The shared {@code vision} namespace used for all MSMP registrations.
* Attached to the running server in {@code SERVER_STARTED} and detached in {@code SERVER_STOPPED}.
*/
private static final MSMPNamespace NAMESPACE = new MSMPNamespace("vision");
private final VisionService service = new VisionService();

/**
* Provides access to the {@link ManagementServer}
* for broadcasting notifications. {@code null} when no server is running.
*/
private static MSMPServer msmp;

/**
* Called by Fabric during mod initialization, before the server starts.
*
* <p>Registers all MSMP methods and notifications and sets up server
* lifecycle hooks for attaching and detaching the namespace.</p>
*/
@Override
public void onInitialize() {
Logger.setLoggerClass(this.getClass());

Config.register();

Endpoints.register(NAMESPACE, () -> msmp, this.service);

ServerLifecycleEvents.SERVER_STARTED.register(server -> {

NAMESPACE.attach(server);
msmp = new MSMPServer(server);
this.service.start();
});

ServerLifecycleEvents.SERVER_STOPPED.register(server -> {

this.service.stop();
NAMESPACE.detach();
msmp = null;
});

Logger.info("Mod initialized.");
Expand Down
141 changes: 141 additions & 0 deletions src/main/java/dev/loat/msmp_vision/VisionService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package dev.loat.msmp_vision;

import dev.loat.msmp_vision.assets.AssetManager;
import dev.loat.msmp_vision.assets.TextureStore;
import dev.loat.msmp_vision.config.Config;
import dev.loat.msmp_vision.config.files.MSMPVisionConfigFile;
import dev.loat.msmp_vision.encode.ImageEncoder;
import dev.loat.msmp_vision.logging.Logger;
import dev.loat.msmp_vision.mesh.TintResolver;
import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotRequest;
import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotResponse;
import dev.loat.msmp_vision.render.Camera;
import dev.loat.msmp_vision.render.RenderExecutor;
import dev.loat.msmp_vision.render.RenderSettings;
import dev.loat.msmp_vision.render.SceneRenderer;
import dev.loat.msmp_vision.world.SnapshotCapture;
import dev.loat.msmp_vision.world.WorldSnapshot;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;

import java.awt.image.BufferedImage;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

/**
* Handles screenshot requests: validates, captures the world on the server thread and renders on worker threads.
*/
public final class VisionService {

private record Runtime(
RenderExecutor executor,
SceneRenderer renderer,
CompletableFuture<Void> ready
) {}

private volatile Runtime runtime;

/**
* Starts asset preparation and the render pool. Call on server start.
*/
public void start() {
this.stop();
MSMPVisionConfigFile config = Config.getConfig();
Path root = FabricLoader.getInstance().getConfigDir().resolve("msmp/vision/client_assets");
AssetManager assets = new AssetManager(root, FabricLoader.getInstance().getRawGameVersion());
TextureStore textures = new TextureStore(assets);
RenderExecutor executor = new RenderExecutor(config.threads.render);
SceneRenderer renderer = new SceneRenderer(assets, textures, executor);

CompletableFuture<Void> ready = assets.initialize().thenRun(() -> TintResolver.initializeColorMaps(assets));
ready.whenComplete((ignored, error) -> {
if (error != null) {
Logger.error("Failed to prepare client assets: " + error);
} else {
Logger.info("Client assets ready (" + executor.threads() + " render threads).");
}
});
this.runtime = new Runtime(executor, renderer, ready);
}

/** Stops the render pool. Call on server stop. */
public void stop() {
Runtime current = this.runtime;
this.runtime = null;
if (current != null) {
current.executor().shutdown();
}
}

/**
* Renders a screenshot.
*
* @param server The server.
* @param request The request.
* @return The response containing a Base64 PNG.
* @throws IllegalArgumentException for invalid parameters.
* @throws IllegalStateException if the renderer is not ready or rendering failed.
*/
public ScreenshotResponse screenshot(MinecraftServer server, ScreenshotRequest request) {
Runtime current = this.runtime;
if (current == null) {
throw new IllegalStateException("The renderer is not running.");
}
if (!current.ready().isDone()) {
throw new IllegalStateException("Client assets are still being prepared, try again shortly.");
}
if (current.ready().isCompletedExceptionally()) {
throw new IllegalStateException("Client assets could not be prepared, see the server log.");
}

MSMPVisionConfigFile config = Config.getConfig();
RenderSettings settings = RenderSettings.from(request, config);
ServerLevel level = this.resolveLevel(server, request.dimension());

long start = System.nanoTime();
Camera camera = new Camera(settings);
WorldSnapshot snapshot = this.capture(server, level, camera, settings, config.requestTimeoutSeconds);
BufferedImage image = current.renderer().render(snapshot, camera, settings, config);
String encoded = ImageEncoder.toBase64Png(image);
int elapsed = (int) ((System.nanoTime() - start) / 1_000_000L);

return new ScreenshotResponse(encoded, "png", image.getWidth(), image.getHeight(), elapsed);
}

private WorldSnapshot capture(MinecraftServer server, ServerLevel level, Camera camera, RenderSettings settings, long timeoutSeconds) {
if (server.isSameThread()) {
return SnapshotCapture.capture(level, camera, settings);
}
try {
return CompletableFuture.supplyAsync(() -> SnapshotCapture.capture(level, camera, settings), server)
.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
throw new IllegalStateException("The server thread did not respond in time.");
} catch (ExecutionException e) {
throw new IllegalStateException("World capture failed: " + e.getCause(), e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while capturing the world.");
}
}

private ServerLevel resolveLevel(MinecraftServer server, String dimension) {
if (dimension == null || dimension.isBlank()) {
return server.overworld();
}
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, Identifier.parse(dimension));
ServerLevel level = server.getLevel(key);
if (level == null) {
throw new IllegalArgumentException("Unknown dimension: " + dimension);
}
return level;
}
}
Loading
Loading