Add Minecraft 26.x support and version Java artifacts

This commit is contained in:
2026-10-07 13:18:31 +05:00
parent 48b34db6f1
commit dac8650ac1
50 changed files with 813 additions and 162 deletions

View File

@@ -3,11 +3,13 @@ import { Header, Footer } from "@/components/Shell";
import { Blocks, Server, Download, ExternalLink } from "lucide-react"; import { Blocks, Server, Download, ExternalLink } from "lucide-react";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import Link from "next/link"; 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 const dynamic = "force-dynamic";
export default async function Downloads() { export default async function Downloads() {
const { t } = await getI18n(); const { t } = await getI18n();
const fabric = existsSync("public/tools/mod/minegeo-fabric-1.21.11.jar"), const pluginPath = `/tools/plugin/minegeo-plugin-${release}.jar`;
plugin = existsSync("public/tools/plugin/minegeo-plugin-1.21.11.jar"); const plugin = existsSync(`public${pluginPath}`);
return ( return (
<> <>
<Header /> <Header />
@@ -33,7 +35,9 @@ export default async function Downloads() {
</p> </p>
<ul> <ul>
<li> <li>
{t("Minecraft Java 1.21.11 + Fabric Loader + Fabric API")} {t(
"Minecraft Java 1.21.11, 26.1 & 26.2 + Fabric Loader + Fabric API",
)}
</li> </li>
<li>{t("Open the capture panel with G")}</li> <li>{t("Open the capture panel with G")}</li>
<li>{t("Link your account through a browser pairing code")}</li> <li>{t("Link your account through a browser pairing code")}</li>
@@ -48,24 +52,35 @@ export default async function Downloads() {
</li> </li>
<li>{t("Contributions check the recorded server address")}</li> <li>{t("Contributions check the recorded server address")}</li>
</ul> </ul>
{fabric ? ( {versions.map((version) =>
existsSync(
`public/tools/mod/minegeo-fabric-${version}-${release}.jar`,
) ? (
<a <a
key={version}
className="button primary" className="button primary"
href="/tools/mod/minegeo-fabric-1.21.11.jar" href={`/tools/mod/minegeo-fabric-${version}-${release}.jar`}
download download
style={{ marginRight: 8, marginBottom: 8 }}
> >
<Download size={16} /> {t("Download Fabric mod")} <Download size={16} /> {t("Download Fabric mod")} · {version}
</a> </a>
) : ( ) : (
<p className="muted"> <p className="muted" key={version}>
{t("The Fabric build is not available yet.")} {version}: {t("The Fabric build is not available yet.")}
</p> </p>
),
)} )}
<p className="muted" style={{ fontSize: 11, marginTop: 20 }}> <p className="muted" style={{ fontSize: 11, marginTop: 20 }}>
{t( {t(
"Install the JAR in your Minecraft mods folder alongside Fabric API.", "Install the JAR in your Minecraft mods folder alongside Fabric API.",
)} )}
</p> </p>
<p className="muted">
{t(
"Java 21 for 1.21.11; Java 25 for 26.1 and 26.2. Both tools connect to minegeo.vercel.app automatically.",
)}
</p>
</article> </article>
<article className="tool-card"> <article className="tool-card">
<Server size={34} /> <Server size={34} />
@@ -77,7 +92,7 @@ export default async function Downloads() {
)} )}
</p> </p>
<ul> <ul>
<li>{t("Bukkit/Paper 1.21.11 · Java 21")}</li> <li>{t("Bukkit/Paper 1.21.11, 26.1 & 26.2")}</li>
<li>{t("Select a bounded area and export existing chunks")}</li> <li>{t("Select a bounded area and export existing chunks")}</li>
<li>{t("Choose up to 24 random, safe surface viewpoints")}</li> <li>{t("Choose up to 24 random, safe surface viewpoints")}</li>
<li> <li>
@@ -90,11 +105,7 @@ export default async function Downloads() {
<li>{t("Vanilla blocks/fluids, simplified lighting")}</li> <li>{t("Vanilla blocks/fluids, simplified lighting")}</li>
</ul> </ul>
{plugin ? ( {plugin ? (
<a <a className="button primary" href={pluginPath} download>
className="button primary"
href="/tools/plugin/minegeo-plugin-1.21.11.jar"
download
>
<Download size={16} /> {t("Download server plugin")} <Download size={16} /> {t("Download server plugin")}
</a> </a>
) : ( ) : (
@@ -104,7 +115,7 @@ export default async function Downloads() {
)} )}
<pre> <pre>
{ {
"/minegeo website https://your-minegeo-site.example\n/minegeo server play.your-server.example\n/minegeo link <dashboard token>\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 <dashboard token>\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"
} }
</pre> </pre>
<p className="muted"> <p className="muted">
@@ -117,10 +128,9 @@ export default async function Downloads() {
)} )}
</p> </p>
<p className="muted" style={{ fontSize: 11 }}> <p className="muted" style={{ fontSize: 11 }}>
Put the JAR in plugins/ and restart. Set website, server-name and {t(
server-address in plugins/MineGeo/config.yml, then run /minegeo "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.",
reload. HTTP LAN addresses are supported for testing. Permission: )}
minegeo.admin.
</p> </p>
</article> </article>
</div> </div>

View File

@@ -11,12 +11,12 @@
"repeated upload is idempotent", "repeated upload is idempotent",
"cancellation preserves completed export", "cancellation preserves completed export",
"24 random positions change on the next 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", "wrong server blocked before upload, including cached retry",
"owner override/default port and cleared address supported" "owner override/default port and cleared address supported"
], ],
"blocks": 10112, "blocks": 10112,
"chunks": 16, "chunks": 16,
"viewpoints": 24, "viewpoints": 24,
"directory": ".cache/plugin-regression-1791207258927" "directory": ".cache/plugin-regression-1791360575134"
} }

View File

@@ -1,18 +1,17 @@
# Server plugin 0.1.2 # Server plugin 0.1.3
## Update and connect ## 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. 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 ```text
minegeo website https://your-minegeo-domain.example
minegeo server play.your-minecraft-server.example minegeo server play.your-minecraft-server.example
minegeo link <token copied from the dashboard> minegeo link <token copied from the dashboard>
``` ```
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). `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. - 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 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 status` reports the active stage and the last completed export folder.
- `minegeo help` lists commands. - `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. 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.

View File

@@ -3,16 +3,22 @@
Commit these website assets together with the source code: Commit these website assets together with the source code:
- `public/images/example-map.webp` — homepage illustration and map-card fallback. - `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/mod/minegeo-fabric-1.21.11-0.1.3.jar` — Java 21 Fabric mod.
- `public/tools/plugin/minegeo-plugin-1.21.11.jar` — server plugin download. - `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`, Next.js serves these directly under `/images/` and `/tools/`. The download page
`/tools/mod/minegeo-fabric-1.21.11.jar`, and uses versioned filenames to avoid stale downloads. Unversioned 1.21.11 URLs
`/tools/plugin/minegeo-plugin-1.21.11.jar`. They are deliberately tracked, so a remain updated compatibility copies. These assets are deliberately tracked, so a
fresh checkout on macOS has the homepage image and both download buttons without fresh checkout on macOS has the homepage image and all download buttons without
building Minecraft tools or running the renderer proof. 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. The build copies the new JARs into `public/tools/`; commit those updated files too.
Gradle caches, intermediate build output, and generated test panoramas remain Gradle caches, intermediate build output, and generated test panoramas remain
ignored. The JARs contain Java bytecode and work across Windows, macOS, and Linux. 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 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`. server. For a production server, run `npm run build` and then `npm start`.
Keep that machine's Supabase credentials in its own `.env.local`. 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.

View File

@@ -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) }
}

View File

@@ -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" }

View File

@@ -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;}}
}

View File

@@ -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;}
}

View File

@@ -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()<minimum){message("Capture at least "+minimum+" locations before uploading.");return;}uploading=true;background.execute(()->{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;}});}
}

View File

@@ -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<Point> points=new ArrayList<>();public final Set<String> 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<minX+512&&z>=minZ&&z<minZ+512;}
public void scan(Minecraft c){
if(scanning==null){int px=Math.floorDiv(c.player.getBlockX(),16),pz=Math.floorDiv(c.player.getBlockZ(),16);outer:for(int radius=0;radius<=12;radius++)for(int dz=-radius;dz<=radius;dz++)for(int dx=-radius;dx<=radius;dx++){if(Math.max(Math.abs(dx),Math.abs(dz))!=radius)continue;int cx=px+dx,cz=pz+dz;String key=cx+","+cz;if(coverage.contains(key)||!contains(cx*16,cz*16))continue;if(c.level.getChunk(cx,cz,ChunkStatus.FULL,false)==null)continue;scanning=key;chunkX=cx;chunkZ=cz;cursor=0;break outer;}}
if(scanning==null)return;if(c.level.getChunk(chunkX,chunkZ,ChunkStatus.FULL,false)==null){scanning=null;return;}
var pos=new BlockPos.MutableBlockPos();for(int n=0;n<64&&cursor<256;n++,cursor++){int x=chunkX*16+(cursor%16),z=chunkZ*16+(cursor/16),y=c.level.getHeight(Heightmap.Types.WORLD_SURFACE,x,z)-1;pos.set(x,y,z);int color=c.level.getBlockState(pos).getMapColor(c.level,pos).col;image.setRGB(x-minX,z-minZ,0xff000000|color);}
if(cursor==256){coverage.add(scanning);scanning=null;}
}
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",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);}
}

View File

@@ -0,0 +1,14 @@
package dev.minegeo.fabric.mixin;
import dev.minegeo.fabric.Capture;
import net.minecraft.client.Camera;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(Camera.class)
public abstract class CameraMixin {
@Shadow protected abstract void setRotation(float yaw,float pitch);
@Inject(method="update",at=@At("TAIL")) private void direction(CallbackInfo ci){if(Capture.active())setRotation(Capture.yaw(),Capture.pitch());}
@Inject(method="getFov",at=@At("HEAD"),cancellable=true) private void fov(CallbackInfoReturnable<Float> ci){if(Capture.active())ci.setReturnValue(90f);}
}

View File

@@ -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();}
}

View File

@@ -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<Integer> ci){if(Capture.active())ci.setReturnValue(Capture.SIZE);}
}

View File

@@ -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": "*"}
}

View File

@@ -0,0 +1 @@
{"required":true,"package":"dev.minegeo.fabric.mixin","compatibilityLevel":"JAVA_25","client":["GameRendererMixin","WindowMixin","${targetMixin}","CameraMixin"],"injectors":{"defaultRequire":1}}

View File

@@ -5,19 +5,18 @@ import net.minecraft.client.gui.widget.*;
import net.minecraft.text.Text; import net.minecraft.text.Text;
public final class CaptureScreen extends Screen { public final class CaptureScreen extends Screen {
private TextFieldWidget website,name,target; private TextFieldWidget name,target;
public CaptureScreen(){super(Text.literal("MineGeo Capture"));} public CaptureScreen(){super(Text.literal("MineGeo Capture"));}
@Override protected void init(){var mod=MineGeoClient.INSTANCE;int x=width/2-130; @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); 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); 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("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("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.website=website.getText();mod.targetMap=target.getText().trim();mod.upload(name.getText());}).dimensions(x+134,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("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()); 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;} @Override public boolean shouldPause(){return false;}
} }

View File

@@ -17,20 +17,21 @@ public final class MineGeoClient implements ClientModInitializer {
public static MineGeoClient INSTANCE; 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 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 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 String targetMap="";
public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending; public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending;
private Path config; 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")))); 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);}); 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 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 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 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 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 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 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()<minimum){message("Capture at least "+minimum+" locations before uploading.");return;}uploading=true;background.execute(()->{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 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()<minimum){message("Capture at least "+minimum+" locations before uploading.");return;}uploading=true;background.execute(()->{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;}});}
} }

View File

@@ -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 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());} 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);} 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);}
} }

View File

@@ -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'

View File

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

View File

@@ -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<RenderTarget> ci){if(Capture.active()&&Capture.framebuffer()!=null)ci.setReturnValue(Capture.framebuffer());}
}

View File

@@ -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'

View File

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

View File

@@ -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;}
}

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View File

@@ -26,7 +26,7 @@ public final class MineGeoPlugin extends JavaPlugin {
private final Gson gson=new GsonBuilder().setPrettyPrinting().create(); private final Gson gson=new GsonBuilder().setPrettyPrinting().create();
private volatile Path latest; private volatile Path latest;
@Override public void onEnable(){ @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 { try {
Files.createDirectories(getDataFolder().toPath().resolve("exports")); Files.createDirectories(getDataFolder().toPath().resolve("exports"));
Path root=getDataFolder().toPath().toAbsolutePath().normalize(),pointer=root.resolve("latest-export.txt"); 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. // 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;} if(!(sender instanceof ConsoleCommandSender||sender instanceof RemoteConsoleCommandSender||sender instanceof Player&&sender.hasPermission("minegeo.admin"))){sender.sendMessage("MineGeo: administrator permission required.");return true;}
try { try {
if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website <http(s)://address:port>","/minegeo server <hostname[:port]> — capture server address","/minegeo link <dashboard token>","/minegeo area <world> <minX> <minZ> <maxX> <maxZ>","/minegeo export <map name> — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo contribute <map ID> — 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 <hostname[:port]> — capture server address","/minegeo link <dashboard token>","/minegeo area <world> <minX> <minZ> <maxX> <maxZ>","/minegeo export <map name> — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo contribute <map ID> — add panoramas (24 owner, 12 guests)","/minegeo status | cancel | reload"});return true;}
switch(args[0].toLowerCase(Locale.ROOT)) { switch(args[0].toLowerCase(Locale.ROOT)) {
case "area" -> { case "area" -> {
requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area <world> <minX> <minZ> <maxX> <maxZ>"); requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");
@@ -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 "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 "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 "reload" -> {requireIdle();reloadConfig();sender.sendMessage("MineGeo configuration reloaded.");}
case "website" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo website <http(s)://address:port>");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 <hostname[:port]>");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 "server" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo server <hostname[:port]>");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 <dashboard token>");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved.");} case "link" -> {requireIdle();if(args.length!=2||args[1].isBlank())throw new IllegalArgumentException("Usage: /minegeo link <dashboard token>");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"); 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 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 { 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()); 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 <token>."); 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 <token>.");
UploadClient client=new UploadClient(website,token);Path directory=latest;busy=true;cancelled.set(false);status="Uploading "+directory.getFileName(); 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;}}); 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."); 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<int[]> chosen=candidates.subList(0,Math.min(desired,candidates.size())); List<int[]> chosen=candidates.subList(0,Math.min(desired,candidates.size()));
for(int i=0;i<chosen.size();i++){int[] p=chosen.get(i);JsonObject point=new JsonObject();point.addProperty("id","p"+i);point.addProperty("x",p[0]+.5);point.addProperty("y",p[1]+1.62);point.addProperty("z",p[2]+.5);points.add(point);} for(int i=0;i<chosen.size();i++){int[] p=chosen.get(i);JsonObject point=new JsonObject();point.addProperty("id","p"+i);point.addProperty("x",p[0]+.5);point.addProperty("y",p[1]+1.62);point.addProperty("z",p[2]+.5);points.add(point);}
if(Files.size(terrain)>50L*1024*1024)throw new IOException("Compressed export exceeds 50 MiB. Select a smaller area."); if(Files.size(terrain)>50L*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); 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. // 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); 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);

View File

@@ -12,6 +12,8 @@ import java.nio.charset.StandardCharsets;
/** Shared TUS client. State is saved beside each completed local dataset. */ /** Shared TUS client. State is saved beside each completed local dataset. */
public final class UploadClient { 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; private final String website,token;
// Next's HTTP server does not support Java's cleartext HTTP/2 upgrade handshake. // 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(); private final HttpClient client=HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(30)).build();

View File

@@ -1,4 +1,4 @@
website: 'http://localhost:3000' # The MineGeo service URL is built in: https://minegeo.vercel.app
token: '' token: ''
server-name: 'My Minecraft server' server-name: 'My Minecraft server'
# Public Minecraft connection address, matching the address used by Fabric players. # Public Minecraft connection address, matching the address used by Fabric players.

View File

@@ -1,5 +1,5 @@
name: MineGeo name: MineGeo
version: 0.1.2 version: 0.1.3
main: dev.minegeo.plugin.MineGeoPlugin main: dev.minegeo.plugin.MineGeoPlugin
api-version: '1.21' api-version: '1.21'
description: Bounded terrain export for MineGeo panorama guessing description: Bounded terrain export for MineGeo panorama guessing

View File

@@ -1,3 +1,3 @@
pluginManagement { repositories { maven { url = 'https://maven.fabricmc.net/' }; gradlePluginPortal(); mavenCentral() } } pluginManagement { repositories { maven { url = 'https://maven.fabricmc.net/' }; gradlePluginPortal(); mavenCentral() } }
rootProject.name = 'minegeo-tools' rootProject.name = 'minegeo-tools'
include 'plugin', 'fabric' include 'plugin', 'fabric', 'fabric261', 'fabric262'

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,17 +3,17 @@ import { createServer } from "node:http";
import { readFile, writeFile, mkdir } from "node:fs/promises"; import { readFile, writeFile, mkdir } from "node:fs/promises";
import { resolve, sep } from "node:path"; import { resolve, sep } from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import { assetsRoot, prepareAssets } from "../scripts/assets.mjs"; import { prepareAssets } from "../scripts/assets.mjs";
import { assertTerrainImage } from "./image-validation.mjs"; import { assertTerrainImage } from "./image-validation.mjs";
/** @param {import('../shared/protocol').TerrainSnapshot} snapshot /** @param {import('../shared/protocol').TerrainSnapshot} snapshot
* @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints * @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints
* @param {string} output * @param {string} output
* @param {{size?:number,signal?:AbortSignal,onProgress?:(event:{percent:number,message:string})=>Promise<void>}} [options] */ * @param {{size?:number,minecraftVersion?:string,signal?:AbortSignal,onProgress?:(event:{percent:number,message:string})=>Promise<void>}} [options] */
export async function renderSnapshot( export async function renderSnapshot(
snapshot, snapshot,
viewpoints, viewpoints,
output, output,
{ size = 1024, signal, onProgress } = {}, { size = 1024, minecraftVersion = "1.21.11", signal, onProgress } = {},
) { ) {
const start = Date.now(); const start = Date.now();
if (signal?.aborted) throw new Error("Rendering cancelled"); if (signal?.aborted) throw new Error("Rendering cancelled");
@@ -21,7 +21,7 @@ export async function renderSnapshot(
percent: 22, percent: 22,
message: "Checking Minecraft models and textures", message: "Checking Minecraft models and textures",
}); });
await prepareAssets(); const assetsRoot = await prepareAssets(minecraftVersion);
await mkdir(output, { recursive: true }); await mkdir(output, { recursive: true });
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
try { try {
@@ -207,9 +207,7 @@ export async function renderSnapshot(
if (map.data.startsWith("data:image/webp;")) if (map.data.startsWith("data:image/webp;"))
await writeFile(`${output}/map.webp`, mapBytes); await writeFile(`${output}/map.webp`, mapBytes);
else else
await sharp(mapBytes) await sharp(mapBytes).webp({ quality: 92 }).toFile(`${output}/map.webp`);
.webp({ quality: 92 })
.toFile(`${output}/map.webp`);
stats.map = { width: map.width, height: map.height, cameraY: 320 }; stats.map = { width: map.width, height: map.height, cameraY: 320 };
await report({ await report({
percent: 90, percent: 90,

View File

@@ -2,7 +2,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { unzipSync } from "fflate"; import { unzipSync } from "fflate";
export const assetsRoot = ".cache/minecraft/1.21.11"; 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 = [ const requiredAssets = [
"blockstates/grass_block.json", "blockstates/grass_block.json",
"blockstates/stone.json", "blockstates/stone.json",
@@ -14,12 +18,14 @@ const requiredAssets = [
"textures/block/stone.png", "textures/block/stone.png",
"textures/block/water_still.png", "textures/block/water_still.png",
]; ];
async function assetsReady() { async function assetsReady(minecraftVersion) {
const assetsRoot = assetsDirectory(minecraftVersion);
const completionMarker = `${assetsRoot}/.minegeo-assets.json`;
try { try {
const marker = JSON.parse(await readFile(completionMarker, "utf8")); const marker = JSON.parse(await readFile(completionMarker, "utf8"));
if ( if (
marker.format !== "minegeo-assets/v2" || marker.format !== "minegeo-assets/v2" ||
marker.minecraftVersion !== "1.21.11" || marker.minecraftVersion !== minecraftVersion ||
!marker.files !marker.files
) )
return false; return false;
@@ -39,10 +45,12 @@ async function assetsReady() {
return false; return false;
} }
} }
export async function prepareAssets() { export async function prepareAssets(minecraftVersion = "1.21.11") {
if (await assetsReady()) return; const assetsRoot = assetsDirectory(minecraftVersion);
const completionMarker = `${assetsRoot}/.minegeo-assets.json`;
if (await assetsReady(minecraftVersion)) return assetsRoot;
console.log( 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 ( const manifest = await (
await fetch( await fetch(
@@ -50,13 +58,14 @@ export async function prepareAssets() {
{ signal: AbortSignal.timeout(60000) }, { signal: AbortSignal.timeout(60000) },
) )
).json(); ).json();
const entry = manifest.versions.find((v) => v.id === "1.21.11"); const entry = manifest.versions.find((v) => v.id === minecraftVersion);
if (!entry) throw new Error("Official Minecraft 1.21.11 metadata missing"); if (!entry)
throw new Error(`Official Minecraft ${minecraftVersion} metadata missing`);
const version = await ( const version = await (
await fetch(entry.url, { signal: AbortSignal.timeout(60000) }) await fetch(entry.url, { signal: AbortSignal.timeout(60000) })
).json(); ).json();
await mkdir(".cache/minecraft", { recursive: true }); await mkdir(".cache/minecraft", { recursive: true });
const jarPath = ".cache/minecraft/client-1.21.11.jar"; const jarPath = `.cache/minecraft/client-${minecraftVersion}.jar`;
let jar; let jar;
try { try {
jar = await readFile(jarPath); jar = await readFile(jarPath);
@@ -101,16 +110,17 @@ export async function prepareAssets() {
completionMarker, completionMarker,
JSON.stringify({ JSON.stringify({
format: "minegeo-assets/v2", format: "minegeo-assets/v2",
minecraftVersion: "1.21.11", minecraftVersion,
clientSha1: version.downloads.client.sha1, clientSha1: version.downloads.client.sha1,
files: Object.keys(entries).length, files: Object.keys(entries).length,
}), }),
); );
if (!(await assetsReady())) if (!(await assetsReady(minecraftVersion)))
throw new Error( throw new Error(
"Minecraft rendering assets are incomplete or corrupt. Run npm run setup to repair them.", "Minecraft rendering assets are incomplete or corrupt. Run npm run setup to repair them.",
); );
console.log( console.log(
`Prepared ${Object.keys(entries).length} verified vanilla model/texture assets.`, `Prepared ${Object.keys(entries).length} verified vanilla model/texture assets.`,
); );
return assetsRoot;
} }

View File

@@ -1,11 +1,21 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync } from "node:fs"; 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 windows = process.platform === "win32";
const command = windows ? "gradlew.bat" : "sh"; const command = windows ? "gradlew.bat" : "sh";
const targets = process.argv.slice(2); const targets = process.argv.slice(2);
if (!targets.some((target) => !target.startsWith("-"))) 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) => { await new Promise((resolve, reject) => {
const child = spawn(command, windows ? targets : ["./gradlew", ...targets], { const child = spawn(command, windows ? targets : ["./gradlew", ...targets], {
cwd: "minecraft", cwd: "minecraft",
@@ -18,15 +28,28 @@ await new Promise((resolve, reject) => {
code === 0 ? resolve() : reject(new Error(`Gradle exited ${code}`)), code === 0 ? resolve() : reject(new Error(`Gradle exited ${code}`)),
); );
}); });
for (const name of ["plugin", "fabric"]) { for (const [name, kind, minecraftVersion] of [
const directory = `public/tools/${name === "fabric" ? "mod" : "plugin"}`; ["plugin", "plugin", "1.21.11"],
const file = `minegeo-${name}-1.21.11.jar`, ["fabric", "fabric", "1.21.11"],
path = `minecraft/${name}/build/libs/${file}`; ["fabric261", "fabric", "26.1"],
if (existsSync(path)) { ["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 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( console.log(
`Updated ${directory}/${file}. Include this JAR in your next commit.`, `Updated ${directory}/${releaseFile} and its compatibility download.`,
); );
} }
} }

View File

@@ -57,7 +57,8 @@ if (
throw new Error( throw new Error(
"This draft contains accepted contributions. Upload a new capture instead of reprocessing mixed media.", "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 // This maintenance command runs with the worker stopped. Compare-and-set
// guards reject intervening publication, ownership of a job, or another repair. // guards reject intervening publication, ownership of a job, or another repair.

View File

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

View File

@@ -1,10 +1,12 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { connect } from "node:net"; 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 { mkdir, copyFile, writeFile, readFile } from "node:fs/promises";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { gunzipSync } from "node:zlib"; import { gunzipSync } from "node:zlib";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import path from "node:path";
import { promisify } from "node:util";
import { datasetSchema } from "../shared/protocol"; import { datasetSchema } from "../shared/protocol";
// Disposable, loopback-only Paper test. No game client is launched. // Disposable, loopback-only Paper test. No game client is launched.
@@ -102,8 +104,50 @@ const api = createServer(async (req, res) => {
await new Promise<void>((r) => api.listen(0, "127.0.0.1", r)); await new Promise<void>((r) => api.listen(0, "127.0.0.1", r));
await writeFile( await writeFile(
`${directory}/plugins/MineGeo/config.yml`, `${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"], { const child = spawn("java", ["-Xmx1536M", "-jar", "paper.jar", "--nogui"], {
cwd: directory, cwd: directory,
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
@@ -165,11 +209,15 @@ const command = async (text: string, pattern: RegExp) => {
child.stdin.write(text + "\n"); child.stdin.write(text + "\n");
await pending; await pending;
}; };
const rconCommand = (text: string, expected: RegExp) => new Promise<void>((resolve, reject) => { const rconCommand = (text: string, expected: RegExp) =>
new Promise<void>((resolve, reject) => {
const socket = connect(25581, "127.0.0.1"); const socket = connect(25581, "127.0.0.1");
let buffer = Buffer.alloc(0); let buffer = Buffer.alloc(0);
let authenticated = false; let authenticated = false;
const timeout = setTimeout(() => finish(new Error(`RCON timed out waiting for ${expected}`)), 10000); const timeout = setTimeout(
() => finish(new Error(`RCON timed out waiting for ${expected}`)),
10000,
);
const finish = (error?: Error) => { const finish = (error?: Error) => {
clearTimeout(timeout); clearTimeout(timeout);
socket.destroy(); socket.destroy();
@@ -199,7 +247,10 @@ const rconCommand = (text: string, expected: RegExp) => new Promise<void>((resol
if (id === 1 && type === 2 && !authenticated) { if (id === 1 && type === 2 && !authenticated) {
authenticated = true; authenticated = true;
socket.write(packet(2, 2, text)); socket.write(packet(2, 2, text));
} else if (id === 2 && expected.test(frame.toString("utf8", 12, length + 2))) { } else if (
id === 2 &&
expected.test(frame.toString("utf8", 12, length + 2))
) {
return finish(); return finish();
} }
} }
@@ -227,7 +278,7 @@ try {
let relative = ( let relative = (
await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8") await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
).trim(); ).trim();
const exportDir = `${directory}/plugins/MineGeo/${relative}`; exportDir = `${directory}/plugins/MineGeo/${relative}`;
const local = datasetSchema.parse( const local = datasetSchema.parse(
JSON.parse(await readFile(`${exportDir}/manifest.json`, "utf8")), 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, (p) => p.x >= -32 && p.x < 32 && p.z >= -32 && p.z < 32,
), ),
); );
await command("minegeo upload", /Upload failed: Upload paused/); await upload(null, /Upload paused/, true);
await command("minegeo upload", /Upload complete\. Open/); await upload(null, /queueing panorama processing|Already uploaded/);
assert.equal(creates, 1); assert.equal(creates, 1);
assert.equal(finalizes, 1); assert.equal(finalizes, 1);
assert.equal(heads, 1); assert.equal(heads, 1);
await command("minegeo upload", /Upload complete\. Open/); await upload(null, /queueing panorama processing|Already uploaded/);
assert.equal(finalizes, 1); assert.equal(finalizes, 1);
received = Buffer.alloc(0); received = Buffer.alloc(0);
expectedServer = "wrong.example.com"; expectedServer = "wrong.example.com";
await command( await upload(
"minegeo contribute 00000000-0000-4000-8000-000000000024", "00000000-0000-4000-8000-000000000024",
/Wrong capture server/, /Wrong capture server/,
true,
); );
assert.equal(creates, 1, "Mismatch must stop before creating a draft"); assert.equal(creates, 1, "Mismatch must stop before creating a draft");
expectedServer = "PLAY.REGRESSION.EXAMPLE.COM.:25565"; expectedServer = "PLAY.REGRESSION.EXAMPLE.COM.:25565";
await command( await upload(
"minegeo contribute 00000000-0000-4000-8000-000000000024", "00000000-0000-4000-8000-000000000024",
/Upload complete\. Open/, /queueing panorama processing|Already uploaded/,
); );
assert.equal(manifest.contributionTo, "00000000-0000-4000-8000-000000000024"); assert.equal(manifest.contributionTo, "00000000-0000-4000-8000-000000000024");
assert.equal(manifest.viewpoints.length, 12); assert.equal(manifest.viewpoints.length, 12);
assert.equal(creates, 2); assert.equal(creates, 2);
expectedServer = "wrong.example.com"; expectedServer = "wrong.example.com";
await command( await upload(
"minegeo contribute 00000000-0000-4000-8000-000000000024", "00000000-0000-4000-8000-000000000024",
/Wrong capture server/, /Wrong capture server/,
true,
); );
assert.equal(creates, 2, "A cached selection must recheck the server"); assert.equal(creates, 2, "A cached selection must recheck the server");
expectedServer = null; expectedServer = null;
await command( await upload(
"minegeo contribute 00000000-0000-4000-8000-000000000024", "00000000-0000-4000-8000-000000000024",
/Upload complete\. Open/, /queueing panorama processing|Already uploaded/,
); );
assert.equal(creates, 2, "Cleared server permits an idempotent retry"); assert.equal(creates, 2, "Cleared server permits an idempotent retry");
assert.equal(finalizes, 2); assert.equal(finalizes, 2);
await command( await upload(
"minegeo contribute 00000000-0000-4000-8000-000000000024", "00000000-0000-4000-8000-000000000024",
/Upload complete\. Open/, /queueing panorama processing|Already uploaded/,
); );
assert.equal(creates, 2); assert.equal(creates, 2);
await command("minegeo export Random Positions", /Export complete:/); await command("minegeo export Random Positions", /Export complete:/);
@@ -321,7 +374,7 @@ try {
"repeated upload is idempotent", "repeated upload is idempotent",
"cancellation preserves completed export", "cancellation preserves completed export",
"24 random positions change on the next 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", "wrong server blocked before upload, including cached retry",
"owner override/default port and cleared address supported", "owner override/default port and cleared address supported",
], ],

View File

@@ -7,7 +7,8 @@ import { assertTerrainImage } from "../renderer/image-validation.mjs";
// Exercise the product's rendering worker without launching a Minecraft server // 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]. // 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 = { const snapshot = {
bounds: { minX: -24, minZ: -24, maxX: 24, maxZ: 24 }, bounds: { minX: -24, minZ: -24, maxX: 24, maxZ: 24 },
coverage: [], coverage: [],
@@ -69,11 +70,12 @@ let points = [
[0.5, 18.5], [0.5, 18.5],
].map(([x, z], i) => ({ id: `p${i}`, x, y: 66.62, z })); ].map(([x, z], i) => ({ id: `p${i}`, x, y: 66.62, z }));
let source = "grass, dirt, water, tree and building regression fixture"; 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 directory = process.argv[2];
const manifest = JSON.parse( const manifest = JSON.parse(
await readFile(`${directory}/manifest.json`, "utf8"), await readFile(`${directory}/manifest.json`, "utf8"),
); );
minecraftVersion = manifest.minecraftVersion;
terrain = JSON.parse( terrain = JSON.parse(
gunzipSync(await readFile(`${directory}/terrain.json.gz`)).toString(), gunzipSync(await readFile(`${directory}/terrain.json.gz`)).toString(),
); );
@@ -81,7 +83,10 @@ if (process.argv[2]) {
source = "cached Paper export (no server started)"; source = "cached Paper export (no server started)";
} }
await mkdir(output, { recursive: true }); 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.meshes > 0, "terrain must produce meshes");
assert.ok(report.emittedFaces > 0, "terrain must produce faces"); assert.ok(report.emittedFaces > 0, "terrain must produce faces");
assert.ok(report.hiddenFaces > 0, "interior terrain faces must be culled"); 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", "blocks without explicit default properties must appear on the aerial map",
); );
const mapPixel = (x, z) => { const mapPixel = (x, z) => {
const pixelX = Math.floor(((x - snapshot.bounds.minX + 0.5) / 48) * mapInfo.width); const pixelX = Math.floor(
const pixelY = Math.floor(((z - snapshot.bounds.minZ + 0.5) / 48) * mapInfo.height); ((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; const offset = (pixelY * mapInfo.width + pixelX) * mapInfo.channels;
return mapPixels.subarray(offset, offset + 3); return mapPixels.subarray(offset, offset + 3);
}; };
if (!process.argv[2]) { if (!process.argv[2] || process.argv[2] === "--fixture") {
const shadedGrass = mapPixel(10, 9); const shadedGrass = mapPixel(10, 9);
const sunlitGrass = mapPixel(10, -9); const sunlitGrass = mapPixel(10, -9);
assert.ok( assert.ok(

View File

@@ -664,5 +664,9 @@
"Room not found": "Комната не найдена", "Room not found": "Комната не найдена",
"Join this room to play": "Присоединитесь к комнате, чтобы играть", "Join this room to play": "Присоединитесь к комнате, чтобы играть",
"Wait for the round to finish": "Дождитесь завершения раунда", "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 плагина поддерживает все три версии."
} }

View File

@@ -41,7 +41,12 @@ export function panoramaPath(
export const datasetSchema = z export const datasetSchema = z
.object({ .object({
format: z.literal("minegeo-dataset/v1"), 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"), dimension: z.literal("minecraft:overworld"),
source: z.enum(["plugin", "fabric"]), source: z.enum(["plugin", "fabric"]),
name: z.string().min(3).max(80), name: z.string().min(3).max(80),

View File

@@ -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 }], assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 100 }],
}; };
assert.equal(datasetSchema.safeParse(base).success, true); 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, assets: [] }).success, false);
assert.equal( assert.equal(
datasetSchema.safeParse({ ...base, source: "fabric" }).success, datasetSchema.safeParse({ ...base, source: "fabric" }).success,

View File

@@ -37,7 +37,11 @@ const jar = zipSync(
); );
const checksum = createHash("sha1").update(jar).digest("hex"); 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 previous = process.cwd();
const cacheParent = resolve(".cache"); const cacheParent = resolve(".cache");
await mkdir(cacheParent, { recursive: true }); await mkdir(cacheParent, { recursive: true });
@@ -54,7 +58,9 @@ async function isolatedCache(t: TestContext, corruptDownload = false) {
requests.push(url); requests.push(url);
if (url.includes("version_manifest_v2")) if (url.includes("version_manifest_v2"))
return Response.json({ 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")) if (url.endsWith("/version"))
return Response.json({ return Response.json({
@@ -91,6 +97,27 @@ test("missing vanilla cache is prepared completely and subsequent runs work offl
assert.deepEqual(requests, []); 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) => { test("an interrupted extraction with only a blockstate cannot masquerade as complete", async (t) => {
await isolatedCache(t); await isolatedCache(t);
await mkdir(`${assetsRoot}/assets/minecraft/blockstates`, { await mkdir(`${assetsRoot}/assets/minecraft/blockstates`, {

View File

@@ -189,6 +189,7 @@ async function processJob(job: {
output, output,
{ {
size: Number(process.env.MINEGEO_FACE_SIZE || 1024), size: Number(process.env.MINEGEO_FACE_SIZE || 1024),
minecraftVersion: manifest.minecraftVersion,
signal: abort.signal, signal: abort.signal,
onProgress: ({ percent, message }) => progress(percent, message), onProgress: ({ percent, message }) => progress(percent, message),
}, },