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

@@ -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;
public final class CaptureScreen extends Screen {
private TextFieldWidget website,name,target;
private TextFieldWidget name,target;
public CaptureScreen(){super(Text.literal("MineGeo Capture"));}
@Override protected void init(){var mod=MineGeoClient.INSTANCE;int x=width/2-130;
website=new TextFieldWidget(textRenderer,x,50,260,20,Text.literal("MineGeo website"));website.setMaxLength(200);website.setText(mod.website);addDrawableChild(website);
name=new TextFieldWidget(textRenderer,x,92,260,20,Text.literal("Map name"));name.setMaxLength(80);name.setText(mod.dataset==null?"My Minecraft map":mod.dataset.name);addDrawableChild(name);
target=new TextFieldWidget(textRenderer,x,126,260,20,Text.literal("Add to map ID (optional)"));target.setMaxLength(36);target.setText(mod.targetMap);addDrawableChild(target);
addDrawableChild(ButtonWidget.builder(Text.literal("Capture panorama"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.capture();}).dimensions(x,154,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Capture panorama"),b->{mod.targetMap=target.getText().trim();mod.capture();}).dimensions(x,154,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getOperatingSystem().open(mod.dataset.directory.toFile());}).dimensions(x+134,154,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Link account"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.pair();}).dimensions(x,182,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Upload / resume"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.upload(name.getText());}).dimensions(x+134,182,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Link account"),b->{mod.targetMap=target.getText().trim();mod.pair();}).dimensions(x,182,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Upload / resume"),b->{mod.targetMap=target.getText().trim();mod.upload(name.getText());}).dimensions(x+134,182,126,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,210,260,20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,238,260,20).build());
}
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website URL (HTTP for LAN testing)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);context.drawTextWithShadow(textRenderer,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"MineGeo service",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,MineGeoClient.website,x,54,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);context.drawTextWithShadow(textRenderer,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
@Override public boolean shouldPause(){return false;}
}

View File

@@ -17,20 +17,21 @@ public final class MineGeoClient implements ClientModInitializer {
public static MineGeoClient INSTANCE;
public final ExecutorService background=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"MineGeo upload");t.setDaemon(true);return t;});
public final Gson gson=new GsonBuilder().setPrettyPrinting().create();
public volatile String website="http://localhost:3000",token="",status="Explore normally to collect surface coverage.";
public static final String website=UploadClient.WEBSITE;
public volatile String token="",status="Explore normally to collect surface coverage.";
public volatile String targetMap="";
public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending;
private Path config;
@Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();website=j.get("website").getAsString();token=j.get("token").getAsString();if(j.has("targetMap"))targetMap=j.get("targetMap").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");}
@Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();token=j.get("token").getAsString();if(j.has("targetMap"))targetMap=j.get("targetMap").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");}
var key=KeyBindingHelper.registerKeyBinding(new KeyBinding("key.minegeo.open",InputUtil.Type.KEYSYM,GLFW.GLFW_KEY_G,KeyBinding.Category.create(Identifier.of("minegeo","capture"))));
ClientTickEvents.END_CLIENT_TICK.register(c->{while(key.wasPressed()){if(Capture.active())Capture.cancel("Capture cancelled");else c.setScreen(new CaptureScreen());}if(c.world!=null&&c.player!=null&&c.world.getRegistryKey().getValue().toString().equals("minecraft:overworld")){try{if(dataset==null||!dataset.server.equals(server()))dataset=SurfaceDataset.open(server());if(!Capture.active()&&!uploading)dataset.scan(c);}catch(Exception e){message("Map collection: "+e.getMessage());}}CaptureFixture.tick(c);});
}
public String server(){var c=MinecraftClient.getInstance();return c.getCurrentServerEntry()!=null?c.getCurrentServerEntry().address:"Singleplayer";}
public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("website",website);j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));}
public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));}
public void message(String text){status=text;var c=MinecraftClient.getInstance();if(c.player!=null)c.player.sendMessage(Text.literal("[MineGeo] "+text),false);}
public void capture(){try{if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=MinecraftClient.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");int limit=24;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());c.setScreen(null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}}
public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}}
public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}}
public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient(website,"");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});}
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;final String target=targetMap.trim();if(!target.isBlank()&&(MinecraftClient.getInstance().getCurrentServerEntry()==null||selected==null||!java.util.Objects.equals(dev.minegeo.plugin.ServerAddress.normalize(server()),dev.minegeo.plugin.ServerAddress.normalize(selected.server)))){message("Join the server this capture belongs to before contributing.");return;}int minimum=target.isBlank()?5:1;if(selected==null||selected.points.size()<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 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(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 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);}
}

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
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -26,7 +26,7 @@ public final class MineGeoPlugin extends JavaPlugin {
private final Gson gson=new GsonBuilder().setPrettyPrinting().create();
private volatile Path latest;
@Override public void onEnable(){
saveDefaultConfig();Objects.requireNonNull(getCommand("minegeo")).setExecutor(this);
saveDefaultConfig();if(getConfig().isSet("website")){getConfig().set("website",null);saveConfig();}Objects.requireNonNull(getCommand("minegeo")).setExecutor(this);
try {
Files.createDirectories(getDataFolder().toPath().resolve("exports"));
Path root=getDataFolder().toPath().toAbsolutePath().normalize(),pointer=root.resolve("latest-export.txt");
@@ -42,7 +42,7 @@ public final class MineGeoPlugin extends JavaPlugin {
// every rejected command receives a visible explanation.
if(!(sender instanceof ConsoleCommandSender||sender instanceof RemoteConsoleCommandSender||sender instanceof Player&&sender.hasPermission("minegeo.admin"))){sender.sendMessage("MineGeo: administrator permission required.");return true;}
try {
if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website <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)) {
case "area" -> {
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 "cancel" -> {cancelled.set(true);sender.sendMessage(busy?"MineGeo cancellation requested. Completed exports are kept.":"MineGeo: no operation is running.");}
case "reload" -> {requireIdle();reloadConfig();sender.sendMessage("MineGeo configuration reloaded.");}
case "website" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo website <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 "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");
@@ -69,8 +69,8 @@ public final class MineGeoPlugin extends JavaPlugin {
private void fail(CommandSender sender,String phase,Exception ex){report(sender,cancelled.get()?"Cancelled. Completed exports are kept.":phase+" failed: "+ex.getMessage());if(!cancelled.get())getLogger().log(Level.WARNING,phase+" failure",ex);}
private void upload(CommandSender sender,String targetMap)throws Exception {
requireIdle();if(latest==null||!Files.isRegularFile(latest.resolve("manifest.json")))throw new IOException("No completed export. Run /minegeo export My World, wait for 'Export complete', then /minegeo upload. Files: "+getDataFolder().toPath().resolve("exports").toAbsolutePath());
String website=getConfig().getString("website"),token=getConfig().getString("token");if(token==null||token.isBlank())throw new IOException("Create a token in the website dashboard, then /minegeo link <token>.");
UploadClient client=new UploadClient(website,token);Path directory=latest;busy=true;cancelled.set(false);status="Uploading "+directory.getFileName();
String website=UploadClient.WEBSITE,token=getConfig().getString("token");if(token==null||token.isBlank())throw new IOException("Create a token in the website dashboard, then /minegeo link <token>.");
UploadClient client=new UploadClient(token);Path directory=latest;busy=true;cancelled.set(false);status="Uploading "+directory.getFileName();
Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{client.upload(directory,targetMap,message->status=message,cancelled::get);report(sender,"Upload complete. Open "+website+"/dashboard to preview and submit "+(targetMap==null?"the private map for moderator review.":"the panoramas to the map owner."));}catch(Exception ex){fail(sender,"Upload",ex);}finally{busy=false;}});
sender.sendMessage("MineGeo upload started. /minegeo status shows progress; retry /minegeo upload after a connection failure.");
}
@@ -133,7 +133,7 @@ public final class MineGeoPlugin extends JavaPlugin {
List<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);}
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);
// 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);

View File

@@ -12,6 +12,8 @@ import java.nio.charset.StandardCharsets;
/** Shared TUS client. State is saved beside each completed local dataset. */
public final class UploadClient {
public static final String WEBSITE="https://minegeo.vercel.app";
public UploadClient(String token){this(WEBSITE,token);}
private final String website,token;
// Next's HTTP server does not support Java's cleartext HTTP/2 upgrade handshake.
private final HttpClient client=HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(30)).build();

View File

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

View File

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

View File

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