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:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<Integer, byte[]> 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<Integer, byte[]> 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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user