Map: Java renderer core (NBT, Anvil, colours, tiles, palette PNG, zoom pyramid, uploader)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-10-01 00:28:01 +00:00
1 parent 9102e2fc52
commit dbe02dc9bf
10 files changed
+1391

No files matched your search

@@ -0,0 +1,154 @@
package net.scopenet.worldmap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import static net.scopenet.worldmap.MapColors.*;
/**
* Which map colour a block shows as. Vanilla blocks follow the real table; anything else (modded blocks) is matched by
* name keywords, then falls back to a stable neutral colour so the same block always looks the same.
*/
public final class BlockColors {
private BlockColors() {}
private static final Map<String, Integer> CACHE = new ConcurrentHashMap<>();
private static final Set<String> CLEAR = Set.of("air", "cave_air", "void_air", "barrier", "light", "structure_void", "glass", "glass_pane",
"tinted_glass", "ladder", "lever", "tripwire", "tripwire_hook", "redstone_wire", "comparator", "repeater", "flower_pot",
"moving_piston", "cobweb", "end_rod", "scaffolding", "vine_air");
private static final String[] CLEAR_PARTS = {"torch", "rail", "button", "pressure_plate", "potted_", "banner", "head", "skull"};
private static final Set<String> WATERY = Set.of("water", "bubble_column", "kelp", "kelp_plant", "seagrass", "tall_seagrass");
private static final String[] DYES = {"light_blue", "light_gray", "white", "orange", "magenta", "yellow", "lime", "pink", "gray", "cyan",
"purple", "blue", "brown", "green", "red", "black"};
private static final int[] DYE_WOOL = {LIGHT_BLUE, LIGHT_GRAY, SNOW, ORANGE, MAGENTA, YELLOW, LIGHT_GREEN, PINK, GRAY, CYAN, PURPLE, BLUE,
BROWN, GREEN, RED, BLACK};
private static final int[] DYE_TERRACOTTA = {39, 44, 36, 37, 38, 40, 41, 42, 43, 45, 46, 47, 48, 49, 50, 51};
private static final String[] DYED = {"wool", "carpet", "concrete", "stained_glass", "glazed_terracotta", "shulker_box", "bed"};
private static final String[] STRIP_PREFIX = {"waxed_", "polished_", "chiseled_", "cut_", "smooth_", "cracked_", "mossy_", "stripped_",
"infested_", "weathered_cut_", "budding_", "reinforced_", "large_", "small_", "tall_"};
private static final String[] STRIP_SUFFIX = {"_stairs", "_slab", "_wall", "_fence_gate", "_fence", "_trapdoor", "_door", "_sign",
"_wall_sign", "_hanging_sign", "_wall_hanging_sign", "_bricks", "_brick", "_tiles", "_tile", "_block", "_pillar"};
private static final int[] UNKNOWN = {STONE, DIRT, WOOD, GRAY, BROWN, LIGHT_GRAY, DEEPSLATE};
/** 0 for blocks a map looks straight through (air, glass, rails, torches). */
public static int of(String blockName) {
return CACHE.computeIfAbsent(blockName, BlockColors::classify);
}
private static int classify(String full) {
String n = full.startsWith("minecraft:") ? full.substring(10) : full.substring(full.indexOf(':') + 1);
int c = lookup(n);
if (c >= 0) return c;
// Peel shape and finish words (cut_sandstone_stairs -> sandstone) and try again.
String s = n;
for (boolean changed = true; changed; ) {
changed = false;
for (String p : STRIP_PREFIX) if (s.startsWith(p) && s.length() > p.length()) { s = s.substring(p.length()); changed = true; }
for (String p : STRIP_SUFFIX) if (s.endsWith(p) && s.length() > p.length()) { s = s.substring(0, s.length() - p.length()); changed = true; }
if (changed && (c = lookup(s)) >= 0) return c;
}
return UNKNOWN[Math.floorMod(n.hashCode(), UNKNOWN.length)];
}
/** The colour for a plain name, or -1 when nothing matches. */
private static int lookup(String n) {
if (CLEAR.contains(n)) return NONE;
for (String p : CLEAR_PARTS) if (n.contains(p)) return NONE;
if (WATERY.contains(n)) return WATER;
if (n.equals("lava")) return FIRE;
for (int i = 0; i < DYES.length; i++) {
String d = DYES[i] + "_";
if (!n.startsWith(d)) continue;
String rest = n.substring(d.length());
if (rest.startsWith("terracotta")) return DYE_TERRACOTTA[i];
for (String k : DYED) if (rest.startsWith(k)) return DYE_WOOL[i];
}
if (n.equals("terracotta")) return ORANGE;
if (n.equals("grass_block") || n.equals("slime_block")) return GRASS;
if (n.startsWith("crimson_")) return n.contains("nylium") ? CRIMSON_NYLIUM : n.contains("hyphae") ? CRIMSON_HYPHAE
: n.contains("stem") || n.contains("planks") ? CRIMSON_STEM : n.contains("fungus") || n.contains("roots") ? CRIMSON_NYLIUM : -1;
if (n.startsWith("warped_")) return n.contains("nylium") ? WARPED_NYLIUM : n.contains("hyphae") ? WARPED_HYPHAE
: n.contains("wart") ? WARPED_WART : n.contains("stem") || n.contains("planks") ? WARPED_STEM : n.contains("fungus") || n.contains("roots") ? WARPED_NYLIUM : -1;
if (n.contains("deepslate")) return DEEPSLATE;
if (n.contains("tuff")) return TERRACOTTA_GRAY;
if (n.contains("calcite")) return TERRACOTTA_WHITE;
if (n.contains("dripstone")) return TERRACOTTA_BROWN;
if (n.contains("blackstone") || n.contains("basalt") || n.contains("obsidian") || n.equals("coal_block")) return BLACK;
if (n.contains("netherrack") || n.contains("nether_brick") || n.contains("nether_gold") || n.contains("nether_quartz") || n.contains("magma")) return NETHER;
if (n.equals("nether_wart_block") || n.equals("shroomlight")) return RED;
if (n.contains("soul_sand") || n.contains("soul_soil") || n.equals("ancient_debris")) return BROWN;
if (n.equals("glowstone")) return SAND;
if (n.equals("bedrock")) return STONE;
if (n.contains("red_sand")) return ORANGE;
if (n.contains("end_stone")) return SAND;
if (n.contains("sandstone") || n.contains("sand") || n.equals("bone_block")) return SAND;
if (n.contains("gravel")) return STONE;
if (n.contains("podzol")) return PODZOL;
if (n.contains("mycelium")) return PURPLE;
if (n.equals("mud") || n.equals("muddy_mangrove_roots")) return TERRACOTTA_CYAN;
if (n.contains("dirt") || n.contains("farmland") || n.contains("granite") || n.equals("packed_mud")) return DIRT;
if (n.equals("clay")) return CLAY;
if (n.contains("snow")) return SNOW;
if (n.equals("ice") || n.endsWith("_ice")) return ICE;
if (n.contains("copper")) return n.startsWith("exposed") ? TERRACOTTA_LIGHT_GRAY : n.startsWith("weathered") ? WARPED_STEM
: n.startsWith("oxidized") ? WARPED_NYLIUM : ORANGE;
if (n.equals("raw_iron_block")) return RAW_IRON;
if (n.equals("raw_gold_block") || n.equals("gold_block")) return GOLD;
if (n.equals("raw_copper_block")) return ORANGE;
if (n.equals("diamond_block")) return DIAMOND;
if (n.equals("emerald_block")) return EMERALD;
if (n.equals("lapis_block")) return LAPIS;
if (n.equals("redstone_block") || n.equals("tnt") || n.equals("fire")) return FIRE;
if (n.contains("amethyst")) return PURPLE;
if (n.contains("quartz") || n.contains("diorite") || n.equals("sea_lantern")) return QUARTZ;
if (n.contains("andesite")) return STONE;
if (n.contains("purpur") || n.equals("chorus_plant") || n.equals("chorus_flower")) return n.contains("chorus") ? PURPLE : MAGENTA;
if (n.equals("dark_prismarine") || n.contains("prismarine_brick")) return DIAMOND;
if (n.contains("prismarine")) return CYAN;
if (n.contains("_ore")) return STONE;
if (n.contains("iron") || n.equals("chain") || n.equals("lantern") || n.equals("soul_lantern") || n.equals("anvil") || n.contains("cauldron")
|| n.equals("hopper") || n.equals("bell") || n.equals("grindstone")) return METAL;
if (n.contains("stone") || n.contains("furnace") || n.contains("dispenser") || n.contains("dropper") || n.contains("observer")
|| n.contains("piston") || n.contains("smoker") || n.equals("spawner") || n.equals("lodestone") || n.contains("brick")) return STONE;
if (n.equals("hay_block")) return YELLOW;
if (n.contains("bamboo_block") || n.contains("bamboo_planks") || n.contains("bamboo_mosaic") || n.equals("sponge") || n.equals("wet_sponge")) return YELLOW;
if (n.contains("melon")) return LIGHT_GREEN;
if (n.contains("pumpkin") || n.equals("honey_block") || n.contains("honeycomb")) return ORANGE;
if (n.equals("glow_lichen")) return GLOW_LICHEN;
if (n.equals("moss_block") || n.equals("moss_carpet")) return GREEN;
if (n.contains("red_mushroom")) return RED;
if (n.contains("brown_mushroom")) return DIRT;
if (n.equals("mushroom_stem")) return WOOL;
if (n.equals("sculk") || n.startsWith("sculk_") || n.equals("dragon_egg")) return BLACK;
if (n.equals("beacon")) return DIAMOND;
if (n.contains("cherry_leaves")) return PINK;
if (n.contains("leaves") || n.contains("cactus") || n.equals("sugar_cane") || n.contains("vine") || n.equals("lily_pad") || n.contains("azalea")
|| n.contains("bush") || n.contains("fern") || n.contains("grass") || n.contains("sapling") || n.contains("flower") || n.contains("tulip")
|| n.contains("poppy") || n.contains("dandelion") || n.contains("orchid") || n.equals("allium") || n.equals("azure_bluet")
|| n.contains("daisy") || n.equals("cornflower") || n.equals("lily_of_the_valley") || n.contains("rose") || n.equals("peony")
|| n.equals("lilac") || n.equals("sunflower") || n.contains("petals") || n.equals("wheat") || n.equals("carrots") || n.equals("potatoes")
|| n.equals("beetroots") || n.equals("bamboo") || n.contains("roots") || n.contains("fungus") || n.contains("wart") || n.equals("dead_bush")
|| n.contains("sprouts") || n.contains("pitcher") || n.contains("torchflower")) return PLANT;
if (n.contains("dark_oak") || n.contains("mangrove_roots")) return n.contains("dark_oak") ? BROWN : WOOD;
if (n.contains("mangrove")) return RED;
if (n.contains("spruce")) return PODZOL;
if (n.contains("birch")) return SAND;
if (n.contains("jungle")) return DIRT;
if (n.contains("acacia")) return ORANGE;
if (n.contains("cherry")) return TERRACOTTA_WHITE;
if (n.contains("oak") || n.contains("planks") || n.endsWith("_log") || n.endsWith("_wood") || n.endsWith("_stem") || n.contains("chest")
|| n.contains("barrel") || n.contains("crafting") || n.contains("bookshelf") || n.contains("table") || n.contains("loom")
|| n.contains("composter") || n.contains("lectern") || n.equals("jukebox") || n.equals("note_block")) return WOOD;
if (n.contains("coal")) return BLACK;
if (n.equals("campfire") || n.equals("soul_campfire")) return PODZOL;
if (n.contains("wool") || n.contains("carpet")) return WOOL;
return -1;
}
public static boolean isWater(int colour) { return colour == WATER; }
}
@@ -0,0 +1,106 @@
package net.scopenet.worldmap;
import java.util.ArrayList;
import java.util.List;
/** Reads one chunk's blocks and finds, for each of its 256 columns, the block a top-down map would show. */
public final class ChunkSampler {
private ChunkSampler() {}
public enum Kind { OVERWORLD, NETHER, END }
/** Per-column results, indexed {@code z * 16 + x}. */
public static final class Columns {
public final int[] color = new int[256];
public final int[] height = new int[256];
/** For water: how many blocks of water sit above the floor. */
public final int[] depth = new int[256];
}
private record Section(int y, int[] colors, long[] data, int bits) {
int colorAt(int x, int y, int z) {
if (data == null) return colors[0];
int index = y << 8 | z << 4 | x;
int perLong = 64 / bits;
int word = index / perLong;
if (word >= data.length) return 0;
int v = (int) (data[word] >>> (index % perLong * bits) & ((1L << bits) - 1));
return v < colors.length ? colors[v] : 0;
}
}
/** True if the chunk was fully generated and could be read (the columns are then filled in). */
public static boolean sample(Nbt.Compound chunk, Kind kind, Columns out) {
String status = chunk.string("Status", "minecraft:full");
if (!status.endsWith("full")) return false;
List<Section> sections = new ArrayList<>();
for (Object o : chunk.list("sections")) {
if (!(o instanceof Nbt.Compound s)) continue;
Nbt.Compound states = s.compound("block_states");
if (states == null) continue;
List<Object> palette = states.list("palette");
if (palette.isEmpty()) continue;
int[] colors = new int[palette.size()];
for (int i = 0; i < colors.length; i++) {
colors[i] = palette.get(i) instanceof Nbt.Compound p ? BlockColors.of(p.string("Name", "minecraft:air")) : 0;
}
long[] data = states.longs("data");
int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(colors.length - 1));
sections.add(new Section(s.integer("Y", 0), colors, colors.length == 1 ? null : data, bits));
}
if (sections.isEmpty()) return false;
sections.sort((a, b) -> Integer.compare(b.y(), a.y()));
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
int i = z * 16 + x;
out.color[i] = 0;
out.height[i] = 0;
out.depth[i] = 0;
if (kind == Kind.NETHER) column(sections, x, z, out, i, true);
else column(sections, x, z, out, i, false);
}
}
return true;
}
private static void column(List<Section> sections, int x, int z, Columns out, int i, boolean roofed) {
boolean pastRoof = !roofed;
int roofColor = 0, roofY = 0;
for (int si = 0; si < sections.size(); si++) {
Section sec = sections.get(si);
for (int ly = 15; ly >= 0; ly--) {
int worldY = sec.y() * 16 + ly;
if (roofed && worldY > 126) continue;
int c = sec.colorAt(x, ly, z);
if (!pastRoof) {
// Under the nether ceiling: skip solid rock until the first open air.
if (c == 0) pastRoof = true;
else if (roofColor == 0) { roofColor = c; roofY = worldY; }
continue;
}
if (c == 0) continue;
out.color[i] = c;
out.height[i] = worldY;
if (c == MapColors.WATER) out.depth[i] = waterDepth(sections, si, ly, x, z);
return;
}
}
if (roofed && roofColor != 0) { // a solid column: show the ceiling instead
out.color[i] = roofColor;
out.height[i] = roofY;
}
}
private static int waterDepth(List<Section> sections, int si, int ly, int x, int z) {
int depth = 1;
for (int s = si; s < sections.size(); s++) {
Section sec = sections.get(s);
for (int y = s == si ? ly - 1 : 15; y >= 0; y--) {
if (sec.colorAt(x, y, z) != MapColors.WATER) return depth;
if (++depth >= 64) return depth;
}
}
return depth;
}
}
@@ -0,0 +1,35 @@
package net.scopenet.worldmap;
/** Minecraft's map colour table (1.20) and the four brightness steps a map applies to each colour. */
public final class MapColors {
private MapColors() {}
public static final int NONE = 0, GRASS = 1, SAND = 2, WOOL = 3, FIRE = 4, ICE = 5, METAL = 6, PLANT = 7, SNOW = 8, CLAY = 9, DIRT = 10,
STONE = 11, WATER = 12, WOOD = 13, QUARTZ = 14, ORANGE = 15, MAGENTA = 16, LIGHT_BLUE = 17, YELLOW = 18, LIGHT_GREEN = 19,
PINK = 20, GRAY = 21, LIGHT_GRAY = 22, CYAN = 23, PURPLE = 24, BLUE = 25, BROWN = 26, GREEN = 27, RED = 28, BLACK = 29,
GOLD = 30, DIAMOND = 31, LAPIS = 32, EMERALD = 33, PODZOL = 34, NETHER = 35, TERRACOTTA_WHITE = 36, TERRACOTTA_ORANGE = 37,
TERRACOTTA_GRAY = 43, TERRACOTTA_LIGHT_GRAY = 44, TERRACOTTA_CYAN = 45, TERRACOTTA_BROWN = 48, CRIMSON_NYLIUM = 52,
CRIMSON_STEM = 53, CRIMSON_HYPHAE = 54, WARPED_NYLIUM = 55, WARPED_STEM = 56, WARPED_HYPHAE = 57, WARPED_WART = 58,
DEEPSLATE = 59, RAW_IRON = 60, GLOW_LICHEN = 61;
private static final int[] RGB = {
0x000000, 0x7FB238, 0xF7E9A3, 0xC7C7C7, 0xFF0000, 0xA0A0FF, 0xA7A7A7, 0x007C00, 0xFFFFFF, 0xA4A8B8, 0x976D4D, 0x707070,
0x4040FF, 0x8F7748, 0xFFFCF5, 0xD87F33, 0xB24CD8, 0x6699D8, 0xE5E533, 0x7FCC19, 0xF27FA5, 0x4C4C4C, 0x999999, 0x4C7F99,
0x7F3FB2, 0x334CB2, 0x664C33, 0x667F33, 0x993333, 0x191919, 0xFAEE4D, 0x5CDBD5, 0x4A80FF, 0x00D93A, 0x815631, 0x700200,
0xD1B1A1, 0x9F5224, 0x95576C, 0x706C8A, 0xBA8524, 0x677535, 0xA04D4E, 0x392923, 0x876B62, 0x575C5C, 0x7A4958, 0x4C3E5C,
0x4C3223, 0x4C522A, 0x8E3C2E, 0x251610, 0xBD3031, 0x943F61, 0x5C191D, 0x167E86, 0x3A8E8C, 0x562C3E, 0x14B485, 0x646464,
0xD8AF93, 0x7FA796};
public static final int COUNT = RGB.length;
/** Brightness factors for the four shades: darkest .. brightest, as a map shows low, normal, high and lowest ground. */
private static final int[] SHADE = {180, 220, 255, 135};
/** Opaque ARGB for a colour id at a shade (0 = low, 1 = normal, 2 = high, 3 = lowest); 0 for no colour. */
public static int argb(int id, int shade) {
if (id <= 0 || id >= COUNT) return 0;
int base = RGB[id], m = SHADE[shade & 3];
int r = (base >> 16 & 0xFF) * m / 255, g = (base >> 8 & 0xFF) * m / 255, b = (base & 0xFF) * m / 255;
return 0xFF000000 | r << 16 | g << 8 | b;
}
}
@@ -0,0 +1,80 @@
package net.scopenet.worldmap;
import java.io.DataInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** A small reader for Minecraft's NBT format: just enough to read chunks. Values are plain Java objects. */
public final class Nbt {
private Nbt() {}
/** A compound tag: names to values (Byte, Short, Integer, Long, Float, Double, String, byte[], int[], long[], List, Compound). */
public static final class Compound extends HashMap<String, Object> {
public Compound compound(String key) { return get(key) instanceof Compound c ? c : null; }
@SuppressWarnings("unchecked")
public List<Object> list(String key) { return get(key) instanceof List<?> l ? (List<Object>) l : List.of(); }
public String string(String key, String fallback) { return get(key) instanceof String s ? s : fallback; }
public long[] longs(String key) { return get(key) instanceof long[] a ? a : null; }
public int integer(String key, int fallback) { return get(key) instanceof Number n ? n.intValue() : fallback; }
}
private static final int MAX_DEPTH = 64;
private static final int MAX_ARRAY = 8 * 1024 * 1024;
/** Reads one named root compound (the form chunks are stored in). */
public static Compound readRoot(DataInputStream in) throws IOException {
byte type = in.readByte();
if (type != 10) throw new IOException("NBT root must be a compound, found tag " + type);
in.readUTF(); // root name, usually empty
return readCompound(in, 0);
}
private static Compound readCompound(DataInputStream in, int depth) throws IOException {
if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply");
Compound out = new Compound();
while (true) {
byte type = in.readByte();
if (type == 0) return out;
String name = in.readUTF();
out.put(name, readValue(in, type, depth + 1));
}
}
private static Object readValue(DataInputStream in, byte type, int depth) throws IOException {
if (depth > MAX_DEPTH) throw new IOException("NBT nested too deeply");
switch (type) {
case 1: return in.readByte();
case 2: return in.readShort();
case 3: return in.readInt();
case 4: return in.readLong();
case 5: return in.readFloat();
case 6: return in.readDouble();
case 7: { byte[] a = new byte[length(in)]; in.readFully(a); return a; }
case 8: return in.readUTF();
case 9: {
byte elementType = in.readByte();
int n = length(in);
List<Object> list = new ArrayList<>(Math.min(n, 4096));
for (int i = 0; i < n; i++) list.add(readValue(in, elementType, depth + 1));
return list;
}
case 10: return readCompound(in, depth);
case 11: { int[] a = new int[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readInt(); return a; }
case 12: { long[] a = new long[length(in)]; for (int i = 0; i < a.length; i++) a[i] = in.readLong(); return a; }
default: throw new IOException("unknown NBT tag " + type);
}
}
private static int length(DataInputStream in) throws IOException {
int n = in.readInt();
if (n < 0 || n > MAX_ARRAY) throw new IOException("NBT array too large: " + n);
return n;
}
}
@@ -0,0 +1,112 @@
package net.scopenet.worldmap;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
/** A minimal PNG writer (RGBA, 8 bit), so rendering needs nothing from the JDK's desktop module. */
public final class Png {
private Png() {}
/**
* Encodes the image. Map tiles only use the map colour table, so they are written as palette PNGs at the smallest bit depth
* that fits (a typical tile is a few KB instead of tens); anything with more than 256 colours falls back to RGBA.
*/
public static byte[] encode(int[] argb, int width, int height) {
Map<Integer, Integer> palette = new LinkedHashMap<>();
for (int c : argb) {
int key = (c >>> 24) == 0 ? 0 : c;
if (!palette.containsKey(key)) {
palette.put(key, palette.size());
if (palette.size() > 256) return rgba(argb, width, height);
}
}
int n = palette.size();
int depth = n <= 2 ? 1 : n <= 4 ? 2 : n <= 16 ? 4 : 8;
int rowBytes = (width * depth + 7) / 8;
byte[] raw = new byte[height * (1 + rowBytes)];
int p = 0;
for (int y = 0; y < height; y++) {
raw[p++] = 0;
for (int x = 0; x < width; x++) {
int c = argb[y * width + x];
int idx = palette.get((c >>> 24) == 0 ? 0 : c);
if (depth == 8) raw[p + x] = (byte) idx;
else raw[p + x * depth / 8] |= (byte) (idx << (8 - depth - (x * depth) % 8));
}
p += rowBytes;
}
byte[] plte = new byte[n * 3];
List<Integer> alpha = new ArrayList<>();
for (var e : palette.entrySet()) {
int c = e.getKey(), i = e.getValue();
plte[i * 3] = (byte) (c >> 16);
plte[i * 3 + 1] = (byte) (c >> 8);
plte[i * 3 + 2] = (byte) c;
alpha.add(c == 0 ? 0 : c >>> 24);
}
int last = -1;
for (int i = 0; i < alpha.size(); i++) if (alpha.get(i) != 255) last = i;
byte[] trns = new byte[last + 1];
for (int i = 0; i <= last; i++) trns[i] = (byte) (int) alpha.get(i);
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
out.writeBytes(SIGNATURE);
chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) depth).put((byte) 3).put((byte) 0).put((byte) 0).put((byte) 0).array());
chunk(out, "PLTE", plte);
if (trns.length > 0) chunk(out, "tRNS", trns);
chunk(out, "IDAT", deflate(raw));
chunk(out, "IEND", new byte[0]);
return out.toByteArray();
}
private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
private static byte[] deflate(byte[] raw) {
Deflater deflater = new Deflater(9);
deflater.setInput(raw);
deflater.finish();
ByteArrayOutputStream z = new ByteArrayOutputStream(raw.length / 4 + 64);
byte[] buf = new byte[16384];
while (!deflater.finished()) z.write(buf, 0, deflater.deflate(buf));
deflater.end();
return z.toByteArray();
}
private static byte[] rgba(int[] argb, int width, int height) {
byte[] raw = new byte[height * (1 + width * 4)];
int p = 0;
for (int y = 0; y < height; y++) {
raw[p++] = 0;
for (int x = 0; x < width; x++) {
int c = argb[y * width + x];
raw[p++] = (byte) (c >> 16);
raw[p++] = (byte) (c >> 8);
raw[p++] = (byte) c;
raw[p++] = (byte) (c >>> 24);
}
}
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
out.writeBytes(SIGNATURE);
chunk(out, "IHDR", ByteBuffer.allocate(13).putInt(width).putInt(height).put((byte) 8).put((byte) 6).put((byte) 0).put((byte) 0).put((byte) 0).array());
chunk(out, "IDAT", deflate(raw));
chunk(out, "IEND", new byte[0]);
return out.toByteArray();
}
private static void chunk(ByteArrayOutputStream out, String type, byte[] data) {
out.writeBytes(ByteBuffer.allocate(4).putInt(data.length).array());
byte[] t = type.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
out.writeBytes(t);
out.writeBytes(data);
CRC32 crc = new CRC32();
crc.update(t);
crc.update(data);
out.writeBytes(ByteBuffer.allocate(4).putInt((int) crc.getValue()).array());
}
}
@@ -0,0 +1,84 @@
package net.scopenet.worldmap;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;
/**
* Read-only access to one Anvil region file (r.X.Z.mca): 32 x 32 chunks. Safe to use while the server is running:
* a chunk caught mid-write simply fails to read and is retried on the next pass.
*/
public final class RegionFile implements AutoCloseable {
private static final int SECTOR = 4096;
private final FileChannel channel;
private final int[] locations = new int[1024];
private final int[] timestamps = new int[1024];
public RegionFile(Path file) throws IOException {
channel = FileChannel.open(file, StandardOpenOption.READ);
try {
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
while (header.hasRemaining()) if (channel.read(header) < 0) break;
header.flip();
if (header.remaining() == 2 * SECTOR) {
header.asIntBuffer().get(locations, 0, 1024);
header.position(SECTOR);
header.asIntBuffer().get(timestamps, 0, 1024);
}
} catch (IOException | RuntimeException e) {
channel.close();
throw e;
}
}
public static int index(int chunkX, int chunkZ) { return (chunkX & 31) + (chunkZ & 31) * 32; }
/** When this chunk was last saved (seconds since 1970), or 0 if the region has no such chunk. */
public int timestamp(int index) { return locations[index] == 0 ? 0 : timestamps[index]; }
/**
* The chunk's NBT, or null if it is missing, uses an unsupported format (external .mcc file or LZ4) or couldn't be read.
* Region files written with LZ4 (a server option since 1.20.5) are skipped.
*/
public Nbt.Compound read(int index) {
int loc = locations[index];
if (loc == 0) return null;
long offset = (long) (loc >>> 8) * SECTOR;
int sectors = loc & 0xFF;
if (offset < 2 * SECTOR || sectors == 0) return null;
try {
ByteBuffer head = ByteBuffer.allocate(5);
if (read(head, offset) < 5) return null;
head.flip();
int length = head.getInt();
int type = head.get() & 0xFF;
if (length < 2 || length - 1 > (long) sectors * SECTOR || type > 3 || type == 0) return null; // 4 = LZ4, 0x80+ = external file
ByteBuffer body = ByteBuffer.allocate(length - 1);
if (read(body, offset + 5) < length - 1) return null;
InputStream raw = new ByteArrayInputStream(body.array());
InputStream stream = type == 1 ? new GZIPInputStream(raw) : type == 2 ? new InflaterInputStream(raw) : raw;
try (DataInputStream in = new DataInputStream(new BufferedInputStream(stream))) {
return Nbt.readRoot(in);
}
} catch (IOException | RuntimeException e) {
return null;
}
}
private int read(ByteBuffer buffer, long position) throws IOException {
int total = 0;
while (buffer.hasRemaining()) {
int n = channel.read(buffer, position + total);
if (n < 0) break;
total += n;
}
return total;
}
@Override public void close() throws IOException { channel.close(); }
}
@@ -0,0 +1,72 @@
package net.scopenet.worldmap;
import java.io.IOException;
import java.nio.file.Path;
/**
* Turns one region file (512 x 512 blocks) into four 256 x 256 map tiles using a vanilla map's look: base colours shaded
* by the slope towards the north, and water shaded by depth.
*/
public final class RegionRenderer {
private RegionRenderer() {}
public static final int TILE = 256;
private static final int SIDE = 512;
/** Four tile images in the order (0,0) (1,0) (0,1) (1,1) of {@code [tileX][tileY]}, or null if nothing renderable was found. */
public static int[][] render(Path file, ChunkSampler.Kind kind) throws IOException {
int[] colors = new int[SIDE * SIDE], heights = new int[SIDE * SIDE], depths = new int[SIDE * SIDE];
ChunkSampler.Columns cols = new ChunkSampler.Columns();
boolean any = false;
try (RegionFile region = new RegionFile(file)) {
for (int i = 0; i < 1024; i++) {
if (region.timestamp(i) == 0) continue;
Nbt.Compound chunk = region.read(i);
if (chunk == null || !ChunkSampler.sample(chunk, kind, cols)) continue;
int cx = (i & 31) * 16, cz = (i >> 5) * 16;
for (int z = 0; z < 16; z++) {
int dst = (cz + z) * SIDE + cx;
System.arraycopy(cols.color, z * 16, colors, dst, 16);
System.arraycopy(cols.height, z * 16, heights, dst, 16);
System.arraycopy(cols.depth, z * 16, depths, dst, 16);
}
any = true;
}
}
if (!any) return null;
int[] pixels = shade(colors, heights, depths);
int[][] tiles = new int[4][TILE * TILE];
for (int ty = 0; ty < 2; ty++) {
for (int tx = 0; tx < 2; tx++) {
int[] tile = tiles[tx + ty * 2];
for (int row = 0; row < TILE; row++) {
System.arraycopy(pixels, (ty * TILE + row) * SIDE + tx * TILE, tile, row * TILE, TILE);
}
}
}
return tiles;
}
/** Shading as a vanilla map does it: slope against the block to the north, depth for water, plus a light dither. */
static int[] shade(int[] colors, int[] heights, int[] depths) {
int[] out = new int[SIDE * SIDE];
for (int z = 0; z < SIDE; z++) {
for (int x = 0; x < SIDE; x++) {
int i = z * SIDE + x;
int c = colors[i];
if (c == 0) continue;
int shade;
if (c == MapColors.WATER) {
double d = depths[i] * 0.1 + ((x + z) & 1) * 0.2;
shade = d < 0.5 ? 2 : d > 0.9 ? 0 : 1;
} else {
int north = z > 0 && colors[i - SIDE] != 0 ? heights[i - SIDE] : heights[i];
double d = (heights[i] - north) * 4.0 / 5.0 + (((x + z) & 1) - 0.5) * 0.4;
shade = d > 0.6 ? 2 : d < -0.6 ? 0 : 1;
}
out[i] = MapColors.argb(c, shade);
}
}
return out;
}
}
@@ -0,0 +1,125 @@
package net.scopenet.worldmap;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
/**
* The local cache of rendered tiles (raw pixels, compressed), plus the zoom pyramid built from them. Zoom 0 is one pixel per
* block; every level up halves the resolution, down to {@code maxZoom}.
*/
public final class TileStore {
public record Key(String dim, int zoom, int x, int y) {
@Override public String toString() { return dim + "/" + zoom + "/" + x + "/" + y; }
public static Key parse(String s) {
String[] p = s.split("/");
return new Key(p[0], Integer.parseInt(p[1]), Integer.parseInt(p[2]), Integer.parseInt(p[3]));
}
}
private static final int SIZE = RegionRenderer.TILE;
private static final int PIXELS = SIZE * SIZE;
private final Path root;
private final Map<Key, int[]> memory = new LinkedHashMap<>(64, 0.75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<Key, int[]> e) { return size() > 96; }
};
public TileStore(Path root) { this.root = root; }
private Path file(Key k) { return root.resolve(k.dim()).resolve(Integer.toString(k.zoom())).resolve(k.x() + "_" + k.y() + ".tile"); }
/** The tile's pixels, or null if it was never rendered. */
public synchronized int[] load(Key k) {
int[] cached = memory.get(k);
if (cached != null) return cached;
Path f = file(k);
if (!Files.isRegularFile(f)) return null;
try (InflaterInputStream in = new InflaterInputStream(new BufferedInputStream(Files.newInputStream(f)))) {
byte[] raw = in.readAllBytes();
if (raw.length != PIXELS * 4) return null;
int[] pixels = new int[PIXELS];
ByteBuffer.wrap(raw).asIntBuffer().get(pixels);
memory.put(k, pixels);
return pixels;
} catch (IOException e) {
return null;
}
}
public synchronized void save(Key k, int[] pixels) throws IOException {
ByteBuffer raw = ByteBuffer.allocate(PIXELS * 4);
raw.asIntBuffer().put(pixels);
Path f = file(k);
Files.createDirectories(f.getParent());
Path tmp = f.resolveSibling(f.getFileName() + ".tmp");
try (DeflaterOutputStream out = new DeflaterOutputStream(Files.newOutputStream(tmp), new Deflater(3))) {
out.write(raw.array());
}
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING);
memory.put(k, pixels);
}
public synchronized void clear() throws IOException {
memory.clear();
if (!Files.isDirectory(root)) return;
try (var walk = Files.walk(root)) {
for (Path p : (Iterable<Path>) walk.sorted(Comparator.reverseOrder())::iterator) if (!p.equals(root)) Files.deleteIfExists(p);
}
}
public static boolean blank(int[] pixels) {
for (int p : pixels) if ((p >>> 24) != 0) return false;
return true;
}
/** Stores a freshly rendered tile and returns true if it differs from what was there. */
public boolean update(Key k, int[] pixels) throws IOException {
int[] old = load(k);
if (old == null ? blank(pixels) : Arrays.equals(old, pixels)) return false;
save(k, pixels);
return true;
}
/**
* Rebuilds the parent tile of {@code child} from its four children. Returns the parent's key if it changed, otherwise null.
*/
public Key rebuildParent(Key child) throws IOException {
int px = child.x() >> 1, py = child.y() >> 1, pz = child.zoom() + 1;
int[] out = new int[PIXELS];
for (int qy = 0; qy < 2; qy++) {
for (int qx = 0; qx < 2; qx++) {
int[] src = load(new Key(child.dim(), child.zoom(), px * 2 + qx, py * 2 + qy));
if (src == null) continue;
int ox = qx * (SIZE / 2), oy = qy * (SIZE / 2);
for (int y = 0; y < SIZE / 2; y++) {
for (int x = 0; x < SIZE / 2; x++) {
out[(oy + y) * SIZE + ox + x] = average(src[(y * 2) * SIZE + x * 2], src[(y * 2) * SIZE + x * 2 + 1],
src[(y * 2 + 1) * SIZE + x * 2], src[(y * 2 + 1) * SIZE + x * 2 + 1]);
}
}
}
}
Key parent = new Key(child.dim(), pz, px, py);
return update(parent, out) ? parent : null;
}
/**
* The most common opaque colour of the four pixels (the first wins a tie). Picking a real map colour instead of blending keeps
* zoomed-out tiles crisp and recognisable, and keeps them palette-sized so they compress well.
*/
static int average(int a, int b, int c, int d) {
int[] px = {a, b, c, d};
int best = 0, bestCount = 0;
for (int i = 0; i < 4; i++) {
if ((px[i] >>> 24) == 0) continue;
int count = 0;
for (int j = 0; j < 4; j++) if (px[j] == px[i]) count++;
if (count > bestCount) { best = px[i]; bestCount = count; }
}
return best;
}
}
@@ -0,0 +1,399 @@
package net.scopenet.worldmap;
import com.google.gson.*;
import net.scopenet.integration.PanelClient;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
/**
* SCOPENET Map, game-server side. Renders the world's region files to map tiles on a background thread (low priority, one
* region at a time) and uploads only the tiles that changed. Player positions and the claims/pins overlay ride along.
* Nothing here touches the server thread except {@link #offerPlayers} and {@link #offerOverlay}.
*/
public final class WorldMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
private record Dim(String id, String dir, ChunkSampler.Kind kind) {}
private static final Dim[] DIMS = {
new Dim("minecraft:overworld", "overworld", ChunkSampler.Kind.OVERWORLD),
new Dim("minecraft:the_nether", "the_nether", ChunkSampler.Kind.NETHER),
new Dim("minecraft:the_end", "the_end", ChunkSampler.Kind.END)};
private static final int BATCH_BYTES = 3 * 1024 * 1024;
private static final long RERENDER_GAP_MS = 90_000;
private static final long COARSE_UPLOAD_GAP_MS = 60_000;
private static final class State {
long epoch = -1;
/** "dim/x.z" -> file mtime (ms) at the time it was rendered. */
Map<String, Long> regions = new HashMap<>();
/** Tiles waiting to be uploaded, as {@link TileStore.Key} strings. */
Set<String> dirty = new HashSet<>();
}
private final PanelClient client;
private final Path worldRoot;
private final Path cacheDir;
private final Consumer<String> log;
private final Gson gson = new Gson();
private final TileStore store;
private final Object wake = new Object();
private final Thread thread;
private final ScheduledExecutorService live = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-map-live");
t.setDaemon(true);
return t;
});
private State state = new State();
private final Map<String, Long> renderedAt = new HashMap<>();
private volatile boolean closed;
private volatile boolean enabled;
private volatile boolean wakeRequested;
private volatile int maxZoom = 6;
private volatile long playerIntervalMs = 2000;
private volatile List<Player> players = List.of();
private volatile String overlay;
private long configAt;
private long coarseUploadAt;
private boolean stateDirty;
private boolean warnedMissing;
private String lastOverlaySent;
private long overlaySentAt;
private String lastPlayersSent = "";
private long playersSentAt;
public WorldMapSync(PanelClient client, Path worldRoot, Path cacheDir, Consumer<String> log) {
this.client = client;
this.worldRoot = worldRoot;
this.cacheDir = cacheDir;
this.log = log;
this.store = new TileStore(cacheDir.resolve("tiles"));
thread = new Thread(this::run, "scopenet-map");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
}
public void start() {
loadState();
thread.start();
live.scheduleWithFixedDelay(this::sendLive, 4, 1, TimeUnit.SECONDS);
}
public void offerPlayers(List<Player> next) {
players = List.copyOf(next);
}
/** The claims and pins as JSON ({@code {"claims":[...],"pins":[...]}}); sent when it changes. */
public void offerOverlay(String json) {
overlay = json;
}
/** The world was saved: look for changed regions soon. */
public void requestScan() {
synchronized (wake) {
wakeRequested = true;
wake.notifyAll();
}
}
@Override public void close() {
closed = true;
live.shutdownNow();
thread.interrupt();
saveState();
}
// ---- main loop ----
private void run() {
while (!closed) {
long pause = 10_000;
try {
if (!checkConfig()) pause = 60_000;
else {
boolean worked = cycle();
pause = worked ? 150 : 10_000;
}
} catch (InterruptedException e) {
return;
} catch (PanelClient.HttpFailure e) {
if (e.status == 403 || e.status == 404) enabled = false;
else log.accept("SCOPENET Map: the panel refused an upload (" + e.getMessage() + ")");
pause = 30_000;
} catch (Exception e) {
log.accept("SCOPENET Map failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
pause = 60_000;
}
if (stateDirty) saveState();
try {
synchronized (wake) {
if (!wakeRequested) wake.wait(pause);
wakeRequested = false;
}
} catch (InterruptedException e) {
return;
}
}
}
private boolean checkConfig() throws IOException, InterruptedException {
long now = System.currentTimeMillis();
if (now < configAt) return enabled;
configAt = now + 60_000;
try {
JsonObject config = client.get("map/config");
boolean on = config.has("enabled") && config.get("enabled").getAsBoolean();
if (config.has("max_zoom")) maxZoom = Math.max(0, Math.min(6, config.get("max_zoom").getAsInt()));
if (config.has("player_interval_ms")) playerIntervalMs = Math.max(500, config.get("player_interval_ms").getAsLong());
long epoch = config.has("epoch") ? config.get("epoch").getAsLong() : 0;
if (on && epoch != state.epoch) {
if (state.epoch >= 0) log.accept("SCOPENET Map was reset on the panel: rendering the world again.");
store.clear();
state = new State();
state.epoch = epoch;
renderedAt.clear();
stateDirty = true;
}
if (on && !enabled) log.accept("SCOPENET Map is on: rendering this world and sending it to the panel.");
enabled = on;
} catch (PanelClient.HttpFailure e) {
enabled = false; // an older panel without the map
}
return enabled;
}
/** One unit of work: render the most useful stale region, then upload what changed. True if anything happened. */
private boolean cycle() throws IOException, InterruptedException {
if (!Files.isDirectory(worldRoot)) {
if (!warnedMissing) {
warnedMissing = true;
log.accept("SCOPENET Map: world folder " + worldRoot + " not found");
}
return false;
}
long now = System.currentTimeMillis();
record Stale(Dim dim, Path file, String key, long mtime, int rx, int rz, double distance) {}
List<Stale> stale = new ArrayList<>();
List<Player> online = players;
for (Dim dim : DIMS) {
Path dir = regionDir(dim);
if (dir == null) continue;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
for (Path file : stream) {
String[] parts = file.getFileName().toString().split("\\.");
int rx, rz;
try {
rx = Integer.parseInt(parts[1]);
rz = Integer.parseInt(parts[2]);
} catch (RuntimeException e) {
continue;
}
long mtime = Files.getLastModifiedTime(file).toMillis();
String key = dim.dir() + "/" + rx + "." + rz;
Long done = state.regions.get(key);
if (done != null && mtime <= done) continue;
Long last = renderedAt.get(key);
if (done != null && last != null && now - last < RERENDER_GAP_MS) continue;
if (Files.size(file) < 8192) continue;
double distance = Double.MAX_VALUE;
for (Player p : online) {
if (!dim.id().equals(p.dimension())) continue;
double dx = p.x() - (rx * 512 + 256), dz = p.z() - (rz * 512 + 256);
distance = Math.min(distance, Math.sqrt(dx * dx + dz * dz));
}
stale.add(new Stale(dim, file, key, mtime, rx, rz, distance));
}
}
}
boolean worked = false;
if (!stale.isEmpty()) {
stale.sort(Comparator.comparingDouble(Stale::distance).thenComparing(Stale::key));
Stale next = stale.get(0);
renderRegion(next.dim(), next.file(), next.key(), next.mtime(), next.rx(), next.rz());
worked = true;
}
if (flushUploads(stale.size() <= 1)) worked = true;
return worked;
}
private void renderRegion(Dim dim, Path file, String key, long mtime, int rx, int rz) throws IOException {
int[][] tiles;
try {
tiles = RegionRenderer.render(file, dim.kind());
} catch (IOException | RuntimeException e) {
// A region being written right now; try again after the gap.
renderedAt.put(key, System.currentTimeMillis());
return;
}
renderedAt.put(key, System.currentTimeMillis());
List<TileStore.Key> changed = new ArrayList<>();
if (tiles != null) {
for (int i = 0; i < 4; i++) {
TileStore.Key k = new TileStore.Key(dim.dir(), 0, rx * 2 + (i & 1), rz * 2 + (i >> 1));
if (store.update(k, tiles[i])) {
changed.add(k);
state.dirty.add(k.toString());
}
}
}
// Rebuild the zoom pyramid above whatever changed.
List<TileStore.Key> level = changed;
for (int z = 0; z < maxZoom && !level.isEmpty(); z++) {
Set<Long> seen = new HashSet<>();
List<TileStore.Key> next = new ArrayList<>();
for (TileStore.Key child : level) {
long id = ((long) (child.x() >> 1) << 32) ^ ((child.y() >> 1) & 0xFFFFFFFFL);
if (!seen.add(id)) continue;
TileStore.Key parent = store.rebuildParent(child);
if (parent != null) {
next.add(parent);
state.dirty.add(parent.toString());
}
}
level = next;
}
state.regions.put(key, mtime);
stateDirty = true;
}
/** Uploads dirty tiles in batches. Coarse zoom levels wait until the world is mostly done so they aren't re-sent constantly. */
private boolean flushUploads(boolean caughtUp) throws IOException, InterruptedException {
if (state.dirty.isEmpty()) return false;
long now = System.currentTimeMillis();
boolean coarseNow = caughtUp || now - coarseUploadAt >= COARSE_UPLOAD_GAP_MS;
List<TileStore.Key> ready = new ArrayList<>();
for (String s : state.dirty) {
TileStore.Key k = TileStore.Key.parse(s);
if (k.zoom() == 0 || coarseNow) ready.add(k);
}
if (ready.isEmpty()) return false;
ready.sort(Comparator.comparing(TileStore.Key::dim).thenComparing(Comparator.comparingInt(TileStore.Key::zoom).reversed()));
if (coarseNow) coarseUploadAt = now;
boolean sentAny = false;
int batches = 0;
int i = 0;
while (i < ready.size() && batches < 8 && !closed) {
String dimension = ready.get(i).dim();
ByteArrayOutputStream body = new ByteArrayOutputStream();
List<TileStore.Key> inBatch = new ArrayList<>();
while (i < ready.size() && ready.get(i).dim().equals(dimension) && body.size() < BATCH_BYTES) {
TileStore.Key k = ready.get(i++);
int[] pixels = store.load(k);
if (pixels == null) {
state.dirty.remove(k.toString());
continue;
}
byte[] png = Png.encode(pixels, RegionRenderer.TILE, RegionRenderer.TILE);
body.writeBytes(ByteBuffer.allocate(13).put((byte) k.zoom()).putInt(k.x()).putInt(k.y()).putInt(png.length).array());
body.writeBytes(png);
inBatch.add(k);
}
if (inBatch.isEmpty()) continue;
client.postBytes("map/tiles?dim=" + idOf(dimension), body.toByteArray());
for (TileStore.Key k : inBatch) state.dirty.remove(k.toString());
stateDirty = true;
sentAny = true;
batches++;
}
return sentAny;
}
private static String idOf(String dir) {
for (Dim d : DIMS) if (d.dir().equals(dir)) return d.id();
return "minecraft:" + dir;
}
// ---- live data ----
private void sendLive() {
if (closed || !enabled) return;
try {
long now = System.currentTimeMillis();
List<Player> snapshot = players;
JsonArray list = new JsonArray();
for (Player p : snapshot) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid());
o.addProperty("name", p.name());
o.addProperty("dimension", p.dimension());
o.addProperty("x", Math.round(p.x() * 10) / 10.0);
o.addProperty("y", Math.round(p.y() * 10) / 10.0);
o.addProperty("z", Math.round(p.z() * 10) / 10.0);
o.addProperty("yaw", Math.round(p.yaw()));
list.add(o);
}
String text = list.toString();
if ((!text.equals(lastPlayersSent) && now - playersSentAt >= playerIntervalMs) || now - playersSentAt >= 10_000) {
JsonObject body = new JsonObject();
body.add("players", list);
client.post("map/players", body);
lastPlayersSent = text;
playersSentAt = now;
}
String ov = overlay;
if (ov != null && (!ov.equals(lastOverlaySent) || now - overlaySentAt >= 5 * 60_000) && now - overlaySentAt >= 10_000) {
client.post("map/overlay", JsonParser.parseString(ov).getAsJsonObject());
lastOverlaySent = ov;
overlaySentAt = now;
}
} catch (PanelClient.HttpFailure e) {
if (e.status == 403 || e.status == 404) enabled = false;
} catch (Exception ignored) {
// the next tick retries
}
}
// ---- world layout ----
private Path regionDir(Dim dim) {
String name = worldRoot.getFileName() == null ? "world" : worldRoot.getFileName().toString();
Path parent = worldRoot.toAbsolutePath().getParent();
List<Path> candidates = switch (dim.dir()) {
case "the_nether" -> Arrays.asList(worldRoot.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
worldRoot.resolve("dimensions/minecraft/the_nether/region"));
case "the_end" -> Arrays.asList(worldRoot.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
worldRoot.resolve("dimensions/minecraft/the_end/region"));
default -> List.of(worldRoot.resolve("region"), worldRoot.resolve("dimensions/minecraft/overworld/region"));
};
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
return null;
}
private static Path sibling(Path parent, String name, String rel) {
return parent == null ? null : parent.resolve(name).resolve(rel);
}
// ---- state ----
private void loadState() {
try {
Path f = cacheDir.resolve("state.json");
if (Files.isRegularFile(f)) {
State s = gson.fromJson(Files.readString(f), State.class);
if (s != null && s.regions != null && s.dirty != null) state = s;
}
} catch (IOException | RuntimeException e) {
state = new State();
}
}
private void saveState() {
try {
Files.createDirectories(cacheDir);
Path f = cacheDir.resolve("state.json");
Path tmp = f.resolveSibling("state.json.tmp");
Files.writeString(tmp, gson.toJson(state));
Files.move(tmp, f, StandardCopyOption.REPLACE_EXISTING);
stateDirty = false;
} catch (IOException e) {
// the cache is only an optimisation
}
}
}