diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java b/integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java new file mode 100644 index 0000000..6aa7ce4 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/BlockColors.java @@ -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 CACHE = new ConcurrentHashMap<>(); + + private static final Set 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 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; } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java b/integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java new file mode 100644 index 0000000..40f6411 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/ChunkSampler.java @@ -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
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 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
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
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; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java b/integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java new file mode 100644 index 0000000..66f9e1a --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/MapColors.java @@ -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; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/Nbt.java b/integrations/common/src/main/java/net/scopenet/worldmap/Nbt.java new file mode 100644 index 0000000..8ee7b71 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/Nbt.java @@ -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 { + public Compound compound(String key) { return get(key) instanceof Compound c ? c : null; } + + @SuppressWarnings("unchecked") + public List list(String key) { return get(key) instanceof List l ? (List) 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 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; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/Png.java b/integrations/common/src/main/java/net/scopenet/worldmap/Png.java new file mode 100644 index 0000000..4f463e6 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/Png.java @@ -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 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 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()); + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/RegionFile.java b/integrations/common/src/main/java/net/scopenet/worldmap/RegionFile.java new file mode 100644 index 0000000..36d741d --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/RegionFile.java @@ -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(); } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java b/integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java new file mode 100644 index 0000000..b9fdbca --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/RegionRenderer.java @@ -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; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java b/integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java new file mode 100644 index 0000000..a5a4169 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/TileStore.java @@ -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 memory = new LinkedHashMap<>(64, 0.75f, true) { + @Override protected boolean removeEldestEntry(Map.Entry 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) 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; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java b/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java new file mode 100644 index 0000000..4fe29e8 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/worldmap/WorldMapSync.java @@ -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 regions = new HashMap<>(); + /** Tiles waiting to be uploaded, as {@link TileStore.Key} strings. */ + Set dirty = new HashSet<>(); + } + + private final PanelClient client; + private final Path worldRoot; + private final Path cacheDir; + private final Consumer 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 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 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 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 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 = new ArrayList<>(); + List online = players; + for (Dim dim : DIMS) { + Path dir = regionDir(dim); + if (dir == null) continue; + try (DirectoryStream 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 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 level = changed; + for (int z = 0; z < maxZoom && !level.isEmpty(); z++) { + Set seen = new HashSet<>(); + List 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 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 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 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 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 + } + } +} diff --git a/integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java b/integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java new file mode 100644 index 0000000..06c45b7 --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/worldmap/MapRenderTest.java @@ -0,0 +1,224 @@ +package net.scopenet.worldmap; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.imageio.ImageIO; +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.file.*; +import java.util.*; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class MapRenderTest { + // ---- a tiny NBT writer for building chunks ---- + + static final class W { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final DataOutputStream out = new DataOutputStream(bytes); + + W compound(String name) throws IOException { out.writeByte(10); out.writeUTF(name); return this; } + W end() throws IOException { out.writeByte(0); return this; } + W string(String name, String v) throws IOException { out.writeByte(8); out.writeUTF(name); out.writeUTF(v); return this; } + W i(String name, int v) throws IOException { out.writeByte(3); out.writeUTF(name); out.writeInt(v); return this; } + W b(String name, int v) throws IOException { out.writeByte(1); out.writeUTF(name); out.writeByte(v); return this; } + W list(String name, int type, int n) throws IOException { out.writeByte(9); out.writeUTF(name); out.writeByte(type); out.writeInt(n); return this; } + W longs(String name, long[] v) throws IOException { + out.writeByte(12); out.writeUTF(name); out.writeInt(v.length); + for (long l : v) out.writeLong(l); + return this; + } + } + + /** A chunk with one section (y = sectionY) whose blocks come from {@code blockAt}(x,y,z) -> palette index. */ + static byte[] chunk(int sectionY, List palette, TriFunction blockAt) throws IOException { + W w = new W().compound(""); + w.string("Status", "minecraft:full"); + w.list("sections", 10, 1); + w.b("Y", sectionY); + w.compound("block_states"); + w.list("palette", 10, palette.size()); + for (String name : palette) w.string("Name", name).end(); + int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(palette.size() - 1)); + int per = 64 / bits; + long[] data = new long[(4096 + per - 1) / per]; + for (int y = 0; y < 16; y++) for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) { + int idx = y << 8 | z << 4 | x; + data[idx / per] |= (long) blockAt.apply(x, y, z) << (idx % per * bits); + } + w.longs("data", data); + w.end(); // block_states + w.end(); // section + w.end(); // root + return w.bytes.toByteArray(); + } + + interface TriFunction { int apply(int x, int y, int z); } + + /** Writes a region file with the given chunks at (cx, cz) -> NBT bytes, zlib compressed. */ + static Path region(Path dir, Map chunks) throws IOException { + Path file = dir.resolve("r.0.0.mca"); + ByteBuffer header = ByteBuffer.allocate(8192); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int sector = 2; + for (var e : chunks.entrySet()) { + ByteArrayOutputStream z = new ByteArrayOutputStream(); + try (DeflaterOutputStream d = new DeflaterOutputStream(z, new Deflater())) { d.write(e.getValue()); } + byte[] comp = z.toByteArray(); + int length = comp.length + 1; + int sectors = (length + 4 + 4095) / 4096; + header.putInt(e.getKey() * 4, sector << 8 | sectors); + header.putInt(4096 + e.getKey() * 4, 1_700_000_000); + ByteBuffer rec = ByteBuffer.allocate(sectors * 4096); + rec.putInt(length).put((byte) 2).put(comp); + body.writeBytes(rec.array()); + sector += sectors; + } + Files.createDirectories(dir); + try (OutputStream out = Files.newOutputStream(file)) { + out.write(header.array()); + out.write(body.toByteArray()); + } + return file; + } + + @Test void blockNamesBecomeMapColours() { + assertEquals(MapColors.NONE, BlockColors.of("minecraft:air")); + assertEquals(MapColors.NONE, BlockColors.of("minecraft:glass")); + assertEquals(MapColors.GRASS, BlockColors.of("minecraft:grass_block")); + assertEquals(MapColors.WATER, BlockColors.of("minecraft:water")); + assertEquals(MapColors.STONE, BlockColors.of("minecraft:stone")); + assertEquals(MapColors.STONE, BlockColors.of("minecraft:mossy_cobblestone_stairs")); + assertEquals(MapColors.WOOD, BlockColors.of("minecraft:oak_planks")); + assertEquals(MapColors.WOOD, BlockColors.of("minecraft:oak_stairs")); + assertEquals(MapColors.PLANT, BlockColors.of("minecraft:oak_leaves")); + assertEquals(MapColors.SAND, BlockColors.of("minecraft:cut_sandstone_slab")); + assertEquals(MapColors.NETHER, BlockColors.of("minecraft:netherrack")); + assertEquals(MapColors.RED, BlockColors.of("minecraft:red_wool") == MapColors.RED ? MapColors.RED : -1); + assertEquals(MapColors.LIGHT_BLUE, BlockColors.of("minecraft:light_blue_concrete")); + assertEquals(MapColors.DEEPSLATE, BlockColors.of("minecraft:deepslate_iron_ore")); + assertEquals(MapColors.FIRE, BlockColors.of("minecraft:lava")); + int modded = BlockColors.of("create:brass_casing"); + assertEquals(modded, BlockColors.of("create:brass_casing"), "stable for unknown blocks"); + assertTrue(modded > 0); + } + + @Test void nbtAndRegionFilesRoundTrip(@TempDir Path dir) throws IOException { + byte[] nbt = chunk(4, List.of("minecraft:air", "minecraft:stone"), (x, y, z) -> y < 8 ? 1 : 0); + Path file = region(dir, Map.of(RegionFile.index(3, 5), nbt)); + try (RegionFile r = new RegionFile(file)) { + assertEquals(1_700_000_000, r.timestamp(RegionFile.index(3, 5))); + assertEquals(0, r.timestamp(RegionFile.index(0, 0))); + assertNull(r.read(RegionFile.index(0, 0))); + Nbt.Compound c = r.read(RegionFile.index(3, 5)); + assertNotNull(c); + assertEquals("minecraft:full", c.string("Status", "")); + assertEquals(1, c.list("sections").size()); + } + } + + @Test void topSurfaceAndWaterDepthAreFound() throws IOException { + // y 0..9 stone, 10..12 water in the left half. + byte[] nbt = chunk(4, List.of("minecraft:air", "minecraft:stone", "minecraft:water"), + (x, y, z) -> y < 6 ? 1 : (x < 8 && y < 9) ? 2 : y < 6 ? 1 : 0); + Nbt.Compound c = new Nbt.Compound(); + try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); } + ChunkSampler.Columns cols = new ChunkSampler.Columns(); + assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.OVERWORLD, cols)); + assertEquals(MapColors.STONE, cols.color[15]); + assertEquals(4 * 16 + 5, cols.height[15]); + assertEquals(MapColors.WATER, cols.color[0]); + assertEquals(4 * 16 + 8, cols.height[0]); + assertEquals(3, cols.depth[0]); + } + + @Test void theNetherIsShownBeneathItsCeiling() throws IOException { + // Section 7 covers y 112..127: bedrock up top (y 124..127 -> local 12..15), open air below, floor of netherrack at local y 3. + byte[] nbt = chunk(7, List.of("minecraft:air", "minecraft:bedrock", "minecraft:netherrack"), + (x, y, z) -> y >= 12 ? 1 : y <= 3 ? 2 : 0); + Nbt.Compound c; + try (var in = new DataInputStream(new ByteArrayInputStream(nbt))) { c = Nbt.readRoot(in); } + ChunkSampler.Columns cols = new ChunkSampler.Columns(); + assertTrue(ChunkSampler.sample(c, ChunkSampler.Kind.NETHER, cols)); + assertEquals(MapColors.NETHER, cols.color[0], "the floor, not the bedrock roof"); + assertEquals(7 * 16 + 3, cols.height[0]); + } + + @Test void regionsRenderIntoPositionedTiles(@TempDir Path dir) throws IOException { + byte[] flat = chunk(0, List.of("minecraft:air", "minecraft:grass_block"), (x, y, z) -> y < 5 ? 1 : 0); + // chunk (17, 2): second tile column (chunk x 16..31), first tile row + Path file = region(dir, Map.of(RegionFile.index(17, 2), flat)); + int[][] tiles = RegionRenderer.render(file, ChunkSampler.Kind.OVERWORLD); + assertNotNull(tiles); + int[] right = tiles[1]; + int px = (17 - 16) * 16 + 4, py = 2 * 16 + 4; // inside the chunk + assertEquals(0xFF, right[py * 256 + px] >>> 24); + int green = right[py * 256 + px]; + assertTrue((green >> 8 & 0xFF) > (green >> 16 & 0xFF), "grass reads as green"); + assertEquals(0, tiles[0][py * 256 + px], "other tiles stay empty"); + assertEquals(0, right[0], "outside the chunk is transparent"); + } + + @Test void slopesAreShadedAgainstTheNorth() { + int n = 512; + int[] colors = new int[n * n], heights = new int[n * n], depths = new int[n * n]; + Arrays.fill(colors, MapColors.STONE); + for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 64 + z / 8 * 3); + int[] px = RegionRenderer.shade(colors, heights, depths); + int uphill = px[16 * n + 3]; // a step up from the north + int flat = px[17 * n + 3]; // same height as its north neighbour + assertTrue(brightness(uphill) >= brightness(flat)); + for (int z = 0; z < n; z++) Arrays.fill(heights, z * n, z * n + n, 200 - z / 8 * 3); + int[] down = RegionRenderer.shade(colors, heights, depths); + assertTrue(brightness(down[16 * n + 3]) <= brightness(down[17 * n + 3])); + } + + private static int brightness(int argb) { return (argb >> 16 & 0xFF) + (argb >> 8 & 0xFF) + (argb & 0xFF); } + + @Test void pngIsAValidImage() throws IOException { + int[] pixels = new int[256 * 256]; + for (int i = 0; i < pixels.length; i++) pixels[i] = (i % 256 < 128) ? 0xFF336699 : 0; + byte[] png = Png.encode(pixels, 256, 256); + var image = ImageIO.read(new ByteArrayInputStream(png)); + assertEquals(256, image.getWidth()); + assertEquals(0xFF336699, image.getRGB(10, 10)); + assertEquals(0, image.getRGB(200, 10) >>> 24); + assertTrue(png.length < 400, "a two-colour tile is tiny: " + png.length); + } + + @Test void realisticTilesStaySmall(@TempDir Path dir) throws IOException { + byte[] hills = chunk(0, List.of("minecraft:air", "minecraft:grass_block", "minecraft:stone", "minecraft:water"), + (x, y, z) -> y < 3 + (x * 7 + z * 3) % 5 ? 1 : y < 9 && x > 8 ? 3 : 0); + Map chunks = new HashMap<>(); + for (int i = 0; i < 256; i++) chunks.put(i, hills); + int[][] tiles = RegionRenderer.render(region(dir, chunks), ChunkSampler.Kind.OVERWORLD); + byte[] png = Png.encode(tiles[0], 256, 256); + byte[] rgba = Png.encode(tiles[0], 256, 256); + assertTrue(png.length < 20_000, "palette PNG size " + png.length); + var image = ImageIO.read(new ByteArrayInputStream(png)); + for (int i = 0; i < 256 * 256; i += 997) assertEquals(tiles[0][i], image.getRGB(i % 256, i / 256), "pixel " + i); + } + + @Test void zoomLevelsAreBuiltFromTheTilesBelow(@TempDir Path dir) throws IOException { + TileStore store = new TileStore(dir); + int[] red = new int[256 * 256]; + Arrays.fill(red, 0xFFFF0000); + TileStore.Key k = new TileStore.Key("overworld", 0, 3, -2); + assertTrue(store.update(k, red)); + assertFalse(store.update(k, red.clone()), "an identical tile is not a change"); + TileStore.Key parent = store.rebuildParent(k); + assertEquals(new TileStore.Key("overworld", 1, 1, -1), parent); + int[] p = store.load(parent); + // child (3,-2) is the lower-right?? x=3 odd -> right, y=-2 even -> top: quadrant (1,0) + assertEquals(0xFFFF0000, p[10 * 256 + 200]); + assertEquals(0, p[10 * 256 + 10]); + assertEquals(parent, TileStore.Key.parse(parent.toString())); + // a fresh store sees the cached tile + assertArrayEquals(red, new TileStore(dir).load(k)); + store.clear(); + assertNull(new TileStore(dir).load(k)); + } +}