diff --git a/app/downloads/page.tsx b/app/downloads/page.tsx index b731e16..3a858b0 100644 --- a/app/downloads/page.tsx +++ b/app/downloads/page.tsx @@ -3,11 +3,13 @@ import { Header, Footer } from "@/components/Shell"; import { Blocks, Server, Download, ExternalLink } from "lucide-react"; import { existsSync } from "node:fs"; import Link from "next/link"; +const release = "0.1.3"; +const versions = ["1.21.11", "26.1", "26.2"]; export const dynamic = "force-dynamic"; export default async function Downloads() { const { t } = await getI18n(); - const fabric = existsSync("public/tools/mod/minegeo-fabric-1.21.11.jar"), - plugin = existsSync("public/tools/plugin/minegeo-plugin-1.21.11.jar"); + const pluginPath = `/tools/plugin/minegeo-plugin-${release}.jar`; + const plugin = existsSync(`public${pluginPath}`); return ( <>
@@ -33,7 +35,9 @@ export default async function Downloads() {

- {fabric ? ( - - {t("Download Fabric mod")} - - ) : ( -

- {t("The Fabric build is not available yet.")} -

+ {versions.map((version) => + existsSync( + `public/tools/mod/minegeo-fabric-${version}-${release}.jar`, + ) ? ( + + {t("Download Fabric mod")} · {version} + + ) : ( +

+ {version}: {t("The Fabric build is not available yet.")} +

+ ), )}

{t( "Install the JAR in your Minecraft mods folder alongside Fabric API.", )}

+

+ {t( + "Java 21 for 1.21.11; Java 25 for 26.1 and 26.2. Both tools connect to minegeo.vercel.app automatically.", + )} +

@@ -77,7 +92,7 @@ export default async function Downloads() { )}

    -
  • {t("Bukkit/Paper 1.21.11 · Java 21")}
  • +
  • {t("Bukkit/Paper 1.21.11, 26.1 & 26.2")}
  • {t("Select a bounded area and export existing chunks")}
  • {t("Choose up to 24 random, safe surface viewpoints")}
  • @@ -90,11 +105,7 @@ export default async function Downloads() {
  • {t("Vanilla blocks/fluids, simplified lighting")}
{plugin ? ( - + {t("Download server plugin")} ) : ( @@ -104,7 +115,7 @@ export default async function Downloads() { )}
               {
-                "/minegeo website https://your-minegeo-site.example\n/minegeo server play.your-server.example\n/minegeo link \n/minegeo area world -64 -64 64 64\n/minegeo export My Overworld\n/minegeo status\n# Wait for Export complete, then:\n/minegeo upload"
+                "/minegeo server play.your-server.example\n/minegeo link \n/minegeo area world -64 -64 64 64\n/minegeo export My Overworld\n/minegeo status\n# Wait for Export complete, then:\n/minegeo upload"
               }
             

@@ -117,10 +128,9 @@ export default async function Downloads() { )}

- Put the JAR in plugins/ and restart. Set website, server-name and - server-address in plugins/MineGeo/config.yml, then run /minegeo - reload. HTTP LAN addresses are supported for testing. Permission: - minegeo.admin. + {t( + "Put the JAR in plugins/ and restart. Set server-name and server-address in plugins/MineGeo/config.yml, then run /minegeo reload. Permission: minegeo.admin. The same plugin JAR supports all three versions.", + )}

diff --git a/artifacts-plugin-regression.json b/artifacts-plugin-regression.json index 4625d4c..2c2a95c 100644 --- a/artifacts-plugin-regression.json +++ b/artifacts-plugin-regression.json @@ -11,12 +11,12 @@ "repeated upload is idempotent", "cancellation preserves completed export", "24 random positions change on the next export", - "contribution command caps at 12 and retries idempotently", + "shared upload client caps contributions at 12 and retries idempotently", "wrong server blocked before upload, including cached retry", "owner override/default port and cleared address supported" ], "blocks": 10112, "chunks": 16, "viewpoints": 24, - "directory": ".cache/plugin-regression-1791207258927" -} \ No newline at end of file + "directory": ".cache/plugin-regression-1791360575134" +} diff --git a/docs/server-plugin.md b/docs/server-plugin.md index 8797f59..fd3e1de 100644 --- a/docs/server-plugin.md +++ b/docs/server-plugin.md @@ -1,18 +1,17 @@ -# Server plugin 0.1.2 +# Server plugin 0.1.3 ## Update and connect -1. Stop the Minecraft server, replace its old MineGeo JAR with `public/tools/plugin/minegeo-plugin-1.21.11.jar`, and restart. Run `version MineGeo` in the console to verify 0.1.2. +1. Stop the Minecraft server, replace its old MineGeo JAR with `public/tools/plugin/minegeo-plugin-0.1.3.jar`, and restart. Run `version MineGeo` in the console to verify 0.1.3. 2. Open your MineGeo website dashboard, sign in, and generate a plugin token. -3. In the Minecraft server console, configure the website and token. Console commands do not need the leading slash: +3. In the Minecraft server console, configure the Minecraft server address and contributor token. Console commands do not need the leading slash: ```text -minegeo website https://your-minegeo-domain.example minegeo server play.your-minecraft-server.example minegeo link ``` -For LAN testing the website may be `http://192.168.1.50:3000`; use your actual address. `localhost` means the computer running Minecraft, which may be different from the Mac running the website. HTTP is supported for testing; use HTTPS when transmitting contributor tokens over a public connection. +The website URL is built in: `https://minegeo.vercel.app`. Existing `website` settings are removed on startup; no URL configuration is needed. `minegeo website` displays the fixed endpoint. Account tokens remain private local configuration. `minegeo server` sets the Minecraft connection hostname/IP, including a non-default port if needed. It is separate from the MineGeo website URL. Use the address players enter in Minecraft, or set `server-address` in `config.yml` and reload before exporting. The plugin records it in each export. Contributions to checked maps reject a missing or different capture address; old exports need to be recreated after configuring it. The map owner can override or clear the expected address in panorama settings. See [contribution server checks](panorama-contributions.md#contribution-server-check). @@ -67,7 +66,7 @@ If the worker stops renewing its lease, the page warns that it has stopped repor - An interrupted transfer resumes with `minegeo upload`. Progress is saved in `.upload-state.json` beside the export. Already received exports are not submitted twice. - `minegeo cancel` cancels capture or stops upload at the next transfer boundary. Completed exports are kept. HTTP requests already in flight can take up to their timeout to stop. -- `minegeo reload` reloads `config.yml` after you edit `website`, `server-name`, `chunks-per-tick`, or other settings. +- `minegeo reload` reloads `config.yml` after you edit `server-name`, `chunks-per-tick`, or other settings. - `minegeo status` reports the active stage and the last completed export folder. - `minegeo help` lists commands. @@ -96,4 +95,4 @@ To regenerate an existing unpublished draft after pulling this fix: The maintenance command prepares plugin rendering assets, keeps the original raw export, clears the previous preview metadata and queues a fresh render. It refuses published revisions and active jobs. Inspect the regenerated private draft and submit it for review again. If the export itself is invalid, create and upload a corrected export instead. -Expected limits: Minecraft 1.21.11 / Java 21, Overworld only, five or more safe viewpoints, at most 2048 blocks per selected side and the worker's terrain/file-size limits. A large selection may need to be split into smaller maps. +Expected limits: Minecraft 1.21.11 / Java 21, or Minecraft 26.1 and 26.2 / Java 25, Overworld only, five or more safe viewpoints, at most 2048 blocks per selected side and the worker's terrain/file-size limits. A large selection may need to be split into smaller maps. diff --git a/docs/shared-assets.md b/docs/shared-assets.md index 691e750..fe38335 100644 --- a/docs/shared-assets.md +++ b/docs/shared-assets.md @@ -3,16 +3,22 @@ Commit these website assets together with the source code: - `public/images/example-map.webp` — homepage illustration and map-card fallback. -- `public/tools/mod/minegeo-fabric-1.21.11.jar` — Fabric mod download. -- `public/tools/plugin/minegeo-plugin-1.21.11.jar` — server plugin download. +- `public/tools/mod/minegeo-fabric-1.21.11-0.1.3.jar` — Java 21 Fabric mod. +- `public/tools/mod/minegeo-fabric-26.1-0.1.3.jar` — Java 25 Fabric mod for 26.1. +- `public/tools/mod/minegeo-fabric-26.2-0.1.3.jar` — Java 25 Fabric mod for 26.2. +- `public/tools/plugin/minegeo-plugin-0.1.3.jar` — Bukkit/Paper plugin for all three. -Next.js serves these directly at `/images/example-map.webp`, -`/tools/mod/minegeo-fabric-1.21.11.jar`, and -`/tools/plugin/minegeo-plugin-1.21.11.jar`. They are deliberately tracked, so a -fresh checkout on macOS has the homepage image and both download buttons without +Next.js serves these directly under `/images/` and `/tools/`. The download page +uses versioned filenames to avoid stale downloads. Unversioned 1.21.11 URLs +remain updated compatibility copies. These assets are deliberately tracked, so a +fresh checkout on macOS has the homepage image and all download buttons without building Minecraft tools or running the renderer proof. -After changing the Java source, run `npm run java:build` with Java 21 installed. +After changing the Java source, run `npm run java:build` with Java 25 selected +as `JAVA_HOME`, and Java 21 available for the older mod and plugin toolchains. +Gradle builds the older Yarn-based mod and both separate Mojang-named 26.x mods. +On Windows/OneDrive, set `MINEGEO_JAVA_BUILD_ROOT` to a local temporary directory +to keep generated Gradle output outside synced folders. The build copies the new JARs into `public/tools/`; commit those updated files too. Gradle caches, intermediate build output, and generated test panoramas remain ignored. The JARs contain Java bytecode and work across Windows, macOS, and Linux. @@ -20,3 +26,21 @@ ignored. The JARs contain Java bytecode and work across Windows, macOS, and Linu On the Mac, pull the commit, run `npm install`, and restart the development server. For a production server, run `npm run build` and then `npm start`. Keep that machine's Supabase credentials in its own `.env.local`. + +Both tools connect to `https://minegeo.vercel.app` without URL setup. The mod +still stores its contributor token and selected map locally; the plugin retains +its contributor token, Minecraft connection address, and export settings. + +The website accepts 1.21.11 and 26.1/26.2 capture manifests. The updated worker +selects and verifies vanilla models/textures for each export's version. Restart +the rendering worker on its existing host after updating these source files; +Vercel deploys only the website and JAR downloads, not that separate worker. + +Verification commands: + +Select Java 25 in `JAVA_HOME` for the Paper matrix. It checks 1.21.11, the +maintained 26.1.2 release of the 26.1 family, and 26.2 without launching a game +client. Fabric builds are compile-checked; client capture was not runtime-tested. + +- `node scripts/test-minecraft-versions.mjs`: local Paper command/export matrix. +- `node scripts/test-renderer.mjs --fixture 26.1` (or `26.2`): versioned rendering. diff --git a/minecraft/build.gradle b/minecraft/build.gradle index d8a2ca7..abb2dd7 100644 --- a/minecraft/build.gradle +++ b/minecraft/build.gradle @@ -1 +1,4 @@ -allprojects { group = 'dev.minegeo'; version = '0.1.2'; repositories { mavenCentral() } } +allprojects { group = 'dev.minegeo'; version = '0.1.3'; repositories { mavenCentral() } } +if (System.getenv('MINEGEO_JAVA_BUILD_ROOT')) { + subprojects { layout.buildDirectory = new File(System.getenv('MINEGEO_JAVA_BUILD_ROOT'), project.name) } +} diff --git a/minecraft/fabric-modern/build.gradle b/minecraft/fabric-modern/build.gradle new file mode 100644 index 0000000..10f9f9b --- /dev/null +++ b/minecraft/fabric-modern/build.gradle @@ -0,0 +1,19 @@ +dependencies { + minecraft "com.mojang:minecraft:${minecraftVersion}" + implementation 'net.fabricmc:fabric-loader:0.19.5' + implementation "net.fabricmc.fabric-api:fabric-api:${fabricApiVersion}" + implementation 'com.google.code.gson:gson:2.11.0' +} +java { toolchain { languageVersion = JavaLanguageVersion.of(25) }; withSourcesJar() } +sourceSets.main.java.srcDirs '../fabric-modern/src/main/java', '../plugin/src/main/java' +sourceSets.main.java.exclude 'dev/minegeo/plugin/MineGeoPlugin.java' +sourceSets.main.resources.srcDir '../fabric-modern/src/main/resources' +processResources { + inputs.property 'version', project.version + inputs.property 'minecraftVersion', minecraftVersion + inputs.property 'targetMixin', minecraftVersion == '26.1' ? 'MinecraftTargetMixin' : 'GameRendererTargetMixin' + filesMatching('fabric.mod.json') { expand version: project.version, minecraftVersion: minecraftVersion } + filesMatching('minegeo.mixins.json') { expand targetMixin: minecraftVersion == '26.1' ? 'MinecraftTargetMixin' : 'GameRendererTargetMixin' } +} +base { archivesName = "minegeo-fabric-${minecraftVersion}" } +jar { archiveFileName = "minegeo-fabric-${minecraftVersion}.jar" } diff --git a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/Capture.java b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/Capture.java new file mode 100644 index 0000000..0dd3a8a --- /dev/null +++ b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/Capture.java @@ -0,0 +1,37 @@ +package dev.minegeo.fabric; +import net.minecraft.client.Minecraft; +import com.mojang.blaze3d.pipeline.*; +import net.minecraft.client.CameraType; +import net.minecraft.client.Screenshot; +import net.minecraft.world.phys.Vec3; +import java.nio.file.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Changes the render camera only: no movement or rotation packets are sent to a server. */ +public final class Capture { + public static final int SIZE=1024; + public static final String[] FACES={"px","nx","py","ny","pz","nz"}; + private static final float[] YAW={-90,90,0,0,0,180},PITCH={0,0,-90,90,0,0}; + private static boolean active;private static RenderTarget buffer;private static Path output; + private static Vec3 position;private static Object world;private static int face,settle; + private static boolean hud,bobbing;private static CameraType perspective; + private static final AtomicBoolean pending=new AtomicBoolean(); + public static boolean active(){return active;}public static RenderTarget framebuffer(){return buffer;} + public static float yaw(){return YAW[Math.min(face,5)];}public static float pitch(){return PITCH[Math.min(face,5)];} + public static void start(Path directory)throws Exception { + var c=Minecraft.getInstance();if(active)throw new IllegalStateException("Capture already running"); + if(c.player==null||c.level==null||!c.level.dimension().identifier().toString().equals("minecraft:overworld"))throw new IllegalStateException("Join an Overworld first"); + if(c.player.isUnderWater())throw new IllegalStateException("Capture from a dry surface location"); + output=directory;Files.createDirectories(output);position=new Vec3(c.player.getX(),c.player.getY(),c.player.getZ());world=c.level; + hud=ClientCompat.hudHidden(c);bobbing=c.options.bobView().get();perspective=c.options.getCameraType(); + try {buffer=ClientCompat.target();face=0;settle=0;pending.set(false);active=true;ClientCompat.hudHidden(c,true);c.options.bobView().set(false);c.options.setCameraType(CameraType.FIRST_PERSON);c.gameRenderer.resize(SIZE,SIZE);} + catch(Throwable e){cancel("Capture setup failed: "+e.getMessage());throw e;} + } + public static void beforeFrame(){if(!active)return;var c=Minecraft.getInstance();if(c.player==null||c.level!=world||ClientCompat.screen(c)!=null||new Vec3(c.player.getX(),c.player.getY(),c.player.getZ()).distanceToSqr(position)>.0004)cancel("Capture cancelled. Stand still until all six views are saved.");} + public static void afterFrame(){if(!active||pending.get()||++settle<4)return;pending.set(true);final int index=face;final Path directory=output; + try{Screenshot.takeScreenshot(buffer,image->{try(image){if(active&&output.equals(directory)){image.writeToFile(directory.resolve(FACES[index]+".png"));var c=Minecraft.getInstance();c.execute(()->{if(!active||!output.equals(directory))return;face++;settle=0;pending.set(false);if(face==6){restore();MineGeoClient.INSTANCE.savedCapture(directory);}});}}catch(Exception e){Minecraft.getInstance().execute(()->cancel("Capture failed: "+e.getMessage()));}});} + catch(Exception e){cancel("Capture failed: "+e.getMessage());} + } + public static void cancel(String reason){if(active||buffer!=null){restore();MineGeoClient.INSTANCE.message(reason);}} + private static void restore(){var c=Minecraft.getInstance();active=false;pending.set(false);ClientCompat.hudHidden(c,hud);c.options.bobView().set(bobbing);if(perspective!=null)c.options.setCameraType(perspective);c.gameRenderer.resize(c.getWindow().getWidth(),c.getWindow().getHeight());if(buffer!=null){buffer.destroyBuffers();buffer=null;}} +} diff --git a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/CaptureScreen.java b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/CaptureScreen.java new file mode 100644 index 0000000..9e8a747 --- /dev/null +++ b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/CaptureScreen.java @@ -0,0 +1,22 @@ +package dev.minegeo.fabric; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.*; +import net.minecraft.network.chat.Component; + +public final class CaptureScreen extends Screen { + private EditBox name,target; + public CaptureScreen(){super(Component.literal("MineGeo Capture"));} + @Override protected void init(){var mod=MineGeoClient.INSTANCE;int x=width/2-130; + name=new EditBox(font,x,92,260,20,Component.literal("Map name"));name.setMaxLength(80);name.setValue(mod.dataset==null?"My Minecraft map":mod.dataset.name);addRenderableWidget(name); + target=new EditBox(font,x,126,260,20,Component.literal("Add to map ID (optional)"));target.setMaxLength(36);target.setValue(mod.targetMap);addRenderableWidget(target); + addRenderableWidget(Button.builder(Component.literal("Capture panorama"),b->{mod.targetMap=target.getValue().trim();mod.capture();}).bounds(x,154,126,20).build()); + addRenderableWidget(Button.builder(Component.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getPlatform().openPath(mod.dataset.directory);}).bounds(x+134,154,126,20).build()); + addRenderableWidget(Button.builder(Component.literal("Link account"),b->{mod.targetMap=target.getValue().trim();mod.pair();}).bounds(x,182,126,20).build()); + addRenderableWidget(Button.builder(Component.literal("Upload / resume"),b->{mod.targetMap=target.getValue().trim();mod.upload(name.getValue());}).bounds(x+134,182,126,20).build()); + addRenderableWidget(Button.builder(Component.literal("New map at this location"),b->{mod.newMap();resize(width,height);}).bounds(x,210,260,20).build()); + addRenderableWidget(Button.builder(Component.literal("Close"),b->onClose()).bounds(x,238,260,20).build()); + } + @Override public void extractRenderState(GuiGraphicsExtractor context,int mouseX,int mouseY,float delta){super.extractRenderState(context,mouseX,mouseY,delta);int x=width/2-130;context.centeredText(font,title,width/2,20,0xff8cdf6b);context.text(font,"MineGeo service",x,38,0xffffffff);context.text(font,MineGeoClient.website,x,54,0xff8cdf6b);context.text(font,"Map name",x,80,0xffffffff);context.text(font,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.centeredText(font,Component.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:font.split(Component.literal(mod.status),260)){context.text(font,line,x,y,0xffd5d5d5);y+=10;}} + @Override public boolean isPauseScreen(){return false;} +} diff --git a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/MineGeoClient.java b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/MineGeoClient.java new file mode 100644 index 0000000..870c08b --- /dev/null +++ b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/MineGeoClient.java @@ -0,0 +1,37 @@ +package dev.minegeo.fabric; +import dev.minegeo.plugin.UploadClient; +import com.google.gson.*; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper; +import net.minecraft.client.Minecraft; +import net.minecraft.client.KeyMapping; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.resources.Identifier; +import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; +import java.nio.file.*; +import java.util.concurrent.*; + +public final class MineGeoClient implements ClientModInitializer { + public static MineGeoClient INSTANCE; + public final ExecutorService background=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"MineGeo upload");t.setDaemon(true);return t;}); + public final Gson gson=new GsonBuilder().setPrettyPrinting().create(); + public static final String website=UploadClient.WEBSITE; + public volatile String token="",status="Explore normally to collect surface coverage."; + public volatile String targetMap=""; + public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending; + private Path config; + @Override public void onInitializeClient(){INSTANCE=this;config=Minecraft.getInstance().gameDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();token=j.get("token").getAsString();if(j.has("targetMap"))targetMap=j.get("targetMap").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");} + var key=KeyMappingHelper.registerKeyMapping(new KeyMapping("key.minegeo.open",InputConstants.Type.KEYSYM,GLFW.GLFW_KEY_G,KeyMapping.Category.register(Identifier.fromNamespaceAndPath("minegeo","capture")))); + ClientTickEvents.END_CLIENT_TICK.register(c->{while(key.consumeClick()){if(Capture.active())Capture.cancel("Capture cancelled");else ClientCompat.screen(c,new CaptureScreen());}if(c.level!=null&&c.player!=null&&c.level.dimension().identifier().toString().equals("minecraft:overworld")){try{if(dataset==null||!dataset.server.equals(server()))dataset=SurfaceDataset.open(server());if(!Capture.active()&&!uploading)dataset.scan(c);}catch(Exception e){message("Map collection: "+e.getMessage());}}}); + } + public String server(){var c=Minecraft.getInstance();return c.getCurrentServer()!=null?c.getCurrentServer().ip:"Singleplayer";} + public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));} + public void message(String text){status=text;var c=Minecraft.getInstance();if(c.player!=null)ClientCompat.message(c,Component.literal("[MineGeo] "+text));} + public void capture(){try{if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=Minecraft.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");int limit=24;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());ClientCompat.screen(c,null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}} + public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}} + public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}} + public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient("");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();Minecraft.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getPlatform().openUri(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();Minecraft.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){Minecraft.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});} + public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;final String target=targetMap.trim();if(!target.isBlank()&&(Minecraft.getInstance().getCurrentServer()==null||selected==null||!java.util.Objects.equals(dev.minegeo.plugin.ServerAddress.normalize(server()),dev.minegeo.plugin.ServerAddress.normalize(selected.server)))){message("Join the server this capture belongs to before contributing.");return;}int minimum=target.isBlank()?5:1;if(selected==null||selected.points.size(){try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(token).upload(selected.directory,target.isBlank()?null:target, progress -> status=progress, () -> false);Minecraft.getInstance().execute(()->message("Upload received. Preview your selection and submit it in your dashboard."));}catch(Exception e){Minecraft.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});} +} diff --git a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/SurfaceDataset.java b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/SurfaceDataset.java new file mode 100644 index 0000000..be0d32f --- /dev/null +++ b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/SurfaceDataset.java @@ -0,0 +1,34 @@ +package dev.minegeo.fabric; +import com.google.gson.*; +import net.minecraft.client.Minecraft; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import java.awt.image.BufferedImage; +import javax.imageio.ImageIO; + +/** Reads only chunks already delivered to the client; never requests or generates chunks. */ +public final class SurfaceDataset { + public record Point(String id,double x,double y,double z){} + public final Path directory;public final String server;public String name="My Minecraft map"; + public final List points=new ArrayList<>();public final Set coverage=new HashSet<>(); + private final BufferedImage image=new BufferedImage(512,512,BufferedImage.TYPE_INT_ARGB); + private int minX,minZ,cursor;private String scanning;private int chunkX,chunkZ; + private SurfaceDataset(Path directory,String server,int x,int z){this.directory=directory;this.server=server;minX=x;minZ=z;for(int iz=0;iz<512;iz++)for(int ix=0;ix<512;ix++)image.setRGB(ix,iz,((ix/16+iz/16)%2==0)?0xff4c493d:0xff3b3932);} + public static SurfaceDataset create(String server)throws Exception{var c=Minecraft.getInstance();var root=c.gameDirectory.toPath().resolve("minegeo");Files.createDirectories(root);var dataset=new SurfaceDataset(root.resolve(UUID.randomUUID().toString()),server,(Math.floorDiv(c.player.getBlockX(),16)-16)*16,(Math.floorDiv(c.player.getBlockZ(),16)-16)*16);Files.createDirectories(dataset.directory);dataset.saveLocal();return dataset;} + public static SurfaceDataset open(String server)throws Exception{Path root=Minecraft.getInstance().gameDirectory.toPath().resolve("minegeo");if(Files.exists(root)){try(var dirs=Files.list(root)){for(Path path:dirs.sorted(Comparator.reverseOrder()).toList()){Path state=path.resolve("local.json");if(!Files.exists(state))continue;var j=JsonParser.parseString(Files.readString(state)).getAsJsonObject();if(!j.get("server").getAsString().equals(server))continue;var d=new SurfaceDataset(path,server,j.get("minX").getAsInt(),j.get("minZ").getAsInt());d.name=j.get("name").getAsString();for(var p:j.getAsJsonArray("viewpoints")){var v=p.getAsJsonObject();d.points.add(new Point(v.get("id").getAsString(),v.get("x").getAsDouble(),v.get("y").getAsDouble(),v.get("z").getAsDouble()));}for(var k:j.getAsJsonArray("coverage"))d.coverage.add(k.getAsString());if(Files.exists(path.resolve("map.png"))){var saved=ImageIO.read(path.resolve("map.png").toFile());d.image.getGraphics().drawImage(saved,0,0,null);}return d;}}}return create(server);} + public boolean contains(double x,double z){return x>=minX&&x=minZ&&z ci){if(Capture.active())ci.setReturnValue(90f);} +} diff --git a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/mixin/GameRendererMixin.java b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/mixin/GameRendererMixin.java new file mode 100644 index 0000000..6247bab --- /dev/null +++ b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/mixin/GameRendererMixin.java @@ -0,0 +1,14 @@ +package dev.minegeo.fabric.mixin; +import dev.minegeo.fabric.Capture; +import net.minecraft.client.renderer.GameRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +@Mixin(GameRenderer.class) +public class GameRendererMixin { + @Inject(method="update",at=@At("HEAD")) private void before(CallbackInfo ci){Capture.beforeFrame();} + @Inject(method="render",at=@At("RETURN")) private void after(CallbackInfo ci){Capture.afterFrame();} + @Inject(method={"renderItemInHand","bobView","bobHurt"},at=@At("HEAD"),cancellable=true) private void clean(CallbackInfo ci){if(Capture.active())ci.cancel();} +} diff --git a/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/mixin/WindowMixin.java b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/mixin/WindowMixin.java new file mode 100644 index 0000000..9f431e2 --- /dev/null +++ b/minecraft/fabric-modern/src/main/java/dev/minegeo/fabric/mixin/WindowMixin.java @@ -0,0 +1,10 @@ +package dev.minegeo.fabric.mixin; +import dev.minegeo.fabric.Capture; +import com.mojang.blaze3d.platform.Window; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +@Mixin(Window.class) +public class WindowMixin { + @Inject(method={"getWidth","getHeight"},at=@At("HEAD"),cancellable=true) private void square(CallbackInfoReturnable ci){if(Capture.active())ci.setReturnValue(Capture.SIZE);} +} diff --git a/minecraft/fabric-modern/src/main/resources/fabric.mod.json b/minecraft/fabric-modern/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..cbd1290 --- /dev/null +++ b/minecraft/fabric-modern/src/main/resources/fabric.mod.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "id": "minegeo", + "version": "${version}", + "name": "MineGeo Capture", + "description": "Capture six-face Minecraft panoramas and surface maps, then upload them to MineGeo.", + "environment": "client", + "entrypoints": {"client": ["dev.minegeo.fabric.MineGeoClient"]}, + "mixins": ["minegeo.mixins.json"], + "depends": {"fabricloader": ">=0.19.5", "minecraft": "~${minecraftVersion}", "java": ">=25", "fabric-api": "*"} +} diff --git a/minecraft/fabric-modern/src/main/resources/minegeo.mixins.json b/minecraft/fabric-modern/src/main/resources/minegeo.mixins.json new file mode 100644 index 0000000..31efc4e --- /dev/null +++ b/minecraft/fabric-modern/src/main/resources/minegeo.mixins.json @@ -0,0 +1 @@ +{"required":true,"package":"dev.minegeo.fabric.mixin","compatibilityLevel":"JAVA_25","client":["GameRendererMixin","WindowMixin","${targetMixin}","CameraMixin"],"injectors":{"defaultRequire":1}} diff --git a/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java b/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java index d69c069..bc27ecf 100644 --- a/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java +++ b/minecraft/fabric/src/main/java/dev/minegeo/fabric/CaptureScreen.java @@ -5,19 +5,18 @@ import net.minecraft.client.gui.widget.*; import net.minecraft.text.Text; public final class CaptureScreen extends Screen { - private TextFieldWidget website,name,target; + private TextFieldWidget name,target; public CaptureScreen(){super(Text.literal("MineGeo Capture"));} @Override protected void init(){var mod=MineGeoClient.INSTANCE;int x=width/2-130; - website=new TextFieldWidget(textRenderer,x,50,260,20,Text.literal("MineGeo website"));website.setMaxLength(200);website.setText(mod.website);addDrawableChild(website); name=new TextFieldWidget(textRenderer,x,92,260,20,Text.literal("Map name"));name.setMaxLength(80);name.setText(mod.dataset==null?"My Minecraft map":mod.dataset.name);addDrawableChild(name); target=new TextFieldWidget(textRenderer,x,126,260,20,Text.literal("Add to map ID (optional)"));target.setMaxLength(36);target.setText(mod.targetMap);addDrawableChild(target); - addDrawableChild(ButtonWidget.builder(Text.literal("Capture panorama"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.capture();}).dimensions(x,154,126,20).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("Capture panorama"),b->{mod.targetMap=target.getText().trim();mod.capture();}).dimensions(x,154,126,20).build()); addDrawableChild(ButtonWidget.builder(Text.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getOperatingSystem().open(mod.dataset.directory.toFile());}).dimensions(x+134,154,126,20).build()); - addDrawableChild(ButtonWidget.builder(Text.literal("Link account"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.pair();}).dimensions(x,182,126,20).build()); - addDrawableChild(ButtonWidget.builder(Text.literal("Upload / resume"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.upload(name.getText());}).dimensions(x+134,182,126,20).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("Link account"),b->{mod.targetMap=target.getText().trim();mod.pair();}).dimensions(x,182,126,20).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("Upload / resume"),b->{mod.targetMap=target.getText().trim();mod.upload(name.getText());}).dimensions(x+134,182,126,20).build()); addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,210,260,20).build()); addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,238,260,20).build()); } - @Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website URL (HTTP for LAN testing)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);context.drawTextWithShadow(textRenderer,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}} + @Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"MineGeo service",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,MineGeoClient.website,x,54,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);context.drawTextWithShadow(textRenderer,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}} @Override public boolean shouldPause(){return false;} } diff --git a/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java b/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java index ec4b642..cc53fc1 100644 --- a/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java +++ b/minecraft/fabric/src/main/java/dev/minegeo/fabric/MineGeoClient.java @@ -17,20 +17,21 @@ public final class MineGeoClient implements ClientModInitializer { public static MineGeoClient INSTANCE; public final ExecutorService background=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"MineGeo upload");t.setDaemon(true);return t;}); public final Gson gson=new GsonBuilder().setPrettyPrinting().create(); - public volatile String website="http://localhost:3000",token="",status="Explore normally to collect surface coverage."; + public static final String website=UploadClient.WEBSITE; + public volatile String token="",status="Explore normally to collect surface coverage."; public volatile String targetMap=""; public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending; private Path config; - @Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();website=j.get("website").getAsString();token=j.get("token").getAsString();if(j.has("targetMap"))targetMap=j.get("targetMap").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");} + @Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();token=j.get("token").getAsString();if(j.has("targetMap"))targetMap=j.get("targetMap").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");} var key=KeyBindingHelper.registerKeyBinding(new KeyBinding("key.minegeo.open",InputUtil.Type.KEYSYM,GLFW.GLFW_KEY_G,KeyBinding.Category.create(Identifier.of("minegeo","capture")))); ClientTickEvents.END_CLIENT_TICK.register(c->{while(key.wasPressed()){if(Capture.active())Capture.cancel("Capture cancelled");else c.setScreen(new CaptureScreen());}if(c.world!=null&&c.player!=null&&c.world.getRegistryKey().getValue().toString().equals("minecraft:overworld")){try{if(dataset==null||!dataset.server.equals(server()))dataset=SurfaceDataset.open(server());if(!Capture.active()&&!uploading)dataset.scan(c);}catch(Exception e){message("Map collection: "+e.getMessage());}}CaptureFixture.tick(c);}); } public String server(){var c=MinecraftClient.getInstance();return c.getCurrentServerEntry()!=null?c.getCurrentServerEntry().address:"Singleplayer";} - public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("website",website);j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));} + public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));} public void message(String text){status=text;var c=MinecraftClient.getInstance();if(c.player!=null)c.player.sendMessage(Text.literal("[MineGeo] "+text),false);} public void capture(){try{if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=MinecraftClient.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");int limit=24;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());c.setScreen(null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}} public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}} public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}} - public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient(website,"");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});} - public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;final String target=targetMap.trim();if(!target.isBlank()&&(MinecraftClient.getInstance().getCurrentServerEntry()==null||selected==null||!java.util.Objects.equals(dev.minegeo.plugin.ServerAddress.normalize(server()),dev.minegeo.plugin.ServerAddress.normalize(selected.server)))){message("Join the server this capture belongs to before contributing.");return;}int minimum=target.isBlank()?5:1;if(selected==null||selected.points.size(){try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory,target.isBlank()?null:target, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview your selection and submit it in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});} + public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient("");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});} + public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;final String target=targetMap.trim();if(!target.isBlank()&&(MinecraftClient.getInstance().getCurrentServerEntry()==null||selected==null||!java.util.Objects.equals(dev.minegeo.plugin.ServerAddress.normalize(server()),dev.minegeo.plugin.ServerAddress.normalize(selected.server)))){message("Join the server this capture belongs to before contributing.");return;}int minimum=target.isBlank()?5:1;if(selected==null||selected.points.size(){try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(token).upload(selected.directory,target.isBlank()?null:target, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview your selection and submit it in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});} } diff --git a/minecraft/fabric/src/main/java/dev/minegeo/fabric/SurfaceDataset.java b/minecraft/fabric/src/main/java/dev/minegeo/fabric/SurfaceDataset.java index edca438..1ab53bc 100644 --- a/minecraft/fabric/src/main/java/dev/minegeo/fabric/SurfaceDataset.java +++ b/minecraft/fabric/src/main/java/dev/minegeo/fabric/SurfaceDataset.java @@ -29,6 +29,6 @@ public final class SurfaceDataset { } private JsonObject state(){var g=MineGeoClient.INSTANCE.gson;var j=new JsonObject();j.addProperty("server",server);j.addProperty("name",name);j.addProperty("minX",minX);j.addProperty("minZ",minZ);j.add("viewpoints",g.toJsonTree(points));j.add("coverage",g.toJsonTree(coverage));return j;} private void saveLocal()throws Exception{Files.createDirectories(directory);Files.writeString(directory.resolve("local.json"),MineGeoClient.INSTANCE.gson.toJson(state()));ImageIO.write(image,"png",directory.resolve("map.png").toFile());} - public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion","1.21.11");j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));} + public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion",net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("minecraft").orElseThrow().getMetadata().getVersion().getFriendlyString());j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));} private void addAsset(JsonArray array,String file)throws Exception{Path p=directory.resolve(file);var j=new JsonObject();j.addProperty("path",file);j.addProperty("bytes",Files.size(p));j.addProperty("sha256",HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(p))));array.add(j);} } diff --git a/minecraft/fabric261/build.gradle b/minecraft/fabric261/build.gradle new file mode 100644 index 0000000..ca32739 --- /dev/null +++ b/minecraft/fabric261/build.gradle @@ -0,0 +1,4 @@ +plugins { id 'net.fabricmc.fabric-loom' version '1.16.3' } +ext.minecraftVersion = '26.1' +ext.fabricApiVersion = '0.145.1+26.1' +apply from: '../fabric-modern/build.gradle' diff --git a/minecraft/fabric261/src/main/java/dev/minegeo/fabric/ClientCompat.java b/minecraft/fabric261/src/main/java/dev/minegeo/fabric/ClientCompat.java new file mode 100644 index 0000000..cde371a --- /dev/null +++ b/minecraft/fabric261/src/main/java/dev/minegeo/fabric/ClientCompat.java @@ -0,0 +1,13 @@ +package dev.minegeo.fabric; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import com.mojang.blaze3d.pipeline.*; +final class ClientCompat { + static boolean hudHidden(Minecraft c){return c.options.hideGui;} + static void hudHidden(Minecraft c,boolean hidden){c.options.hideGui=hidden;} + static Screen screen(Minecraft c){return c.screen;} + static void screen(Minecraft c,Screen screen){c.setScreen(screen);} + static void message(Minecraft c,Component message){c.gui.getChat().addClientSystemMessage(message);} + static RenderTarget target(){return new TextureTarget("MineGeo capture",Capture.SIZE,Capture.SIZE,true);} +} diff --git a/minecraft/fabric261/src/main/java/dev/minegeo/fabric/mixin/MinecraftTargetMixin.java b/minecraft/fabric261/src/main/java/dev/minegeo/fabric/mixin/MinecraftTargetMixin.java new file mode 100644 index 0000000..d1f8829 --- /dev/null +++ b/minecraft/fabric261/src/main/java/dev/minegeo/fabric/mixin/MinecraftTargetMixin.java @@ -0,0 +1,12 @@ +package dev.minegeo.fabric.mixin; +import dev.minegeo.fabric.Capture; +import net.minecraft.client.Minecraft; +import com.mojang.blaze3d.pipeline.RenderTarget; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.*; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +@Mixin(Minecraft.class) +public class MinecraftTargetMixin { + @Inject(method="getMainRenderTarget",at=@At("HEAD"),cancellable=true) + private void square(CallbackInfoReturnable ci){if(Capture.active()&&Capture.framebuffer()!=null)ci.setReturnValue(Capture.framebuffer());} +} diff --git a/minecraft/fabric262/build.gradle b/minecraft/fabric262/build.gradle new file mode 100644 index 0000000..0f5678e --- /dev/null +++ b/minecraft/fabric262/build.gradle @@ -0,0 +1,4 @@ +plugins { id 'net.fabricmc.fabric-loom' version '1.16.3' } +ext.minecraftVersion = '26.2' +ext.fabricApiVersion = '0.161.0+26.2' +apply from: '../fabric-modern/build.gradle' diff --git a/minecraft/fabric262/src/main/java/dev/minegeo/fabric/ClientCompat.java b/minecraft/fabric262/src/main/java/dev/minegeo/fabric/ClientCompat.java new file mode 100644 index 0000000..e3b52eb --- /dev/null +++ b/minecraft/fabric262/src/main/java/dev/minegeo/fabric/ClientCompat.java @@ -0,0 +1,14 @@ +package dev.minegeo.fabric; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import com.mojang.blaze3d.pipeline.*; +import com.mojang.blaze3d.GpuFormat; +final class ClientCompat { + static boolean hudHidden(Minecraft c){return c.gui.hud.isHidden();} + static void hudHidden(Minecraft c,boolean hidden){if(c.gui.hud.isHidden()!=hidden)c.gui.hud.toggle();} + static Screen screen(Minecraft c){return c.gui.screen();} + static void screen(Minecraft c,Screen screen){c.gui.setScreen(screen);} + static void message(Minecraft c,Component message){c.gui.hud.getChat().addClientSystemMessage(message);} + static RenderTarget target(){return new TextureTarget("MineGeo capture",Capture.SIZE,Capture.SIZE,true,GpuFormat.RGBA8_UNORM);} +} diff --git a/minecraft/fabric262/src/main/java/dev/minegeo/fabric/mixin/GameRendererTargetMixin.java b/minecraft/fabric262/src/main/java/dev/minegeo/fabric/mixin/GameRendererTargetMixin.java new file mode 100644 index 0000000..107898a --- /dev/null +++ b/minecraft/fabric262/src/main/java/dev/minegeo/fabric/mixin/GameRendererTargetMixin.java @@ -0,0 +1,16 @@ +package dev.minegeo.fabric.mixin; +import dev.minegeo.fabric.Capture; +import net.minecraft.client.renderer.GameRenderer; +import com.mojang.blaze3d.pipeline.RenderTarget; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.injection.*; +@Mixin(GameRenderer.class) +public class GameRendererTargetMixin { + @Shadow @Final private RenderTarget mainRenderTarget; + // 26.2 moved the framebuffer into GameRenderer. Redirect reads through the + // game's rendering abstraction; keep the actual window target intact. + @Redirect(method="*",at=@At(value="FIELD",target="Lnet/minecraft/client/renderer/GameRenderer;mainRenderTarget:Lcom/mojang/blaze3d/pipeline/RenderTarget;",opcode=180)) + private RenderTarget square(GameRenderer renderer){return Capture.active()&&Capture.framebuffer()!=null?Capture.framebuffer():mainRenderTarget;} +} diff --git a/minecraft/gradle/wrapper/gradle-wrapper.properties b/minecraft/gradle/wrapper/gradle-wrapper.properties index 23449a2..dbc3ce4 100644 --- a/minecraft/gradle/wrapper/gradle-wrapper.properties +++ b/minecraft/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java b/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java index b32a41a..83b0057 100644 --- a/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java +++ b/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java @@ -26,7 +26,7 @@ public final class MineGeoPlugin extends JavaPlugin { private final Gson gson=new GsonBuilder().setPrettyPrinting().create(); private volatile Path latest; @Override public void onEnable(){ - saveDefaultConfig();Objects.requireNonNull(getCommand("minegeo")).setExecutor(this); + saveDefaultConfig();if(getConfig().isSet("website")){getConfig().set("website",null);saveConfig();}Objects.requireNonNull(getCommand("minegeo")).setExecutor(this); try { Files.createDirectories(getDataFolder().toPath().resolve("exports")); Path root=getDataFolder().toPath().toAbsolutePath().normalize(),pointer=root.resolve("latest-export.txt"); @@ -42,7 +42,7 @@ public final class MineGeoPlugin extends JavaPlugin { // every rejected command receives a visible explanation. if(!(sender instanceof ConsoleCommandSender||sender instanceof RemoteConsoleCommandSender||sender instanceof Player&&sender.hasPermission("minegeo.admin"))){sender.sendMessage("MineGeo: administrator permission required.");return true;} try { - if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website ","/minegeo server — capture server address","/minegeo link ","/minegeo area ","/minegeo export — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo contribute — add panoramas (24 owner, 12 guests)","/minegeo status | cancel | reload"});return true;} + if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website — show the built-in MineGeo service","/minegeo server — capture server address","/minegeo link ","/minegeo area ","/minegeo export — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo contribute — add panoramas (24 owner, 12 guests)","/minegeo status | cancel | reload"});return true;} switch(args[0].toLowerCase(Locale.ROOT)) { case "area" -> { requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area "); @@ -54,7 +54,7 @@ public final class MineGeoPlugin extends JavaPlugin { case "status" -> {sender.sendMessage("MineGeo: "+status);if(latest!=null)sender.sendMessage("Latest complete export: "+latest.toAbsolutePath());} case "cancel" -> {cancelled.set(true);sender.sendMessage(busy?"MineGeo cancellation requested. Completed exports are kept.":"MineGeo: no operation is running.");} case "reload" -> {requireIdle();reloadConfig();sender.sendMessage("MineGeo configuration reloaded.");} - case "website" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo website ");new UploadClient(args[1],"");getConfig().set("website",args[1]);saveConfig();sender.sendMessage("MineGeo website saved: "+args[1]);} + case "website" -> {if(args.length!=1)throw new IllegalArgumentException("The MineGeo website is fixed to "+UploadClient.WEBSITE+". No URL configuration is needed.");sender.sendMessage("MineGeo website: "+UploadClient.WEBSITE);} case "server" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo server ");String address=ServerAddress.normalize(args[1]);getConfig().set("server-address",address);saveConfig();sender.sendMessage("Capture server address saved: "+address+". Export again to record it.");} case "link" -> {requireIdle();if(args.length!=2||args[1].isBlank())throw new IllegalArgumentException("Usage: /minegeo link ");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved.");} case "export" -> export(sender,args.length>1?String.join(" ",Arrays.copyOfRange(args,1,args.length)):"Overworld Explorer"); @@ -69,8 +69,8 @@ public final class MineGeoPlugin extends JavaPlugin { private void fail(CommandSender sender,String phase,Exception ex){report(sender,cancelled.get()?"Cancelled. Completed exports are kept.":phase+" failed: "+ex.getMessage());if(!cancelled.get())getLogger().log(Level.WARNING,phase+" failure",ex);} private void upload(CommandSender sender,String targetMap)throws Exception { requireIdle();if(latest==null||!Files.isRegularFile(latest.resolve("manifest.json")))throw new IOException("No completed export. Run /minegeo export My World, wait for 'Export complete', then /minegeo upload. Files: "+getDataFolder().toPath().resolve("exports").toAbsolutePath()); - String website=getConfig().getString("website"),token=getConfig().getString("token");if(token==null||token.isBlank())throw new IOException("Create a token in the website dashboard, then /minegeo link ."); - UploadClient client=new UploadClient(website,token);Path directory=latest;busy=true;cancelled.set(false);status="Uploading "+directory.getFileName(); + String website=UploadClient.WEBSITE,token=getConfig().getString("token");if(token==null||token.isBlank())throw new IOException("Create a token in the website dashboard, then /minegeo link ."); + UploadClient client=new UploadClient(token);Path directory=latest;busy=true;cancelled.set(false);status="Uploading "+directory.getFileName(); Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{client.upload(directory,targetMap,message->status=message,cancelled::get);report(sender,"Upload complete. Open "+website+"/dashboard to preview and submit "+(targetMap==null?"the private map for moderator review.":"the panoramas to the map owner."));}catch(Exception ex){fail(sender,"Upload",ex);}finally{busy=false;}}); sender.sendMessage("MineGeo upload started. /minegeo status shows progress; retry /minegeo upload after a connection failure."); } @@ -133,7 +133,7 @@ public final class MineGeoPlugin extends JavaPlugin { List chosen=candidates.subList(0,Math.min(desired,candidates.size())); for(int i=0;i50L*1024*1024)throw new IOException("Compressed export exceeds 50 MiB. Select a smaller area."); - JsonObject manifest=new JsonObject();manifest.addProperty("format","minegeo-dataset/v1");manifest.addProperty("minecraftVersion","1.21.11");manifest.addProperty("dimension","minecraft:overworld");manifest.addProperty("source","plugin");manifest.addProperty("name",name);manifest.addProperty("description","Vanilla surface terrain exported from "+serverName);manifest.addProperty("serverName",serverName);if(serverAddress!=null)manifest.addProperty("serverAddress",serverAddress);manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points); + JsonObject manifest=new JsonObject();manifest.addProperty("format","minegeo-dataset/v1");manifest.addProperty("minecraftVersion",Bukkit.getBukkitVersion().split("[-+]",2)[0].replaceFirst("[.]build[.].*$",""));manifest.addProperty("dimension","minecraft:overworld");manifest.addProperty("source","plugin");manifest.addProperty("name",name);manifest.addProperty("description","Vanilla surface terrain exported from "+serverName);manifest.addProperty("serverName",serverName);if(serverAddress!=null)manifest.addProperty("serverAddress",serverAddress);manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points); JsonArray assets=new JsonArray();JsonObject asset=new JsonObject();asset.addProperty("path","terrain.json.gz");asset.addProperty("bytes",Files.size(terrain));MessageDigest digest=MessageDigest.getInstance("SHA-256");try(InputStream input=Files.newInputStream(terrain)){byte[] buffer=new byte[65536];int read;while((read=input.read(buffer))!=-1)digest.update(buffer,0,read);}asset.addProperty("sha256",HexFormat.of().formatHex(digest.digest()));assets.add(asset);manifest.add("assets",assets); // The manifest is the completion marker. Partial folders never become upload candidates. if(cancelled.get())throw new IOException("Cancelled");Files.writeString(out.resolve("manifest.json.tmp"),gson.toJson(manifest));Files.move(out.resolve("manifest.json.tmp"),out.resolve("manifest.json"),StandardCopyOption.REPLACE_EXISTING); diff --git a/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java b/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java index 42307da..87f450b 100644 --- a/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java +++ b/minecraft/plugin/src/main/java/dev/minegeo/plugin/UploadClient.java @@ -12,6 +12,8 @@ import java.nio.charset.StandardCharsets; /** Shared TUS client. State is saved beside each completed local dataset. */ public final class UploadClient { + public static final String WEBSITE="https://minegeo.vercel.app"; + public UploadClient(String token){this(WEBSITE,token);} private final String website,token; // Next's HTTP server does not support Java's cleartext HTTP/2 upgrade handshake. private final HttpClient client=HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(30)).build(); diff --git a/minecraft/plugin/src/main/resources/config.yml b/minecraft/plugin/src/main/resources/config.yml index c754b10..6906e93 100644 --- a/minecraft/plugin/src/main/resources/config.yml +++ b/minecraft/plugin/src/main/resources/config.yml @@ -1,4 +1,4 @@ -website: 'http://localhost:3000' +# The MineGeo service URL is built in: https://minegeo.vercel.app token: '' server-name: 'My Minecraft server' # Public Minecraft connection address, matching the address used by Fabric players. diff --git a/minecraft/plugin/src/main/resources/plugin.yml b/minecraft/plugin/src/main/resources/plugin.yml index 2c2d827..849c2c7 100644 --- a/minecraft/plugin/src/main/resources/plugin.yml +++ b/minecraft/plugin/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: MineGeo -version: 0.1.2 +version: 0.1.3 main: dev.minegeo.plugin.MineGeoPlugin api-version: '1.21' description: Bounded terrain export for MineGeo panorama guessing diff --git a/minecraft/settings.gradle b/minecraft/settings.gradle index 1af9894..c20f825 100644 --- a/minecraft/settings.gradle +++ b/minecraft/settings.gradle @@ -1,3 +1,3 @@ pluginManagement { repositories { maven { url = 'https://maven.fabricmc.net/' }; gradlePluginPortal(); mavenCentral() } } rootProject.name = 'minegeo-tools' -include 'plugin', 'fabric' +include 'plugin', 'fabric', 'fabric261', 'fabric262' diff --git a/public/tools/mod/minegeo-fabric-1.21.11-0.1.3.jar b/public/tools/mod/minegeo-fabric-1.21.11-0.1.3.jar new file mode 100644 index 0000000..3a5c90d Binary files /dev/null and b/public/tools/mod/minegeo-fabric-1.21.11-0.1.3.jar differ diff --git a/public/tools/mod/minegeo-fabric-1.21.11.jar b/public/tools/mod/minegeo-fabric-1.21.11.jar index c5d9fbc..3a5c90d 100644 Binary files a/public/tools/mod/minegeo-fabric-1.21.11.jar and b/public/tools/mod/minegeo-fabric-1.21.11.jar differ diff --git a/public/tools/mod/minegeo-fabric-26.1-0.1.3.jar b/public/tools/mod/minegeo-fabric-26.1-0.1.3.jar new file mode 100644 index 0000000..7a04246 Binary files /dev/null and b/public/tools/mod/minegeo-fabric-26.1-0.1.3.jar differ diff --git a/public/tools/mod/minegeo-fabric-26.1.jar b/public/tools/mod/minegeo-fabric-26.1.jar new file mode 100644 index 0000000..7a04246 Binary files /dev/null and b/public/tools/mod/minegeo-fabric-26.1.jar differ diff --git a/public/tools/mod/minegeo-fabric-26.2-0.1.3.jar b/public/tools/mod/minegeo-fabric-26.2-0.1.3.jar new file mode 100644 index 0000000..a093efe Binary files /dev/null and b/public/tools/mod/minegeo-fabric-26.2-0.1.3.jar differ diff --git a/public/tools/mod/minegeo-fabric-26.2.jar b/public/tools/mod/minegeo-fabric-26.2.jar new file mode 100644 index 0000000..a093efe Binary files /dev/null and b/public/tools/mod/minegeo-fabric-26.2.jar differ diff --git a/public/tools/plugin/minegeo-plugin-0.1.3.jar b/public/tools/plugin/minegeo-plugin-0.1.3.jar new file mode 100644 index 0000000..64e594a Binary files /dev/null and b/public/tools/plugin/minegeo-plugin-0.1.3.jar differ diff --git a/public/tools/plugin/minegeo-plugin-1.21.11.jar b/public/tools/plugin/minegeo-plugin-1.21.11.jar index 14b34c3..64e594a 100644 Binary files a/public/tools/plugin/minegeo-plugin-1.21.11.jar and b/public/tools/plugin/minegeo-plugin-1.21.11.jar differ diff --git a/renderer/render.mjs b/renderer/render.mjs index 9d7f51c..03de26b 100644 --- a/renderer/render.mjs +++ b/renderer/render.mjs @@ -3,17 +3,17 @@ import { createServer } from "node:http"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { resolve, sep } from "node:path"; import sharp from "sharp"; -import { assetsRoot, prepareAssets } from "../scripts/assets.mjs"; +import { prepareAssets } from "../scripts/assets.mjs"; import { assertTerrainImage } from "./image-validation.mjs"; /** @param {import('../shared/protocol').TerrainSnapshot} snapshot * @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints * @param {string} output - * @param {{size?:number,signal?:AbortSignal,onProgress?:(event:{percent:number,message:string})=>Promise}} [options] */ + * @param {{size?:number,minecraftVersion?:string,signal?:AbortSignal,onProgress?:(event:{percent:number,message:string})=>Promise}} [options] */ export async function renderSnapshot( snapshot, viewpoints, output, - { size = 1024, signal, onProgress } = {}, + { size = 1024, minecraftVersion = "1.21.11", signal, onProgress } = {}, ) { const start = Date.now(); if (signal?.aborted) throw new Error("Rendering cancelled"); @@ -21,7 +21,7 @@ export async function renderSnapshot( percent: 22, message: "Checking Minecraft models and textures", }); - await prepareAssets(); + const assetsRoot = await prepareAssets(minecraftVersion); await mkdir(output, { recursive: true }); const server = createServer(async (req, res) => { try { @@ -207,9 +207,7 @@ export async function renderSnapshot( if (map.data.startsWith("data:image/webp;")) await writeFile(`${output}/map.webp`, mapBytes); else - await sharp(mapBytes) - .webp({ quality: 92 }) - .toFile(`${output}/map.webp`); + await sharp(mapBytes).webp({ quality: 92 }).toFile(`${output}/map.webp`); stats.map = { width: map.width, height: map.height, cameraY: 320 }; await report({ percent: 90, diff --git a/scripts/assets.mjs b/scripts/assets.mjs index f83d9ee..c7aebc0 100644 --- a/scripts/assets.mjs +++ b/scripts/assets.mjs @@ -2,7 +2,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { unzipSync } from "fflate"; export const assetsRoot = ".cache/minecraft/1.21.11"; -const completionMarker = `${assetsRoot}/.minegeo-assets.json`; +export function assetsDirectory(version = "1.21.11") { + if (!/^(?:1\.21\.11|26\.[12](?:\.\d+)*)$/.test(version)) + throw new Error("Unsupported Minecraft asset version"); + return `.cache/minecraft/${version}`; +} const requiredAssets = [ "blockstates/grass_block.json", "blockstates/stone.json", @@ -14,12 +18,14 @@ const requiredAssets = [ "textures/block/stone.png", "textures/block/water_still.png", ]; -async function assetsReady() { +async function assetsReady(minecraftVersion) { + const assetsRoot = assetsDirectory(minecraftVersion); + const completionMarker = `${assetsRoot}/.minegeo-assets.json`; try { const marker = JSON.parse(await readFile(completionMarker, "utf8")); if ( marker.format !== "minegeo-assets/v2" || - marker.minecraftVersion !== "1.21.11" || + marker.minecraftVersion !== minecraftVersion || !marker.files ) return false; @@ -39,10 +45,12 @@ async function assetsReady() { return false; } } -export async function prepareAssets() { - if (await assetsReady()) return; +export async function prepareAssets(minecraftVersion = "1.21.11") { + const assetsRoot = assetsDirectory(minecraftVersion); + const completionMarker = `${assetsRoot}/.minegeo-assets.json`; + if (await assetsReady(minecraftVersion)) return assetsRoot; console.log( - "Preparing Minecraft 1.21.11 models and textures for terrain rendering…", + `Preparing Minecraft ${minecraftVersion} models and textures for terrain rendering…`, ); const manifest = await ( await fetch( @@ -50,13 +58,14 @@ export async function prepareAssets() { { signal: AbortSignal.timeout(60000) }, ) ).json(); - const entry = manifest.versions.find((v) => v.id === "1.21.11"); - if (!entry) throw new Error("Official Minecraft 1.21.11 metadata missing"); + const entry = manifest.versions.find((v) => v.id === minecraftVersion); + if (!entry) + throw new Error(`Official Minecraft ${minecraftVersion} metadata missing`); const version = await ( await fetch(entry.url, { signal: AbortSignal.timeout(60000) }) ).json(); await mkdir(".cache/minecraft", { recursive: true }); - const jarPath = ".cache/minecraft/client-1.21.11.jar"; + const jarPath = `.cache/minecraft/client-${minecraftVersion}.jar`; let jar; try { jar = await readFile(jarPath); @@ -101,16 +110,17 @@ export async function prepareAssets() { completionMarker, JSON.stringify({ format: "minegeo-assets/v2", - minecraftVersion: "1.21.11", + minecraftVersion, clientSha1: version.downloads.client.sha1, files: Object.keys(entries).length, }), ); - if (!(await assetsReady())) + if (!(await assetsReady(minecraftVersion))) throw new Error( "Minecraft rendering assets are incomplete or corrupt. Run npm run setup to repair them.", ); console.log( `Prepared ${Object.keys(entries).length} verified vanilla model/texture assets.`, ); + return assetsRoot; } diff --git a/scripts/java-build.mjs b/scripts/java-build.mjs index 7ca6e62..60d170a 100644 --- a/scripts/java-build.mjs +++ b/scripts/java-build.mjs @@ -1,11 +1,21 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, copyFile } from "node:fs/promises"; +import { mkdir, copyFile, readFile } from "node:fs/promises"; +import path from "node:path"; const windows = process.platform === "win32"; const command = windows ? "gradlew.bat" : "sh"; const targets = process.argv.slice(2); if (!targets.some((target) => !target.startsWith("-"))) - targets.unshift(":plugin:build", ":fabric:build"); + targets.unshift( + ":plugin:build", + ":fabric:build", + ":fabric261:build", + ":fabric262:build", + ); +const version = (await readFile("minecraft/build.gradle", "utf8")).match( + /version = '([^']+)'/, +)?.[1]; +if (!version) throw new Error("Minecraft tools release version is missing"); await new Promise((resolve, reject) => { const child = spawn(command, windows ? targets : ["./gradlew", ...targets], { cwd: "minecraft", @@ -18,15 +28,28 @@ await new Promise((resolve, reject) => { code === 0 ? resolve() : reject(new Error(`Gradle exited ${code}`)), ); }); -for (const name of ["plugin", "fabric"]) { - const directory = `public/tools/${name === "fabric" ? "mod" : "plugin"}`; - const file = `minegeo-${name}-1.21.11.jar`, - path = `minecraft/${name}/build/libs/${file}`; - if (existsSync(path)) { +for (const [name, kind, minecraftVersion] of [ + ["plugin", "plugin", "1.21.11"], + ["fabric", "fabric", "1.21.11"], + ["fabric261", "fabric", "26.1"], + ["fabric262", "fabric", "26.2"], +]) { + const directory = `public/tools/${kind === "fabric" ? "mod" : "plugin"}`; + const file = `minegeo-${kind}-${minecraftVersion}.jar`; + const buildRoot = process.env.MINEGEO_JAVA_BUILD_ROOT + ? path.join(process.env.MINEGEO_JAVA_BUILD_ROOT, name) + : `minecraft/${name}/build`; + const artifact = path.join(buildRoot, "libs", file); + if (existsSync(artifact)) { await mkdir(directory, { recursive: true }); - await copyFile(path, `${directory}/${file}`); + await copyFile(artifact, `${directory}/${file}`); + const releaseFile = + kind === "plugin" + ? `minegeo-plugin-${version}.jar` + : `minegeo-fabric-${minecraftVersion}-${version}.jar`; + await copyFile(artifact, `${directory}/${releaseFile}`); console.log( - `Updated ${directory}/${file}. Include this JAR in your next commit.`, + `Updated ${directory}/${releaseFile} and its compatibility download.`, ); } } diff --git a/scripts/reprocess.mjs b/scripts/reprocess.mjs index bf3239a..83d3ac6 100644 --- a/scripts/reprocess.mjs +++ b/scripts/reprocess.mjs @@ -57,7 +57,8 @@ if ( throw new Error( "This draft contains accepted contributions. Upload a new capture instead of reprocessing mixed media.", ); -if (dataset.source === "plugin") await prepareAssets(); +if (dataset.source === "plugin") + await prepareAssets(input.manifest.minecraftVersion); // This maintenance command runs with the worker stopped. Compare-and-set // guards reject intervening publication, ownership of a job, or another repair. diff --git a/scripts/test-minecraft-versions.mjs b/scripts/test-minecraft-versions.mjs new file mode 100644 index 0000000..f629d40 --- /dev/null +++ b/scripts/test-minecraft-versions.mjs @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { gunzipSync } from "node:zlib"; +import path from "node:path"; +import { stripVTControlCharacters } from "node:util"; + +// Disposable servers listen only on loopback. No fixture uploads or tokens are +// sent to MineGeo. Keep exports/logs locally as compatibility evidence. +const root = path.resolve(".cache/minecraft-compat"); +const java = process.env.JAVA_HOME + ? path.join( + process.env.JAVA_HOME, + "bin", + process.platform === "win32" ? "java.exe" : "java", + ) + : "java"; +async function serverJar(version) { + await mkdir(root, { recursive: true }); + const file = `${root}/paper-${version}.jar`; + if (version === "1.21.11" && !existsSync(file)) + await copyFile(".cache/paper-proof/paper.jar", file); + if (existsSync(file)) return file; + const response = await fetch( + `https://fill.papermc.io/v3/projects/paper/versions/${version}/builds`, + { + headers: { + "User-Agent": "MineGeo/0.1.3 local compatibility verification", + }, + }, + ); + assert.equal(response.status, 200); + const builds = await response.json(); + const download = (builds.find((b) => b.channel === "STABLE") ?? builds[0]) + .downloads["server:default"]; + const bytes = Buffer.from(await (await fetch(download.url)).arrayBuffer()); + assert.equal( + createHash("sha256").update(bytes).digest("hex"), + download.checksums.sha256, + ); + await writeFile(file, bytes); + return file; +} +function monitor(child, label) { + let output = ""; + const waiters = new Set(); + let ended = false; + const record = (bytes) => { + output += stripVTControlCharacters(bytes.toString()); + for (const w of waiters) + if (w.pattern.test(output.slice(w.start))) { + clearTimeout(w.timer); + waiters.delete(w); + w.resolve(); + } + }; + child.stdout.on("data", record); + child.stderr.on("data", record); + const exit = new Promise((resolve) => + child.on("exit", (code) => { + ended = true; + for (const w of waiters) { + clearTimeout(w.timer); + w.reject(new Error(`${label} exited ${code}: ${output.slice(-5000)}`)); + } + waiters.clear(); + resolve(code); + }), + ); + return { + get output() { + return output; + }, + exit, + wait(pattern, start = output.length, timeout = 180000) { + return new Promise((resolve, reject) => { + if (pattern.test(output.slice(start))) return resolve(); + if (ended) + return reject( + new Error(`${label} already exited: ${output.slice(-5000)}`), + ); + const w = { + pattern, + start, + resolve, + reject, + timer: setTimeout(() => { + waiters.delete(w); + reject( + new Error( + `${label} timed out for ${pattern}: ${output.slice(-5000)}`, + ), + ); + }, timeout), + }; + waiters.add(w); + }); + }, + async command(command, pattern) { + const ready = this.wait(pattern); + child.stdin.write(command + "\n"); + await ready; + }, + }; +} +if (process.argv.includes("--fabric")) + throw new Error( + "This check runs PaperMC servers only; Minecraft clients are disabled.", + ); +const selected = process.argv + .find((arg) => arg.startsWith("--version=")) + ?.split("=")[1]; +const matrix = ["1.21.11", "26.1.2", "26.2"].filter( + (v) => !selected || v === selected, +); +for (const version of matrix) { + const directory = `${root}/plugin-${version}-${Date.now()}`; + await mkdir(`${directory}/plugins`, { recursive: true }); + await copyFile(await serverJar(version), `${directory}/paper.jar`); + await copyFile( + "public/tools/plugin/minegeo-plugin-0.1.3.jar", + `${directory}/plugins/minegeo.jar`, + ); + await writeFile(`${directory}/eula.txt`, "eula=true\n"); + await writeFile( + `${directory}/server.properties`, + [ + "server-ip=127.0.0.1", + `server-port=25582`, + "online-mode=false", + "level-type=minecraft:flat", + 'generator-settings={"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:dirt","height":2},{"block":"minecraft:grass_block","height":1}],"biome":"minecraft:plains"}', + "generate-structures=false", + "view-distance=3", + "simulation-distance=2", + "max-players=1", + "spawn-protection=0", + ].join("\n") + "\n", + ); + const server = spawn(java, ["-Xmx1536M", "-jar", "paper.jar", "--nogui"], { + cwd: directory, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + const logs = monitor(server, `Paper ${version}`); + try { + await logs.wait(/Done \(/, 0); + console.log(`Paper ${version} started.`); + + await logs.command("version MineGeo", /MineGeo version 0\.1\.3/); + await logs.command( + "minegeo website", + /MineGeo website: https:\/\/minegeo\.vercel\.app/, + ); + await logs.command( + "minegeo website http://127.0.0.1:9999", + /website is fixed to https:\/\/minegeo\.vercel\.app/, + ); + await logs.command( + "minegeo server play.compat.example", + /Capture server address saved/, + ); + await logs.command("minegeo area world -16 -16 16 16", /area selected/); + await logs.command("forceload add -16 -16 15 15", /Marked|already marked/); + await new Promise((r) => setTimeout(r, 5000)); + await logs.command( + "minegeo export Compatibility World", + /Export complete:/, + ); + const relative = ( + await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8") + ).trim(); + const manifest = JSON.parse( + await readFile( + `${directory}/plugins/MineGeo/${relative}/manifest.json`, + "utf8", + ), + ); + assert.equal(manifest.minecraftVersion, version); + assert.equal(manifest.viewpoints.length, 24); + const terrain = JSON.parse( + gunzipSync( + await readFile( + `${directory}/plugins/MineGeo/${relative}/terrain.json.gz`, + ), + ).toString(), + ); + assert.ok(terrain.blocks.length > 0); + await logs.command( + "minegeo upload", + /Create a token in the website dashboard/, + ); + console.log( + `PLUGIN ${version} PASSED: fixed endpoint, commands, real export, correct version, ${terrain.blocks.length} blocks.`, + ); + } finally { + server.stdin.write("stop\n"); + const timeout = setTimeout(() => server.kill(), 20000); + await logs.exit; + clearTimeout(timeout); + await writeFile(`${directory}/server.log`, logs.output); + } +} diff --git a/scripts/test-plugin.ts b/scripts/test-plugin.ts index cc24d8f..cfd3255 100644 --- a/scripts/test-plugin.ts +++ b/scripts/test-plugin.ts @@ -1,10 +1,12 @@ import { createServer } from "node:http"; import { connect } from "node:net"; -import { spawn } from "node:child_process"; +import { spawn, execFile } from "node:child_process"; import { mkdir, copyFile, writeFile, readFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { gunzipSync } from "node:zlib"; import assert from "node:assert/strict"; +import path from "node:path"; +import { promisify } from "node:util"; import { datasetSchema } from "../shared/protocol"; // Disposable, loopback-only Paper test. No game client is launched. @@ -102,8 +104,50 @@ const api = createServer(async (req, res) => { await new Promise((r) => api.listen(0, "127.0.0.1", r)); await writeFile( `${directory}/plugins/MineGeo/config.yml`, - `website: 'http://127.0.0.1:${(api.address() as any).port}'\ntoken: 'fixture-token'\nserver-name: 'Regression world'\nserver-address: 'play.regression.example.com'\nchunks-per-tick: 2\nviewpoints: 24\nmax-area-size: 2048\n`, + `website: 'http://127.0.0.1:${(api.address() as any).port}'\ntoken: ''\nserver-name: 'Regression world'\nserver-address: 'play.regression.example.com'\nchunks-per-tick: 2\nviewpoints: 24\nmax-area-size: 2048\n`, ); +// Exercise the shared upload transport against the loopback mock separately. +// The deployed plugin always uses its hardcoded production endpoint. +const execute = promisify(execFile); +let exportDir: string; +const pluginJar = path.resolve("public/tools/plugin/minegeo-plugin-0.1.3.jar"); +await execute("javac", [ + "-cp", + pluginJar, + "-d", + directory, + "tests/java/UploadSmoke.java", +]); +await writeFile( + directory + "/fixture-auth.json", + JSON.stringify({ token: "fixture-token" }), +); +const upload = async ( + target: string | null, + expected: RegExp, + fails = false, +) => { + const args = [ + "-cp", + directory + path.delimiter + pluginJar, + "UploadSmoke", + "http://127.0.0.1:" + (api.address() as any).port, + exportDir, + directory + "/fixture-auth.json", + ]; + if (target) args.push(target); + let output = "", + code = 0; + try { + const result = await execute("java", args, { windowsHide: true }); + output = result.stdout + result.stderr; + } catch (error: any) { + output = error.stdout + error.stderr; + code = error.code; + } + assert.equal(code === 0, !fails, output); + assert.match(output, expected); +}; const child = spawn("java", ["-Xmx1536M", "-jar", "paper.jar", "--nogui"], { cwd: directory, stdio: ["pipe", "pipe", "pipe"], @@ -165,46 +209,53 @@ const command = async (text: string, pattern: RegExp) => { child.stdin.write(text + "\n"); await pending; }; -const rconCommand = (text: string, expected: RegExp) => new Promise((resolve, reject) => { - const socket = connect(25581, "127.0.0.1"); - let buffer = Buffer.alloc(0); - let authenticated = false; - const timeout = setTimeout(() => finish(new Error(`RCON timed out waiting for ${expected}`)), 10000); - const finish = (error?: Error) => { - clearTimeout(timeout); - socket.destroy(); - if (error) reject(error); - else resolve(); - }; - const packet = (id: number, type: number, body: string) => { - const payload = Buffer.from(body); - const frame = Buffer.alloc(payload.length + 14); - frame.writeInt32LE(payload.length + 10, 0); - frame.writeInt32LE(id, 4); - frame.writeInt32LE(type, 8); - payload.copy(frame, 12); - return frame; - }; - socket.on("connect", () => socket.write(packet(1, 3, "minegeo-fixture"))); - socket.on("error", finish); - socket.on("data", (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - while (buffer.length >= 4 && buffer.length >= buffer.readInt32LE(0) + 4) { - const length = buffer.readInt32LE(0); - const frame = buffer.subarray(0, length + 4); - buffer = buffer.subarray(length + 4); - const id = frame.readInt32LE(4); - const type = frame.readInt32LE(8); - if (id === -1) return finish(new Error("RCON authentication failed")); - if (id === 1 && type === 2 && !authenticated) { - authenticated = true; - socket.write(packet(2, 2, text)); - } else if (id === 2 && expected.test(frame.toString("utf8", 12, length + 2))) { - return finish(); +const rconCommand = (text: string, expected: RegExp) => + new Promise((resolve, reject) => { + const socket = connect(25581, "127.0.0.1"); + let buffer = Buffer.alloc(0); + let authenticated = false; + const timeout = setTimeout( + () => finish(new Error(`RCON timed out waiting for ${expected}`)), + 10000, + ); + const finish = (error?: Error) => { + clearTimeout(timeout); + socket.destroy(); + if (error) reject(error); + else resolve(); + }; + const packet = (id: number, type: number, body: string) => { + const payload = Buffer.from(body); + const frame = Buffer.alloc(payload.length + 14); + frame.writeInt32LE(payload.length + 10, 0); + frame.writeInt32LE(id, 4); + frame.writeInt32LE(type, 8); + payload.copy(frame, 12); + return frame; + }; + socket.on("connect", () => socket.write(packet(1, 3, "minegeo-fixture"))); + socket.on("error", finish); + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4 && buffer.length >= buffer.readInt32LE(0) + 4) { + const length = buffer.readInt32LE(0); + const frame = buffer.subarray(0, length + 4); + buffer = buffer.subarray(length + 4); + const id = frame.readInt32LE(4); + const type = frame.readInt32LE(8); + if (id === -1) return finish(new Error("RCON authentication failed")); + if (id === 1 && type === 2 && !authenticated) { + authenticated = true; + socket.write(packet(2, 2, text)); + } else if ( + id === 2 && + expected.test(frame.toString("utf8", 12, length + 2)) + ) { + return finish(); + } } - } + }); }); -}); try { await wait(/Done \(/, 0, 180000); await command("minegeo help", /MineGeo commands:/); @@ -227,7 +278,7 @@ try { let relative = ( await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8") ).trim(); - const exportDir = `${directory}/plugins/MineGeo/${relative}`; + exportDir = `${directory}/plugins/MineGeo/${relative}`; const local = datasetSchema.parse( JSON.parse(await readFile(`${exportDir}/manifest.json`, "utf8")), ); @@ -243,44 +294,46 @@ try { (p) => p.x >= -32 && p.x < 32 && p.z >= -32 && p.z < 32, ), ); - await command("minegeo upload", /Upload failed: Upload paused/); - await command("minegeo upload", /Upload complete\. Open/); + await upload(null, /Upload paused/, true); + await upload(null, /queueing panorama processing|Already uploaded/); assert.equal(creates, 1); assert.equal(finalizes, 1); assert.equal(heads, 1); - await command("minegeo upload", /Upload complete\. Open/); + await upload(null, /queueing panorama processing|Already uploaded/); assert.equal(finalizes, 1); received = Buffer.alloc(0); expectedServer = "wrong.example.com"; - await command( - "minegeo contribute 00000000-0000-4000-8000-000000000024", + await upload( + "00000000-0000-4000-8000-000000000024", /Wrong capture server/, + true, ); assert.equal(creates, 1, "Mismatch must stop before creating a draft"); expectedServer = "PLAY.REGRESSION.EXAMPLE.COM.:25565"; - await command( - "minegeo contribute 00000000-0000-4000-8000-000000000024", - /Upload complete\. Open/, + await upload( + "00000000-0000-4000-8000-000000000024", + /queueing panorama processing|Already uploaded/, ); assert.equal(manifest.contributionTo, "00000000-0000-4000-8000-000000000024"); assert.equal(manifest.viewpoints.length, 12); assert.equal(creates, 2); expectedServer = "wrong.example.com"; - await command( - "minegeo contribute 00000000-0000-4000-8000-000000000024", + await upload( + "00000000-0000-4000-8000-000000000024", /Wrong capture server/, + true, ); assert.equal(creates, 2, "A cached selection must recheck the server"); expectedServer = null; - await command( - "minegeo contribute 00000000-0000-4000-8000-000000000024", - /Upload complete\. Open/, + await upload( + "00000000-0000-4000-8000-000000000024", + /queueing panorama processing|Already uploaded/, ); assert.equal(creates, 2, "Cleared server permits an idempotent retry"); assert.equal(finalizes, 2); - await command( - "minegeo contribute 00000000-0000-4000-8000-000000000024", - /Upload complete\. Open/, + await upload( + "00000000-0000-4000-8000-000000000024", + /queueing panorama processing|Already uploaded/, ); assert.equal(creates, 2); await command("minegeo export Random Positions", /Export complete:/); @@ -321,7 +374,7 @@ try { "repeated upload is idempotent", "cancellation preserves completed export", "24 random positions change on the next export", - "contribution command caps at 12 and retries idempotently", + "shared upload client caps contributions at 12 and retries idempotently", "wrong server blocked before upload, including cached retry", "owner override/default port and cleared address supported", ], diff --git a/scripts/test-renderer.mjs b/scripts/test-renderer.mjs index 0f81113..9ed50f8 100644 --- a/scripts/test-renderer.mjs +++ b/scripts/test-renderer.mjs @@ -7,7 +7,8 @@ import { assertTerrainImage } from "../renderer/image-validation.mjs"; // Exercise the product's rendering worker without launching a Minecraft server // or driving the website. A cached real export can also be supplied as argv[2]. -const output = ".cache/renderer-regression"; +let minecraftVersion = process.argv[3] || "1.21.11"; +const output = `.cache/renderer-regression-${minecraftVersion}`; const snapshot = { bounds: { minX: -24, minZ: -24, maxX: 24, maxZ: 24 }, coverage: [], @@ -69,11 +70,12 @@ let points = [ [0.5, 18.5], ].map(([x, z], i) => ({ id: `p${i}`, x, y: 66.62, z })); let source = "grass, dirt, water, tree and building regression fixture"; -if (process.argv[2]) { +if (process.argv[2] && process.argv[2] !== "--fixture") { const directory = process.argv[2]; const manifest = JSON.parse( await readFile(`${directory}/manifest.json`, "utf8"), ); + minecraftVersion = manifest.minecraftVersion; terrain = JSON.parse( gunzipSync(await readFile(`${directory}/terrain.json.gz`)).toString(), ); @@ -81,7 +83,10 @@ if (process.argv[2]) { source = "cached Paper export (no server started)"; } await mkdir(output, { recursive: true }); -const report = await renderSnapshot(terrain, points, output, { size: 256 }); +const report = await renderSnapshot(terrain, points, output, { + size: 256, + minecraftVersion, +}); assert.ok(report.meshes > 0, "terrain must produce meshes"); assert.ok(report.emittedFaces > 0, "terrain must produce faces"); assert.ok(report.hiddenFaces > 0, "interior terrain faces must be culled"); @@ -106,12 +111,16 @@ assert.ok( "blocks without explicit default properties must appear on the aerial map", ); const mapPixel = (x, z) => { - const pixelX = Math.floor(((x - snapshot.bounds.minX + 0.5) / 48) * mapInfo.width); - const pixelY = Math.floor(((z - snapshot.bounds.minZ + 0.5) / 48) * mapInfo.height); + const pixelX = Math.floor( + ((x - snapshot.bounds.minX + 0.5) / 48) * mapInfo.width, + ); + const pixelY = Math.floor( + ((z - snapshot.bounds.minZ + 0.5) / 48) * mapInfo.height, + ); const offset = (pixelY * mapInfo.width + pixelX) * mapInfo.channels; return mapPixels.subarray(offset, offset + 3); }; -if (!process.argv[2]) { +if (!process.argv[2] || process.argv[2] === "--fixture") { const shadedGrass = mapPixel(10, 9); const sunlitGrass = mapPixel(10, -9); assert.ok( diff --git a/shared/locales/ru.json b/shared/locales/ru.json index 42e4a8f..5c75725 100644 --- a/shared/locales/ru.json +++ b/shared/locales/ru.json @@ -664,5 +664,9 @@ "Room not found": "Комната не найдена", "Join this room to play": "Присоединитесь к комнате, чтобы играть", "Wait for the round to finish": "Дождитесь завершения раунда", - "Players can only be removed before the match starts": "Удалять игроков можно только до начала игры" + "Players can only be removed before the match starts": "Удалять игроков можно только до начала игры", + "Minecraft Java 1.21.11, 26.1 & 26.2 + Fabric Loader + Fabric API": "Minecraft Java 1.21.11, 26.1 и 26.2 + Fabric Loader + Fabric API", + "Bukkit/Paper 1.21.11, 26.1 & 26.2": "Bukkit/Paper 1.21.11, 26.1 и 26.2", + "Java 21 for 1.21.11; Java 25 for 26.1 and 26.2. Both tools connect to minegeo.vercel.app automatically.": "Java 21 для 1.21.11; Java 25 для 26.1 и 26.2. Оба инструмента автоматически подключаются к minegeo.vercel.app.", + "Put the JAR in plugins/ and restart. Set server-name and server-address in plugins/MineGeo/config.yml, then run /minegeo reload. Permission: minegeo.admin. The same plugin JAR supports all three versions.": "Поместите JAR в plugins/ и перезапустите сервер. Укажите server-name и server-address в plugins/MineGeo/config.yml, затем выполните /minegeo reload. Разрешение: minegeo.admin. Один JAR плагина поддерживает все три версии." } diff --git a/shared/protocol.ts b/shared/protocol.ts index 6726560..47f4d5c 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -41,7 +41,12 @@ export function panoramaPath( export const datasetSchema = z .object({ format: z.literal("minegeo-dataset/v1"), - minecraftVersion: z.literal("1.21.11"), + minecraftVersion: z + .string() + .regex( + /^(?:1\.21\.11|26\.[12](?:\.\d+)*)$/, + "Unsupported Minecraft version", + ), dimension: z.literal("minecraft:overworld"), source: z.enum(["plugin", "fabric"]), name: z.string().min(3).max(80), diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index c0eeacd..c22e55e 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -46,6 +46,21 @@ test("dataset requires five unique in-bounds viewpoints and safe paths", () => { assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 100 }], }; assert.equal(datasetSchema.safeParse(base).success, true); + for (const minecraftVersion of ["26.1", "26.1.1", "26.1.2", "26.2"]) + assert.equal( + datasetSchema.safeParse({ ...base, minecraftVersion }).success, + true, + ); + for (const minecraftVersion of [ + "26.3", + "1.21.10", + "26.2-snapshot", + "../secrets", + ]) + assert.equal( + datasetSchema.safeParse({ ...base, minecraftVersion }).success, + false, + ); assert.equal(datasetSchema.safeParse({ ...base, assets: [] }).success, false); assert.equal( datasetSchema.safeParse({ ...base, source: "fabric" }).success, diff --git a/tests/renderer-assets.test.ts b/tests/renderer-assets.test.ts index 5ca93cb..401d3eb 100644 --- a/tests/renderer-assets.test.ts +++ b/tests/renderer-assets.test.ts @@ -37,7 +37,11 @@ const jar = zipSync( ); const checksum = createHash("sha1").update(jar).digest("hex"); -async function isolatedCache(t: TestContext, corruptDownload = false) { +async function isolatedCache( + t: TestContext, + corruptDownload = false, + minecraftVersion = "1.21.11", +) { const previous = process.cwd(); const cacheParent = resolve(".cache"); await mkdir(cacheParent, { recursive: true }); @@ -54,7 +58,9 @@ async function isolatedCache(t: TestContext, corruptDownload = false) { requests.push(url); if (url.includes("version_manifest_v2")) return Response.json({ - versions: [{ id: "1.21.11", url: "https://fixture.invalid/version" }], + versions: [ + { id: minecraftVersion, url: "https://fixture.invalid/version" }, + ], }); if (url.endsWith("/version")) return Response.json({ @@ -91,6 +97,27 @@ test("missing vanilla cache is prepared completely and subsequent runs work offl assert.deepEqual(requests, []); }); +test("26.x assets use distinct verified caches and reject unsupported versions", async (t) => { + await isolatedCache(t, false, "26.2"); + const directory = await prepareAssets("26.2"); + assert.equal(directory, ".cache/minecraft/26.2"); + const marker = JSON.parse( + await readFile(`${directory}/.minegeo-assets.json`, "utf8"), + ); + assert.equal(marker.minecraftVersion, "26.2"); + await assert.rejects(readFile(`${assetsRoot}/.minegeo-assets.json`), { + code: "ENOENT", + }); + await assert.rejects( + prepareAssets("../secrets"), + /Unsupported Minecraft asset version/, + ); + await assert.rejects( + prepareAssets("26.3"), + /Unsupported Minecraft asset version/, + ); +}); + test("an interrupted extraction with only a blockstate cannot masquerade as complete", async (t) => { await isolatedCache(t); await mkdir(`${assetsRoot}/assets/minecraft/blockstates`, { diff --git a/worker/main.ts b/worker/main.ts index db918d8..4598ff1 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -189,6 +189,7 @@ async function processJob(job: { output, { size: Number(process.env.MINEGEO_FACE_SIZE || 1024), + minecraftVersion: manifest.minecraftVersion, signal: abort.signal, onProgress: ({ percent, message }) => progress(percent, message), },