From 319ae70481cee1690edf7dbe1f47cbf37d51ad13 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 09:42:52 +0000 Subject: [PATCH 1/3] feat: add Minecraft 26.1.2 spigot support with auto beta-tag workflow - Add bukkit-helper-26-1-2 (cloned from bukkit-helper-121-11), pointing at the Paper dev bundle 26.1.2.build.57-stable. Source uses Mojang- mapped NMS imports and resolves CraftBukkit classes via reflection at runtime; getBiomeReg / initReflection / initCraftBukkitClasses are synchronized to avoid the broken-double-checked-locking race that the 121-x helpers historically had. - Bump io.papermc.paperweight.userdev to 2.0.0-beta.21 (required for paperweight 'data version 8' dev bundles -- beta.19 rejects 26.1.2 at setup time) and add org.gradle.toolchains.foojay-resolver-convention 1.0.0 so the JDK 25 toolchain Paper 26.1+ requires gets auto- provisioned by Gradle. - Compile bukkit-helper-26-1-2 with the JDK 25 toolchain but emit Java 21 bytecode via options.release = 21 (so the Shadow plugin running on JDK 21 can read its class files), while pinning the org.gradle.jvm.version resolution attribute to 25 so the Java-25-only paper-api still resolves. Drop reobfJar (Paper 26.1+ ships Mojang- mapped only, no reobf mappings) and have spigot consume this module via configuration: 'default' to bypass paperweight's now-empty reobf variant. - Wire the new helper into spigot/build.gradle and dispatch every (MC: 26.x) server in spigot/.../Helper.java to it. Bump the project version to 26.1.2-beta.1. - Add .github/workflows/auto-tag-beta.yml: every push to a claude/** branch derives the beta base from build.gradle (regex metachars escaped), computes the next .N, syncs build.gradle, commits the bump back with [skip ci], builds the spigot jar with the JDK 25 toolchain and publishes a GitHub Release with the jar attached. Concurrency is serialized across branches so simultaneous pushes don't race for the same tag. The workflow does NOT create PRs; that is done out-of-band by an operator with pull_requests:write. - Migrate release.yml off the deprecated actions/upload-release-asset@v1 to gh release upload --clobber (built into the GitHub Actions runner, supports re-runs without 'asset already exists' failures). --- .github/workflows/auto-tag-beta.yml | 143 +++++ .github/workflows/release.yml | 11 +- build.gradle | 4 +- bukkit-helper-26-1-2/build.gradle | 54 ++ .../BukkitVersionHelperSpigot26_1_2.java | 546 ++++++++++++++++++ .../helper/v26_1_2/MapChunkCache26_1_2.java | 181 ++++++ .../org/dynmap/bukkit/helper/v26_1_2/NBT.java | 143 +++++ settings.gradle | 7 +- spigot/build.gradle | 13 + .../main/java/org/dynmap/bukkit/Helper.java | 8 + 10 files changed, 1100 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/auto-tag-beta.yml create mode 100644 bukkit-helper-26-1-2/build.gradle create mode 100644 bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/BukkitVersionHelperSpigot26_1_2.java create mode 100644 bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/MapChunkCache26_1_2.java create mode 100644 bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/NBT.java diff --git a/.github/workflows/auto-tag-beta.yml b/.github/workflows/auto-tag-beta.yml new file mode 100644 index 000000000..1346b44f5 --- /dev/null +++ b/.github/workflows/auto-tag-beta.yml @@ -0,0 +1,143 @@ +name: Auto Tag Beta + +# On every push to a `claude/**` branch: +# 1. Read the current beta base from build.gradle (e.g. "26.1.2-beta") so +# the tag prefix tracks build.gradle automatically and only one place +# needs editing when MC ships a new version. +# 2. Compute the next .N tag (max existing + 1; first run = .1). +# 3. Sync build.gradle to that tag, commit it back with [skip ci] so this +# workflow does not loop on its own commit. +# 4. Build the spigot jar with the JDK 25 toolchain Paper 26.1+ requires. +# 5. Create the tag + GitHub Release and upload the spigot jar as an asset. +# +# All `claude/**` branches share a single tag namespace. Concurrency is +# serialized below (`group: auto-tag-beta`) so two simultaneous pushes can't +# race for the same tag, but the counter (e.g. .5 -> .6 -> .7) is still +# global. If you need per-branch numbering, change the concurrency group and +# put the branch name into the tag prefix. +# +# The PR itself is created out-of-band by a human/operator with proper gh +# credentials; this workflow only handles tagging and releases. + +on: + push: + branches: + - 'claude/**' + +concurrency: + group: auto-tag-beta + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute next beta version + id: next + run: | + set -euo pipefail + git fetch --tags --force + # Derive the beta base (e.g. "26.1.2-beta") from build.gradle so + # bumping MC versions only requires editing build.gradle, not this + # workflow. Expects the current version to look like ".". + BASE=$(sed -nE "s/^ *version *= *'(.+)\.[0-9]+'.*/\1/p" build.gradle | head -n1) + if [ -z "${BASE}" ]; then + echo "Could not derive beta base from build.gradle" >&2 + exit 1 + fi + # Escape regex metacharacters in BASE before splicing it into sed + # below; the `.`s in e.g. "26.1.2-beta" would otherwise match any + # char and let stray tags like "26X1X2-beta.5" slip through. + BASE_RE=$(printf '%s' "${BASE}" | sed 's/[.[\^$*+?(){}|\\]/\\&/g') + LAST=$(git tag --list "${BASE}.*" \ + | sed -n "s/^${BASE_RE}\.\([0-9]\+\)\$/\1/p" \ + | sort -n | tail -n 1) + NEXT=$(( ${LAST:-0} + 1 )) + TAG="${BASE}.${NEXT}" + echo "base=${BASE}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "Next tag: ${TAG}" + + - name: Sync build.gradle version + run: | + set -euo pipefail + sed -i -E "s/^( *version *= *')[^']+(')/\1${{ steps.next.outputs.tag }}\2/" build.gradle + grep -E "^ *version *= *'" build.gradle + + - name: Commit version bump + env: + TAG: ${{ steps.next.outputs.tag }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if ! git diff --quiet -- build.gradle; then + git add build.gradle + git commit -m "chore(release): bump version to ${TAG} [skip ci]" + git push origin "HEAD:${GITHUB_REF_NAME}" + else + echo "build.gradle already at ${TAG}; nothing to commit." + fi + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + cache: gradle + + - name: Build spigot jar + env: + USERNAME: ${{ secrets.USERNAME }} + TOKEN: ${{ secrets.TOKEN }} + JAVA_HOME: ${{ env.JAVA_HOME_21_X64 }} + # Run Gradle itself on JDK 21 (Shadow 9.x is happy there); the + # paperweight subproject toolchain auto-uses JDK 25 via foojay. + run: | + ./gradlew :spigot:build \ + -Porg.gradle.java.installations.paths="${JAVA_HOME_25_X64},${JAVA_HOME_21_X64}" + + - name: Locate built jar + id: jar + run: | + set -euo pipefail + JAR_FILE=$(ls target/Dynmap-*-spigot.jar | tail -n 1) + echo "path=${JAR_FILE}" >> "$GITHUB_OUTPUT" + echo "name=$(basename "${JAR_FILE}")" >> "$GITHUB_OUTPUT" + + - name: Create tag and GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.next.outputs.tag }} + BASE: ${{ steps.next.outputs.base }} + JAR_PATH: ${{ steps.jar.outputs.path }} + run: | + set -euo pipefail + # The version-bump push (if any) updated origin; re-resolve HEAD. + git fetch origin "${GITHUB_REF_NAME}" + SHA=$(git rev-parse "origin/${GITHUB_REF_NAME}") + git tag "${TAG}" "${SHA}" + git push origin "${TAG}" + gh release create "${TAG}" \ + "${JAR_PATH}" \ + --title "Dynmap ${TAG}" \ + --notes "Automated ${BASE} build from commit ${SHA}." \ + --prerelease \ + --target "${SHA}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5eed1b93..71a7bd505 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,11 +33,8 @@ jobs: echo "jar_name=$(basename $JAR_FILE)" >> $GITHUB_OUTPUT - name: Upload Release Asset - uses: actions/upload-release-asset@v1 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ github.event.release.upload_url }} - asset_path: ${{ steps.find_jar.outputs.jar_file }} - asset_name: ${{ steps.find_jar.outputs.jar_name }} - asset_content_type: application/java-archive + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ github.event.release.tag_name }}" \ + "${{ steps.find_jar.outputs.jar_file }}" --clobber diff --git a/build.gradle b/build.gradle index 083b0a188..bbd7daa36 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ allprojects { apply plugin: 'java' group = 'us.dynmap' - version = '3.9-SNAPSHOT' + version = '26.1.2-beta.1' } @@ -47,7 +47,7 @@ subprojects { apply plugin: 'maven-publish' // Set Java version - paperweight modules define their own Java version - if (project.name != 'bukkit-helper-121-11') { + if (project.name != 'bukkit-helper-121-11' && project.name != 'bukkit-helper-26-1-2') { java { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 diff --git a/bukkit-helper-26-1-2/build.gradle b/bukkit-helper-26-1-2/build.gradle new file mode 100644 index 000000000..341f34f77 --- /dev/null +++ b/bukkit-helper-26-1-2/build.gradle @@ -0,0 +1,54 @@ +plugins { + id 'io.papermc.paperweight.userdev' +} + +eclipse { + project { + name = "Dynmap(Spigot-26.1.2)" + } +} + +description = 'bukkit-helper-26.1.2' + +// Minecraft 26.1+ Paper requires Java 25 at runtime, so the userdev setup +// (paperclip patching) needs to launch under Java 25 as well. The Shadow +// plugin in this build runs on JDK 21 and cannot read Java 25 (major 69) +// bytecode, so emit Java 21 bytecode here while keeping the Java 25 toolchain +// for compilation against Paper's API. `options.release` (rather than +// source/targetCompatibility) keeps the org.gradle.jvm.version attribute at +// 25 so paper-api (which requires JVM 25) still resolves. +// +// FIXME: drop the toolchain-25 + release-21 + JVM-25-attribute dance once the +// Shadow plugin can run on JDK 25 (Gradle would then run on 25 too and read +// major-69 class files natively). +java { + toolchain.languageVersion = JavaLanguageVersion.of(25) +} +tasks.named('compileJava').configure { + options.release = 21 +} + +// `options.release = 21` would normally narrow the resolution attribute to +// JVM 21, but paper-api 26.1.2 declares JVM 25 in its module metadata. Force +// the consumer attribute back to 25 so dependency resolution succeeds; the +// emitted bytecode is still Java 21 thanks to `--release`. +[configurations.compileClasspath, configurations.runtimeClasspath].each { cfg -> + cfg.attributes { + attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 25) + } +} + +dependencies { + implementation project(':bukkit-helper') + implementation project(':dynmap-api') + implementation project(path: ':DynmapCore', configuration: 'shadow') + // Paper publishes 26.1.2 dev bundles as "26.1.2.build.-stable" (no -R0.1-SNAPSHOT for the new version scheme) + paperweight.paperDevBundle("26.1.2.build.57-stable") +} + +// Paper 26.1+ ships Mojang-mapped at runtime; the dev bundle no longer +// provides reobf-to-Spigot mappings, so the reobfJar task can't run. Disable +// it. Consumers (spigot) must then pull from the `runtimeElements` +// configuration explicitly because paperweight registers `reobf` as the +// preferred runtime variant. +tasks.named('reobfJar').configure { enabled = false } diff --git a/bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/BukkitVersionHelperSpigot26_1_2.java b/bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/BukkitVersionHelperSpigot26_1_2.java new file mode 100644 index 000000000..cb5f56a95 --- /dev/null +++ b/bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/BukkitVersionHelperSpigot26_1_2.java @@ -0,0 +1,546 @@ +package org.dynmap.bukkit.helper.v26_1_2; + +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.dynmap.DynmapChunk; +import org.dynmap.Log; +import org.dynmap.bukkit.helper.BukkitMaterial; +import org.dynmap.bukkit.helper.BukkitVersionHelper; +import org.dynmap.bukkit.helper.BukkitWorld; +import org.dynmap.bukkit.helper.BukkitVersionHelperGeneric.TexturesPayload; +import org.dynmap.renderer.DynmapBlockState; +import org.dynmap.utils.MapChunkCache; +import org.dynmap.utils.Polygon; + +import com.google.common.collect.Iterables; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import com.mojang.authlib.properties.PropertyMap; + +import net.minecraft.core.HolderLookup; +import net.minecraft.core.IdMapper; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.ByteArrayTag; +import net.minecraft.nbt.ByteTag; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.DoubleTag; +import net.minecraft.nbt.FloatTag; +import net.minecraft.nbt.IntArrayTag; +import net.minecraft.nbt.IntTag; +import net.minecraft.nbt.LongTag; +import net.minecraft.nbt.ShortTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.resources.Identifier; +import net.minecraft.nbt.Tag; +import net.minecraft.server.MinecraftServer; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.LiquidBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + + +/** + * Helper for isolation of bukkit version specific issues + */ +public class BukkitVersionHelperSpigot26_1_2 extends BukkitVersionHelper { + + // CraftBukkit class references (loaded via reflection for Paper/Spigot compatibility) + private static Class craftWorldClass; + private static Class craftChunkClass; + private static Class craftPlayerClass; + private static Method craftWorldGetHandle; + private static Method craftWorldGetMinHeight; + private static Method craftChunkGetHandle; + private static Method craftPlayerGetProfile; + private static boolean initialized = false; + + private static synchronized void initCraftBukkitClasses() { + if (initialized) return; + initialized = true; + + // Paper 1.20.5+ uses unversioned org.bukkit.craftbukkit. We deliberately + // don't carry a Spigot-versioned fallback here: the real package name on + // Spigot 26.1.2 hasn't been verified, and a wrong fallback would mask + // the genuine ClassNotFoundException with a misleading "fell through to + // Spigot" path. If a Spigot-versioned helper is ever needed, add it + // after confirming the package against an actual Spigot 26.1.2 build. + String prefix = "org.bukkit.craftbukkit"; + try { + craftWorldClass = Class.forName(prefix + ".CraftWorld"); + craftChunkClass = Class.forName(prefix + ".CraftChunk"); + craftPlayerClass = Class.forName(prefix + ".entity.CraftPlayer"); + + craftWorldGetHandle = craftWorldClass.getMethod("getHandle"); + craftWorldGetMinHeight = craftWorldClass.getMethod("getMinHeight"); + craftChunkGetHandle = craftChunkClass.getMethod("getHandle", ChunkStatus.class); + craftPlayerGetProfile = craftPlayerClass.getMethod("getProfile"); + + Log.info("[Dynmap] Using CraftBukkit package: " + prefix); + } catch (ClassNotFoundException | NoSuchMethodException e) { + Log.severe("[Dynmap] Failed to find CraftBukkit classes!"); + } + } + + @Override + public boolean isUnsafeAsync() { + return false; + } + + /** + * Get block short name list + */ + @Override + public String[] getBlockNames() { + IdMapper bsids = Block.BLOCK_STATE_REGISTRY; + Block baseb = null; + Iterator iter = bsids.iterator(); + ArrayList names = new ArrayList(); + while (iter.hasNext()) { + BlockState bs = iter.next(); + Block b = bs.getBlock(); + // If this is new block vs last, it's the base block state + if (b != baseb) { + baseb = b; + continue; + } + Identifier id = BuiltInRegistries.BLOCK.getKey(b); + String bn = id.toString(); + if (bn != null) { + names.add(bn); + Log.info("block=" + bn); + } + } + return names.toArray(new String[0]); + } + + private static Object biomeRegistry = null; + private static java.lang.reflect.Method getKeyMethod = null; + private static java.lang.reflect.Method getIdMethod = null; + + private static synchronized Object getBiomeReg() { + if (biomeRegistry == null) { + biomeRegistry = MinecraftServer.getServer().registryAccess().lookup(Registries.BIOME).orElseThrow(); + // Cache reflection methods + try { + getKeyMethod = biomeRegistry.getClass().getMethod("getKey", Object.class); + getIdMethod = biomeRegistry.getClass().getMethod("getId", Object.class); + } catch (Exception e) { + Log.severe("Failed to get biome registry methods: " + e.getMessage()); + } + } + return biomeRegistry; + } + + @SuppressWarnings("unchecked") + private static Iterator getBiomeIterator() { + return ((Iterable) getBiomeReg()).iterator(); + } + + private static int getBiomeId(Biome biome) { + try { + getBiomeReg(); // ensure methods are cached + return (Integer) getIdMethod.invoke(biomeRegistry, biome); + } catch (Exception e) { + return -1; + } + } + + private static Identifier getBiomeKey(Biome biome) { + try { + getBiomeReg(); // ensure methods are cached + return (Identifier) getKeyMethod.invoke(biomeRegistry, biome); + } catch (Exception e) { + Log.warning("Failed to get biome key: " + e.getMessage()); + return null; + } + } + + private Object[] biomelist; + /** + * Get list of defined biomebase objects + */ + @Override + public Object[] getBiomeBaseList() { + if (biomelist == null) { + biomelist = new Biome[256]; + Iterator iter = getBiomeIterator(); + while (iter.hasNext()) { + Biome b = iter.next(); + int bidx = getBiomeId(b); + if (bidx >= biomelist.length) { + biomelist = Arrays.copyOf(biomelist, bidx + biomelist.length); + } + biomelist[bidx] = b; + } + } + return biomelist; + } + + /** Get ID from biomebase */ + @Override + public int getBiomeBaseID(Object bb) { + return getBiomeId((Biome)bb); + } + + public static IdentityHashMap dataToState; + + /** + * Initialize block states (org.dynmap.blockstate.DynmapBlockState) + */ + @Override + public void initializeBlockStates() { + dataToState = new IdentityHashMap(); + HashMap lastBlockState = new HashMap(); + IdMapper bsids = Block.BLOCK_STATE_REGISTRY; + Block baseb = null; + Iterator iter = bsids.iterator(); + ArrayList names = new ArrayList(); + + // Loop through block data states + DynmapBlockState.Builder bld = new DynmapBlockState.Builder(); + while (iter.hasNext()) { + BlockState bd = iter.next(); + Block b = bd.getBlock(); + Identifier id = BuiltInRegistries.BLOCK.getKey(b); + String bname = id.toString(); + DynmapBlockState lastbs = lastBlockState.get(bname); // See if we have seen this one + int idx = 0; + if (lastbs != null) { // Yes + idx = lastbs.getStateCount(); // Get number of states so far, since this is next + } + // Build state name + String sb = ""; + String fname = bd.toString(); + int off1 = fname.indexOf('['); + if (off1 >= 0) { + int off2 = fname.indexOf(']'); + sb = fname.substring(off1+1, off2); + } + int lightAtten = bd.getLightDampening(); // MC 26.1: renamed from getLightBlock() + // Fill in base attributes + bld.setBaseState(lastbs).setStateIndex(idx).setBlockName(bname).setStateName(sb).setAttenuatesLight(lightAtten); + if (bd.isSolid()) { bld.setSolid(); } + if (bd.isAir()) { bld.setAir(); } + if (bd.is(BlockTags.OVERWORLD_NATURAL_LOGS)) { bld.setLog(); } + if (bd.is(BlockTags.LEAVES)) { bld.setLeaves(); } + if (!bd.getFluidState().isEmpty() && !(bd.getBlock() instanceof LiquidBlock)) { + bld.setWaterlogged(); + } + DynmapBlockState dbs = bld.build(); // Build state + + dataToState.put(bd, dbs); + lastBlockState.put(bname, (lastbs == null) ? dbs : lastbs); + Log.verboseinfo("blk=" + bname + ", idx=" + idx + ", state=" + sb + ", waterlogged=" + dbs.isWaterlogged()); + } + } + /** + * Create chunk cache for given chunks of given world + * @param dw - world + * @param chunks - chunk list + * @return cache + */ + @Override + public MapChunkCache getChunkCache(BukkitWorld dw, List chunks) { + MapChunkCache26_1_2 c = new MapChunkCache26_1_2(gencache); + c.setChunks(dw, chunks); + return c; + } + + /** + * Get biome base water multiplier + */ + @Override + public int getBiomeBaseWaterMult(Object bb) { + Biome biome = (Biome) bb; + return biome.getWaterColor(); + } + + /** Get temperature from biomebase */ + @Override + public float getBiomeBaseTemperature(Object bb) { + return ((Biome)bb).getBaseTemperature(); + } + + /** Get humidity from biomebase */ + @Override + public float getBiomeBaseHumidity(Object bb) { + String vals = ((Biome)bb).climateSettings.toString(); + float humidity = 0.5F; + int idx = vals.indexOf("downfall="); + if (idx >= 0) { + humidity = Float.parseFloat(vals.substring(idx+9, vals.indexOf(']', idx))); + } + return humidity; + } + + @Override + public Polygon getWorldBorder(World world) { + Polygon p = null; + WorldBorder wb = world.getWorldBorder(); + if (wb != null) { + Location c = wb.getCenter(); + double size = wb.getSize(); + if ((size > 1) && (size < 1E7)) { + size = size / 2; + p = new Polygon(); + p.addVertex(c.getX()-size, c.getZ()-size); + p.addVertex(c.getX()+size, c.getZ()-size); + p.addVertex(c.getX()+size, c.getZ()+size); + p.addVertex(c.getX()-size, c.getZ()+size); + } + } + return p; + } + // Send title/subtitle to user + public void sendTitleText(Player p, String title, String subtitle, int fadeInTicks, int stayTicks, int fadeOutTIcks) { + if (p != null) { + p.sendTitle(title, subtitle, fadeInTicks, stayTicks, fadeOutTIcks); + } + } + + /** + * Get material map by block ID + */ + @Override + public BukkitMaterial[] getMaterialList() { + return new BukkitMaterial[4096]; // Not used + } + + @Override + public void unloadChunkNoSave(World w, Chunk c, int cx, int cz) { + Log.severe("unloadChunkNoSave not implemented"); + } + + private String[] biomenames; + @Override + public String[] getBiomeNames() { + if (biomenames == null) { + biomenames = new String[256]; + Iterator iter = getBiomeIterator(); + while (iter.hasNext()) { + Biome b = iter.next(); + int bidx = getBiomeId(b); + if (bidx >= biomenames.length) { + biomenames = Arrays.copyOf(biomenames, bidx + biomenames.length); + } + biomenames[bidx] = b.toString(); + } + } + return biomenames; + } + + @Override + public String getStateStringByCombinedId(int blkid, int meta) { + Log.severe("getStateStringByCombinedId not implemented"); + return null; + } + @Override + /** Get ID string from biomebase */ + public String getBiomeBaseIDString(Object bb) { + Identifier key = getBiomeKey((Biome)bb); + return key != null ? key.getPath() : ""; + } + @Override + public String getBiomeBaseResourceLocsation(Object bb) { + Identifier key = getBiomeKey((Biome)bb); + return key != null ? key.toString() : ""; + } + + @Override + public Object getUnloadQueue(World world) { + Log.warning("getUnloadQueue not implemented yet"); + return null; + } + + @Override + public boolean isInUnloadQueue(Object unloadqueue, int x, int z) { + Log.warning("isInUnloadQueue not implemented yet"); + return false; + } + + @Override + public Object[] getBiomeBaseFromSnapshot(ChunkSnapshot css) { + Log.warning("getBiomeBaseFromSnapshot not implemented yet"); + return new Object[256]; + } + + @Override + public long getInhabitedTicks(Chunk c) { + initCraftBukkitClasses(); + try { + Object handle = craftChunkGetHandle.invoke(c, ChunkStatus.FULL); + return ((LevelChunk)handle).getInhabitedTime(); + } catch (Exception e) { + Log.warning("getInhabitedTicks failed: " + e.getMessage()); + return 0; + } + } + + @Override + public Map getTileEntitiesForChunk(Chunk c) { + initCraftBukkitClasses(); + try { + Object handle = craftChunkGetHandle.invoke(c, ChunkStatus.FULL); + return ((LevelChunk)handle).getBlockEntities(); + } catch (Exception e) { + Log.warning("getTileEntitiesForChunk failed: " + e.getMessage()); + return new HashMap<>(); + } + } + + @Override + public int getTileEntityX(Object te) { + BlockEntity blockent = (BlockEntity) te; + return blockent.getBlockPos().getX(); + } + + @Override + public int getTileEntityY(Object te) { + BlockEntity blockent = (BlockEntity) te; + return blockent.getBlockPos().getY(); + } + + @Override + public int getTileEntityZ(Object te) { + BlockEntity blockent = (BlockEntity) te; + return blockent.getBlockPos().getZ(); + } + + @Override + public Object readTileEntityNBT(Object te, World w) { + initCraftBukkitClasses(); + try { + BlockEntity blockent = (BlockEntity) te; + Object handle = craftWorldGetHandle.invoke(w); + Method registryAccess = handle.getClass().getMethod("registryAccess"); + HolderLookup.Provider registry = (HolderLookup.Provider) registryAccess.invoke(handle); + return blockent.saveCustomOnly(registry); + } catch (Exception e) { + Log.warning("readTileEntityNBT failed: " + e.getMessage()); + return null; + } + } + + @Override + public Object getFieldValue(Object nbt, String field) { + CompoundTag rec = (CompoundTag) nbt; + Tag val = rec.get(field); + if(val == null) return null; + if(val instanceof ByteTag) { + return ((ByteTag)val).byteValue(); + } + else if(val instanceof ShortTag) { + return ((ShortTag)val).shortValue(); + } + else if(val instanceof IntTag) { + return ((IntTag)val).intValue(); + } + else if(val instanceof LongTag) { + return ((LongTag)val).longValue(); + } + else if(val instanceof FloatTag) { + return ((FloatTag)val).floatValue(); + } + else if(val instanceof DoubleTag) { + return ((DoubleTag)val).doubleValue(); + } + else if(val instanceof ByteArrayTag) { + return ((ByteArrayTag)val).getAsByteArray(); + } + else if(val instanceof StringTag) { + return val.asString().orElse(""); + } + else if(val instanceof IntArrayTag) { + return ((IntArrayTag)val).getAsIntArray(); + } + return null; + } + + @Override + public Player[] getOnlinePlayers() { + Collection p = Bukkit.getServer().getOnlinePlayers(); + return p.toArray(new Player[0]); + } + + @Override + public double getHealth(Player p) { + return p.getHealth(); + } + + private static final Gson gson = new GsonBuilder().create(); + + /** + * Get skin URL for player + * @param player + */ + @Override + public String getSkinURL(Player player) { + initCraftBukkitClasses(); + String url = null; + try { + GameProfile profile = (GameProfile) craftPlayerGetProfile.invoke(player); + if (profile != null) { + PropertyMap pm = profile.properties(); + if (pm != null) { + Collection txt = pm.get("textures"); + Property textureProperty = Iterables.getFirst(pm.get("textures"), null); + if (textureProperty != null) { + String val = textureProperty.value(); + if (val != null) { + TexturesPayload result = null; + try { + String json = new String(Base64.getDecoder().decode(val), StandardCharsets.UTF_8); + result = gson.fromJson(json, TexturesPayload.class); + } catch (JsonParseException e) { + } catch (IllegalArgumentException x) { + Log.warning("Malformed response from skin URL check: " + val); + } + if ((result != null) && (result.textures != null) && (result.textures.containsKey("SKIN"))) { + url = result.textures.get("SKIN").url; + } + } + } + } + } + } catch (Exception e) { + Log.warning("getSkinURL failed: " + e.getMessage()); + } + return url; + } + // Get minY for world + @Override + public int getWorldMinY(World w) { + initCraftBukkitClasses(); + try { + return (Integer) craftWorldGetMinHeight.invoke(w); + } catch (Exception e) { + Log.warning("getWorldMinY failed: " + e.getMessage()); + return 0; + } + } + @Override + public boolean useGenericCache() { + return true; + } + +} diff --git a/bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/MapChunkCache26_1_2.java b/bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/MapChunkCache26_1_2.java new file mode 100644 index 000000000..08b7e3293 --- /dev/null +++ b/bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/MapChunkCache26_1_2.java @@ -0,0 +1,181 @@ +package org.dynmap.bukkit.helper.v26_1_2; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeSpecialEffects; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.storage.SerializableChunkData; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.dynmap.DynmapChunk; +import org.dynmap.Log; +import org.dynmap.bukkit.helper.BukkitWorld; +import org.dynmap.common.BiomeMap; +import org.dynmap.common.chunk.GenericChunk; +import org.dynmap.common.chunk.GenericChunkCache; +import org.dynmap.common.chunk.GenericMapChunkCache; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +/** + * Container for managing chunks - dependent upon using chunk snapshots, since rendering is off server thread + */ +public class MapChunkCache26_1_2 extends GenericMapChunkCache { + private World w; + + // CraftBukkit reflection support for Paper/Spigot compatibility + private static Class craftWorldClass; + private static Class craftServerClass; + private static Method craftWorldGetHandle; + private static Method craftWorldIsChunkLoaded; + private static Method craftServerGetServer; + private static boolean initialized = false; + + private static synchronized void initReflection() { + if (initialized) return; + initialized = true; + + // Paper 1.20.5+ uses unversioned org.bukkit.craftbukkit. See the matching + // note in BukkitVersionHelperSpigot26_1_2#initCraftBukkitClasses about + // why we don't carry a Spigot-versioned fallback here. + String prefix = "org.bukkit.craftbukkit"; + try { + craftWorldClass = Class.forName(prefix + ".CraftWorld"); + craftServerClass = Class.forName(prefix + ".CraftServer"); + + craftWorldGetHandle = craftWorldClass.getMethod("getHandle"); + craftWorldIsChunkLoaded = craftWorldClass.getMethod("isChunkLoaded", int.class, int.class); + craftServerGetServer = craftServerClass.getMethod("getServer"); + + Log.info("[Dynmap] MapChunkCache using CraftBukkit package: " + prefix); + } catch (ClassNotFoundException | NoSuchMethodException e) { + Log.severe("[Dynmap] MapChunkCache failed to find CraftBukkit classes!"); + } + } + + private ServerLevel getServerLevel(World world) { + initReflection(); + try { + return (ServerLevel) craftWorldGetHandle.invoke(world); + } catch (Exception e) { + Log.severe("Failed to get ServerLevel: " + e.getMessage()); + return null; + } + } + + private boolean isChunkLoaded(World world, int x, int z) { + initReflection(); + try { + return (Boolean) craftWorldIsChunkLoaded.invoke(world, x, z); + } catch (Exception e) { + Log.warning("Failed to check chunk loaded status: " + e.getMessage()); + return false; + } + } + + private MinecraftServer getMinecraftServer() { + initReflection(); + try { + return (MinecraftServer) craftServerGetServer.invoke(Bukkit.getServer()); + } catch (Exception e) { + Log.severe("Failed to get MinecraftServer: " + e.getMessage()); + return null; + } + } + + /** + * Construct empty cache + */ + public MapChunkCache26_1_2(GenericChunkCache cc) { + super(cc); + } + + @Override + protected Supplier getLoadedChunkAsync(DynmapChunk chunk) { + ServerLevel serverLevel = getServerLevel(w); + MinecraftServer server = getMinecraftServer(); + if (serverLevel == null || server == null) { + return () -> null; + } + + CompletableFuture> chunkData = CompletableFuture.supplyAsync(() -> { + LevelChunk c = serverLevel.getChunkIfLoaded(chunk.x, chunk.z); + if (c == null || !c.loaded) { + return Optional.empty(); + } + return Optional.of(SerializableChunkData.copyOf(serverLevel, c)); + }, server); + return () -> chunkData.join().map(SerializableChunkData::write).map(NBT.NBTCompound::new).map(this::parseChunkFromNBT).orElse(null); + } + + protected GenericChunk getLoadedChunk(DynmapChunk chunk) { + ServerLevel serverLevel = getServerLevel(w); + if (serverLevel == null) return null; + + if (!isChunkLoaded(w, chunk.x, chunk.z)) return null; + LevelChunk c = serverLevel.getChunkIfLoaded(chunk.x, chunk.z); + if (c == null || !c.loaded) return null; + SerializableChunkData chunkData = SerializableChunkData.copyOf(serverLevel, c); + CompoundTag nbt = chunkData.write(); + return nbt != null ? parseChunkFromNBT(new NBT.NBTCompound(nbt)) : null; + } + + @Override + protected Supplier loadChunkAsync(DynmapChunk chunk) { + ServerLevel serverLevel = getServerLevel(w); + if (serverLevel == null) { + return () -> null; + } + + CompletableFuture> genericChunk = serverLevel.getChunkSource().chunkMap.read(new ChunkPos(chunk.x, chunk.z)); + return () -> genericChunk.join().map(NBT.NBTCompound::new).map(this::parseChunkFromNBT).orElse(null); + } + + protected GenericChunk loadChunk(DynmapChunk chunk) { + ServerLevel serverLevel = getServerLevel(w); + if (serverLevel == null) return null; + + CompoundTag nbt = null; + ChunkPos cc = new ChunkPos(chunk.x, chunk.z); + GenericChunk gc = null; + try { + nbt = serverLevel + .getChunkSource() + .chunkMap + .read(cc) + .join().get(); + } catch (CancellationException cx) { + } catch (NoSuchElementException snex) { + } + if (nbt != null) { + gc = parseChunkFromNBT(new NBT.NBTCompound(nbt)); + } + return gc; + } + + public void setChunks(BukkitWorld dw, List chunks) { + this.w = dw.getWorld(); + super.setChunks(dw, chunks); + } + + @Override + public int getFoliageColor(BiomeMap bm, int[] colormap, int x, int z) { + return bm.getBiomeObject().map(Biome::getSpecialEffects).flatMap(BiomeSpecialEffects::foliageColorOverride).orElse(colormap[bm.biomeLookup()]); + } + + @Override + public int getGrassColor(BiomeMap bm, int[] colormap, int x, int z) { + BiomeSpecialEffects effects = bm.getBiomeObject().map(Biome::getSpecialEffects).orElse(null); + if (effects == null) return colormap[bm.biomeLookup()]; + return effects.grassColorModifier().modifyColor(x, z, effects.grassColorOverride().orElse(colormap[bm.biomeLookup()])); + } +} diff --git a/bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/NBT.java b/bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/NBT.java new file mode 100644 index 000000000..fe2b7ea22 --- /dev/null +++ b/bukkit-helper-26-1-2/src/main/java/org/dynmap/bukkit/helper/v26_1_2/NBT.java @@ -0,0 +1,143 @@ +package org.dynmap.bukkit.helper.v26_1_2; + +import org.dynmap.common.chunk.GenericBitStorage; +import org.dynmap.common.chunk.GenericNBTCompound; +import org.dynmap.common.chunk.GenericNBTList; + +import java.util.Optional; +import java.util.Set; +import net.minecraft.nbt.Tag; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.util.SimpleBitStorage; + +public class NBT { + + public static class NBTCompound implements GenericNBTCompound { + private final CompoundTag obj; + public NBTCompound(CompoundTag t) { + this.obj = t; + } + @Override + public Set getAllKeys() { + return obj.keySet(); + } + @Override + public boolean contains(String s) { + return obj.contains(s); + } + @Override + public boolean contains(String s, int i) { + Tag base = obj.get(s); + if (base == null) + return false; + byte type = base.getId(); + if (type == i) + return true; + else if (i != TAG_ANY_NUMERIC) + return false; + return type == TAG_BYTE || type == TAG_SHORT || type == TAG_INT || type == TAG_LONG || type == TAG_FLOAT + || type == TAG_DOUBLE; + } + @Override + public byte getByte(String s) { + return obj.getByteOr(s, (byte)0); + } + @Override + public short getShort(String s) { + return obj.getShortOr(s, (short)0); + } + @Override + public int getInt(String s) { + return obj.getIntOr(s, 0); + } + @Override + public long getLong(String s) { + return obj.getLongOr(s, 0L); + } + @Override + public float getFloat(String s) { + return obj.getFloatOr(s, 0.0f); + } + @Override + public double getDouble(String s) { + return obj.getDoubleOr(s, 0.0); + } + @Override + public String getString(String s) { + return obj.getStringOr(s, ""); + } + @Override + public byte[] getByteArray(String s) { + Optional byteArr = obj.getByteArray(s); + return byteArr.orElseGet(() -> new byte[0]); + } + @Override + public int[] getIntArray(String s) { + Optional intArr = obj.getIntArray(s); + return intArr.orElseGet(() -> new int[0]); + } + @Override + public long[] getLongArray(String s) { + Optional longArr = obj.getLongArray(s); + return longArr.orElseGet(() -> new long[0]); + } + @Override + public GenericNBTCompound getCompound(String s) { + return new NBTCompound(obj.getCompoundOrEmpty(s)); + } + @Override + public GenericNBTList getList(String s, int i) { + return new NBTList(obj.getListOrEmpty(s)); + } + @Override + public boolean getBoolean(String s) { + return getByte(s) != 0; + } + @Override + public String getAsString(String s) { + Tag tag = obj.get(s); + return tag != null ? tag.asString().orElse("") : ""; + } + @Override + public GenericBitStorage makeBitStorage(int bits, int count, long[] data) { + return new OurBitStorage(bits, count, data); + } + public String toString() { + return obj.toString(); + } + } + + public static class NBTList implements GenericNBTList { + private final ListTag obj; + public NBTList(ListTag t) { + obj = t; + } + @Override + public int size() { + return obj.size(); + } + @Override + public String getString(int idx) { + return obj.getStringOr(idx, ""); + } + @Override + public GenericNBTCompound getCompound(int idx) { + return new NBTCompound(obj.getCompoundOrEmpty(idx)); + } + public String toString() { + return obj.toString(); + } + } + + public static class OurBitStorage implements GenericBitStorage { + private final SimpleBitStorage bs; + public OurBitStorage(int bits, int count, long[] data) { + bs = new SimpleBitStorage(bits, count, data); + } + @Override + public int get(int idx) { + return bs.get(idx); + } + } +} diff --git a/settings.gradle b/settings.gradle index 449a8f85b..f73758cca 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,7 +9,10 @@ pluginManagement { plugins { // Apply paperweight.userdev to root project with 'apply false' for multi-project builds // See: https://docs.papermc.io/paper/dev/userdev/ - id 'io.papermc.paperweight.userdev' version '2.0.0-beta.19' apply false + id 'io.papermc.paperweight.userdev' version '2.0.0-beta.21' apply false + // Auto-download JDK toolchains; bukkit-helper-26-1-2 requires JDK 25 + // (Paper 26.1+ runtime), and not all CI runners have it preinstalled. + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' } rootProject.name = 'dynmap-common' @@ -42,6 +45,7 @@ include ':bukkit-helper-121-6' // helper is rewritten to use reflection like bukkit-helper-121-11. // include ':bukkit-helper-121-10' include ':bukkit-helper-121-11' +include ':bukkit-helper-26-1-2' include ':bukkit-helper' include ':dynmap-api' include ':DynmapCore' @@ -95,6 +99,7 @@ project(':bukkit-helper-121-5').projectDir = "$rootDir/bukkit-helper-121-5" as F project(':bukkit-helper-121-6').projectDir = "$rootDir/bukkit-helper-121-6" as File // project(':bukkit-helper-121-10').projectDir = "$rootDir/bukkit-helper-121-10" as File project(':bukkit-helper-121-11').projectDir = "$rootDir/bukkit-helper-121-11" as File +project(':bukkit-helper-26-1-2').projectDir = "$rootDir/bukkit-helper-26-1-2" as File project(':bukkit-helper').projectDir = "$rootDir/bukkit-helper" as File project(':dynmap-api').projectDir = "$rootDir/dynmap-api" as File project(':DynmapCore').projectDir = "$rootDir/DynmapCore" as File diff --git a/spigot/build.gradle b/spigot/build.gradle index 5d14071aa..3f8aff984 100644 --- a/spigot/build.gradle +++ b/spigot/build.gradle @@ -112,6 +112,18 @@ dependencies { implementation(project(':bukkit-helper-121-11')) { transitive = false } + // WORKAROUND: paperweight registers its `reobf` configuration as the + // preferred runtime variant, but Paper 26.1+ ships Mojang-mapped only and + // the dev bundle no longer provides reobf mappings, so reobfJar can't run + // and the `reobf` outgoing artifact is empty. Pinning `configuration: + // 'default'` here bypasses Gradle variant matching and pulls the plain + // jar instead, so org.dynmap.bukkit.helper.v26_1_2.* actually lands in + // the shaded spigot jar. Do NOT "align" this back to the sibling + // bukkit-helper modules' style without first verifying paperweight + // exposes a working reobf jar for this Minecraft version. + implementation(project(path: ':bukkit-helper-26-1-2', configuration: 'default')) { + transitive = false + } } processResources { @@ -160,6 +172,7 @@ shadowJar { include(dependency(':bukkit-helper-121-6')) // include(dependency(':bukkit-helper-121-10')) include(dependency(':bukkit-helper-121-11')) + include(dependency(':bukkit-helper-26-1-2:.*')) } relocate('org.bstats', 'org.dynmap.bstats') destinationDirectory = file '../target' diff --git a/spigot/src/main/java/org/dynmap/bukkit/Helper.java b/spigot/src/main/java/org/dynmap/bukkit/Helper.java index 26cc72e7f..6a17ff3cd 100644 --- a/spigot/src/main/java/org/dynmap/bukkit/Helper.java +++ b/spigot/src/main/java/org/dynmap/bukkit/Helper.java @@ -61,6 +61,14 @@ else if (v.contains("(MC: 1.21.9)") || v.contains("(MC: 1.21.10)")) { else if (v.contains("(MC: 1.21.")) { // Set up in case 1.21.12 works 'as is' BukkitVersionHelper.helper = loadVersionHelper("org.dynmap.bukkit.helper.v121_11.BukkitVersionHelperSpigot121_11"); } + // Minecraft adopted year.drop.hotfix versioning starting with MC 26.1 (March 2026). + // Match all 26.x releases here so future hotfixes load the same helper until specialised. + // NOTE: this is a prefix match. When a future MC 26.x release needs its own helper, + // add the more specific check (e.g. "(MC: 26.2.")) ABOVE this branch -- otherwise + // the new version will be swallowed by the fallback and load the wrong helper. + else if (v.contains("(MC: 26.")) { + BukkitVersionHelper.helper = loadVersionHelper("org.dynmap.bukkit.helper.v26_1_2.BukkitVersionHelperSpigot26_1_2"); + } else if (v.contains("(MC: 1.20)") || v.contains("(MC: 1.20.1)")) { BukkitVersionHelper.helper = loadVersionHelper("org.dynmap.bukkit.helper.v120.BukkitVersionHelperSpigot120"); } From 09552448efa4fd89195cc20fcbb5b45987a385f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 10:11:11 +0000 Subject: [PATCH 2/3] chore: retrigger CI after rebase to v3.0-fork tip From 9669811eaa9d43b686c93db69cfacefad687d3c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 10:11:34 +0000 Subject: [PATCH 3/3] chore(release): bump version to 26.1.2-beta.9 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index bbd7daa36..d6551df98 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ allprojects { apply plugin: 'java' group = 'us.dynmap' - version = '26.1.2-beta.1' + version = '26.1.2-beta.9' }