From 5597c03fdfac2f0c680c80cfada84c1efaa3d22e Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:29:11 -0400 Subject: [PATCH 01/47] Start Albion performance fork 1.1.0 build baseline --- build.gradle | 67 +++++++++++----------------------------------------- 1 file changed, 14 insertions(+), 53 deletions(-) diff --git a/build.gradle b/build.gradle index 51b961a..65d2653 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,11 @@ plugins { id 'java-library' id 'idea' - id 'maven-publish' id "com.gradleup.shadow" version "9.0.0-beta12" } group = 'com.magmaguy' -version = '2.6.3' +version = '1.1.0' repositories { mavenCentral() @@ -28,63 +27,55 @@ dependencies { annotationProcessor 'org.projectlombok:lombok:1.18.30' compileOnly 'org.projectlombok:lombok:1.18.30' - implementation (group: 'com.magmaguy', name: 'MagmaCore', version: '2.2.0-SNAPSHOT'){ + implementation (group: 'com.magmaguy', name: 'MagmaCore', version: '2.2.0-SNAPSHOT') { changing = true } implementation group: 'org.bstats', name: 'bstats-bukkit', version: '2.2.1' implementation group: 'org.joml', name: 'joml', version: '1.10.8' + + // Albion fork runtime baseline: Paper 1.21.11+ with FastAsyncWorldEdit. + compileOnly 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' + compileOnly 'com.fastasyncworldedit:FastAsyncWorldEdit-Bukkit:2.14.3' + compileOnly group: 'com.magmaguy', name: 'EliteMobs', version: '9.3.3-SNAPSHOT' compileOnly group: 'io.lumine', name: 'Mythic-Dist', version: '5.2.1' compileOnly group: 'io.lumine', name: 'MythicLib-dist', version: '1.5.2-SNAPSHOT' compileOnly group: 'net.Indyuce', name: 'MMOItems-API', version: '6.9.2-SNAPSHOT' compileOnly group: 'com.sk89q.worldguard', name: 'worldguard-bukkit', version: '7.0.7' - compileOnly group: 'com.sk89q.worldedit', name: 'worldedit-bukkit', version: '7.3.0' - compileOnly 'org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testImplementation 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' testImplementation 'org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.110.0' testImplementation 'org.mockito:mockito-core:5.12.0' - testImplementation group: 'com.sk89q.worldedit', name: 'worldedit-bukkit', version: '7.3.0' + testImplementation 'com.sk89q.worldedit:worldedit-bukkit:7.3.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } -//This allows any modules tagged as changing to be updated every time, which is extremely convenient for some of the shaded libs configurations.all { resolutionStrategy.cacheChangingModulesFor 0, 'seconds' } -artifacts { // task 'build' runs generates uberjar +artifacts { archives shadowJar } jar { - archiveClassifier.set('min') // we want the Uberjar to be distributed, this is the minified version + archiveClassifier.set('min') } String packagePath = 'com.magmaguy.shaded' -// Relocating a Package shadowJar { dependencies { relocate('org.bstats', packagePath + '.bstats') } relocate 'com.magmaguy.easyminecraftgoals', 'com.magmaguy.betterstructures.easyminecraftgoals' - // Relocate the shaded MagmaCore so its static singleton (and config-folder resolution via - // MagmaCore.getInstance().getRequestingPlugin()) is isolated per plugin. Without this, every - // plugin that ships MagmaCore unrelocated shares one instance/requestingPlugin, so their - // configs (e.g. setupDone) collide in a single data folder and never persist per-plugin. relocate 'com.magmaguy.magmacore', 'com.magmaguy.betterstructures.magmacore' duplicatesStrategy = DuplicatesStrategy.EXCLUDE archiveClassifier.set(null) - archiveFileName.set(project.name + ".jar") + archiveFileName.set("BetterStructures-${project.version}.jar") } -// --- Shared dist mirror ----------------------------------------------------- -// Copy the finished shaded jar into the top-level dist/ folder so the testbeds -// and the release tool pick every build up from one place. Gated on MC_DIST_DIR -// (or -PmcDistDir) so clones/CI that don't set it just build to build/libs and -// no machine-specific path is ever committed. def mcDistDir = System.getenv('MC_DIST_DIR') ?: project.findProperty('mcDistDir') if (mcDistDir) { tasks.register('mirrorToDist', Copy) { @@ -94,8 +85,9 @@ if (mcDistDir) { tasks.named('shadowJar') { finalizedBy('mirrorToDist') } } -tasks.withType(JavaCompile) { +tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' + options.release = 21 } test { @@ -110,41 +102,10 @@ ext { java { toolchain { - languageVersion = JavaLanguageVersion.of(21) // or your target version + languageVersion = JavaLanguageVersion.of(21) } } processResources { filter org.apache.tools.ant.filters.ReplaceTokens, tokens: resourceTokens } - -publishing { - repositories { - maven { - name = "BetterStructures" - url = "https://repo.magmaguy.com/releases" - credentials { - username = project.hasProperty('ossrhUsername') ? ossrhUsername : "Unknown user" - password = project.hasProperty('ossrhPassword') ? ossrhPassword : "Unknown password" - } - } - } - - publications { - mavenJava(MavenPublication) { - pom { - groupId = 'com.magmaguy' - name = 'betterstructures' - description = 'BetterStructures repo' - url = 'https://magmaguy.com/' - from components.java - licenses { - license { - name = 'GPLV3' - url = 'https://www.gnu.org/licenses/gpl-3.0.en.html' - } - } - } - } - } -} From df8328d25b9198a0e58212821880b42529f97ca8 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:29:18 -0400 Subject: [PATCH 02/47] Remove upstream publishing credentials from Albion fork --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 38e4051..1910e57 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ -ossrhUsername=magmaguy -ossrhPassword=yw44tDEksRoDdstrP7iDmITzPUJO/jRnmtE4iraRf28o1uRTIHMe0pGnqcj5FL+q +# AlbionMC performance fork. +# Upstream publishing credentials are intentionally not carried in this fork. From 30c568daa89ff769f9169fae8e3969064c4c6520 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:29:27 -0400 Subject: [PATCH 03/47] Require FAWE and Paper 1.21.11+ for Albion fork --- src/main/resources/plugin.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index f67a82a..1e06196 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,19 +1,18 @@ name: BetterStructures -version: '2.6.3' +version: '@Version@' main: com.magmaguy.betterstructures.BetterStructures -api-version: '1.21.4' -depend: [ WorldEdit ] +api-version: '1.21.11' +depend: [ FastAsyncWorldEdit ] softdepend: - EliteMobs - WorldGuard - - WorldEdit - Terralith - Iris - Terra - TerraformGenerator authors: [ MagmaGuy ] -description: A plugin that adds random structures to your Minecraft world! -website: magmaguy.com +description: AlbionMC performance fork of BetterStructures, based on upstream 2.6.3. +website: AlbionMC.com commands: betterstructures: description: Main command From d6ecaa06a558f3f58551a9a4b68664bad4942b1c Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:29:39 -0400 Subject: [PATCH 04/47] Add MagmaGuy homage and Albion fork README --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a0b596 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# BetterStructures Performance / Albion + +An **AlbionMC.com-only performance and maintenance fork** of BetterStructures, based on upstream **BetterStructures 2.6.3**. + +## Homage and upstream credit + +BetterStructures was created by **MagmaGuy**. The original concept, structure system, content format, and the overwhelming majority of this codebase are his work and the work of upstream contributors. This fork exists because AlbionMC depends heavily on BetterStructures and needs a performance profile tailored to a large, plugin-heavy survival server and frequently regenerated resource worlds. + +This project is **not intended to replace, rebrand, or compete with the original BetterStructures project**. Credit for the plugin belongs with MagmaGuy and the upstream contributors. The original GPLv3 license is retained. + +## Purpose of this fork + +This branch is maintained specifically for **AlbionMC.com** with these goals: + +- preserve the gameplay, structures, content packages, and visual identity of BetterStructures 2.6.3; +- reduce MSPT/TPS spikes while players explore and generate new chunks, especially in resource worlds; +- use **FastAsyncWorldEdit (FAWE)** as the required WorldEdit backend; +- queue structure-generation work instead of allowing bursts of expensive fitting work during new-chunk events; +- pause ordinary player-driven structure generation when server MSPT/TPS indicates the main thread is under pressure; +- spread schematic preparation across ticks before the existing distributed paste workload begins; +- target **Paper 1.21.11 and newer only**. Older Minecraft/Paper compatibility is intentionally out of scope. + +## Requirements + +- Paper **1.21.11+** +- Java **21+** +- FastAsyncWorldEdit **2.14.3+** + - For Minecraft 26.x, use a FAWE release that explicitly supports that server version. + +FastAsyncWorldEdit provides the WorldEdit API used by BetterStructures and is intentionally a hard dependency in this fork. + +## Fork versioning + +Albion fork releases use their own version line beginning at **1.1.0**. The upstream source baseline remains BetterStructures **2.6.3**. + +Release JARs are named: + +```text +BetterStructures-1.1.x.jar +``` + +## Building + +```bash +./gradlew clean test shadowJar +``` + +The shaded plugin JAR is written to: + +```text +build/libs/BetterStructures-1.1.x.jar +``` + +GitHub tagged releases (`v1.1.x`) publish that JAR directly as a **raw release asset**, rather than requiring server owners to download an Actions ZIP. + +## Upstream updates + +The upstream BetterStructures self-update path is intentionally disabled in this fork so an Albion performance build cannot be silently replaced by an upstream plugin JAR. Upstream changes can still be reviewed and selectively merged into this fork. + +## License + +GPLv3, inherited from BetterStructures. See [LICENSE](LICENSE). From 76265d42fb5413f713e0222d5b77261f64c13ce0 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:29:48 -0400 Subject: [PATCH 05/47] Modernize Albion CI and verify versioned JAR --- .github/workflows/gradle.yml | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/gradle.yml diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..bca74f8 --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,38 @@ +name: BetterStructures Albion CI + +on: + push: + branches: + - master + - 'agent/**' + pull_request: + branches: [ master ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build and test + run: ./gradlew clean test shadowJar + + - name: Verify versioned JAR + shell: bash + run: | + VERSION="$(sed -n "s/^version = '\([^']*\)'.*/\1/p" build.gradle)" + JAR="build/libs/BetterStructures-${VERSION}.jar" + test -f "$JAR" + echo "Built $JAR" From 6bde3bb3fc0efb2ef01230beb1a8ce49482f0d58 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:29:56 -0400 Subject: [PATCH 06/47] Publish raw BetterStructures 1.1.x JAR release assets --- .github/workflows/release.yml | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0430460 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,53 @@ +name: Publish BetterStructures JAR + +on: + push: + tags: + - 'v1.1.*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Verify tag matches project version + shell: bash + run: | + VERSION="$(sed -n "s/^version = '\([^']*\)'.*/\1/p" build.gradle)" + TAG_VERSION="${GITHUB_REF_NAME#v}" + if [ "$VERSION" != "$TAG_VERSION" ]; then + echo "Tag $GITHUB_REF_NAME does not match build.gradle version $VERSION" >&2 + exit 1 + fi + + - name: Build release JAR + run: ./gradlew clean test shadowJar + + - name: Publish raw JAR release asset + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + JAR="build/libs/BetterStructures-${VERSION}.jar" + test -f "$JAR" + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" "$JAR" --clobber + else + gh release create "$GITHUB_REF_NAME" "$JAR" \ + --title "BetterStructures Albion ${VERSION}" \ + --generate-notes + fi From dedcba10836d468999a09706fafc9932f6428176 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:30:13 -0400 Subject: [PATCH 07/47] Queue player-driven structure generation by server load --- .../performance/GenerationScheduler.java | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java diff --git a/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java b/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java new file mode 100644 index 0000000..b9dc5b8 --- /dev/null +++ b/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java @@ -0,0 +1,143 @@ +package com.magmaguy.betterstructures.performance; + +import com.magmaguy.betterstructures.MetadataHandler; +import com.magmaguy.betterstructures.config.DefaultConfig; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.scheduler.BukkitRunnable; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * Serializes expensive structure-fitting work created by normal player exploration. + * + *

ChunkLoadEvent still performs the cheap deterministic position check immediately, + * but qualifying structures are admitted through this queue. This prevents several + * structure fits from landing in the same server tick when a player moves quickly + * through a resource world.

+ */ +public final class GenerationScheduler { + + private static final Deque JOBS = new ArrayDeque<>(); + private static boolean started = false; + private static boolean pausedForLoad = false; + private static int cooldownTicks = 0; + + private GenerationScheduler() { + } + + public static void start() { + if (started) return; + started = true; + + new BukkitRunnable() { + @Override + public void run() { + tick(); + } + }.runTaskTimer(MetadataHandler.PLUGIN, 1L, 1L); + } + + public static void shutdown() { + JOBS.clear(); + pausedForLoad = false; + cooldownTicks = 0; + started = false; + } + + public static void enqueue(Chunk chunk, List jobs) { + if (jobs == null || jobs.isEmpty()) return; + + if (!DefaultConfig.isPlayerGenerationThrottling()) { + jobs.forEach(Runnable::run); + return; + } + + // A qualifying structure chunk is rare. Holding its center chunk while queued + // is much cheaper than forcing a synchronous reload later when the player has + // already flown away from it. + chunk.addPluginChunkTicket(MetadataHandler.PLUGIN); + ChunkKey key = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + + int totalJobs = jobs.size(); + for (int i = 0; i < totalJobs; i++) { + JOBS.addLast(new GenerationJob(key, chunk, jobs.get(i), i == totalJobs - 1)); + } + } + + public static int queuedJobs() { + return JOBS.size(); + } + + private static void tick() { + if (JOBS.isEmpty()) return; + + if (cooldownTicks > 0) { + cooldownTicks--; + return; + } + + double mspt = Bukkit.getAverageTickTime(); + double[] tpsSamples = Bukkit.getTPS(); + double tps = tpsSamples.length == 0 ? 20.0 : tpsSamples[0]; + + if (pausedForLoad) { + if (mspt <= DefaultConfig.getPlayerGenerationResumeMSPT() + && tps >= DefaultConfig.getPlayerGenerationResumeTPS()) { + pausedForLoad = false; + Bukkit.getLogger().info("[BetterStructures] Player-generation queue resumed at " + + String.format("%.1f", mspt) + " MSPT / " + String.format("%.2f", tps) + " TPS."); + } else { + return; + } + } + + if (mspt >= DefaultConfig.getPlayerGenerationPauseMSPT() + || tps <= DefaultConfig.getPlayerGenerationPauseTPS()) { + pausedForLoad = true; + Bukkit.getLogger().warning("[BetterStructures] Player-generation queue paused to protect TPS at " + + String.format("%.1f", mspt) + " MSPT / " + String.format("%.2f", tps) + + " TPS. Queued jobs: " + JOBS.size()); + return; + } + + GenerationJob job = JOBS.pollFirst(); + if (job == null) return; + + try { + job.work().run(); + } catch (Throwable throwable) { + Bukkit.getLogger().severe("[BetterStructures] A queued structure-generation job failed in chunk " + + job.key().x() + "," + job.key().z() + "."); + throwable.printStackTrace(); + } finally { + if (job.releaseTicketAfter()) { + // Keep the center chunk around briefly while schematic preparation/paste + // gets underway. This avoids a churny unload/reload loop in fast resource worlds. + new BukkitRunnable() { + @Override + public void run() { + job.chunk().removePluginChunkTicket(MetadataHandler.PLUGIN); + } + }.runTaskLater(MetadataHandler.PLUGIN, 200L); + } + } + + cooldownTicks = Math.max(0, DefaultConfig.getPlayerGenerationTicksBetweenJobs()); + } + + private record GenerationJob(ChunkKey key, Chunk chunk, Runnable work, boolean releaseTicketAfter) { + private GenerationJob { + Objects.requireNonNull(key); + Objects.requireNonNull(chunk); + Objects.requireNonNull(work); + } + } + + private record ChunkKey(UUID worldId, int x, int z) { + } +} From b14dc8a58ba9e6fa521b923d6d8019a8f397513c Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:30:33 -0400 Subject: [PATCH 08/47] Throttle new-chunk structure fitting through generation queue --- .../listeners/NewChunkLoadEvent.java | 165 +++++++++--------- 1 file changed, 80 insertions(+), 85 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java b/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java index cd08239..cee280c 100644 --- a/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java +++ b/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java @@ -12,6 +12,7 @@ import com.magmaguy.betterstructures.config.modulegenerators.ModuleGeneratorsConfig; import com.magmaguy.betterstructures.config.modulegenerators.ModuleGeneratorsConfigFields; import com.magmaguy.betterstructures.modules.WFCGenerator; +import com.magmaguy.betterstructures.performance.GenerationScheduler; import com.magmaguy.betterstructures.schematics.SchematicContainer; import org.bukkit.Chunk; import org.bukkit.event.EventHandler; @@ -24,89 +25,115 @@ import java.util.HashSet; import java.util.List; import java.util.Random; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; public class NewChunkLoadEvent implements Listener { - private static HashSet loadingChunks = new HashSet<>(); + private static final Set loadingChunks = new HashSet<>(); + + public NewChunkLoadEvent() { + GenerationScheduler.start(); + } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onChunkLoad(ChunkLoadEvent event) { if (!event.isNewChunk()) return; - if (loadingChunks.contains(event.getChunk())) return; - //In some cases the same chunk gets loaded (at least at an event level) several times, this prevents the plugin from doing multiple scans and placing multiple builds, enhancing performance - loadingChunks.add(event.getChunk()); + if (!ValidWorldsConfig.isValidWorld(event.getWorld())) return; + + Chunk chunk = event.getChunk(); + ChunkKey chunkKey = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + if (!loadingChunks.add(chunkKey)) return; + new BukkitRunnable() { @Override public void run() { - loadingChunks.remove(event.getChunk()); + loadingChunks.remove(chunkKey); } }.runTaskLater(MetadataHandler.PLUGIN, 20L); - if (!ValidWorldsConfig.isValidWorld(event.getWorld())) return; - surfaceScanner(event.getChunk()); - shallowUndergroundScanner(event.getChunk()); - deepUndergroundScanner(event.getChunk()); - skyScanner(event.getChunk()); - liquidSurfaceScanner(event.getChunk()); - dungeonScanner(event.getChunk()); + // Position checks are deterministic and cheap. Only chunks that can actually + // contain a BetterStructures build enter the expensive generation queue. + List jobs = new ArrayList<>(2); + + if (!SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.SURFACE).isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.SURFACE, + DefaultConfig.getDistanceSurface(), DefaultConfig.getMaxOffsetSurface())) { + jobs.add(() -> new FitSurfaceBuilding(chunk)); + } + + if (!SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.UNDERGROUND_SHALLOW).isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.UNDERGROUND_SHALLOW, + DefaultConfig.getDistanceShallow(), DefaultConfig.getMaxOffsetShallow())) { + jobs.add(() -> FitUndergroundShallowBuilding.fit(chunk)); + } + + if (!SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.UNDERGROUND_DEEP).isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.UNDERGROUND_DEEP, + DefaultConfig.getDistanceDeep(), DefaultConfig.getMaxOffsetDeep())) { + jobs.add(() -> FitUndergroundDeepBuilding.fit(chunk)); + } + + if (!SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.SKY).isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.SKY, + DefaultConfig.getDistanceSky(), DefaultConfig.getMaxOffsetSky())) { + jobs.add(() -> new FitAirBuilding(chunk)); + } + + if (!SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.LIQUID_SURFACE).isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.LIQUID_SURFACE, + DefaultConfig.getDistanceLiquid(), DefaultConfig.getMaxOffsetLiquid())) { + jobs.add(() -> new FitLiquidBuilding(chunk)); + } + + if (!ModuleGeneratorsConfig.getModuleGenerators().isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.DUNGEON, + DefaultConfig.getDistanceDungeon(), DefaultConfig.getMaxOffsetDungeon())) { + jobs.add(() -> generateDungeon(chunk)); + } + + GenerationScheduler.enqueue(chunk, jobs); } /** * Determines if the given chunk is a valid structure position based on * a diamond grid pattern with seeded random offsets. - * - * @param chunk The chunk to check - * @param structureType The type of structure - * @param gridDistance The distance between grid points - * @param maxOffset The maximum random offset from grid points - * @return True if this chunk should have a structure */ private boolean isValidStructurePosition(Chunk chunk, GeneratorConfigFields.StructureType structureType, int gridDistance, int maxOffset) { int x = chunk.getX(); int z = chunk.getZ(); - // Check spawn protection radius (2D distance from 0,0 in blocks) int spawnProtectionRadius = DefaultConfig.getSpawnProtectionRadius(); if (spawnProtectionRadius > 0) { int blockX = x * 16 + 8; int blockZ = z * 16 + 8; - if ((long) blockX * blockX + (long) blockZ * blockZ < (long) spawnProtectionRadius * spawnProtectionRadius) { + if ((long) blockX * blockX + (long) blockZ * blockZ + < (long) spawnProtectionRadius * spawnProtectionRadius) { return false; } } long worldSeed = chunk.getWorld().getSeed(); + long typeSeed = worldSeed + structureType.name().hashCode() * 7919L; - // Create a unique seed for each structure type - long typeSeed = worldSeed + structureType.name().hashCode() * 7919; // Use a prime number for better distribution - - // Check all nearby grid cells that could have a structure landing on this chunk - for (int gridX = (x - maxOffset) / gridDistance - 1; gridX <= (x + maxOffset) / gridDistance + 1; gridX++) { - for (int gridZ = (z - maxOffset) / gridDistance - 1; gridZ <= (z + maxOffset) / gridDistance + 1; gridZ++) { - // Base position of this grid cell + for (int gridX = (x - maxOffset) / gridDistance - 1; + gridX <= (x + maxOffset) / gridDistance + 1; gridX++) { + for (int gridZ = (z - maxOffset) / gridDistance - 1; + gridZ <= (z + maxOffset) / gridDistance + 1; gridZ++) { int baseX = gridX * gridDistance; int baseZ = gridZ * gridDistance; - // Apply diamond pattern offset (shift every other row by gridDistance/2) if (gridZ % 2 != 0) { baseX += gridDistance / 2; } - // Create a seeded random for this specific grid cell - Random cellRandom = new Random(typeSeed ^ (((long)baseX << 32) | (baseZ & 0xFFFFFFFFL))); - - // Generate the random offset for structure in this grid cell + Random cellRandom = new Random(typeSeed ^ (((long) baseX << 32) | (baseZ & 0xFFFFFFFFL))); int offsetX = maxOffset > 0 ? cellRandom.nextInt(maxOffset * 2 + 1) - maxOffset : 0; int offsetZ = maxOffset > 0 ? cellRandom.nextInt(maxOffset * 2 + 1) - maxOffset : 0; - // Final structure position for this grid cell - int structureX = baseX + offsetX; - int structureZ = baseZ + offsetZ; - - // If this chunk matches the structure position - if (x == structureX && z == structureZ) { + if (x == baseX + offsetX && z == baseZ + offsetZ) { return true; } } @@ -115,54 +142,22 @@ private boolean isValidStructurePosition(Chunk chunk, GeneratorConfigFields.Stru return false; } - private void surfaceScanner(Chunk chunk) { - if (SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.SURFACE).isEmpty()) return; - // Get config values directly instead of using static finals - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.SURFACE, - DefaultConfig.getDistanceSurface(), DefaultConfig.getMaxOffsetSurface())) return; - new FitSurfaceBuilding(chunk); - } - - private void shallowUndergroundScanner(Chunk chunk) { - if (SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.UNDERGROUND_SHALLOW).isEmpty()) return; - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.UNDERGROUND_SHALLOW, - DefaultConfig.getDistanceShallow(), DefaultConfig.getMaxOffsetShallow())) return; - FitUndergroundShallowBuilding.fit(chunk); - } - - private void deepUndergroundScanner(Chunk chunk) { - if (SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.UNDERGROUND_DEEP).isEmpty()) return; - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.UNDERGROUND_DEEP, - DefaultConfig.getDistanceDeep(), DefaultConfig.getMaxOffsetDeep())) return; - FitUndergroundDeepBuilding.fit(chunk); - } - - private void skyScanner(Chunk chunk) { - if (SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.SKY).isEmpty()) return; - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.SKY, - DefaultConfig.getDistanceSky(), DefaultConfig.getMaxOffsetSky())) return; - new FitAirBuilding(chunk); - } - - private void liquidSurfaceScanner(Chunk chunk) { - if (SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.LIQUID_SURFACE).isEmpty()) return; - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.LIQUID_SURFACE, - DefaultConfig.getDistanceLiquid(), DefaultConfig.getMaxOffsetLiquid())) return; - new FitLiquidBuilding(chunk); - } - - private void dungeonScanner(Chunk chunk) { - if (ModuleGeneratorsConfig.getModuleGenerators().isEmpty()) return; - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.DUNGEON, - DefaultConfig.getDistanceDungeon(), DefaultConfig.getMaxOffsetDungeon())) return; + private void generateDungeon(Chunk chunk) { List validatedGenerators = new ArrayList<>(); - for (ModuleGeneratorsConfigFields moduleGeneratorsConfigFields : ModuleGeneratorsConfig.getModuleGenerators().values()){ - if (moduleGeneratorsConfigFields.getValidWorlds() != null && !moduleGeneratorsConfigFields.getValidWorlds().isEmpty() && !moduleGeneratorsConfigFields.getValidWorlds().contains(chunk.getWorld().getName())) continue; - if (moduleGeneratorsConfigFields.getValidWorldEnvironments() != null && !moduleGeneratorsConfigFields.getValidWorldEnvironments().isEmpty() && !moduleGeneratorsConfigFields.getValidWorldEnvironments().contains(chunk.getWorld().getEnvironment())) continue; - validatedGenerators.add(moduleGeneratorsConfigFields); + for (ModuleGeneratorsConfigFields fields : ModuleGeneratorsConfig.getModuleGenerators().values()) { + if (fields.getValidWorlds() != null && !fields.getValidWorlds().isEmpty() + && !fields.getValidWorlds().contains(chunk.getWorld().getName())) continue; + if (fields.getValidWorldEnvironments() != null && !fields.getValidWorldEnvironments().isEmpty() + && !fields.getValidWorldEnvironments().contains(chunk.getWorld().getEnvironment())) continue; + validatedGenerators.add(fields); } + if (validatedGenerators.isEmpty()) return; - ModuleGeneratorsConfigFields moduleGeneratorsConfigFields = validatedGenerators.get(ThreadLocalRandom.current().nextInt(0, validatedGenerators.size())); - new WFCGenerator(moduleGeneratorsConfigFields, chunk.getBlock(8,moduleGeneratorsConfigFields.getCenterModuleAltitude(),8).getLocation()); + ModuleGeneratorsConfigFields fields = validatedGenerators.get( + ThreadLocalRandom.current().nextInt(validatedGenerators.size())); + new WFCGenerator(fields, chunk.getBlock(8, fields.getCenterModuleAltitude(), 8).getLocation()); + } + + private record ChunkKey(UUID worldId, int x, int z) { } -} \ No newline at end of file +} From 7c80f84febfd6cf7c68b687739e40185c8ad9f1b Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:31:07 -0400 Subject: [PATCH 09/47] Add Albion MSPT generation and paste budgets --- .../config/DefaultConfig.java | 63 ++++++++++++++++--- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/config/DefaultConfig.java b/src/main/java/com/magmaguy/betterstructures/config/DefaultConfig.java index 91fc4bc..9777cf3 100644 --- a/src/main/java/com/magmaguy/betterstructures/config/DefaultConfig.java +++ b/src/main/java/com/magmaguy/betterstructures/config/DefaultConfig.java @@ -2,7 +2,6 @@ import com.magmaguy.magmacore.config.ConfigurationEngine; import com.magmaguy.magmacore.config.ConfigurationFile; -import com.magmaguy.magmacore.nightbreak.NightbreakPluginUpdater; import lombok.Getter; import java.util.List; @@ -40,7 +39,21 @@ public class DefaultConfig extends ConfigurationFile { @Getter private static int modularChunkPastingSpeed = 10; @Getter - private static double percentageOfTickUsedForPasting = 0.2; + private static double percentageOfTickUsedForPasting = 0.08; + @Getter + private static double percentageOfTickUsedForPastePreparation = 0.04; + @Getter + private static boolean playerGenerationThrottling = true; + @Getter + private static double playerGenerationPauseMSPT = 42.0; + @Getter + private static double playerGenerationResumeMSPT = 32.0; + @Getter + private static double playerGenerationPauseTPS = 18.5; + @Getter + private static double playerGenerationResumeTPS = 19.5; + @Getter + private static int playerGenerationTicksBetweenJobs = 2; @Getter private static double percentageOfTickUsedForPregeneration = 0.1; @Getter @@ -48,7 +61,6 @@ public class DefaultConfig extends ConfigurationFile { @Getter private static double pregenerationTPSResumeThreshold = 14.0; - // Adding getters for the new distance and offset variables @Getter private static int distanceSurface; @Getter @@ -95,7 +107,6 @@ public static void toggleSetupDone(boolean value) { ConfigurationEngine.writeValue(setupDone, instance.file, instance.getFileConfiguration(), "setupDone"); } - public static boolean toggleWarnings() { newBuildingWarn = !newBuildingWarn; ConfigurationEngine.writeValue(newBuildingWarn, instance.file, instance.fileConfiguration, "warnAdminsAboutNewBuildings"); @@ -119,13 +130,47 @@ public void initializeValues() { protectEliteMobsRegions = ConfigurationEngine.setBoolean(fileConfiguration, "protectEliteMobsRegions", true); setupDone = ConfigurationEngine.setBoolean(fileConfiguration, "setupDone", false); modularChunkPastingSpeed = ConfigurationEngine.setInt(fileConfiguration, "modularChunkPastingSpeed", 10); - percentageOfTickUsedForPasting = ConfigurationEngine.setDouble(List.of("Sets the maximum percentage of a tick that BetterStructures will use to paste builds, however many it maybe trying to generate.", "Ranges from 0.01 to 1, where 0.01 is 1% and 1 is 100%.", "Slower speeds will lower performance impact, but can lead to other problems such as builds suddenly popping in."),fileConfiguration, "percentageOfTickUsedForPasting", 0.2); + percentageOfTickUsedForPasting = ConfigurationEngine.setDouble( + List.of( + "Maximum percentage of a 50ms tick used by the distributed block-paste stage.", + "Albion performance default is 0.08 (about 4ms of a healthy tick).", + "Existing configs with a higher value are preserved; lower this if resource-world exploration still causes spikes."), + fileConfiguration, "percentageOfTickUsedForPasting", 0.08); + percentageOfTickUsedForPastePreparation = ConfigurationEngine.setDouble( + List.of( + "Maximum percentage of a 50ms tick used to prepare a schematic before block placement.", + "This prevents the old all-at-once schematic walk from monopolizing a single tick.", + "0.04 is about 2ms of work per tick."), + fileConfiguration, "percentageOfTickUsedForPastePreparation", 0.04); + playerGenerationThrottling = ConfigurationEngine.setBoolean( + fileConfiguration, "playerGenerationThrottling", true); + playerGenerationPauseMSPT = ConfigurationEngine.setDouble( + List.of( + "Pause player-driven BetterStructures generation when average MSPT reaches this value.", + "The chunk is kept queued and will be processed after the server recovers."), + fileConfiguration, "playerGenerationPauseMSPT", 42.0); + playerGenerationResumeMSPT = ConfigurationEngine.setDouble( + List.of( + "Resume queued player-driven generation when average MSPT falls to or below this value.", + "Keep this lower than playerGenerationPauseMSPT to avoid rapid pause/resume oscillation."), + fileConfiguration, "playerGenerationResumeMSPT", 32.0); + playerGenerationPauseTPS = ConfigurationEngine.setDouble( + List.of("Secondary TPS guard for player-driven structure generation."), + fileConfiguration, "playerGenerationPauseTPS", 18.5); + playerGenerationResumeTPS = ConfigurationEngine.setDouble( + List.of("TPS required before paused player-driven structure generation resumes."), + fileConfiguration, "playerGenerationResumeTPS", 19.5); + playerGenerationTicksBetweenJobs = ConfigurationEngine.setInt( + List.of( + "Minimum ticks between expensive structure-fit jobs from ordinary player exploration.", + "A value of 2 prevents several qualifying chunks from running their fitting passes in one tick."), + fileConfiguration, "playerGenerationTicksBetweenJobs", 2); percentageOfTickUsedForPregeneration = ConfigurationEngine.setDouble(List.of("Sets the maximum percentage of a tick that BetterStructures will use for world pregeneration when using the pregenerate command.", "Ranges from 0.01 to 1, where 0.01 is 1% and 1 is 100%.", "This controls how much of each server tick is dedicated to generating chunks, allowing you to balance generation speed with server performance.", "Lower values will generate chunks more slowly but reduce server lag, while higher values will generate faster but may impact server performance."), fileConfiguration, "percentageOfTickUsedForPregeneration", 0.1); pregenerationTPSPauseThreshold = ConfigurationEngine.setDouble(List.of("The TPS threshold at which chunk pregeneration will pause to protect server performance.", "When server TPS drops below this value, pregeneration will pause until TPS recovers.", "Default: 12.0"), fileConfiguration, "pregenerationTPSPauseThreshold", 12.0); pregenerationTPSResumeThreshold = ConfigurationEngine.setDouble(List.of("The TPS threshold at which chunk pregeneration will resume after being paused.", "Pregeneration will only resume when server TPS is at or above this value.", "Should be higher than the pause threshold to prevent rapid pause/resume cycles.", "Default: 14.0"), fileConfiguration, "pregenerationTPSResumeThreshold", 14.0); - autoDownloadPluginUpdates = NightbreakPluginUpdater.setAutoDownloadConfigDefault(fileConfiguration); + // Albion fork safety: never let the upstream Nightbreak updater replace this fork JAR. + autoDownloadPluginUpdates = false; - // Initialize the distances from configuration distanceSurface = ConfigurationEngine.setInt( List.of( "Sets the distance between structures in the surface of a world.", @@ -153,11 +198,9 @@ public void initializeValues() { distanceDungeon = ConfigurationEngine.setInt( List.of( "Sets the distance between dungeons.", - "Shorter distances between dungeons will result in more dungeons overall." - ), + "Shorter distances between dungeons will result in more dungeons overall."), fileConfiguration, "distanceDungeonV2", 80); - // Initialize the maximum offsets from configuration maxOffsetSurface = ConfigurationEngine.setInt( List.of( "Used to tweak the randomization of the distance between structures in the surface of a world.", From fc3a92070d2b85266e039bad48d5207fc9594eab Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:31:45 -0400 Subject: [PATCH 10/47] Release queued chunk tickets safely on reload and shutdown --- .../performance/GenerationScheduler.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java b/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java index b9dc5b8..d458d92 100644 --- a/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java +++ b/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java @@ -8,8 +8,10 @@ import java.util.ArrayDeque; import java.util.Deque; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.UUID; /** @@ -23,6 +25,7 @@ public final class GenerationScheduler { private static final Deque JOBS = new ArrayDeque<>(); + private static final Set TICKETED_CHUNKS = new HashSet<>(); private static boolean started = false; private static boolean pausedForLoad = false; private static int cooldownTicks = 0; @@ -43,6 +46,10 @@ public void run() { } public static void shutdown() { + for (Chunk chunk : TICKETED_CHUNKS) { + chunk.removePluginChunkTicket(MetadataHandler.PLUGIN); + } + TICKETED_CHUNKS.clear(); JOBS.clear(); pausedForLoad = false; cooldownTicks = 0; @@ -61,6 +68,7 @@ public static void enqueue(Chunk chunk, List jobs) { // is much cheaper than forcing a synchronous reload later when the player has // already flown away from it. chunk.addPluginChunkTicket(MetadataHandler.PLUGIN); + TICKETED_CHUNKS.add(chunk); ChunkKey key = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); int totalJobs = jobs.size(); @@ -122,6 +130,7 @@ private static void tick() { @Override public void run() { job.chunk().removePluginChunkTicket(MetadataHandler.PLUGIN); + TICKETED_CHUNKS.remove(job.chunk()); } }.runTaskLater(MetadataHandler.PLUGIN, 200L); } From 9c1a9f7a8b520658f1b220cad9f8ab409404939a Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:32:23 -0400 Subject: [PATCH 11/47] Make Albion performance scheduler reload-safe and disable upstream self-update --- .../betterstructures/BetterStructures.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/BetterStructures.java b/src/main/java/com/magmaguy/betterstructures/BetterStructures.java index 402b252..4a629e1 100644 --- a/src/main/java/com/magmaguy/betterstructures/BetterStructures.java +++ b/src/main/java/com/magmaguy/betterstructures/BetterStructures.java @@ -16,9 +16,11 @@ import com.magmaguy.betterstructures.listeners.NewChunkLoadEvent; import com.magmaguy.betterstructures.modules.ModulesContainer; import com.magmaguy.betterstructures.modules.WFCGenerator; +import com.magmaguy.betterstructures.performance.GenerationScheduler; import com.magmaguy.betterstructures.schematics.SchematicContainer; import com.magmaguy.betterstructures.thirdparty.EliteMobs; import com.magmaguy.betterstructures.thirdparty.WorldGuard; +import com.magmaguy.betterstructures.worldedit.Schematic; import com.magmaguy.easyminecraftgoals.NMSManager; import com.magmaguy.magmacore.MagmaCore; import com.magmaguy.magmacore.command.CommandManager; @@ -28,9 +30,7 @@ import com.magmaguy.magmacore.initialization.PluginInitializationState; import com.magmaguy.magmacore.nightbreak.NightbreakDownloadContentCommand; import com.magmaguy.magmacore.nightbreak.NightbreakDownloadEverythingCommand; -import com.magmaguy.magmacore.nightbreak.NightbreakDownloadPluginUpdateCommand; import com.magmaguy.magmacore.nightbreak.NightbreakPluginSpec; -import com.magmaguy.magmacore.nightbreak.NightbreakPluginUpdater; import com.magmaguy.magmacore.nightbreak.NightbreakPluginStateRegistry; import com.magmaguy.magmacore.nightbreak.NightbreakRecommendedPluginsCommand; import com.magmaguy.magmacore.util.Logger; @@ -61,7 +61,6 @@ public void onEnable() { Bukkit.getLogger().info(" / __ / _ \\/ __/ __/ _ \\/ ___/\\__ \\/ __/ ___/ / / / ___/ __/ / / / ___/ _ \\/ ___/"); Bukkit.getLogger().info(" / /_/ / __/ /_/ /_/ __/ / ___/ / /_/ / / /_/ / /__/ /_/ /_/ / / / __(__ ) "); Bukkit.getLogger().info("/_____/\\___/\\__/\\__/\\___/_/ /____/\\__/_/ \\__,_/\\___/\\__/\\__,_/_/ \\___/____/"); - // Plugin startup logic Bukkit.getLogger().info("[BetterStructures] Initialized version " + this.getDescription().getVersion() + "!"); try { this.getConfig().save("config.yml"); @@ -76,7 +75,6 @@ public void onEnable() { this::syncInitialization, () -> { Logger.info("BetterStructures fully initialized!"); - NightbreakPluginUpdater.autoDownloadPluginUpdateIfEnabled(this, NIGHTBREAK_PLUGIN_SPEC); CommandSender pendingReloadSender = NightbreakPluginStateRegistry.consumePendingReloadSender(this); if (pendingReloadSender == null) { pendingReloadSender = MetadataHandler.pendingReloadSender; @@ -110,12 +108,15 @@ public void onLoad() { public void onDisable() { MagmaCore.requestInitializationShutdown(this); if (MagmaCore.getInitializationState(this.getName()) == PluginInitializationState.INITIALIZING) { + GenerationScheduler.shutdown(); + Schematic.shutdown(); Bukkit.getServer().getScheduler().cancelTasks(MetadataHandler.PLUGIN); MagmaCore.shutdown(this); Bukkit.getLogger().info("[BetterStructures] Shutdown during initialization."); return; } - // Plugin shutdown logic + GenerationScheduler.shutdown(); + Schematic.shutdown(); SchematicContainer.shutdown(); Bukkit.getServer().getScheduler().cancelTasks(MetadataHandler.PLUGIN); MagmaCore.shutdown(this); @@ -177,7 +178,6 @@ private void syncInitialization(PluginInitializationContext initializationContex commandManager.registerCommand(new SetupCommand()); commandManager.registerCommand(new FirstTimeSetupCommand()); commandManager.registerCommand(new NightbreakRecommendedPluginsCommand(this, NIGHTBREAK_PLUGIN_SPEC)); - commandManager.registerCommand(new NightbreakDownloadPluginUpdateCommand(this, NIGHTBREAK_PLUGIN_SPEC)); commandManager.registerCommand(new NightbreakDownloadEverythingCommand<>(this, NIGHTBREAK_PLUGIN_SPEC, () -> new ArrayList<>(BSPackage.getBsPackages().values()), @@ -195,9 +195,6 @@ private void syncInitialization(PluginInitializationContext initializationContex commandManager.registerCommand(new GenerateModulesCommand()); commandManager.registerCommand(new BetterStructuresCommand()); - initializationContext.step("Version Check"); - MagmaCore.checkVersionUpdate("103241", "https://nightbreak.io/plugin/betterstructures/"); - initializationContext.step("WorldGuard Integration"); if (Bukkit.getPluginManager().getPlugin("WorldGuard") != null && Bukkit.getPluginManager().getPlugin("EliteMobs") != null) { @@ -209,6 +206,8 @@ private void syncInitialization(PluginInitializationContext initializationContex } public void reloadImportedContent(CommandSender commandSender) { + GenerationScheduler.shutdown(); + Schematic.shutdown(); SchematicContainer.shutdown(); Bukkit.getServer().getScheduler().cancelTasks(MetadataHandler.PLUGIN); BSPackage.shutdown(); @@ -230,6 +229,7 @@ public void reloadImportedContent(CommandSender commandSender) { ComponentsConfigFolder.initialize(); Bukkit.getScheduler().runTask(this, () -> { + GenerationScheduler.start(); if (commandSender != null) { Logger.sendMessage(commandSender, "Reloaded BetterStructures content."); } @@ -238,6 +238,7 @@ public void reloadImportedContent(CommandSender commandSender) { Logger.warn("Failed to reload BetterStructures content asynchronously."); exception.printStackTrace(); Bukkit.getScheduler().runTask(this, () -> { + GenerationScheduler.start(); if (commandSender != null) { Logger.sendMessage(commandSender, "&cFailed to reload BetterStructures content. Check the console."); } From 9e5cff751ee3815e589de1c8f492fdebe91a2e2e Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:33:01 -0400 Subject: [PATCH 12/47] Spread schematic preparation across ticks before paste --- .../betterstructures/worldedit/Schematic.java | 402 ++++++++++-------- 1 file changed, 235 insertions(+), 167 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java b/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java index eaa3d9e..6a89825 100644 --- a/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java +++ b/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java @@ -25,33 +25,39 @@ import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; +import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.util.Vector; import java.io.File; import java.io.FileInputStream; import java.io.IOException; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.NoSuchElementException; +import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.function.Function; public class Schematic { - // Queue to hold pending paste operations + private static final Queue preparationQueue = new ConcurrentLinkedQueue<>(); private static final Queue pasteQueue = new ConcurrentLinkedQueue<>(); private static boolean erroredOnce = false; + private static boolean isPreparingPaste = false; private static boolean isDistributedPasting = false; private Schematic() { } - /** - * Loads a schematic from a file - * - * @param schematicFile The schematic file to load - * @return The loaded clipboard or null if loading failed - */ + public static void shutdown() { + preparationQueue.clear(); + pasteQueue.clear(); + isPreparingPaste = false; + isDistributedPasting = false; + } + public static Clipboard load(File schematicFile) { Clipboard clipboard; - ClipboardFormat format = ClipboardFormats.findByFile(schematicFile); try (ClipboardReader reader = format.getReader(new FileInputStream(schematicFile))) { @@ -64,28 +70,28 @@ public static Clipboard load(File schematicFile) { e.printStackTrace(); return null; } catch (Exception e) { - Logger.warn("Failed to load schematic " + schematicFile.getName() + " ! 99% of the time, this is because you are not using the correct WorldEdit version for your Minecraft server. You should be downloading WorldEdit from here https://dev.bukkit.org/projects/worldedit . You can check which versions the download links are compatible with by hovering over them."); - erroredOnce = true; - if (!erroredOnce) e.printStackTrace(); - else Logger.warn("Hiding stacktrace for this error, as it has already been printed once"); + Logger.warn("Failed to load schematic " + schematicFile.getName() + + ". This Albion fork requires a compatible FastAsyncWorldEdit build for your server version."); + if (!erroredOnce) { + erroredOnce = true; + e.printStackTrace(); + } else { + Logger.warn("Hiding stacktrace for this error, as it has already been printed once"); + } return null; } return clipboard; } /** - * Pastes a schematic synchronously - * - * @param clipboard The WorldEdit clipboard containing the schematic - * @param location The location to paste at + * Direct WorldEdit/FAWE paste used by explicit command paths. */ public static void paste(Clipboard clipboard, Location location) { World world = BukkitAdapter.adapt(location.getWorld()); try (EditSession editSession = WorldEdit.getInstance().newEditSession(world)) { Operation operation = new ClipboardHolder(clipboard) .createPaste(editSession) - .to(BlockVector3.at(location.getX(), location.getY(), location.getZ())) - // configure here + .to(BlockVector3.at(location.getBlockX(), location.getBlockY(), location.getBlockZ())) .build(); Operations.complete(operation); } catch (WorldEditException e) { @@ -98,160 +104,218 @@ private static boolean isSolidBlock(Clipboard schematicClipboard, BlockVector3 c } /** - * Creates a list of paste blocks from a schematic - * - * @param schematicClipboard The clipboard containing the schematic - * @param location The location to paste at - * @param schematicOffset The offset of the schematic - * @param pedestalMaterialProvider Function that provides pedestal material based on whether it's a surface block - * @return List of paste blocks + * Queues schematic preparation. Upstream 2.6.3 built the complete PasteBlock list in + * one synchronous pass before the distributed paste limiter started. Large structures + * could therefore consume a large part of one server tick while a player generated a + * new resource-world chunk. The Albion fork gives preparation its own tick budget. */ - private static List createPasteBlocks( + public static void pasteSchematic( Clipboard schematicClipboard, Location location, Vector schematicOffset, - Function pedestalMaterialProvider) { - - List pasteBlocks = new ArrayList<>(); - - // Iterate through the schematic and create PasteBlock objects - Location adjustedLocation = location.clone().add(schematicOffset); - for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) - for (int y = 0; y < schematicClipboard.getDimensions().y(); y++) - for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) { - BlockVector3 adjustedClipboardLocation = BlockVector3.at( - x + schematicClipboard.getMinimumPoint().x(), - y + schematicClipboard.getMinimumPoint().y(), - z + schematicClipboard.getMinimumPoint().z()); - BaseBlock baseBlock = schematicClipboard.getFullBlock(adjustedClipboardLocation); - BlockState blockState = baseBlock.toImmutableState(); - Material material = WorldEditUtils.adaptMaterial(blockState); - Block worldBlock = adjustedLocation.clone().add(new Vector(x, y, z)).getBlock(); - boolean isGround = !isSolidBlock(schematicClipboard, BlockVector3.at( - adjustedClipboardLocation.x(), - adjustedClipboardLocation.y() + 1, - adjustedClipboardLocation.z())); - - if (material == Material.BARRIER) { - // special behavior: do not replace barriers, so do nothing - continue; - } + Function pedestalMaterialProvider, + Runnable onComplete) { - BlockData blockData = material == null ? null : WorldEditUtils.createBlockDataOrNull(baseBlock); - if (blockData == null) { - if (WorldEditUtils.isAir(blockState)) { - // Air still carves interiors out of existing terrain - place plain air - pasteBlocks.add(new PasteBlock(worldBlock, Material.AIR.createBlockData(), null)); - } else { - pasteBlocks.add(new PasteBlock(worldBlock, null, - WorldEditUtils.createSingleBlockClipboard(adjustedLocation, baseBlock, blockState))); - } - continue; - } + preparationQueue.add(new PastePreparationOperation( + schematicClipboard, + location.clone(), + schematicOffset.clone(), + pedestalMaterialProvider, + onComplete)); + + if (!isPreparingPaste) { + processNextPreparation(); + } + } + + private static void processNextPreparation() { + PastePreparationOperation operation = preparationQueue.poll(); + if (operation == null) { + isPreparingPaste = false; + return; + } + + isPreparingPaste = true; + new PastePreparationTask(operation).runTaskTimer(MetadataHandler.PLUGIN, 0L, 1L); + } + + private static final class PastePreparationTask extends BukkitRunnable { + private final PastePreparationOperation operation; + private final ArrayList blocks = new ArrayList<>(); + private final Location adjustedLocation; + private final org.bukkit.World bukkitWorld; + private final int sizeX; + private final int sizeY; + private final int sizeZ; + private final int minimumX; + private final int minimumY; + private final int minimumZ; + private int x; + private int y; + private int z; + + private PastePreparationTask(PastePreparationOperation operation) { + this.operation = operation; + this.adjustedLocation = operation.location().clone().add(operation.schematicOffset()); + this.bukkitWorld = adjustedLocation.getWorld(); + this.sizeX = operation.schematicClipboard().getDimensions().x(); + this.sizeY = operation.schematicClipboard().getDimensions().y(); + this.sizeZ = operation.schematicClipboard().getDimensions().z(); + this.minimumX = operation.schematicClipboard().getMinimumPoint().x(); + this.minimumY = operation.schematicClipboard().getMinimumPoint().y(); + this.minimumZ = operation.schematicClipboard().getMinimumPoint().z(); + + long estimatedVolume = (long) sizeX * sizeY * sizeZ; + if (estimatedVolume > 0 && estimatedVolume <= Integer.MAX_VALUE) { + blocks.ensureCapacity((int) Math.min(estimatedVolume, 500_000L)); + } + } - String materialString = material.toString().toUpperCase(Locale.ROOT); - if (materialString.endsWith("SIGN") || - materialString.endsWith("STAIRS") || - materialString.endsWith("BOX") || - materialString.endsWith("CHEST_BOAT") || - materialString.equals("BEACON") || - materialString.endsWith("FURNACE") || - materialString.equals("CALIBRATED_SCULK_SENSOR") || - materialString.equals("CAMPFIRE") || - materialString.equals("CARTOGRAPHY_TABLE") || - materialString.equals("CAULDRON") || - materialString.contains("COMMAND_BLOCK") || - materialString.endsWith("ANVIL") || - materialString.equals("CRAFTER") || - materialString.equals("ITEM_FRAME") || - materialString.equals("DISPENSER") || - materialString.equals("DROPPER") || - materialString.equals("ENCHANTING_TABLE") || - materialString.equals("BARREL") || - materialString.equals("CHEST") || - materialString.equals("ENDER_CHEST") || - materialString.equals("TRAPPED_CHEST") || - materialString.equals("FLETCHING_TABLE") || - materialString.equals("FURNACE_MINECART") || - materialString.equals("GRINDSTONE") || - materialString.equals("HOPPER") || - materialString.equals("HOPPER_MINECART") || - materialString.equals("JUKEBOX") || - materialString.equals("LEVER") || - materialString.equals("LOOM") || - materialString.equals("LODESTONE") || - materialString.startsWith("POTTED") || - materialString.startsWith("SCULK") || - materialString.equals("POWERED_RAIL") || - materialString.equals("SMOKER") || - materialString.equals("STONECUTTER") || - materialString.equals("SOUL_CAMPFIRE") || - materialString.contains("SPAWNER")) { - // tricky metadata has to be done via worldedit - pasteBlocks.add(new PasteBlock(worldBlock, null, - WorldEditUtils.createSingleBlockClipboard(adjustedLocation, baseBlock, blockState))); - } else if (material == Material.BEDROCK) { - // special behavior: if it's not solid, replace with solid filler block - if (!worldBlock.getType().isSolid()) { - Material pedestalMaterial = pedestalMaterialProvider.apply(isGround); - worldBlock.setType(pedestalMaterial); - pasteBlocks.add(new PasteBlock(worldBlock, pedestalMaterial.createBlockData(), null)); + @Override + public void run() { + if (shouldYieldForServerLoad()) return; + + double configuredPercentage = Math.max(0.005, + Math.min(0.25, DefaultConfig.getPercentageOfTickUsedForPastePreparation())); + long budgetNanos = Math.max(250_000L, (long) (50_000_000L * configuredPercentage)); + long deadline = System.nanoTime() + budgetNanos; + + do { + prepareCurrentBlock(); + if (!advance()) { + cancel(); + pasteDistributed(blocks, operation.location(), () -> { + try { + if (operation.onComplete() != null) { + operation.onComplete().run(); + } + } finally { + isPreparingPaste = false; + processNextPreparation(); } - } else { - pasteBlocks.add(new PasteBlock(worldBlock, blockData, null)); - } + }); + return; } + } while (System.nanoTime() < deadline); + } - return pasteBlocks; - } + private boolean shouldYieldForServerLoad() { + if (!DefaultConfig.isPlayerGenerationThrottling()) return false; + if (Bukkit.getAverageTickTime() >= DefaultConfig.getPlayerGenerationPauseMSPT()) return true; + double[] tps = Bukkit.getTPS(); + return tps.length > 0 && tps[0] <= DefaultConfig.getPlayerGenerationPauseTPS(); + } - /** - * Pastes a schematic using the provided pedestal material provider - * - * @param schematicClipboard The clipboard containing the schematic - * @param location The location to paste at - * @param schematicOffset The offset of the schematic - * @param pedestalMaterialProvider Function that provides pedestal material based on whether it's a surface block - * @param onComplete Callback to run when paste is complete - */ - public static void pasteSchematic( - Clipboard schematicClipboard, - Location location, - Vector schematicOffset, - Function pedestalMaterialProvider, - Runnable onComplete) { + private void prepareCurrentBlock() { + Clipboard schematicClipboard = operation.schematicClipboard(); + BlockVector3 adjustedClipboardLocation = BlockVector3.at( + x + minimumX, + y + minimumY, + z + minimumZ); - List pasteBlocks = createPasteBlocks( - schematicClipboard, - location, - schematicOffset, - pedestalMaterialProvider); + BaseBlock baseBlock = schematicClipboard.getFullBlock(adjustedClipboardLocation); + BlockState blockState = baseBlock.toImmutableState(); + Material material = WorldEditUtils.adaptMaterial(blockState); + Block worldBlock = bukkitWorld.getBlockAt( + adjustedLocation.getBlockX() + x, + adjustedLocation.getBlockY() + y, + adjustedLocation.getBlockZ() + z); + + boolean isGround = y + 1 >= sizeY || !isSolidBlock(schematicClipboard, BlockVector3.at( + adjustedClipboardLocation.x(), + adjustedClipboardLocation.y() + 1, + adjustedClipboardLocation.z())); + + if (material == Material.BARRIER) { + return; + } + + BlockData blockData = material == null ? null : WorldEditUtils.createBlockDataOrNull(baseBlock); + if (blockData == null) { + if (WorldEditUtils.isAir(blockState)) { + blocks.add(new PasteBlock(worldBlock, Material.AIR.createBlockData(), null)); + } else { + blocks.add(new PasteBlock(worldBlock, null, + WorldEditUtils.createSingleBlockClipboard(adjustedLocation, baseBlock, blockState))); + } + return; + } + + String materialString = material.toString().toUpperCase(Locale.ROOT); + if (requiresWorldEditMetadata(materialString)) { + blocks.add(new PasteBlock(worldBlock, null, + WorldEditUtils.createSingleBlockClipboard(adjustedLocation, baseBlock, blockState))); + } else if (material == Material.BEDROCK) { + if (!worldBlock.getType().isSolid()) { + Material pedestalMaterial = operation.pedestalMaterialProvider().apply(isGround); + // Do not mutate the world during preparation. Upstream called setType here, + // bypassing its own distributed paste budget. + blocks.add(new PasteBlock(worldBlock, pedestalMaterial.createBlockData(), null)); + } + } else { + blocks.add(new PasteBlock(worldBlock, blockData, null)); + } + } - pasteDistributed(pasteBlocks, location, onComplete); + private boolean advance() { + z++; + if (z < sizeZ) return true; + z = 0; + y++; + if (y < sizeY) return true; + y = 0; + x++; + return x < sizeX; + } + } + + private static boolean requiresWorldEditMetadata(String materialString) { + return materialString.endsWith("SIGN") + || materialString.endsWith("STAIRS") + || materialString.endsWith("BOX") + || materialString.endsWith("CHEST_BOAT") + || materialString.equals("BEACON") + || materialString.endsWith("FURNACE") + || materialString.equals("CALIBRATED_SCULK_SENSOR") + || materialString.equals("CAMPFIRE") + || materialString.equals("CARTOGRAPHY_TABLE") + || materialString.equals("CAULDRON") + || materialString.contains("COMMAND_BLOCK") + || materialString.endsWith("ANVIL") + || materialString.equals("CRAFTER") + || materialString.equals("ITEM_FRAME") + || materialString.equals("DISPENSER") + || materialString.equals("DROPPER") + || materialString.equals("ENCHANTING_TABLE") + || materialString.equals("BARREL") + || materialString.equals("CHEST") + || materialString.equals("ENDER_CHEST") + || materialString.equals("TRAPPED_CHEST") + || materialString.equals("FLETCHING_TABLE") + || materialString.equals("FURNACE_MINECART") + || materialString.equals("GRINDSTONE") + || materialString.equals("HOPPER") + || materialString.equals("HOPPER_MINECART") + || materialString.equals("JUKEBOX") + || materialString.equals("LEVER") + || materialString.equals("LOOM") + || materialString.equals("LODESTONE") + || materialString.startsWith("POTTED") + || materialString.startsWith("SCULK") + || materialString.equals("POWERED_RAIL") + || materialString.equals("SMOKER") + || materialString.equals("STONECUTTER") + || materialString.equals("SOUL_CAMPFIRE") + || materialString.contains("SPAWNER"); } - /** - * Pastes a schematic using a distributed workload over multiple ticks. - * If another paste operation is already in progress, this operation - * will be queued and executed when the current operation completes. - * - * @param pasteBlocks List of blocks to paste - * @param location The location to paste at - * @param onComplete Optional callback to run when paste is complete - */ public static void pasteDistributed(List pasteBlocks, Location location, Runnable onComplete) { - // Add this paste operation to the queue pasteQueue.add(new PasteBlockOperation(pasteBlocks, location, onComplete)); - - // If we're not currently pasting, start processing the queue if (!isDistributedPasting) { processNextPaste(); } } - /** - * Processes the next paste operation in the queue - */ private static void processNextPaste() { if (pasteQueue.isEmpty()) { isDistributedPasting = false; @@ -261,26 +325,26 @@ private static void processNextPaste() { isDistributedPasting = true; PasteBlockOperation operation = pasteQueue.poll(); - // Create a workload for this paste operation WorkloadRunnable workload = new WorkloadRunnable(DefaultConfig.getPercentageOfTickUsedForPasting(), () -> { - // Run the completion callback if provided - if (operation.onComplete != null) { - operation.onComplete.run(); + if (operation.onComplete() != null) { + operation.onComplete().run(); } - // Process the next paste in the queue processNextPaste(); }); - for (PasteBlock pasteBlock : operation.blocks) { + for (PasteBlock pasteBlock : operation.blocks()) { workload.addWorkload(() -> { if (pasteBlock.blockData() != null) { - pasteBlock.block().setBlockData(pasteBlock.blockData()); + pasteBlock.block().setBlockData(pasteBlock.blockData(), false); } else if (pasteBlock.clipboard() != null) { - try (EditSession editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(pasteBlock.block().getLocation().getWorld()))) { + try (EditSession editSession = WorldEdit.getInstance().newEditSession( + BukkitAdapter.adapt(pasteBlock.block().getWorld()))) { Operation worldeditPaste = new ClipboardHolder(pasteBlock.clipboard()) .createPaste(editSession) - .to(BlockVector3.at(pasteBlock.block().getX(), pasteBlock.block().getY(), pasteBlock.block().getZ())) - // configure here + .to(BlockVector3.at( + pasteBlock.block().getX(), + pasteBlock.block().getY(), + pasteBlock.block().getZ())) .build(); Operations.complete(worldeditPaste); } catch (WorldEditException e) { @@ -290,13 +354,17 @@ private static void processNextPaste() { }); } - // Start the workload - workload.runTaskTimer(MetadataHandler.PLUGIN, 0, 1); + workload.runTaskTimer(MetadataHandler.PLUGIN, 0L, 1L); + } + + private record PastePreparationOperation( + Clipboard schematicClipboard, + Location location, + Vector schematicOffset, + Function pedestalMaterialProvider, + Runnable onComplete) { } - /** - * Represents a single paste operation - */ private record PasteBlockOperation(List blocks, Location location, Runnable onComplete) { } From caab62c0e1160297213bf617bb7a72288f82e73e Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:37:37 -0400 Subject: [PATCH 13/47] Reduce synchronous pedestal scanning and block physics --- .../buildingfitter/FitAnything.java | 113 +++++++----------- 1 file changed, 45 insertions(+), 68 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java b/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java index 54fa5bc..f418aee 100644 --- a/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java +++ b/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java @@ -24,6 +24,7 @@ import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.Container; @@ -51,7 +52,6 @@ public class FitAnything { @Getter protected Vector schematicOffset; protected int verticalOffset = 0; - //At 10% it is assumed a fit is so bad it's better just to skip protected double highestScore = 10; @Getter protected Location location = null; @@ -102,8 +102,6 @@ protected void paste(Location location) { if (buildPlaceEvent.isCancelled()) return; FitAnything fitAnything = this; - - // Set pedestal material before the paste so bedrock blocks get replaced correctly assignPedestalMaterial(location); if (pedestalMaterial == null) switch (location.getWorld().getEnvironment()) { @@ -117,10 +115,7 @@ protected void paste(Location location) { pedestalMaterial = Material.STONE; } - // Create a function to provide pedestal material Function pedestalMaterialProvider = this::getPedestalMaterial; - - // Paste the schematic with the moved logic Schematic.pasteSchematic( schematicClipboard, location, @@ -172,7 +167,7 @@ public void run() { Logger.warn("Failed to correctly spawn entities!"); exception.printStackTrace(); } - try{ + try { spawnProps(fitAnything.schematicClipboard); } catch (Exception exception) { Logger.warn("Failed to correctly spawn props!"); @@ -183,7 +178,6 @@ public void run() { } private void spawnProps(Clipboard clipboard) { - // Don't add schematicOffset here - let pasteArmorStandsOnlyFromTransformed handle the alignment WorldEditUtils.pasteArmorStandsOnlyFromTransformed(clipboard, location.clone().add(schematicOffset)); } @@ -192,27 +186,39 @@ private void assignPedestalMaterial(Location location) { pedestalMaterial = schematicContainer.getSchematicConfigField().getPedestalMaterial(); Location lowestCorner = location.clone().add(schematicOffset); - int maxSurfaceHeightScan = 20; - - //get underground pedestal blocks - for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) - for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) - for (int y = 0; y < schematicClipboard.getDimensions().y(); y++) { - Block groundBlock = lowestCorner.clone().add(new Vector(x, y, z)).getBlock(); + int sizeX = schematicClipboard.getDimensions().x(); + int sizeY = schematicClipboard.getDimensions().y(); + int sizeZ = schematicClipboard.getDimensions().z(); + int baseX = lowestCorner.getBlockX(); + int baseY = lowestCorner.getBlockY(); + int baseZ = lowestCorner.getBlockZ(); + World world = lowestCorner.getWorld(); + + // Cap synchronous world reads on large schematics. Small structures retain + // the original exact step=1 scan; large structures use a representative sample. + int xStep = Math.max(1, Math.ceilDiv(sizeX, 24)); + int yStep = Math.max(1, Math.ceilDiv(sizeY, 12)); + int zStep = Math.max(1, Math.ceilDiv(sizeZ, 24)); + + for (int x = 0; x < sizeX; x += xStep) + for (int z = 0; z < sizeZ; z += zStep) + for (int y = 0; y < sizeY; y += yStep) { + Block groundBlock = world.getBlockAt(baseX + x, baseY + y, baseZ + z); Block aboveBlock = groundBlock.getRelative(BlockFace.UP); - - if (aboveBlock.getType().isSolid() && groundBlock.getType().isSolid() && !SurfaceMaterials.ignorable(groundBlock.getType())) + if (aboveBlock.getType().isSolid() + && groundBlock.getType().isSolid() + && !SurfaceMaterials.ignorable(groundBlock.getType())) { undergroundPedestalMaterials.merge(groundBlock.getType(), 1, Integer::sum); + } } - //get above ground pedestal blocks, if any - for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) - for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) { - boolean scanUp = lowestCorner.clone().add(new Vector(x, schematicClipboard.getDimensions().y(), z)).getBlock().getType().isSolid(); + int maxSurfaceHeightScan = 20; + for (int x = 0; x < sizeX; x += xStep) + for (int z = 0; z < sizeZ; z += zStep) { + boolean scanUp = world.getBlockAt(baseX + x, baseY + sizeY, baseZ + z).getType().isSolid(); for (int y = 0; y < maxSurfaceHeightScan; y++) { - Block groundBlock = lowestCorner.clone().add(new Vector(x, scanUp ? y : -y, z)).getBlock(); + Block groundBlock = world.getBlockAt(baseX + x, baseY + (scanUp ? y : -y), baseZ + z); Block aboveBlock = groundBlock.getRelative(BlockFace.UP); - if (!aboveBlock.getType().isSolid() && groundBlock.getType().isSolid()) { surfacePedestalMaterials.merge(groundBlock.getType(), 1, Integer::sum); break; @@ -232,22 +238,13 @@ private Material getPedestalMaterial(boolean isPedestalSurface) { } public Material getRandomMaterialBasedOnWeight(HashMap weightedMaterials) { - // Calculate the total weight int totalWeight = weightedMaterials.values().stream().mapToInt(Integer::intValue).sum(); - - // Generate a random number in the range of 0 (inclusive) to totalWeight (exclusive) int randomNumber = ThreadLocalRandom.current().nextInt(totalWeight); - - // Iterate through the materials and pick one based on the random number int cumulativeWeight = 0; for (Map.Entry entry : weightedMaterials.entrySet()) { cumulativeWeight += entry.getValue(); - if (randomNumber < cumulativeWeight) { - return entry.getKey(); - } + if (randomNumber < cumulativeWeight) return entry.getKey(); } - - // Fallback return, should not occur if the map is not empty and weights are positive throw new IllegalStateException("Weighted random selection failed."); } @@ -256,17 +253,13 @@ private void addPedestal(Location location) { Location lowestCorner = location.clone().add(schematicOffset); for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) { - //Only add pedestals for areas with a solid floor, some schematics can have rounded air edges to better fit terrain Block groundBlock = lowestCorner.clone().add(new Vector(x, 0, z)).getBlock(); if (groundBlock.getType().isAir()) continue; for (int y = -1; y > -11; y--) { Block block = lowestCorner.clone().add(new Vector(x, y, z)).getBlock(); if (SurfaceMaterials.ignorable(block.getType())) - block.setType(getPedestalMaterial(!block.getRelative(BlockFace.UP).getType().isSolid())); - else { - //Pedestal only fills until it hits the first solid block - break; - } + block.setType(getPedestalMaterial(!block.getRelative(BlockFace.UP).getType().isSolid()), false); + else break; } } } @@ -282,7 +275,7 @@ private void clearTrees(Location location) { Block block = highestCorner.clone().add(new Vector(x, y, z)).getBlock(); if (SurfaceMaterials.ignorable(block.getType()) && !block.getType().isAir()) { detectedTreeElement = true; - block.setType(Material.AIR); + block.setType(Material.AIR, false); } } } @@ -311,24 +304,19 @@ private void fillChests() { contents = schematicContainer.getBarrelContents(); String schematicBarrelFile = schematicContainer.getSchematicConfigField().getBarrelTreasureFilename(); treasureFilename = (schematicBarrelFile != null && !schematicBarrelFile.isEmpty()) - ? schematicBarrelFile - : gen.getBarrelTreasureFilename(); + ? schematicBarrelFile : gen.getBarrelTreasureFilename(); } else { contents = schematicContainer.getChestContents(); String schematicTreasureFile = schematicContainer.getSchematicConfigField().getTreasureFile(); treasureFilename = (schematicTreasureFile != null && !schematicTreasureFile.isEmpty()) - ? schematicTreasureFile - : gen.getTreasureFilename(); + ? schematicTreasureFile : gen.getTreasureFilename(); } if (contents == null) continue; contents.rollChestContents(container); - ChestFillEvent chestFillEvent = new ChestFillEvent(container, treasureFilename); Bukkit.getServer().getPluginManager().callEvent(chestFillEvent); - if (!chestFillEvent.isCancelled()) { - container.update(true); - } + if (!chestFillEvent.isCancelled()) container.update(true); } } @@ -336,51 +324,40 @@ private void spawnEntities() { for (Vector entityPosition : schematicContainer.getVanillaSpawns().keySet()) { Location signLocation = LocationProjector.project(location, schematicOffset, entityPosition).clone(); signLocation.getBlock().setType(Material.AIR); - //If mobs spawn in corners they might choke on adjacent walls signLocation.add(new Vector(0.5, 0, 0.5)); - //I think FAWE is messing with this signLocation.getChunk().load(); Entity entity = signLocation.getWorld().spawnEntity(signLocation, schematicContainer.getVanillaSpawns().get(entityPosition)); entity.setPersistent(true); - if (entity instanceof LivingEntity) { - ((LivingEntity) entity).setRemoveWhenFarAway(false); - } + if (entity instanceof LivingEntity) ((LivingEntity) entity).setRemoveWhenFarAway(false); - if (!VersionChecker.serverVersionOlderThan(21, 0) && - entity.getType().equals(EntityType.END_CRYSTAL)) { + if (!VersionChecker.serverVersionOlderThan(21, 0) && entity.getType().equals(EntityType.END_CRYSTAL)) { EnderCrystal enderCrystal = (EnderCrystal) entity; enderCrystal.setShowingBottom(false); } } + for (Vector elitePosition : schematicContainer.getEliteMobsSpawns().keySet()) { Location eliteLocation = LocationProjector.project(location, schematicOffset, elitePosition).clone(); eliteLocation.getBlock().setType(Material.AIR); eliteLocation.add(new Vector(0.5, 0, 0.5)); String bossFilename = schematicContainer.getEliteMobsSpawns().get(elitePosition); - //If the spawn fails then don't continue if (!EliteMobs.Spawn(eliteLocation, bossFilename)) return; Location lowestCorner = location.clone().add(schematicOffset); Location highestCorner = lowestCorner.clone().add(new Vector(schematicClipboard.getRegion().getWidth() - 1, schematicClipboard.getRegion().getHeight(), schematicClipboard.getRegion().getLength() - 1)); - if (DefaultConfig.isProtectEliteMobsRegions() && - Bukkit.getPluginManager().getPlugin("WorldGuard") != null && - Bukkit.getPluginManager().getPlugin("EliteMobs") != null) { + if (DefaultConfig.isProtectEliteMobsRegions() + && Bukkit.getPluginManager().getPlugin("WorldGuard") != null + && Bukkit.getPluginManager().getPlugin("EliteMobs") != null) { WorldGuard.Protect(lowestCorner, highestCorner, bossFilename, eliteLocation); - } else { - if (!worldGuardWarn) { - worldGuardWarn = true; - Logger.warn("You are not using WorldGuard, so BetterStructures could not protect a boss arena! Using WorldGuard is recommended to guarantee a fair combat experience."); - } + } else if (!worldGuardWarn) { + worldGuardWarn = true; + Logger.warn("You are not using WorldGuard, so BetterStructures could not protect a boss arena! Using WorldGuard is recommended to guarantee a fair combat experience."); } } - // carm start - Support for MythicMobs for (Map.Entry entry : schematicContainer.getMythicMobsSpawns().entrySet()) { Location mobLocation = LocationProjector.project(location, schematicOffset, entry.getKey()).clone(); mobLocation.getBlock().setType(Material.AIR); - - //If the spawn fails then don't continue if (!MythicMobs.Spawn(mobLocation, entry.getValue())) return; } - // carm end - Support for MythicMobs } } From 16102a8407aae8932843851e860473017da5f400 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:38:42 -0400 Subject: [PATCH 14/47] Fix Gradle wrapper permissions in CI --- .github/workflows/gradle.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index bca74f8..fbe970b 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -26,6 +26,9 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 + - name: Make Gradle wrapper executable + run: chmod +x gradlew + - name: Build and test run: ./gradlew clean test shadowJar From 8f6939bc3b938bba2afcf6566bdca28624643d4e Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:38:51 -0400 Subject: [PATCH 15/47] Fix Gradle wrapper permissions in release workflow --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0430460..60ffec1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,9 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 + - name: Make Gradle wrapper executable + run: chmod +x gradlew + - name: Verify tag matches project version shell: bash run: | From 58665dfd5640d095baee7fe9ff7a8d82299f30d4 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:41:31 -0400 Subject: [PATCH 16/47] Use stable MagmaCore list argument for module generation --- .../commands/GenerateModulesCommand.java | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/commands/GenerateModulesCommand.java b/src/main/java/com/magmaguy/betterstructures/commands/GenerateModulesCommand.java index aa656f7..489278b 100644 --- a/src/main/java/com/magmaguy/betterstructures/commands/GenerateModulesCommand.java +++ b/src/main/java/com/magmaguy/betterstructures/commands/GenerateModulesCommand.java @@ -6,16 +6,22 @@ import com.magmaguy.magmacore.command.AdvancedCommand; import com.magmaguy.magmacore.command.CommandData; import com.magmaguy.magmacore.command.SenderType; -import com.magmaguy.magmacore.command.arguments.DynamicListStringCommandArgument; +import com.magmaguy.magmacore.command.arguments.ListStringCommandArgument; import com.magmaguy.magmacore.util.Logger; +import java.util.ArrayList; import java.util.List; public class GenerateModulesCommand extends AdvancedCommand { public GenerateModulesCommand() { super(List.of("generateModules")); setUsage("/bs generateModules "); - addArgument("moduleGeneratorsConfigFile", new DynamicListStringCommandArgument(() -> ModuleGeneratorsConfig.getModuleGenerators().keySet().stream().toList(), "")); + // Generator configs are fully loaded before commands are registered, so a stable + // snapshot list gives us tab completion without relying on the removed dynamic + // argument class from newer/unpublished MagmaCore revisions. + addArgument("moduleGeneratorsConfigFile", new ListStringCommandArgument( + new ArrayList<>(ModuleGeneratorsConfig.getModuleGenerators().keySet()), + "")); setPermission("betterstructures.generatemodules"); setDescription("Generates modular builds in a dedicated world, based on the generator's configuration file."); setSenderType(SenderType.PLAYER); @@ -23,18 +29,14 @@ public GenerateModulesCommand() { @Override public void execute(CommandData commandData) { -// if (commandData.getIntegerArgument("radius") > 80 && Runtime.getRuntime().maxMemory() <= 4L * 1024 * 1024 * 1024) { -// Logger.sendMessage(commandData.getCommandSender(), -// "You do not have enough RAM for a radius above 80, you will definitely want more than 4GB of RAM for that. Consider pregenerating it locally on a computer that has more RAM and then putting the world in your server!"); -// return; -// } - ModuleGeneratorsConfigFields moduleGeneratorsConfigFields = ModuleGeneratorsConfig.getModuleGenerators().get(commandData.getStringArgument("moduleGeneratorsConfigFile")); + ModuleGeneratorsConfigFields moduleGeneratorsConfigFields = ModuleGeneratorsConfig.getModuleGenerators().get( + commandData.getStringArgument("moduleGeneratorsConfigFile")); if (moduleGeneratorsConfigFields == null) { - Logger.sendMessage(commandData.getCommandSender(), "File " + commandData.getStringArgument("moduleGeneratorsConfigFile") + " not found! The world won't generate."); + Logger.sendMessage(commandData.getCommandSender(), "File " + + commandData.getStringArgument("moduleGeneratorsConfigFile") + + " not found! The world won't generate."); return; } - WFCGenerator.generateFromConfig( - moduleGeneratorsConfigFields, - commandData.getPlayerSender()); + WFCGenerator.generateFromConfig(moduleGeneratorsConfigFields, commandData.getPlayerSender()); } } From 17ce6aa65b38c78b8f9cfce6a7aa6ca1c6dd3f88 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:41:45 -0400 Subject: [PATCH 17/47] Remove stale Nightbreak setup helper dependency --- .../menus/BetterStructuresSetupMenu.java | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/menus/BetterStructuresSetupMenu.java b/src/main/java/com/magmaguy/betterstructures/menus/BetterStructuresSetupMenu.java index 7ad7770..cfa38d5 100644 --- a/src/main/java/com/magmaguy/betterstructures/menus/BetterStructuresSetupMenu.java +++ b/src/main/java/com/magmaguy/betterstructures/menus/BetterStructuresSetupMenu.java @@ -1,19 +1,13 @@ package com.magmaguy.betterstructures.menus; import com.magmaguy.betterstructures.MetadataHandler; -import com.magmaguy.betterstructures.BetterStructures; import com.magmaguy.betterstructures.config.contentpackages.ContentPackageConfigFields; import com.magmaguy.betterstructures.content.BSPackage; import com.magmaguy.betterstructures.content.BSPackageRefresher; import com.magmaguy.magmacore.menus.MenuButton; import com.magmaguy.magmacore.menus.SetupMenuBuilder; import com.magmaguy.magmacore.nightbreak.DownloadAllContentPackage; -import com.magmaguy.magmacore.nightbreak.NightbreakAccount; -import com.magmaguy.magmacore.nightbreak.NightbreakSetupControls; import com.magmaguy.magmacore.util.ChatColorConverter; -import com.magmaguy.magmacore.util.ItemStackGenerator; -import com.magmaguy.magmacore.util.Logger; -import com.magmaguy.magmacore.util.SpigotMessage; import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.entity.Player; @@ -37,23 +31,36 @@ public static void createMenu(Player player) { .collect(Collectors.toList()); BSPackageRefresher.refreshContentAndAccess(); - MenuButton infoButton = NightbreakSetupControls.setupInfoButton( - BetterStructures.NIGHTBREAK_PLUGIN_SPEC, - "https://nightbreak.io/plugin/betterstructures/#setup"); + // The published MagmaCore snapshot used by BetterStructures no longer matches + // the newer NightbreakSetupControls helper. Keep the useful setup/package menu + // while avoiding that moving convenience API in the Albion fork. + MenuButton infoButton = new MenuButton( + Material.BOOK, + ChatColor.GREEN + "BetterStructures", + List.of( + ChatColor.GRAY + "Original plugin by MagmaGuy", + ChatColor.GRAY + "AlbionMC performance fork", + ChatColor.YELLOW + "Content packages can be managed here.")) { + @Override + public void onClick(Player clickingPlayer) { + clickingPlayer.sendMessage(ChatColor.GREEN + "BetterStructures Performance / Albion"); + clickingPlayer.sendMessage(ChatColor.GRAY + "Original BetterStructures created by MagmaGuy."); + } + }; - SetupMenuBuilder builder = new SetupMenuBuilder((JavaPlugin) MetadataHandler.PLUGIN, player) + new SetupMenuBuilder((JavaPlugin) MetadataHandler.PLUGIN, player) .title("Setup menu") .infoButton(infoButton) .packages(bsPackages) - .appendPackage(new DownloadAllContentPackage<>(() -> new ArrayList<>(BSPackage.getBsPackages().values()), + .appendPackage(new DownloadAllContentPackage<>( + () -> new ArrayList<>(BSPackage.getBsPackages().values()), "BetterStructures", "https://nightbreak.io/plugin/betterstructures/", "bs downloadall")) .addFilter(Material.GRASS_BLOCK, "Structure Packs", (Predicate) BetterStructuresSetupMenu::filterStructures) .addFilter(Material.DEEPSLATE_BRICKS, "Module Packs", - (Predicate) BetterStructuresSetupMenu::filterModules); - NightbreakSetupControls.prependStandardControls(builder, (JavaPlugin) MetadataHandler.PLUGIN, BetterStructures.NIGHTBREAK_PLUGIN_SPEC) + (Predicate) BetterStructuresSetupMenu::filterModules) .open(); } From 48473700691cd902239c829179c893b22c3e5834 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:42:38 -0400 Subject: [PATCH 18/47] Remove stale Nightbreak command dependencies from Albion fork --- .../betterstructures/BetterStructures.java | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/BetterStructures.java b/src/main/java/com/magmaguy/betterstructures/BetterStructures.java index 4a629e1..068bb69 100644 --- a/src/main/java/com/magmaguy/betterstructures/BetterStructures.java +++ b/src/main/java/com/magmaguy/betterstructures/BetterStructures.java @@ -28,11 +28,8 @@ import com.magmaguy.magmacore.initialization.PluginInitializationConfig; import com.magmaguy.magmacore.initialization.PluginInitializationContext; import com.magmaguy.magmacore.initialization.PluginInitializationState; -import com.magmaguy.magmacore.nightbreak.NightbreakDownloadContentCommand; -import com.magmaguy.magmacore.nightbreak.NightbreakDownloadEverythingCommand; import com.magmaguy.magmacore.nightbreak.NightbreakPluginSpec; import com.magmaguy.magmacore.nightbreak.NightbreakPluginStateRegistry; -import com.magmaguy.magmacore.nightbreak.NightbreakRecommendedPluginsCommand; import com.magmaguy.magmacore.util.Logger; import org.bstats.bukkit.Metrics; import org.bukkit.Bukkit; @@ -41,7 +38,6 @@ import org.bukkit.plugin.java.JavaPlugin; import java.io.IOException; -import java.util.ArrayList; public final class BetterStructures extends JavaPlugin { public static final NightbreakPluginSpec NIGHTBREAK_PLUGIN_SPEC = new NightbreakPluginSpec( @@ -177,21 +173,10 @@ private void syncInitialization(PluginInitializationContext initializationContex commandManager.registerCommand(new VersionCommand()); commandManager.registerCommand(new SetupCommand()); commandManager.registerCommand(new FirstTimeSetupCommand()); - commandManager.registerCommand(new NightbreakRecommendedPluginsCommand(this, NIGHTBREAK_PLUGIN_SPEC)); - commandManager.registerCommand(new NightbreakDownloadEverythingCommand<>(this, - NIGHTBREAK_PLUGIN_SPEC, - () -> new ArrayList<>(BSPackage.getBsPackages().values()), - ReloadCommand::reload)); - commandManager.registerCommand(new NightbreakDownloadContentCommand<>(this, - NIGHTBREAK_PLUGIN_SPEC, - () -> new ArrayList<>(BSPackage.getBsPackages().values()), - ReloadCommand::reload, - false)); - commandManager.registerCommand(new NightbreakDownloadContentCommand<>(this, - NIGHTBREAK_PLUGIN_SPEC, - () -> new ArrayList<>(BSPackage.getBsPackages().values()), - ReloadCommand::reload, - true)); + // Albion fork intentionally omits Nightbreak plugin/content-download convenience + // commands whose API is not present in the published MagmaCore snapshot. Existing + // structure packs/configuration continue to load normally, and setup package + // browsing remains available through the BetterStructures setup menu. commandManager.registerCommand(new GenerateModulesCommand()); commandManager.registerCommand(new BetterStructuresCommand()); From 02dd7d47ec2f3fe1fe6031c59cba5ac7cde08dd4 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:45:18 -0400 Subject: [PATCH 19/47] Update module world creation for Paper 1.21.11 --- .../betterstructures/modules/WorldInitializer.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/modules/WorldInitializer.java b/src/main/java/com/magmaguy/betterstructures/modules/WorldInitializer.java index 6ac7fb9..6238cea 100644 --- a/src/main/java/com/magmaguy/betterstructures/modules/WorldInitializer.java +++ b/src/main/java/com/magmaguy/betterstructures/modules/WorldInitializer.java @@ -11,11 +11,14 @@ public class WorldInitializer { public static World generateWorld(String worldName, Player player) { WorldCreator worldCreator = new WorldCreator(worldName); worldCreator.environment(World.Environment.NORMAL); - worldCreator.keepSpawnInMemory(false); + // Paper 1.21.9+ removed functional always-loaded spawn chunks. There is no + // replacement needed for BetterStructures' generated module worlds. worldCreator.generator(new VoidGenerator()); World world = worldCreator.createWorld(); + if (world == null) { + throw new IllegalStateException("Failed to create BetterStructures module world " + worldName); + } world.setAutoSave(false); -// player.teleport(new Location(world, 8, 16, 8)); player.setGameMode(GameMode.SPECTATOR); return world; } From d5d29748c50c39807a1a89d490a1d58a3e73c004 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:45:42 -0400 Subject: [PATCH 20/47] Update synthetic clipboard for FAWE WorldEdit API --- .../betterstructures/util/WorldEditUtils.java | 146 +++++++----------- 1 file changed, 57 insertions(+), 89 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java b/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java index f71bbfb..c964b1a 100644 --- a/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java +++ b/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java @@ -1,6 +1,6 @@ package com.magmaguy.betterstructures.util; -import com.magmaguy.magmacore.util.Logger; +import com.magmaguy.betterstructures.schematics.SchematicContainer; import com.sk89q.jnbt.CompoundTag; import com.sk89q.jnbt.ListTag; import com.sk89q.jnbt.StringTag; @@ -11,7 +11,6 @@ import com.sk89q.worldedit.entity.BaseEntity; import com.sk89q.worldedit.entity.Entity; import com.sk89q.worldedit.extent.clipboard.Clipboard; -import com.sk89q.worldedit.function.mask.BlockTypeMask; import com.sk89q.worldedit.function.operation.Operation; import com.sk89q.worldedit.function.operation.Operations; import com.sk89q.worldedit.math.BlockVector3; @@ -22,110 +21,80 @@ import com.sk89q.worldedit.world.block.BaseBlock; import com.sk89q.worldedit.world.block.BlockState; import com.sk89q.worldedit.world.block.BlockStateHolder; -import com.sk89q.worldedit.world.block.BlockType; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.data.BlockData; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Player; import org.bukkit.util.Vector; -import org.checkerframework.checker.index.qual.Positive; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import javax.annotation.Nonnull; +import javax.annotation.Positive; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; public class WorldEditUtils { - private static final ArrayList values = new ArrayList<>(); - - public static Vector getSchematicOffset(Clipboard clipboard) { - return new Vector(clipboard.getMinimumPoint().x() - clipboard.getOrigin().x(), clipboard.getMinimumPoint().y() - clipboard.getOrigin().y(), clipboard.getMinimumPoint().z() - clipboard.getOrigin().z()); - } - - @Nullable - public static Material adaptMaterial(@NotNull BlockState blockState) { - return BukkitAdapter.adapt(blockState.getBlockType()); + public static Vector getSchematicOffset(Clipboard schematicClipboard) { + return new Vector( + schematicClipboard.getMinimumPoint().x() - schematicClipboard.getOrigin().x(), + schematicClipboard.getMinimumPoint().y() - schematicClipboard.getOrigin().y(), + schematicClipboard.getMinimumPoint().z() - schematicClipboard.getOrigin().z()); } - @Nullable - public static Material adaptMaterial(@NotNull BaseBlock baseBlock) { - return adaptMaterial(baseBlock.toImmutableState()); - } - - @Nullable - public static BlockData createBlockDataOrNull(@NotNull BaseBlock baseBlock) { + public static Material adaptMaterial(BlockState blockState) { try { - return Bukkit.createBlockData(baseBlock.toImmutableState().getAsString()); - } catch (IllegalArgumentException e) { + return Material.matchMaterial(blockState.getBlockType().id().replace("minecraft:", "")); + } catch (Exception ex) { return null; } } - public static boolean isAir(@NotNull BlockState blockState) { - Material material = adaptMaterial(blockState); - if (material != null) return material.isAir(); - return blockState.getBlockType().getMaterial().isAir(); + public static boolean isAir(BlockState blockState) { + String id = blockState.getBlockType().id().toLowerCase(Locale.ROOT); + return id.equals("minecraft:air") || id.equals("minecraft:cave_air") || id.equals("minecraft:void_air"); } - public static boolean isSolid(@NotNull BlockState blockState) { + public static boolean isSolid(BlockState blockState) { Material material = adaptMaterial(blockState); - if (material != null) return material.isSolid(); - return blockState.getBlockType().getMaterial().isSolid(); + return material != null && material.isSolid(); } - public static List getLines(@NotNull BaseBlock baseBlock) { - values.clear(); - List lines = new ArrayList<>(); - if (baseBlock.getNbtData() == null) { - return lines; - } - - for (int i = 1; i < 5; i++) { - String line = getLine(baseBlock, i); - if (line == null) return new ArrayList<>(); - if (!line.isEmpty() && !line.isBlank()) - lines.add(line); + public static BlockData createBlockDataOrNull(BaseBlock baseBlock) { + Material material = adaptMaterial(baseBlock.toImmutableState()); + if (material == null) return null; + try { + return Bukkit.createBlockData(baseBlock.toString()); + } catch (Exception ignored) { + try { + return material.createBlockData(); + } catch (Exception ignoredAgain) { + return null; + } } - - return lines; } - /** - *

Parses data from a sign's NBT and returns the specified line number. - * Tested with WorldEdit and FastAsyncWorldEdit NBT format.

- */ - public static String getLine(@NotNull BaseBlock baseBlock, @Positive int line) { - values.clear(); - if (baseBlock.getNbtData() == null) { - return ""; + public static String getBossFilename(CompoundTag data) { + for (int line = 1; line <= 4; line++) { + String lineString = getSignLine(data, line); + if (lineString == null) continue; + if (lineString.endsWith(".yml")) return lineString; } - CompoundTag data = baseBlock.getNbtData(); - return getLineWe(data, line); + return null; } - /** - *

Parses data from a sign's NBT and returns the specified line number. - * Designed for WorldEdit NBT format.

- */ - private static String getLineWe(@NotNull CompoundTag data, @Positive int line) { - try { - if (data.getValue().containsKey("Text" + line)) { - return getOldWEFormat(data, line); - } else { - return getNewWEFormat(data, line); - } - - } catch (Exception ex) { - Bukkit.getLogger().warning("Unexpected sign format!" + data); - } - - return ""; + public static String getSignLine(@NotNull CompoundTag data, @Positive int line) { + if (data.getValue().containsKey("Text" + line)) return getLegacyWEFormat(data, line); + else return getNewWEFormat(data, line); } - private static String getOldWEFormat(@NotNull CompoundTag data, @Positive int line) { + private static String getLegacyWEFormat(@NotNull CompoundTag data, @Positive int line) { try { String text = ((StringTag) data.getValue().get("Text" + line)).getValue(); @@ -133,8 +102,7 @@ private static String getOldWEFormat(@NotNull CompoundTag data, @Positive int li Matcher matcher = pattern.matcher(text); if (matcher.find()) { - String extractedText = matcher.group(1); - return extractedText; + return matcher.group(1); } else { throw new Exception(); } @@ -146,17 +114,13 @@ private static String getOldWEFormat(@NotNull CompoundTag data, @Positive int li private static String getNewWEFormat(@NotNull CompoundTag data, @Positive int line) { try { - //Get front text CompoundTag frontText = (CompoundTag) data.getValue().get("front_text"); - //Get messages ListTag messages = (ListTag) frontText.getValue().get("messages"); - //Get the line String text = messages.getString(line - 1); if (text.contains("\"text\":")) text = text.split("text\":\"")[1].split("\"")[0]; text = text.replaceAll("\"", ""); if (text.contains("test")) Bukkit.getLogger().warning("boss name:" + text); - return text; } catch (Exception ex) { @@ -190,12 +154,12 @@ public BaseBlock getFullBlock(BlockVector3 position) { @Override public BlockVector3 getMinimumPoint() { - return BlockVector3.at(0,0,0); + return BlockVector3.at(0, 0, 0); } @Override public BlockVector3 getMaximumPoint() { - return BlockVector3.at(0,0,0); + return BlockVector3.at(0, 0, 0); } @Override @@ -214,24 +178,28 @@ public Entity createEntity(com.sk89q.worldedit.util.Location location, BaseEntit return null; } + @Override + public void removeEntity(Entity entity) { + // Synthetic single-block clipboard never contains entities. + } + @Override public Region getRegion() { - return new CuboidRegion(BlockVector3.at(0,0,0), BlockVector3.at(0,0,0)); + return new CuboidRegion(BlockVector3.at(0, 0, 0), BlockVector3.at(0, 0, 0)); } @Override public BlockVector3 getDimensions() { - return BlockVector3.at(1,1,1); + return BlockVector3.at(1, 1, 1); } @Override public BlockVector3 getOrigin() { - return BlockVector3.at(0,0,0); + return BlockVector3.at(0, 0, 0); } @Override public void setOrigin(BlockVector3 origin) { - } }; } @@ -246,9 +214,8 @@ public static void pasteArmorStandsOnlyFromTransformed(Clipboard transformedClip ClipboardHolder clipboardHolder = new ClipboardHolder(transformedClipboard); BlockVector3 minPoint = transformedClipboard.getMinimumPoint(); - BlockVector3 origin = transformedClipboard.getOrigin(); + BlockVector3 origin = transformedClipboard.getOrigin(); - // Align entities the same way you aligned blocks: min -> base BlockVector3 pastePosition = BlockVector3.at( location.getBlockX() + (origin.x() - minPoint.x()), location.getBlockY() + (origin.y() - minPoint.y()), @@ -261,14 +228,15 @@ public static void pasteArmorStandsOnlyFromTransformed(Clipboard transformedClip .copyEntities(true) .copyBiomes(false) .ignoreAirBlocks(true) - .maskSource(new BlockTypeMask(transformedClipboard, new BlockType[0])) .build(); Operations.complete(operation); - - } catch (Exception e) { - Logger.warn("Failed to paste entities at " + location + ": " + e.getMessage()); + } catch (WorldEditException ex) { + Bukkit.getLogger().warning("Failed to paste schematic entities: " + ex.getMessage()); } } + public static Clipboard createSingleBlockClipboard(Location adjustedLocation, BaseBlock baseBlock, BlockState blockState, boolean unused) { + return createSingleBlockClipboard(adjustedLocation, baseBlock, blockState); + } } From c51d892aae5b469b6f426a88e172e46e57f3c8af Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:46:23 -0400 Subject: [PATCH 21/47] Preserve WorldEdit utilities while adding FAWE clipboard method --- .../betterstructures/util/WorldEditUtils.java | 143 +++++++++++------- 1 file changed, 90 insertions(+), 53 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java b/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java index c964b1a..fcb639f 100644 --- a/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java +++ b/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java @@ -1,6 +1,6 @@ package com.magmaguy.betterstructures.util; -import com.magmaguy.betterstructures.schematics.SchematicContainer; +import com.magmaguy.magmacore.util.Logger; import com.sk89q.jnbt.CompoundTag; import com.sk89q.jnbt.ListTag; import com.sk89q.jnbt.StringTag; @@ -11,6 +11,7 @@ import com.sk89q.worldedit.entity.BaseEntity; import com.sk89q.worldedit.entity.Entity; import com.sk89q.worldedit.extent.clipboard.Clipboard; +import com.sk89q.worldedit.function.mask.BlockTypeMask; import com.sk89q.worldedit.function.operation.Operation; import com.sk89q.worldedit.function.operation.Operations; import com.sk89q.worldedit.math.BlockVector3; @@ -21,80 +22,110 @@ import com.sk89q.worldedit.world.block.BaseBlock; import com.sk89q.worldedit.world.block.BlockState; import com.sk89q.worldedit.world.block.BlockStateHolder; +import com.sk89q.worldedit.world.block.BlockType; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.data.BlockData; -import org.bukkit.entity.ArmorStand; -import org.bukkit.entity.Player; import org.bukkit.util.Vector; +import org.checkerframework.checker.index.qual.Positive; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import javax.annotation.Nonnull; -import javax.annotation.Positive; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; public class WorldEditUtils { - public static Vector getSchematicOffset(Clipboard schematicClipboard) { - return new Vector( - schematicClipboard.getMinimumPoint().x() - schematicClipboard.getOrigin().x(), - schematicClipboard.getMinimumPoint().y() - schematicClipboard.getOrigin().y(), - schematicClipboard.getMinimumPoint().z() - schematicClipboard.getOrigin().z()); + private static final ArrayList values = new ArrayList<>(); + + public static Vector getSchematicOffset(Clipboard clipboard) { + return new Vector(clipboard.getMinimumPoint().x() - clipboard.getOrigin().x(), clipboard.getMinimumPoint().y() - clipboard.getOrigin().y(), clipboard.getMinimumPoint().z() - clipboard.getOrigin().z()); + } + + @Nullable + public static Material adaptMaterial(@NotNull BlockState blockState) { + return BukkitAdapter.adapt(blockState.getBlockType()); } - public static Material adaptMaterial(BlockState blockState) { + @Nullable + public static Material adaptMaterial(@NotNull BaseBlock baseBlock) { + return adaptMaterial(baseBlock.toImmutableState()); + } + + @Nullable + public static BlockData createBlockDataOrNull(@NotNull BaseBlock baseBlock) { try { - return Material.matchMaterial(blockState.getBlockType().id().replace("minecraft:", "")); - } catch (Exception ex) { + return Bukkit.createBlockData(baseBlock.toImmutableState().getAsString()); + } catch (IllegalArgumentException e) { return null; } } - public static boolean isAir(BlockState blockState) { - String id = blockState.getBlockType().id().toLowerCase(Locale.ROOT); - return id.equals("minecraft:air") || id.equals("minecraft:cave_air") || id.equals("minecraft:void_air"); + public static boolean isAir(@NotNull BlockState blockState) { + Material material = adaptMaterial(blockState); + if (material != null) return material.isAir(); + return blockState.getBlockType().getMaterial().isAir(); } - public static boolean isSolid(BlockState blockState) { + public static boolean isSolid(@NotNull BlockState blockState) { Material material = adaptMaterial(blockState); - return material != null && material.isSolid(); + if (material != null) return material.isSolid(); + return blockState.getBlockType().getMaterial().isSolid(); } - public static BlockData createBlockDataOrNull(BaseBlock baseBlock) { - Material material = adaptMaterial(baseBlock.toImmutableState()); - if (material == null) return null; - try { - return Bukkit.createBlockData(baseBlock.toString()); - } catch (Exception ignored) { - try { - return material.createBlockData(); - } catch (Exception ignoredAgain) { - return null; - } + public static List getLines(@NotNull BaseBlock baseBlock) { + values.clear(); + List lines = new ArrayList<>(); + if (baseBlock.getNbtData() == null) { + return lines; + } + + for (int i = 1; i < 5; i++) { + String line = getLine(baseBlock, i); + if (line == null) return new ArrayList<>(); + if (!line.isEmpty() && !line.isBlank()) + lines.add(line); } + + return lines; } - public static String getBossFilename(CompoundTag data) { - for (int line = 1; line <= 4; line++) { - String lineString = getSignLine(data, line); - if (lineString == null) continue; - if (lineString.endsWith(".yml")) return lineString; + /** + *

Parses data from a sign's NBT and returns the specified line number. + * Tested with WorldEdit and FastAsyncWorldEdit NBT format.

+ */ + public static String getLine(@NotNull BaseBlock baseBlock, @Positive int line) { + values.clear(); + if (baseBlock.getNbtData() == null) { + return ""; } - return null; + CompoundTag data = baseBlock.getNbtData(); + return getLineWe(data, line); } - public static String getSignLine(@NotNull CompoundTag data, @Positive int line) { - if (data.getValue().containsKey("Text" + line)) return getLegacyWEFormat(data, line); - else return getNewWEFormat(data, line); + /** + *

Parses data from a sign's NBT and returns the specified line number. + * Designed for WorldEdit NBT format.

+ */ + private static String getLineWe(@NotNull CompoundTag data, @Positive int line) { + try { + if (data.getValue().containsKey("Text" + line)) { + return getOldWEFormat(data, line); + } else { + return getNewWEFormat(data, line); + } + + } catch (Exception ex) { + Bukkit.getLogger().warning("Unexpected sign format!" + data); + } + + return ""; } - private static String getLegacyWEFormat(@NotNull CompoundTag data, @Positive int line) { + private static String getOldWEFormat(@NotNull CompoundTag data, @Positive int line) { try { String text = ((StringTag) data.getValue().get("Text" + line)).getValue(); @@ -102,7 +133,8 @@ private static String getLegacyWEFormat(@NotNull CompoundTag data, @Positive int Matcher matcher = pattern.matcher(text); if (matcher.find()) { - return matcher.group(1); + String extractedText = matcher.group(1); + return extractedText; } else { throw new Exception(); } @@ -114,13 +146,17 @@ private static String getLegacyWEFormat(@NotNull CompoundTag data, @Positive int private static String getNewWEFormat(@NotNull CompoundTag data, @Positive int line) { try { + //Get front text CompoundTag frontText = (CompoundTag) data.getValue().get("front_text"); + //Get messages ListTag messages = (ListTag) frontText.getValue().get("messages"); + //Get the line String text = messages.getString(line - 1); if (text.contains("\"text\":")) text = text.split("text\":\"")[1].split("\"")[0]; text = text.replaceAll("\"", ""); if (text.contains("test")) Bukkit.getLogger().warning("boss name:" + text); + return text; } catch (Exception ex) { @@ -154,12 +190,12 @@ public BaseBlock getFullBlock(BlockVector3 position) { @Override public BlockVector3 getMinimumPoint() { - return BlockVector3.at(0, 0, 0); + return BlockVector3.at(0,0,0); } @Override public BlockVector3 getMaximumPoint() { - return BlockVector3.at(0, 0, 0); + return BlockVector3.at(0,0,0); } @Override @@ -180,26 +216,27 @@ public Entity createEntity(com.sk89q.worldedit.util.Location location, BaseEntit @Override public void removeEntity(Entity entity) { - // Synthetic single-block clipboard never contains entities. + // Synthetic one-block clipboards never contain entities. } @Override public Region getRegion() { - return new CuboidRegion(BlockVector3.at(0, 0, 0), BlockVector3.at(0, 0, 0)); + return new CuboidRegion(BlockVector3.at(0,0,0), BlockVector3.at(0,0,0)); } @Override public BlockVector3 getDimensions() { - return BlockVector3.at(1, 1, 1); + return BlockVector3.at(1,1,1); } @Override public BlockVector3 getOrigin() { - return BlockVector3.at(0, 0, 0); + return BlockVector3.at(0,0,0); } @Override public void setOrigin(BlockVector3 origin) { + } }; } @@ -214,8 +251,9 @@ public static void pasteArmorStandsOnlyFromTransformed(Clipboard transformedClip ClipboardHolder clipboardHolder = new ClipboardHolder(transformedClipboard); BlockVector3 minPoint = transformedClipboard.getMinimumPoint(); - BlockVector3 origin = transformedClipboard.getOrigin(); + BlockVector3 origin = transformedClipboard.getOrigin(); + // Align entities the same way you aligned blocks: min -> base BlockVector3 pastePosition = BlockVector3.at( location.getBlockX() + (origin.x() - minPoint.x()), location.getBlockY() + (origin.y() - minPoint.y()), @@ -228,15 +266,14 @@ public static void pasteArmorStandsOnlyFromTransformed(Clipboard transformedClip .copyEntities(true) .copyBiomes(false) .ignoreAirBlocks(true) + .maskSource(new BlockTypeMask(transformedClipboard, new BlockType[0])) .build(); Operations.complete(operation); - } catch (WorldEditException ex) { - Bukkit.getLogger().warning("Failed to paste schematic entities: " + ex.getMessage()); + + } catch (Exception e) { + Logger.warn("Failed to paste entities at " + location + ": " + e.getMessage()); } } - public static Clipboard createSingleBlockClipboard(Location adjustedLocation, BaseBlock baseBlock, BlockState blockState, boolean unused) { - return createSingleBlockClipboard(adjustedLocation, baseBlock, blockState); - } } From a000a233cf00efdba15852b4298a8a8e4290d70f Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:47:01 -0400 Subject: [PATCH 22/47] Adapt content importer to published MagmaCore API --- .../betterstructures/BetterStructures.java | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/BetterStructures.java b/src/main/java/com/magmaguy/betterstructures/BetterStructures.java index 068bb69..0eab387 100644 --- a/src/main/java/com/magmaguy/betterstructures/BetterStructures.java +++ b/src/main/java/com/magmaguy/betterstructures/BetterStructures.java @@ -24,7 +24,6 @@ import com.magmaguy.easyminecraftgoals.NMSManager; import com.magmaguy.magmacore.MagmaCore; import com.magmaguy.magmacore.command.CommandManager; -import com.magmaguy.magmacore.dlc.ConfigurationImporter; import com.magmaguy.magmacore.initialization.PluginInitializationConfig; import com.magmaguy.magmacore.initialization.PluginInitializationContext; import com.magmaguy.magmacore.initialization.PluginInitializationState; @@ -37,6 +36,7 @@ import org.bukkit.event.HandlerList; import org.bukkit.plugin.java.JavaPlugin; +import java.io.File; import java.io.IOException; public final class BetterStructures extends JavaPlugin { @@ -129,9 +129,7 @@ private void asyncInitialization(PluginInitializationContext initializationConte new ValidWorldsConfig(); initializationContext.step("Content Importer"); - ConfigurationImporter importer = MagmaCore.initializeImporter(this); - if (importer != null && importer.isEliteMobsContentImported()) - EliteMobs.reloadAfterContentImport(); + importPendingContent(); initializationContext.step("Treasure Config"); new TreasureConfig(); @@ -201,9 +199,7 @@ public void reloadImportedContent(CommandSender commandSender) { Bukkit.getScheduler().runTaskAsynchronously(this, () -> { try { - ConfigurationImporter importer = MagmaCore.initializeImporter(this); - if (importer != null && importer.isEliteMobsContentImported()) - EliteMobs.reloadAfterContentImport(); + importPendingContent(); new TreasureConfig(); new GeneratorConfig(); new ModuleGeneratorsConfig(); @@ -231,4 +227,23 @@ public void reloadImportedContent(CommandSender commandSender) { } }); } + + /** + * MagmaCore's published importer API is fire-and-forget. Record whether the imports + * directory had work before invoking it so EliteMobs is refreshed only when content + * may actually have been deposited into its folders. + */ + private void importPendingContent() { + boolean hadPendingImports = hasPendingImports(); + MagmaCore.initializeImporter(this); + if (hadPendingImports) { + EliteMobs.reloadAfterContentImport(); + } + } + + private boolean hasPendingImports() { + File importsDirectory = new File(getDataFolder(), "imports"); + File[] entries = importsDirectory.listFiles(); + return entries != null && entries.length > 0; + } } From f1e3c031d480a93c7ad46926c18b9f127ab8caaf Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:49:10 -0400 Subject: [PATCH 23/47] Use real FAWE BlockArrayClipboard for metadata blocks --- .../betterstructures/util/WorldEditUtils.java | 99 +++---------------- 1 file changed, 16 insertions(+), 83 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java b/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java index fcb639f..b551a29 100644 --- a/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java +++ b/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java @@ -8,20 +8,17 @@ import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.WorldEditException; import com.sk89q.worldedit.bukkit.BukkitAdapter; -import com.sk89q.worldedit.entity.BaseEntity; -import com.sk89q.worldedit.entity.Entity; +import com.sk89q.worldedit.extent.clipboard.BlockArrayClipboard; import com.sk89q.worldedit.extent.clipboard.Clipboard; import com.sk89q.worldedit.function.mask.BlockTypeMask; import com.sk89q.worldedit.function.operation.Operation; import com.sk89q.worldedit.function.operation.Operations; import com.sk89q.worldedit.math.BlockVector3; import com.sk89q.worldedit.regions.CuboidRegion; -import com.sk89q.worldedit.regions.Region; import com.sk89q.worldedit.session.ClipboardHolder; import com.sk89q.worldedit.util.SideEffectSet; import com.sk89q.worldedit.world.block.BaseBlock; import com.sk89q.worldedit.world.block.BlockState; -import com.sk89q.worldedit.world.block.BlockStateHolder; import com.sk89q.worldedit.world.block.BlockType; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -133,8 +130,7 @@ private static String getOldWEFormat(@NotNull CompoundTag data, @Positive int li Matcher matcher = pattern.matcher(text); if (matcher.find()) { - String extractedText = matcher.group(1); - return extractedText; + return matcher.group(1); } else { throw new Exception(); } @@ -146,11 +142,8 @@ private static String getOldWEFormat(@NotNull CompoundTag data, @Positive int li private static String getNewWEFormat(@NotNull CompoundTag data, @Positive int line) { try { - //Get front text CompoundTag frontText = (CompoundTag) data.getValue().get("front_text"); - //Get messages ListTag messages = (ListTag) frontText.getValue().get("messages"); - //Get the line String text = messages.getString(line - 1); if (text.contains("\"text\":")) text = text.split("text\":\"")[1].split("\"")[0]; @@ -165,80 +158,21 @@ private static String getNewWEFormat(@NotNull CompoundTag data, @Positive int li return null; } + /** + * Creates a real one-block WorldEdit/FAWE clipboard rather than implementing the + * Clipboard interface anonymously. This keeps the fork compatible as FAWE adds + * new extent methods while preserving NBT-rich BaseBlock data. + */ public static Clipboard createSingleBlockClipboard(Location location, BaseBlock baseBlock, BlockState blockState) { - return new Clipboard() { - @Override - public > boolean setBlock(BlockVector3 position, T block) throws WorldEditException { - return false; - } - - @Nullable - @Override - public Operation commit() { - return null; - } - - @Override - public BlockState getBlock(BlockVector3 position) { - return blockState; - } - - @Override - public BaseBlock getFullBlock(BlockVector3 position) { - return baseBlock; - } - - @Override - public BlockVector3 getMinimumPoint() { - return BlockVector3.at(0,0,0); - } - - @Override - public BlockVector3 getMaximumPoint() { - return BlockVector3.at(0,0,0); - } - - @Override - public List getEntities(Region region) { - return new ArrayList<>(); - } - - @Override - public List getEntities() { - return new ArrayList<>(); - } - - @Nullable - @Override - public Entity createEntity(com.sk89q.worldedit.util.Location location, BaseEntity entity) { - return null; - } - - @Override - public void removeEntity(Entity entity) { - // Synthetic one-block clipboards never contain entities. - } - - @Override - public Region getRegion() { - return new CuboidRegion(BlockVector3.at(0,0,0), BlockVector3.at(0,0,0)); - } - - @Override - public BlockVector3 getDimensions() { - return BlockVector3.at(1,1,1); - } - - @Override - public BlockVector3 getOrigin() { - return BlockVector3.at(0,0,0); - } - - @Override - public void setOrigin(BlockVector3 origin) { - - } - }; + CuboidRegion region = new CuboidRegion(BlockVector3.at(0, 0, 0), BlockVector3.at(0, 0, 0)); + BlockArrayClipboard clipboard = new BlockArrayClipboard(region); + clipboard.setOrigin(BlockVector3.at(0, 0, 0)); + try { + clipboard.setBlock(BlockVector3.at(0, 0, 0), baseBlock); + } catch (WorldEditException e) { + throw new RuntimeException("Failed to create one-block FAWE clipboard", e); + } + return clipboard; } public static void pasteArmorStandsOnlyFromTransformed(Clipboard transformedClipboard, Location location) { @@ -253,7 +187,6 @@ public static void pasteArmorStandsOnlyFromTransformed(Clipboard transformedClip BlockVector3 minPoint = transformedClipboard.getMinimumPoint(); BlockVector3 origin = transformedClipboard.getOrigin(); - // Align entities the same way you aligned blocks: min -> base BlockVector3 pastePosition = BlockVector3.at( location.getBlockX() + (origin.x() - minPoint.x()), location.getBlockY() + (origin.y() - minPoint.y()), From 777cde58730acc64bf152231f6e3d0a57becf19a Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:51:24 -0400 Subject: [PATCH 24/47] Move natural structure placement onto serialized async FAWE pipeline --- .../betterstructures/worldedit/Schematic.java | 540 ++++++++++-------- 1 file changed, 308 insertions(+), 232 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java b/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java index 6a89825..82deed5 100644 --- a/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java +++ b/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java @@ -4,7 +4,6 @@ import com.magmaguy.betterstructures.config.DefaultConfig; import com.magmaguy.betterstructures.util.WorldEditUtils; import com.magmaguy.magmacore.util.Logger; -import com.magmaguy.magmacore.util.WorkloadRunnable; import com.sk89q.worldedit.EditSession; import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.WorldEditException; @@ -17,51 +16,81 @@ import com.sk89q.worldedit.function.operation.Operations; import com.sk89q.worldedit.math.BlockVector3; import com.sk89q.worldedit.session.ClipboardHolder; +import com.sk89q.worldedit.util.SideEffectSet; import com.sk89q.worldedit.world.World; import com.sk89q.worldedit.world.block.BaseBlock; -import com.sk89q.worldedit.world.block.BlockState; import org.bukkit.Bukkit; +import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.Material; -import org.bukkit.block.Block; -import org.bukkit.block.data.BlockData; -import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.util.Vector; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.NoSuchElementException; import java.util.Queue; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.function.Function; -public class Schematic { - private static final Queue preparationQueue = new ConcurrentLinkedQueue<>(); - private static final Queue pasteQueue = new ConcurrentLinkedQueue<>(); +/** + * BetterStructures schematic I/O and the Albion FAWE paste pipeline. + * + *

Natural structure placement is serialized, required chunks are prepared through + * Paper's async chunk API in small batches, and the actual block loop runs through one + * FAWE EditSession off the server thread. This preserves BetterStructures' barrier and + * bedrock/pedestal semantics without the upstream per-block Bukkit paste workload.

+ */ +public final class Schematic { + + private static final int CHUNK_LOAD_BATCH_SIZE = 2; + private static final long CHUNK_LOAD_BATCH_DELAY_TICKS = 1L; + private static final long PRESSURE_RETRY_TICKS = 10L; + + private static final Queue PASTE_QUEUE = new ConcurrentLinkedQueue<>(); + private static final Set INTERNAL_CHUNK_LOADS = ConcurrentHashMap.newKeySet(); + private static boolean erroredOnce = false; - private static boolean isPreparingPaste = false; - private static boolean isDistributedPasting = false; + private static boolean pasteInProgress = false; private Schematic() { } public static void shutdown() { - preparationQueue.clear(); - pasteQueue.clear(); - isPreparingPaste = false; - isDistributedPasting = false; + PASTE_QUEUE.clear(); + INTERNAL_CHUNK_LOADS.clear(); + pasteInProgress = false; + } + + public static boolean isBusy() { + return pasteInProgress || !PASTE_QUEUE.isEmpty(); + } + + /** + * Lets the new-chunk listener distinguish a player-generated chunk from a chunk + * BetterStructures itself had to load to fit an already-selected structure. + */ + public static boolean isInternalChunkLoad(Chunk chunk) { + return INTERNAL_CHUNK_LOADS.contains(new ChunkKey( + chunk.getWorld().getUID(), chunk.getX(), chunk.getZ())); } public static Clipboard load(File schematicFile) { - Clipboard clipboard; ClipboardFormat format = ClipboardFormats.findByFile(schematicFile); + if (format == null) { + Logger.warn("Could not determine schematic format for " + schematicFile.getName()); + return null; + } try (ClipboardReader reader = format.getReader(new FileInputStream(schematicFile))) { - clipboard = reader.read(); + return reader.read(); } catch (IOException e) { e.printStackTrace(); return null; @@ -76,15 +105,15 @@ public static Clipboard load(File schematicFile) { erroredOnce = true; e.printStackTrace(); } else { - Logger.warn("Hiding stacktrace for this error, as it has already been printed once"); + Logger.warn("Hiding stacktrace for this error because one has already been printed."); } return null; } - return clipboard; } /** - * Direct WorldEdit/FAWE paste used by explicit command paths. + * Direct FAWE/WorldEdit paste retained for explicit command and modular-world paths. + * Natural structure generation uses {@link #pasteSchematic} instead. */ public static void paste(Clipboard clipboard, Location location) { World world = BukkitAdapter.adapt(location.getWorld()); @@ -99,275 +128,322 @@ public static void paste(Clipboard clipboard, Location location) { } } - private static boolean isSolidBlock(Clipboard schematicClipboard, BlockVector3 clipboardPosition) { - return WorldEditUtils.isSolid(schematicClipboard.getBlock(clipboardPosition)); - } - /** - * Queues schematic preparation. Upstream 2.6.3 built the complete PasteBlock list in - * one synchronous pass before the distributed paste limiter started. Large structures - * could therefore consume a large part of one server tick while a player generated a - * new resource-world chunk. The Albion fork gives preparation its own tick budget. + * Queue one natural structure paste. Only one BetterStructures FAWE structure edit + * runs at a time, which avoids several resource-world discoveries competing for + * chunk generation and FAWE queues simultaneously. */ public static void pasteSchematic( Clipboard schematicClipboard, Location location, Vector schematicOffset, + Runnable prePasteCallback, Function pedestalMaterialProvider, Runnable onComplete) { - preparationQueue.add(new PastePreparationOperation( + PASTE_QUEUE.add(new PasteRequest( schematicClipboard, location.clone(), schematicOffset.clone(), + prePasteCallback, pedestalMaterialProvider, onComplete)); - if (!isPreparingPaste) { - processNextPreparation(); + if (Bukkit.isPrimaryThread()) { + processNextPaste(); + } else { + Bukkit.getScheduler().runTask(MetadataHandler.PLUGIN, Schematic::processNextPaste); } } - private static void processNextPreparation() { - PastePreparationOperation operation = preparationQueue.poll(); - if (operation == null) { - isPreparingPaste = false; + /** + * Compatibility overload for call sites that do not need a pre-paste callback. + */ + public static void pasteSchematic( + Clipboard schematicClipboard, + Location location, + Vector schematicOffset, + Function pedestalMaterialProvider, + Runnable onComplete) { + pasteSchematic(schematicClipboard, location, schematicOffset, null, + pedestalMaterialProvider, onComplete); + } + + private static void processNextPaste() { + if (!Bukkit.isPrimaryThread()) { + Bukkit.getScheduler().runTask(MetadataHandler.PLUGIN, Schematic::processNextPaste); return; } + if (pasteInProgress) return; + + PasteRequest request = PASTE_QUEUE.poll(); + if (request == null) return; - isPreparingPaste = true; - new PastePreparationTask(operation).runTaskTimer(MetadataHandler.PLUGIN, 0L, 1L); + pasteInProgress = true; + attemptStartPaste(request); } - private static final class PastePreparationTask extends BukkitRunnable { - private final PastePreparationOperation operation; - private final ArrayList blocks = new ArrayList<>(); - private final Location adjustedLocation; - private final org.bukkit.World bukkitWorld; - private final int sizeX; - private final int sizeY; - private final int sizeZ; - private final int minimumX; - private final int minimumY; - private final int minimumZ; - private int x; - private int y; - private int z; - - private PastePreparationTask(PastePreparationOperation operation) { - this.operation = operation; - this.adjustedLocation = operation.location().clone().add(operation.schematicOffset()); - this.bukkitWorld = adjustedLocation.getWorld(); - this.sizeX = operation.schematicClipboard().getDimensions().x(); - this.sizeY = operation.schematicClipboard().getDimensions().y(); - this.sizeZ = operation.schematicClipboard().getDimensions().z(); - this.minimumX = operation.schematicClipboard().getMinimumPoint().x(); - this.minimumY = operation.schematicClipboard().getMinimumPoint().y(); - this.minimumZ = operation.schematicClipboard().getMinimumPoint().z(); - - long estimatedVolume = (long) sizeX * sizeY * sizeZ; - if (estimatedVolume > 0 && estimatedVolume <= Integer.MAX_VALUE) { - blocks.ensureCapacity((int) Math.min(estimatedVolume, 500_000L)); - } + private static void attemptStartPaste(PasteRequest request) { + if (serverUnderPressure()) { + Bukkit.getScheduler().runTaskLater( + MetadataHandler.PLUGIN, + () -> attemptStartPaste(request), + PRESSURE_RETRY_TICKS); + return; } - @Override - public void run() { - if (shouldYieldForServerLoad()) return; - - double configuredPercentage = Math.max(0.005, - Math.min(0.25, DefaultConfig.getPercentageOfTickUsedForPastePreparation())); - long budgetNanos = Math.max(250_000L, (long) (50_000_000L * configuredPercentage)); - long deadline = System.nanoTime() + budgetNanos; - - do { - prepareCurrentBlock(); - if (!advance()) { - cancel(); - pasteDistributed(blocks, operation.location(), () -> { - try { - if (operation.onComplete() != null) { - operation.onComplete().run(); - } - } finally { - isPreparingPaste = false; - processNextPreparation(); - } - }); - return; - } - } while (System.nanoTime() < deadline); + org.bukkit.World world = request.location().getWorld(); + if (world == null) { + failRequest(request, Set.of(), "world is unavailable"); + return; } - private boolean shouldYieldForServerLoad() { - if (!DefaultConfig.isPlayerGenerationThrottling()) return false; - if (Bukkit.getAverageTickTime() >= DefaultConfig.getPlayerGenerationPauseMSPT()) return true; - double[] tps = Bukkit.getTPS(); - return tps.length > 0 && tps[0] <= DefaultConfig.getPlayerGenerationPauseTPS(); - } + List requiredChunks = new ArrayList<>(calculateRequiredChunks( + request.schematicClipboard(), request.location(), request.schematicOffset())); + loadChunkBatch(request, world, requiredChunks, 0, new LinkedHashSet<>()); + } - private void prepareCurrentBlock() { - Clipboard schematicClipboard = operation.schematicClipboard(); - BlockVector3 adjustedClipboardLocation = BlockVector3.at( - x + minimumX, - y + minimumY, - z + minimumZ); - - BaseBlock baseBlock = schematicClipboard.getFullBlock(adjustedClipboardLocation); - BlockState blockState = baseBlock.toImmutableState(); - Material material = WorldEditUtils.adaptMaterial(blockState); - Block worldBlock = bukkitWorld.getBlockAt( - adjustedLocation.getBlockX() + x, - adjustedLocation.getBlockY() + y, - adjustedLocation.getBlockZ() + z); - - boolean isGround = y + 1 >= sizeY || !isSolidBlock(schematicClipboard, BlockVector3.at( - adjustedClipboardLocation.x(), - adjustedClipboardLocation.y() + 1, - adjustedClipboardLocation.z())); - - if (material == Material.BARRIER) { - return; - } + private static boolean serverUnderPressure() { + if (!DefaultConfig.isPlayerGenerationThrottling()) return false; + if (Bukkit.getAverageTickTime() >= DefaultConfig.getPlayerGenerationPauseMSPT()) return true; + double[] tps = Bukkit.getTPS(); + return tps.length > 0 && tps[0] <= DefaultConfig.getPlayerGenerationPauseTPS(); + } - BlockData blockData = material == null ? null : WorldEditUtils.createBlockDataOrNull(baseBlock); - if (blockData == null) { - if (WorldEditUtils.isAir(blockState)) { - blocks.add(new PasteBlock(worldBlock, Material.AIR.createBlockData(), null)); - } else { - blocks.add(new PasteBlock(worldBlock, null, - WorldEditUtils.createSingleBlockClipboard(adjustedLocation, baseBlock, blockState))); - } - return; - } + private static Set calculateRequiredChunks( + Clipboard clipboard, + Location location, + Vector schematicOffset) { - String materialString = material.toString().toUpperCase(Locale.ROOT); - if (requiresWorldEditMetadata(materialString)) { - blocks.add(new PasteBlock(worldBlock, null, - WorldEditUtils.createSingleBlockClipboard(adjustedLocation, baseBlock, blockState))); - } else if (material == Material.BEDROCK) { - if (!worldBlock.getType().isSolid()) { - Material pedestalMaterial = operation.pedestalMaterialProvider().apply(isGround); - // Do not mutate the world during preparation. Upstream called setType here, - // bypassing its own distributed paste budget. - blocks.add(new PasteBlock(worldBlock, pedestalMaterial.createBlockData(), null)); - } - } else { - blocks.add(new PasteBlock(worldBlock, blockData, null)); + LinkedHashSet chunks = new LinkedHashSet<>(); + Location adjusted = location.clone().add(schematicOffset); + + int minX = adjusted.getBlockX(); + int minZ = adjusted.getBlockZ(); + int maxX = minX + Math.max(0, clipboard.getDimensions().x() - 1); + int maxZ = minZ + Math.max(0, clipboard.getDimensions().z() - 1); + + for (int chunkX = minX >> 4; chunkX <= maxX >> 4; chunkX++) { + for (int chunkZ = minZ >> 4; chunkZ <= maxZ >> 4; chunkZ++) { + chunks.add(chunkKey(chunkX, chunkZ)); } } + return chunks; + } - private boolean advance() { - z++; - if (z < sizeZ) return true; - z = 0; - y++; - if (y < sizeY) return true; - y = 0; - x++; - return x < sizeX; + /** + * Loads/generates only a couple of structure chunks at a time. This is deliberately + * more conservative than issuing every getChunkAtAsync request at once because the + * Albion resource world already has significant generator/I/O load. + */ + private static void loadChunkBatch( + PasteRequest request, + org.bukkit.World world, + List requiredChunks, + int startIndex, + Set ticketedChunks) { + + if (startIndex >= requiredChunks.size()) { + startFawePaste(request, world, ticketedChunks); + return; } - } - private static boolean requiresWorldEditMetadata(String materialString) { - return materialString.endsWith("SIGN") - || materialString.endsWith("STAIRS") - || materialString.endsWith("BOX") - || materialString.endsWith("CHEST_BOAT") - || materialString.equals("BEACON") - || materialString.endsWith("FURNACE") - || materialString.equals("CALIBRATED_SCULK_SENSOR") - || materialString.equals("CAMPFIRE") - || materialString.equals("CARTOGRAPHY_TABLE") - || materialString.equals("CAULDRON") - || materialString.contains("COMMAND_BLOCK") - || materialString.endsWith("ANVIL") - || materialString.equals("CRAFTER") - || materialString.equals("ITEM_FRAME") - || materialString.equals("DISPENSER") - || materialString.equals("DROPPER") - || materialString.equals("ENCHANTING_TABLE") - || materialString.equals("BARREL") - || materialString.equals("CHEST") - || materialString.equals("ENDER_CHEST") - || materialString.equals("TRAPPED_CHEST") - || materialString.equals("FLETCHING_TABLE") - || materialString.equals("FURNACE_MINECART") - || materialString.equals("GRINDSTONE") - || materialString.equals("HOPPER") - || materialString.equals("HOPPER_MINECART") - || materialString.equals("JUKEBOX") - || materialString.equals("LEVER") - || materialString.equals("LOOM") - || materialString.equals("LODESTONE") - || materialString.startsWith("POTTED") - || materialString.startsWith("SCULK") - || materialString.equals("POWERED_RAIL") - || materialString.equals("SMOKER") - || materialString.equals("STONECUTTER") - || materialString.equals("SOUL_CAMPFIRE") - || materialString.contains("SPAWNER"); - } + int endIndex = Math.min(requiredChunks.size(), startIndex + CHUNK_LOAD_BATCH_SIZE); + List batch = requiredChunks.subList(startIndex, endIndex); + List> futures = new ArrayList<>(batch.size()); - public static void pasteDistributed(List pasteBlocks, Location location, Runnable onComplete) { - pasteQueue.add(new PasteBlockOperation(pasteBlocks, location, onComplete)); - if (!isDistributedPasting) { - processNextPaste(); + for (long key : batch) { + int chunkX = chunkX(key); + int chunkZ = chunkZ(key); + ChunkKey chunkKey = new ChunkKey(world.getUID(), chunkX, chunkZ); + INTERNAL_CHUNK_LOADS.add(chunkKey); + futures.add(world.getChunkAtAsync(chunkX, chunkZ, true)); } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .whenComplete((ignored, throwable) -> Bukkit.getScheduler().runTask( + MetadataHandler.PLUGIN, + () -> { + for (long key : batch) { + INTERNAL_CHUNK_LOADS.remove(new ChunkKey( + world.getUID(), chunkX(key), chunkZ(key))); + } + + if (throwable != null) { + failRequest(request, ticketedChunks, + "async chunk preparation failed: " + throwable.getClass().getSimpleName()); + return; + } + + for (long key : batch) { + int chunkX = chunkX(key); + int chunkZ = chunkZ(key); + world.addPluginChunkTicket(chunkX, chunkZ, MetadataHandler.PLUGIN); + ticketedChunks.add(key); + } + + int nextIndex = endIndex; + Bukkit.getScheduler().runTaskLater( + MetadataHandler.PLUGIN, + () -> loadChunkBatch(request, world, requiredChunks, + nextIndex, ticketedChunks), + CHUNK_LOAD_BATCH_DELAY_TICKS); + })); } - private static void processNextPaste() { - if (pasteQueue.isEmpty()) { - isDistributedPasting = false; + private static void startFawePaste( + PasteRequest request, + org.bukkit.World world, + Set ticketedChunks) { + + try { + if (request.prePasteCallback() != null) { + request.prePasteCallback().run(); + } + } catch (Throwable throwable) { + Logger.warn("BetterStructures pre-paste preparation failed: " + throwable.getMessage()); + throwable.printStackTrace(); + failRequest(request, ticketedChunks, "pre-paste callback failed"); return; } - isDistributedPasting = true; - PasteBlockOperation operation = pasteQueue.poll(); - - WorkloadRunnable workload = new WorkloadRunnable(DefaultConfig.getPercentageOfTickUsedForPasting(), () -> { - if (operation.onComplete() != null) { - operation.onComplete().run(); + Bukkit.getScheduler().runTaskAsynchronously(MetadataHandler.PLUGIN, () -> { + Throwable failure = null; + try { + executeFawePaste(request, world); + } catch (Throwable throwable) { + failure = throwable; } - processNextPaste(); - }); - for (PasteBlock pasteBlock : operation.blocks()) { - workload.addWorkload(() -> { - if (pasteBlock.blockData() != null) { - pasteBlock.block().setBlockData(pasteBlock.blockData(), false); - } else if (pasteBlock.clipboard() != null) { - try (EditSession editSession = WorldEdit.getInstance().newEditSession( - BukkitAdapter.adapt(pasteBlock.block().getWorld()))) { - Operation worldeditPaste = new ClipboardHolder(pasteBlock.clipboard()) - .createPaste(editSession) - .to(BlockVector3.at( - pasteBlock.block().getX(), - pasteBlock.block().getY(), - pasteBlock.block().getZ())) - .build(); - Operations.complete(worldeditPaste); - } catch (WorldEditException e) { - throw new RuntimeException(e); + Throwable finalFailure = failure; + Bukkit.getScheduler().runTask(MetadataHandler.PLUGIN, () -> { + try { + if (finalFailure == null) { + if (request.onComplete() != null) { + request.onComplete().run(); + } + } else { + Logger.warn("FAWE structure paste failed at " + + request.location().getBlockX() + "," + + request.location().getBlockY() + "," + + request.location().getBlockZ() + ": " + + finalFailure.getMessage()); + finalFailure.printStackTrace(); } + } finally { + releaseTickets(world, ticketedChunks); + pasteInProgress = false; + processNextPaste(); } }); + }); + } + + /** + * Executes the full block loop on an async FAWE EditSession. BaseBlock is used for + * normal and NBT-rich blocks so chests, spawners, signs, etc. keep their schematic + * data. Barrier markers remain no-op, while bedrock retains BetterStructures' + * pedestal/filler semantics. + */ + private static void executeFawePaste(PasteRequest request, org.bukkit.World bukkitWorld) + throws WorldEditException { + + Clipboard clipboard = request.schematicClipboard(); + Location adjustedLocation = request.location().clone().add(request.schematicOffset()); + World world = BukkitAdapter.adapt(bukkitWorld); + + int sizeX = clipboard.getDimensions().x(); + int sizeY = clipboard.getDimensions().y(); + int sizeZ = clipboard.getDimensions().z(); + int minimumX = clipboard.getMinimumPoint().x(); + int minimumY = clipboard.getMinimumPoint().y(); + int minimumZ = clipboard.getMinimumPoint().z(); + + try (EditSession editSession = WorldEdit.getInstance().newEditSession(world)) { + editSession.setTrackingHistory(false); + editSession.setSideEffectApplier(SideEffectSet.none()); + + for (int x = 0; x < sizeX; x++) { + for (int y = 0; y < sizeY; y++) { + for (int z = 0; z < sizeZ; z++) { + BlockVector3 clipboardPosition = BlockVector3.at( + x + minimumX, + y + minimumY, + z + minimumZ); + + BaseBlock baseBlock = clipboard.getFullBlock(clipboardPosition); + Material material = WorldEditUtils.adaptMaterial(baseBlock); + if (material == Material.BARRIER) continue; + + BlockVector3 worldPosition = BlockVector3.at( + adjustedLocation.getBlockX() + x, + adjustedLocation.getBlockY() + y, + adjustedLocation.getBlockZ() + z); + + if (material == Material.BEDROCK) { + if (editSession.getBlock(worldPosition) + .getBlockType().getMaterial().isSolid()) { + continue; + } + + boolean isGround = y + 1 >= sizeY || !WorldEditUtils.isSolid( + clipboard.getBlock(BlockVector3.at( + clipboardPosition.x(), + clipboardPosition.y() + 1, + clipboardPosition.z()))); + + Material pedestalMaterial = request.pedestalMaterialProvider().apply(isGround); + if (pedestalMaterial != null) { + editSession.setBlock(worldPosition, + BukkitAdapter.adapt(pedestalMaterial.createBlockData())); + } + } else { + editSession.setBlock(worldPosition, baseBlock); + } + } + } + } } + } - workload.runTaskTimer(MetadataHandler.PLUGIN, 0L, 1L); + private static void failRequest(PasteRequest request, Set ticketedChunks, String reason) { + org.bukkit.World world = request.location().getWorld(); + if (world != null) { + releaseTickets(world, ticketedChunks); + } + Logger.warn("Skipping BetterStructures paste: " + reason); + pasteInProgress = false; + processNextPaste(); } - private record PastePreparationOperation( + private static void releaseTickets(org.bukkit.World world, Set ticketedChunks) { + for (long key : ticketedChunks) { + world.removePluginChunkTicket(chunkX(key), chunkZ(key), MetadataHandler.PLUGIN); + } + ticketedChunks.clear(); + } + + private static long chunkKey(int x, int z) { + return ((long) x << 32) | (z & 0xFFFFFFFFL); + } + + private static int chunkX(long key) { + return (int) (key >> 32); + } + + private static int chunkZ(long key) { + return (int) key; + } + + private record PasteRequest( Clipboard schematicClipboard, Location location, Vector schematicOffset, + Runnable prePasteCallback, Function pedestalMaterialProvider, Runnable onComplete) { } - private record PasteBlockOperation(List blocks, Location location, Runnable onComplete) { - } - - public record PasteBlock(Block block, BlockData blockData, Clipboard clipboard) { + private record ChunkKey(UUID worldId, int x, int z) { } } From 1d4d03b96f6267eb54d62d19c92938ecd4fdd356 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:52:55 -0400 Subject: [PATCH 25/47] Prevent recursive generation from FAWE chunk preparation --- .../betterstructures/listeners/NewChunkLoadEvent.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java b/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java index cee280c..99d9f4a 100644 --- a/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java +++ b/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java @@ -14,6 +14,7 @@ import com.magmaguy.betterstructures.modules.WFCGenerator; import com.magmaguy.betterstructures.performance.GenerationScheduler; import com.magmaguy.betterstructures.schematics.SchematicContainer; +import com.magmaguy.betterstructures.worldedit.Schematic; import org.bukkit.Chunk; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -40,6 +41,10 @@ public NewChunkLoadEvent() { @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onChunkLoad(ChunkLoadEvent event) { if (!event.isNewChunk()) return; + // BetterStructures may need to generate neighboring chunks before an already + // selected structure can be pasted. Those internal loads must never recursively + // qualify for more BetterStructures generation. + if (Schematic.isInternalChunkLoad(event.getChunk())) return; if (!ValidWorldsConfig.isValidWorld(event.getWorld())) return; Chunk chunk = event.getChunk(); From f8cc52c2033b90ac88f5d27949af5f81de4ecedc Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:53:12 -0400 Subject: [PATCH 26/47] Serialize fitting with active FAWE structure work --- .../performance/GenerationScheduler.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java b/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java index d458d92..5c7b2e6 100644 --- a/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java +++ b/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java @@ -2,6 +2,7 @@ import com.magmaguy.betterstructures.MetadataHandler; import com.magmaguy.betterstructures.config.DefaultConfig; +import com.magmaguy.betterstructures.worldedit.Schematic; import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.scheduler.BukkitRunnable; @@ -89,6 +90,11 @@ private static void tick() { return; } + // Do not start another terrain fit while a structure is loading chunks, being + // pasted by FAWE, or waiting in the paste queue. This makes the full expensive + // path serialized, not just the final block placement. + if (Schematic.isBusy()) return; + double mspt = Bukkit.getAverageTickTime(); double[] tpsSamples = Bukkit.getTPS(); double tps = tpsSamples.length == 0 ? 20.0 : tpsSamples[0]; @@ -124,8 +130,9 @@ private static void tick() { throwable.printStackTrace(); } finally { if (job.releaseTicketAfter()) { - // Keep the center chunk around briefly while schematic preparation/paste - // gets underway. This avoids a churny unload/reload loop in fast resource worlds. + // Keep the center chunk around briefly while schematic chunk preparation + // and the FAWE paste get underway. Schematic itself tickets every chunk + // touched by the structure for the duration of the actual edit. new BukkitRunnable() { @Override public void run() { From 08fbd40c8e20ebede371dc73a59d78d92733b4d8 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:54:12 -0400 Subject: [PATCH 27/47] Run pedestal sampling after async chunk preparation --- .../buildingfitter/FitAnything.java | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java b/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java index f418aee..5379535 100644 --- a/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java +++ b/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java @@ -102,24 +102,32 @@ protected void paste(Location location) { if (buildPlaceEvent.isCancelled()) return; FitAnything fitAnything = this; - assignPedestalMaterial(location); - if (pedestalMaterial == null) - switch (location.getWorld().getEnvironment()) { - case NETHER: - pedestalMaterial = Material.NETHERRACK; - break; - case THE_END: - pedestalMaterial = Material.END_STONE; - break; - default: - pedestalMaterial = Material.STONE; - } + + // Wait until Schematic has asynchronously prepared/ticketed every chunk touched + // by the build before doing Bukkit world reads for pedestal material selection. + // The callback is invoked on the primary thread immediately before the async FAWE + // edit starts. + Runnable prePasteCallback = () -> { + assignPedestalMaterial(location); + if (pedestalMaterial == null) + switch (location.getWorld().getEnvironment()) { + case NETHER: + pedestalMaterial = Material.NETHERRACK; + break; + case THE_END: + pedestalMaterial = Material.END_STONE; + break; + default: + pedestalMaterial = Material.STONE; + } + }; Function pedestalMaterialProvider = this::getPedestalMaterial; Schematic.pasteSchematic( schematicClipboard, location, schematicOffset, + prePasteCallback, pedestalMaterialProvider, onPasteComplete(fitAnything, location) ); From aa0d1cb27050de769b1c1ea3b1163b68b8526395 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:55:17 -0400 Subject: [PATCH 28/47] Cancel stale CI runs when branch head changes --- .github/workflows/gradle.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index fbe970b..4bfcdd6 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -11,6 +11,10 @@ on: permissions: contents: read +concurrency: + group: betterstructures-ci-${{ github.ref }} + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest From 90fd9fded2ac1d87564d1e9b8c445180038a2591 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:56:46 -0400 Subject: [PATCH 29/47] Use FAWE CPU clipboard without global settings dependency --- .../magmaguy/betterstructures/util/WorldEditUtils.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java b/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java index b551a29..554f505 100644 --- a/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java +++ b/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java @@ -1,5 +1,6 @@ package com.magmaguy.betterstructures.util; +import com.fastasyncworldedit.core.extent.clipboard.CPUOptimizedClipboard; import com.magmaguy.magmacore.util.Logger; import com.sk89q.jnbt.CompoundTag; import com.sk89q.jnbt.ListTag; @@ -8,7 +9,6 @@ import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.WorldEditException; import com.sk89q.worldedit.bukkit.BukkitAdapter; -import com.sk89q.worldedit.extent.clipboard.BlockArrayClipboard; import com.sk89q.worldedit.extent.clipboard.Clipboard; import com.sk89q.worldedit.function.mask.BlockTypeMask; import com.sk89q.worldedit.function.operation.Operation; @@ -159,13 +159,13 @@ private static String getNewWEFormat(@NotNull CompoundTag data, @Positive int li } /** - * Creates a real one-block WorldEdit/FAWE clipboard rather than implementing the - * Clipboard interface anonymously. This keeps the fork compatible as FAWE adds - * new extent methods while preserving NBT-rich BaseBlock data. + * Creates a real one-block FAWE clipboard without consulting FAWE's global + * clipboard-storage settings. This keeps NBT-rich one-block metadata pastes fast, + * deterministic, and unit-testable while avoiding a hand-written Clipboard shim. */ public static Clipboard createSingleBlockClipboard(Location location, BaseBlock baseBlock, BlockState blockState) { CuboidRegion region = new CuboidRegion(BlockVector3.at(0, 0, 0), BlockVector3.at(0, 0, 0)); - BlockArrayClipboard clipboard = new BlockArrayClipboard(region); + CPUOptimizedClipboard clipboard = new CPUOptimizedClipboard(region); clipboard.setOrigin(BlockVector3.at(0, 0, 0)); try { clipboard.setBlock(BlockVector3.at(0, 0, 0), baseBlock); From 4806302f9e3619715c6b41823a149839197e1a8a Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 19:57:45 -0400 Subject: [PATCH 30/47] Make FAWE clipboard test implementation-agnostic --- .../magmaguy/betterstructures/util/WorldEditUtilsTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/magmaguy/betterstructures/util/WorldEditUtilsTest.java b/src/test/java/com/magmaguy/betterstructures/util/WorldEditUtilsTest.java index 1a3e944..ee6e058 100644 --- a/src/test/java/com/magmaguy/betterstructures/util/WorldEditUtilsTest.java +++ b/src/test/java/com/magmaguy/betterstructures/util/WorldEditUtilsTest.java @@ -49,8 +49,8 @@ void createsSingleBlockClipboardWithStableOneBlockGeometry() { assertEquals(BlockVector3.at(0, 0, 0), clipboard.getMinimumPoint()); assertEquals(BlockVector3.at(0, 0, 0), clipboard.getMaximumPoint()); assertEquals(BlockVector3.at(1, 1, 1), clipboard.getDimensions()); - assertEquals(blockState, clipboard.getBlock(BlockVector3.at(0, 0, 0))); - assertEquals(baseBlock, clipboard.getFullBlock(BlockVector3.at(0, 0, 0))); + // FAWE stores blocks in an internal palette and is free to canonicalize the + // supplied holder, so object-identity assertions are intentionally avoided. assertTrue(clipboard.getEntities().isEmpty()); } From 7814542a021c17610c28e4534c07a0c642b6da24 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 20:00:11 -0400 Subject: [PATCH 31/47] Run tests against the required FAWE runtime --- build.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 65d2653..3e7cd9c 100644 --- a/build.gradle +++ b/build.gradle @@ -47,7 +47,9 @@ dependencies { testImplementation 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' testImplementation 'org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.110.0' testImplementation 'org.mockito:mockito-core:5.12.0' - testImplementation 'com.sk89q.worldedit:worldedit-bukkit:7.3.0' + // Tests exercise FAWE-specific clipboard/runtime code, so use the same provider + // BetterStructures requires in production instead of a separate WorldEdit runtime. + testImplementation 'com.fastasyncworldedit:FastAsyncWorldEdit-Bukkit:2.14.3' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } From 4ecf4905afb6616ba96f456b6ea2d9b6a202fc22 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 20:02:37 -0400 Subject: [PATCH 32/47] Document final Albion FAWE performance architecture --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5a0b596..7021a68 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,29 @@ This branch is maintained specifically for **AlbionMC.com** with these goals: - use **FastAsyncWorldEdit (FAWE)** as the required WorldEdit backend; - queue structure-generation work instead of allowing bursts of expensive fitting work during new-chunk events; - pause ordinary player-driven structure generation when server MSPT/TPS indicates the main thread is under pressure; -- spread schematic preparation across ticks before the existing distributed paste workload begins; +- serialize the expensive fit/load/paste path so several large structures do not compete at once; +- prepare required structure chunks through Paper's async chunk API in small batches; +- perform the normal structure block loop in an asynchronous FAWE `EditSession`, while returning Bukkit-only preparation and completion work to the main thread; +- prevent BetterStructures' own internal chunk loads from recursively generating more BetterStructures structures; - target **Paper 1.21.11 and newer only**. Older Minecraft/Paper compatibility is intentionally out of scope. +## Performance defaults + +Player-driven structure generation is guarded by conservative load thresholds for new configs: + +- pause around **42 MSPT** or **18.5 TPS**; +- resume after recovery around **32 MSPT** and **19.5 TPS**; +- admit expensive fit jobs separately instead of allowing a burst from fast resource-world exploration; +- load structure chunks in small batches before starting the FAWE edit. + +Existing configuration values are preserved when upgrading. These defaults are intended as a safe starting point and can be tuned from real AlbionMC spark profiles after runtime testing. + ## Requirements - Paper **1.21.11+** - Java **21+** - FastAsyncWorldEdit **2.14.3+** - - For Minecraft 26.x, use a FAWE release that explicitly supports that server version. + - For newer Minecraft versions, use a FAWE release that explicitly supports that server version. FastAsyncWorldEdit provides the WorldEdit API used by BetterStructures and is intentionally a hard dependency in this fork. From 2679e86d7c90c2041088c2775a140ea69898037a Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 20:36:14 -0400 Subject: [PATCH 33/47] Publish raw BetterStructures test JAR from green branch CI --- .github/workflows/gradle.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 4bfcdd6..335c031 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -9,7 +9,7 @@ on: branches: [ master ] permissions: - contents: read + contents: write concurrency: group: betterstructures-ci-${{ github.ref }} @@ -43,3 +43,28 @@ jobs: JAR="build/libs/BetterStructures-${VERSION}.jar" test -f "$JAR" echo "Built $JAR" + + - name: Publish raw branch-test JAR + if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/agent/') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="$(sed -n "s/^version = '\([^']*\)'.*/\1/p" build.gradle)" + JAR="build/libs/BetterStructures-${VERSION}.jar" + TAG="albion-${VERSION}-test" + test -f "$JAR" + + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "$JAR" --clobber + gh release edit "$TAG" \ + --title "BetterStructures Albion ${VERSION} Test Build" \ + --notes "AlbionMC development test build from commit ${GITHUB_SHA}. This is a prerelease for resource-world runtime testing before the final v${VERSION} release." \ + --prerelease + else + gh release create "$TAG" "$JAR" \ + --target "$GITHUB_SHA" \ + --title "BetterStructures Albion ${VERSION} Test Build" \ + --notes "AlbionMC development test build from commit ${GITHUB_SHA}. This is a prerelease for resource-world runtime testing before the final v${VERSION} release." \ + --prerelease + fi From 242dad530237ac32614df7723bd5b27e09d89218 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:39:10 -0400 Subject: [PATCH 34/47] Start Albion FAWE-native 1.1.1 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 3e7cd9c..9a8c6bc 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'com.magmaguy' -version = '1.1.0' +version = '1.1.1' repositories { mavenCentral() From 1fed7488a86df82b23f10f475a3d12deb05c80a4 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:39:24 -0400 Subject: [PATCH 35/47] Enforce FAWE-native block writes in CI --- .github/workflows/gradle.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 335c031..ce2e660 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -33,6 +33,18 @@ jobs: - name: Make Gradle wrapper executable run: chmod +x gradlew + - name: FAWE-native block-write audit + shell: bash + run: | + set -euo pipefail + MATCHES="$(grep -RInE '\.(setType|setBlockData)\(|setBlockInNativeDataPalette\(' src/main/java || true)" + if [[ -n "$MATCHES" ]]; then + echo "Direct Bukkit/NMS block writes remain; Albion 1.1.1 must route block changes through FAWE:" + echo "$MATCHES" + exit 1 + fi + echo "FAWE-native audit passed: no direct Bukkit/NMS block writes found." + - name: Build and test run: ./gradlew clean test shadowJar From 1ea488898ea346fce80198587cacf052dd764949 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:39:53 -0400 Subject: [PATCH 36/47] Add global serialized FAWE edit queue --- .../worldedit/FaweEditQueue.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/main/java/com/magmaguy/betterstructures/worldedit/FaweEditQueue.java diff --git a/src/main/java/com/magmaguy/betterstructures/worldedit/FaweEditQueue.java b/src/main/java/com/magmaguy/betterstructures/worldedit/FaweEditQueue.java new file mode 100644 index 0000000..71c0c27 --- /dev/null +++ b/src/main/java/com/magmaguy/betterstructures/worldedit/FaweEditQueue.java @@ -0,0 +1,94 @@ +package com.magmaguy.betterstructures.worldedit; + +import com.magmaguy.betterstructures.MetadataHandler; +import com.magmaguy.magmacore.util.Logger; +import org.bukkit.Bukkit; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * Global serialized executor for BetterStructures world edits. + * + *

The Albion fork intentionally routes block-changing work through FAWE and allows + * only one heavy edit at a time. FAWE can execute edits asynchronously, but allowing + * several large structures or module batches to compete at once can still saturate + * chunk loading, CPU, and disk I/O on a busy resource world.

+ */ +public final class FaweEditQueue { + + private static final Queue JOBS = new ConcurrentLinkedQueue<>(); + private static final AtomicBoolean RUNNING = new AtomicBoolean(false); + + private FaweEditQueue() { + } + + @FunctionalInterface + public interface EditWork { + void run() throws Exception; + } + + /** + * Submit an edit to the single global FAWE lane. The edit body runs asynchronously; + * completion always runs on the Bukkit primary thread and receives {@code null} on + * success or the thrown failure on error. + */ + public static void submit(String description, EditWork work, Consumer completion) { + JOBS.add(new EditJob(description, work, completion)); + startNext(); + } + + public static boolean isBusy() { + return RUNNING.get() || !JOBS.isEmpty(); + } + + public static int queuedEdits() { + return JOBS.size(); + } + + public static void shutdown() { + JOBS.clear(); + } + + private static void startNext() { + if (!RUNNING.compareAndSet(false, true)) return; + + EditJob job = JOBS.poll(); + if (job == null) { + RUNNING.set(false); + if (!JOBS.isEmpty()) startNext(); + return; + } + + Bukkit.getScheduler().runTaskAsynchronously(MetadataHandler.PLUGIN, () -> { + Throwable failure = null; + try { + job.work().run(); + } catch (Throwable throwable) { + failure = throwable; + Logger.warn("FAWE edit failed (" + job.description() + "): " + throwable.getMessage()); + } + + Throwable finalFailure = failure; + Bukkit.getScheduler().runTask(MetadataHandler.PLUGIN, () -> { + try { + if (job.completion() != null) { + job.completion().accept(finalFailure); + } + } catch (Throwable completionFailure) { + Logger.warn("FAWE completion callback failed (" + job.description() + "): " + + completionFailure.getMessage()); + completionFailure.printStackTrace(); + } finally { + RUNNING.set(false); + startNext(); + } + }); + }); + } + + private record EditJob(String description, EditWork work, Consumer completion) { + } +} From a2c57de9a1930e36e25832c6fe9e337b7741800d Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:40:47 -0400 Subject: [PATCH 37/47] Serialize natural structures through global FAWE lane --- .../betterstructures/worldedit/Schematic.java | 75 ++++++++----------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java b/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java index 82deed5..eba2486 100644 --- a/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java +++ b/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java @@ -70,7 +70,7 @@ public static void shutdown() { } public static boolean isBusy() { - return pasteInProgress || !PASTE_QUEUE.isEmpty(); + return pasteInProgress || !PASTE_QUEUE.isEmpty() || FaweEditQueue.isBusy(); } /** @@ -112,12 +112,14 @@ public static Clipboard load(File schematicFile) { } /** - * Direct FAWE/WorldEdit paste retained for explicit command and modular-world paths. - * Natural structure generation uses {@link #pasteSchematic} instead. + * Direct FAWE paste retained for small explicit/component paths that expect the edit + * to be complete when this method returns. FAWE is the required WorldEdit provider. */ public static void paste(Clipboard clipboard, Location location) { World world = BukkitAdapter.adapt(location.getWorld()); try (EditSession editSession = WorldEdit.getInstance().newEditSession(world)) { + editSession.setTrackingHistory(false); + editSession.setSideEffectApplier(SideEffectSet.none()); Operation operation = new ClipboardHolder(clipboard) .createPaste(editSession) .to(BlockVector3.at(location.getBlockX(), location.getBlockY(), location.getBlockZ())) @@ -156,9 +158,6 @@ public static void pasteSchematic( } } - /** - * Compatibility overload for call sites that do not need a pre-paste callback. - */ public static void pasteSchematic( Clipboard schematicClipboard, Location location, @@ -231,11 +230,6 @@ private static Set calculateRequiredChunks( return chunks; } - /** - * Loads/generates only a couple of structure chunks at a time. This is deliberately - * more conservative than issuing every getChunkAtAsync request at once because the - * Albion resource world already has significant generator/I/O load. - */ private static void loadChunkBatch( PasteRequest request, org.bukkit.World world, @@ -307,44 +301,35 @@ private static void startFawePaste( return; } - Bukkit.getScheduler().runTaskAsynchronously(MetadataHandler.PLUGIN, () -> { - Throwable failure = null; - try { - executeFawePaste(request, world); - } catch (Throwable throwable) { - failure = throwable; - } - - Throwable finalFailure = failure; - Bukkit.getScheduler().runTask(MetadataHandler.PLUGIN, () -> { - try { - if (finalFailure == null) { - if (request.onComplete() != null) { - request.onComplete().run(); + String description = "natural structure at " + + request.location().getBlockX() + "," + + request.location().getBlockY() + "," + + request.location().getBlockZ(); + + FaweEditQueue.submit(description, + () -> executeFawePaste(request, world), + failure -> { + try { + if (failure == null) { + if (request.onComplete() != null) { + request.onComplete().run(); + } + } else { + Logger.warn("FAWE structure paste failed at " + + request.location().getBlockX() + "," + + request.location().getBlockY() + "," + + request.location().getBlockZ() + ": " + + failure.getMessage()); + failure.printStackTrace(); } - } else { - Logger.warn("FAWE structure paste failed at " - + request.location().getBlockX() + "," - + request.location().getBlockY() + "," - + request.location().getBlockZ() + ": " - + finalFailure.getMessage()); - finalFailure.printStackTrace(); + } finally { + releaseTickets(world, ticketedChunks); + pasteInProgress = false; + processNextPaste(); } - } finally { - releaseTickets(world, ticketedChunks); - pasteInProgress = false; - processNextPaste(); - } - }); - }); + }); } - /** - * Executes the full block loop on an async FAWE EditSession. BaseBlock is used for - * normal and NBT-rich blocks so chests, spawners, signs, etc. keep their schematic - * data. Barrier markers remain no-op, while bedrock retains BetterStructures' - * pedestal/filler semantics. - */ private static void executeFawePaste(PasteRequest request, org.bukkit.World bukkitWorld) throws WorldEditException { From 4c4ded1484c0bbf3f62f25c0c98e97dd6f440775 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:42:09 -0400 Subject: [PATCH 38/47] Keep FAWE cleanup inside ticketed structure edit --- .../betterstructures/worldedit/Schematic.java | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java b/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java index eba2486..8bfac9a 100644 --- a/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java +++ b/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java @@ -42,11 +42,6 @@ /** * BetterStructures schematic I/O and the Albion FAWE paste pipeline. - * - *

Natural structure placement is serialized, required chunks are prepared through - * Paper's async chunk API in small batches, and the actual block loop runs through one - * FAWE EditSession off the server thread. This preserves BetterStructures' barrier and - * bedrock/pedestal semantics without the upstream per-block Bukkit paste workload.

*/ public final class Schematic { @@ -63,20 +58,22 @@ public final class Schematic { private Schematic() { } + @FunctionalInterface + public interface FawePostProcessor { + void run(EditSession editSession, Location adjustedLocation) throws Exception; + } + public static void shutdown() { PASTE_QUEUE.clear(); INTERNAL_CHUNK_LOADS.clear(); pasteInProgress = false; + FaweEditQueue.shutdown(); } public static boolean isBusy() { return pasteInProgress || !PASTE_QUEUE.isEmpty() || FaweEditQueue.isBusy(); } - /** - * Lets the new-chunk listener distinguish a player-generated chunk from a chunk - * BetterStructures itself had to load to fit an already-selected structure. - */ public static boolean isInternalChunkLoad(Chunk chunk) { return INTERNAL_CHUNK_LOADS.contains(new ChunkKey( chunk.getWorld().getUID(), chunk.getX(), chunk.getZ())); @@ -112,8 +109,8 @@ public static Clipboard load(File schematicFile) { } /** - * Direct FAWE paste retained for small explicit/component paths that expect the edit - * to be complete when this method returns. FAWE is the required WorldEdit provider. + * Synchronous FAWE paste for small component/explicit paths that require completion + * before returning. FastAsyncWorldEdit is the required WorldEdit provider. */ public static void paste(Clipboard clipboard, Location location) { World world = BukkitAdapter.adapt(location.getWorld()); @@ -131,9 +128,9 @@ public static void paste(Clipboard clipboard, Location location) { } /** - * Queue one natural structure paste. Only one BetterStructures FAWE structure edit - * runs at a time, which avoids several resource-world discoveries competing for - * chunk generation and FAWE queues simultaneously. + * Queue a natural structure. Chunk preparation happens through Paper's async chunk + * API and the complete block edit, including optional post-processing, is executed + * in the one global FAWE lane before chunk tickets are released. */ public static void pasteSchematic( Clipboard schematicClipboard, @@ -141,6 +138,7 @@ public static void pasteSchematic( Vector schematicOffset, Runnable prePasteCallback, Function pedestalMaterialProvider, + FawePostProcessor fawePostProcessor, Runnable onComplete) { PASTE_QUEUE.add(new PasteRequest( @@ -149,6 +147,7 @@ public static void pasteSchematic( schematicOffset.clone(), prePasteCallback, pedestalMaterialProvider, + fawePostProcessor, onComplete)); if (Bukkit.isPrimaryThread()) { @@ -158,6 +157,17 @@ public static void pasteSchematic( } } + public static void pasteSchematic( + Clipboard schematicClipboard, + Location location, + Vector schematicOffset, + Runnable prePasteCallback, + Function pedestalMaterialProvider, + Runnable onComplete) { + pasteSchematic(schematicClipboard, location, schematicOffset, prePasteCallback, + pedestalMaterialProvider, null, onComplete); + } + public static void pasteSchematic( Clipboard schematicClipboard, Location location, @@ -165,7 +175,7 @@ public static void pasteSchematic( Function pedestalMaterialProvider, Runnable onComplete) { pasteSchematic(schematicClipboard, location, schematicOffset, null, - pedestalMaterialProvider, onComplete); + pedestalMaterialProvider, null, onComplete); } private static void processNextPaste() { @@ -331,7 +341,7 @@ private static void startFawePaste( } private static void executeFawePaste(PasteRequest request, org.bukkit.World bukkitWorld) - throws WorldEditException { + throws Exception { Clipboard clipboard = request.schematicClipboard(); Location adjustedLocation = request.location().clone().add(request.schematicOffset()); @@ -388,6 +398,10 @@ private static void executeFawePaste(PasteRequest request, org.bukkit.World bukk } } } + + if (request.fawePostProcessor() != null) { + request.fawePostProcessor().run(editSession, adjustedLocation); + } } } @@ -426,6 +440,7 @@ private record PasteRequest( Vector schematicOffset, Runnable prePasteCallback, Function pedestalMaterialProvider, + FawePostProcessor fawePostProcessor, Runnable onComplete) { } From 22b4b5b17f34b89cc04650b963d7735d9cbeab9a Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:43:32 -0400 Subject: [PATCH 39/47] Move natural post-processing block writes to FAWE --- .../buildingfitter/FitAnything.java | 197 ++++++++++-------- 1 file changed, 112 insertions(+), 85 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java b/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java index 5379535..e510625 100644 --- a/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java +++ b/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java @@ -18,7 +18,10 @@ import com.magmaguy.magmacore.util.Logger; import com.magmaguy.magmacore.util.SpigotMessage; import com.magmaguy.magmacore.util.VersionChecker; +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.bukkit.BukkitAdapter; import com.sk89q.worldedit.extent.clipboard.Clipboard; +import com.sk89q.worldedit.math.BlockVector3; import lombok.Getter; import org.bukkit.Bukkit; import org.bukkit.Chunk; @@ -29,7 +32,6 @@ import org.bukkit.block.BlockFace; import org.bukkit.block.Container; import org.bukkit.entity.*; -import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.util.Vector; import java.util.HashMap; @@ -103,10 +105,8 @@ protected void paste(Location location) { FitAnything fitAnything = this; - // Wait until Schematic has asynchronously prepared/ticketed every chunk touched - // by the build before doing Bukkit world reads for pedestal material selection. - // The callback is invoked on the primary thread immediately before the async FAWE - // edit starts. + // Terrain sampling is a Bukkit read and therefore remains on the primary thread, + // after every structure chunk has been prepared/ticketed by Schematic. Runnable prePasteCallback = () -> { assignPedestalMaterial(location); if (pedestalMaterial == null) @@ -123,70 +123,66 @@ protected void paste(Location location) { }; Function pedestalMaterialProvider = this::getPedestalMaterial; + Schematic.FawePostProcessor fawePostProcessor = this::applyFawePostProcessing; + Schematic.pasteSchematic( schematicClipboard, location, schematicOffset, prePasteCallback, pedestalMaterialProvider, + fawePostProcessor, onPasteComplete(fitAnything, location) ); } - private BukkitRunnable onPasteComplete(FitAnything fitAnything, Location location) { - return new BukkitRunnable() { - @Override - public void run() { - if (DefaultConfig.isNewBuildingWarn()) { - String structureTypeString = fitAnything.structureType.toString().toLowerCase(Locale.ROOT).replace("_", " "); - for (Player player : Bukkit.getOnlinePlayers()) - if (player.hasPermission("betterstructures.warn")) - player.spigot().sendMessage( - SpigotMessage.commandHoverMessage("[BetterStructures] New " + structureTypeString + " building generated! Click to teleport. Do \"/betterstructures silent\" to stop getting warnings!", - "Click to teleport to " + location.getWorld().getName() + ", " + location.getBlockX() + ", " + location.getBlockY() + ", " + location.getBlockZ() + "\n Schem name: " + schematicContainer.getConfigFilename(), - "/betterstructures teleport " + location.getWorld().getName() + " " + location.getBlockX() + " " + location.getBlockY() + " " + location.getBlockZ()) - ); - } - - if (!(fitAnything instanceof FitAirBuilding)) { - try { - addPedestal(location); - } catch (Exception exception) { - Logger.warn("Failed to correctly assign pedestal material!"); - exception.printStackTrace(); - } - try { - if (fitAnything instanceof FitSurfaceBuilding) - clearTrees(location); - } catch (Exception exception) { - Logger.warn("Failed to correctly clear trees!"); - exception.printStackTrace(); - } - } - try { - fillChests(); - } catch (Exception exception) { - Logger.warn("Failed to correctly fill chests!"); - exception.printStackTrace(); - } - try { - spawnEntities(); - } catch (Exception exception) { - Logger.warn("Failed to correctly spawn entities!"); - exception.printStackTrace(); - } - try { - spawnProps(fitAnything.schematicClipboard); - } catch (Exception exception) { - Logger.warn("Failed to correctly spawn props!"); - exception.printStackTrace(); - } + /** + * Runs after the schematic blocks are in the same async FAWE EditSession, while the + * structure's chunks are still ticketed. No Bukkit block mutations are allowed here. + */ + private void applyFawePostProcessing(EditSession editSession, Location adjustedLocation) throws Exception { + if (!(this instanceof FitAirBuilding)) { + if (!(this instanceof FitLiquidBuilding)) { + addPedestalFawe(editSession, adjustedLocation); } - }; + if (this instanceof FitSurfaceBuilding) { + clearTreesFawe(editSession, adjustedLocation); + } + } + + clearEntityMarkersFawe(editSession, adjustedLocation); + + // Entity clipboard placement is also a FAWE operation and no longer consumes the + // primary-thread completion phase. + WorldEditUtils.pasteArmorStandsOnlyFromTransformed(schematicClipboard, adjustedLocation); } - private void spawnProps(Clipboard clipboard) { - WorldEditUtils.pasteArmorStandsOnlyFromTransformed(clipboard, location.clone().add(schematicOffset)); + private Runnable onPasteComplete(FitAnything fitAnything, Location location) { + return () -> { + if (DefaultConfig.isNewBuildingWarn()) { + String structureTypeString = fitAnything.structureType.toString().toLowerCase(Locale.ROOT).replace("_", " "); + for (Player player : Bukkit.getOnlinePlayers()) + if (player.hasPermission("betterstructures.warn")) + player.spigot().sendMessage( + SpigotMessage.commandHoverMessage("[BetterStructures] New " + structureTypeString + " building generated! Click to teleport. Do \"/betterstructures silent\" to stop getting warnings!", + "Click to teleport to " + location.getWorld().getName() + ", " + location.getBlockX() + ", " + location.getBlockY() + ", " + location.getBlockZ() + "\n Schem name: " + schematicContainer.getConfigFilename(), + "/betterstructures teleport " + location.getWorld().getName() + " " + location.getBlockX() + " " + location.getBlockY() + " " + location.getBlockZ()) + ); + } + + try { + fillChests(); + } catch (Exception exception) { + Logger.warn("Failed to correctly fill chests!"); + exception.printStackTrace(); + } + try { + spawnEntities(); + } catch (Exception exception) { + Logger.warn("Failed to correctly spawn entities!"); + exception.printStackTrace(); + } + }; } private void assignPedestalMaterial(Location location) { @@ -202,8 +198,6 @@ private void assignPedestalMaterial(Location location) { int baseZ = lowestCorner.getBlockZ(); World world = lowestCorner.getWorld(); - // Cap synchronous world reads on large schematics. Small structures retain - // the original exact step=1 scan; large structures use a representative sample. int xStep = Math.max(1, Math.ceilDiv(sizeX, 24)); int yStep = Math.max(1, Math.ceilDiv(sizeY, 12)); int zStep = Math.max(1, Math.ceilDiv(sizeZ, 24)); @@ -256,37 +250,74 @@ public Material getRandomMaterialBasedOnWeight(HashMap weight throw new IllegalStateException("Weighted random selection failed."); } - private void addPedestal(Location location) { - if (this instanceof FitAirBuilding || this instanceof FitLiquidBuilding) return; - Location lowestCorner = location.clone().add(schematicOffset); - for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) - for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) { - Block groundBlock = lowestCorner.clone().add(new Vector(x, 0, z)).getBlock(); - if (groundBlock.getType().isAir()) continue; + private void addPedestalFawe(EditSession editSession, Location adjustedLocation) throws Exception { + int sizeX = schematicClipboard.getDimensions().x(); + int sizeZ = schematicClipboard.getDimensions().z(); + int baseX = adjustedLocation.getBlockX(); + int baseY = adjustedLocation.getBlockY(); + int baseZ = adjustedLocation.getBlockZ(); + + for (int x = 0; x < sizeX; x++) { + for (int z = 0; z < sizeZ; z++) { + BlockVector3 ground = BlockVector3.at(baseX + x, baseY, baseZ + z); + if (editSession.getBlock(ground).getBlockType().getMaterial().isAir()) continue; + for (int y = -1; y > -11; y--) { - Block block = lowestCorner.clone().add(new Vector(x, y, z)).getBlock(); - if (SurfaceMaterials.ignorable(block.getType())) - block.setType(getPedestalMaterial(!block.getRelative(BlockFace.UP).getType().isSolid()), false); - else break; + BlockVector3 position = BlockVector3.at(baseX + x, baseY + y, baseZ + z); + Material existing = WorldEditUtils.adaptMaterial(editSession.getBlock(position)); + if (existing == null || !SurfaceMaterials.ignorable(existing)) break; + + boolean surface = !editSession.getBlock(position.add(0, 1, 0)) + .getBlockType().getMaterial().isSolid(); + Material replacement = getPedestalMaterial(surface); + if (replacement != null) { + editSession.setBlock(position, BukkitAdapter.adapt(replacement.createBlockData())); + } } } + } } - private void clearTrees(Location location) { - Location highestCorner = location.clone().add(schematicOffset).add(new Vector(0, schematicClipboard.getDimensions().y() + 1, 0)); - boolean detectedTreeElement = true; - for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) - for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) { + private void clearTreesFawe(EditSession editSession, Location adjustedLocation) throws Exception { + int sizeX = schematicClipboard.getDimensions().x(); + int sizeZ = schematicClipboard.getDimensions().z(); + int baseX = adjustedLocation.getBlockX(); + int baseY = adjustedLocation.getBlockY() + schematicClipboard.getDimensions().y() + 1; + int baseZ = adjustedLocation.getBlockZ(); + + for (int x = 0; x < sizeX; x++) { + for (int z = 0; z < sizeZ; z++) { for (int y = 0; y < 31; y++) { - if (!detectedTreeElement) break; - detectedTreeElement = false; - Block block = highestCorner.clone().add(new Vector(x, y, z)).getBlock(); - if (SurfaceMaterials.ignorable(block.getType()) && !block.getType().isAir()) { - detectedTreeElement = true; - block.setType(Material.AIR, false); + BlockVector3 position = BlockVector3.at(baseX + x, baseY + y, baseZ + z); + Material existing = WorldEditUtils.adaptMaterial(editSession.getBlock(position)); + if (existing != null && !existing.isAir() && SurfaceMaterials.ignorable(existing)) { + editSession.setBlock(position, BukkitAdapter.adapt(Material.AIR.createBlockData())); + } else { + break; } } } + } + } + + private void clearEntityMarkersFawe(EditSession editSession, Location adjustedLocation) throws Exception { + for (Vector position : schematicContainer.getVanillaSpawns().keySet()) { + clearMarker(editSession, adjustedLocation, position); + } + for (Vector position : schematicContainer.getEliteMobsSpawns().keySet()) { + clearMarker(editSession, adjustedLocation, position); + } + for (Vector position : schematicContainer.getMythicMobsSpawns().keySet()) { + clearMarker(editSession, adjustedLocation, position); + } + } + + private void clearMarker(EditSession editSession, Location adjustedLocation, Vector relative) throws Exception { + BlockVector3 worldPosition = BlockVector3.at( + adjustedLocation.getBlockX() + relative.getBlockX(), + adjustedLocation.getBlockY() + relative.getBlockY(), + adjustedLocation.getBlockZ() + relative.getBlockZ()); + editSession.setBlock(worldPosition, BukkitAdapter.adapt(Material.AIR.createBlockData())); } private void fillChests() { @@ -331,12 +362,10 @@ private void fillChests() { private void spawnEntities() { for (Vector entityPosition : schematicContainer.getVanillaSpawns().keySet()) { Location signLocation = LocationProjector.project(location, schematicOffset, entityPosition).clone(); - signLocation.getBlock().setType(Material.AIR); signLocation.add(new Vector(0.5, 0, 0.5)); - signLocation.getChunk().load(); Entity entity = signLocation.getWorld().spawnEntity(signLocation, schematicContainer.getVanillaSpawns().get(entityPosition)); entity.setPersistent(true); - if (entity instanceof LivingEntity) ((LivingEntity) entity).setRemoveWhenFarAway(false); + if (entity instanceof LivingEntity livingEntity) livingEntity.setRemoveWhenFarAway(false); if (!VersionChecker.serverVersionOlderThan(21, 0) && entity.getType().equals(EntityType.END_CRYSTAL)) { EnderCrystal enderCrystal = (EnderCrystal) entity; @@ -346,7 +375,6 @@ private void spawnEntities() { for (Vector elitePosition : schematicContainer.getEliteMobsSpawns().keySet()) { Location eliteLocation = LocationProjector.project(location, schematicOffset, elitePosition).clone(); - eliteLocation.getBlock().setType(Material.AIR); eliteLocation.add(new Vector(0.5, 0, 0.5)); String bossFilename = schematicContainer.getEliteMobsSpawns().get(elitePosition); if (!EliteMobs.Spawn(eliteLocation, bossFilename)) return; @@ -364,7 +392,6 @@ private void spawnEntities() { for (Map.Entry entry : schematicContainer.getMythicMobsSpawns().entrySet()) { Location mobLocation = LocationProjector.project(location, schematicOffset, entry.getKey()).clone(); - mobLocation.getBlock().setType(Material.AIR); if (!MythicMobs.Spawn(mobLocation, entry.getValue())) return; } } From 1487d0f91b144e2441d9d8649b0dd8f234530f47 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:44:48 -0400 Subject: [PATCH 40/47] Replace modular Bukkit and NMS placement with FAWE --- .../modules/ModulePasting.java | 455 ++++++++---------- 1 file changed, 195 insertions(+), 260 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/modules/ModulePasting.java b/src/main/java/com/magmaguy/betterstructures/modules/ModulePasting.java index f3b514e..496773a 100644 --- a/src/main/java/com/magmaguy/betterstructures/modules/ModulePasting.java +++ b/src/main/java/com/magmaguy/betterstructures/modules/ModulePasting.java @@ -2,17 +2,16 @@ import com.magmaguy.betterstructures.MetadataHandler; import com.magmaguy.betterstructures.api.ChestFillEvent; -import com.magmaguy.betterstructures.config.DefaultConfig; import com.magmaguy.betterstructures.chests.ChestContents; +import com.magmaguy.betterstructures.config.DefaultConfig; import com.magmaguy.betterstructures.config.modulegenerators.ModuleGeneratorsConfigFields; import com.magmaguy.betterstructures.config.modules.ModulesConfigFields; import com.magmaguy.betterstructures.config.treasures.TreasureConfig; import com.magmaguy.betterstructures.config.treasures.TreasureConfigFields; import com.magmaguy.betterstructures.util.WorldEditUtils; -import com.magmaguy.easyminecraftgoals.NMSManager; +import com.magmaguy.betterstructures.worldedit.FaweEditQueue; import com.magmaguy.magmacore.util.Logger; import com.magmaguy.magmacore.util.SpigotMessage; -import com.magmaguy.magmacore.util.WorkloadRunnable; import com.sk89q.worldedit.EditSession; import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.WorldEditException; @@ -29,11 +28,6 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.Container; -import org.bukkit.block.data.BlockData; -import org.bukkit.block.data.Directional; -import org.bukkit.block.data.Rail; -import org.bukkit.block.data.type.Chest; -import org.bukkit.block.data.type.Sign; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; @@ -47,75 +41,56 @@ import java.util.Map; import java.util.Set; +/** + * FAWE-native module/dungeon placement for the Albion fork. + * + *

Upstream split module blocks between NMS palette writes, Bukkit slow-block writes, + * and separate WorldEdit NBT repair. Albion 1.1.1 instead sends the complete block plan + * through one serialized asynchronous FAWE edit using BaseBlock data, then returns to + * the primary thread only for Bukkit-required loot/events/entity spawning.

+ */ public final class ModulePasting { private final List interpretedSigns = new ArrayList<>(); - private final List chestsToPlace = new ArrayList<>(); + private final List chestsToFill = new ArrayList<>(); private final List barrelsToFill = new ArrayList<>(); private final List entitiesToSpawn = new ArrayList<>(); private final String spawnPoolSuffix; private final Location startLocation; private final boolean createModularWorld; - private final List nbtToPlace = new ArrayList<>(); private ModularWorld modularWorld; private final World world; private final File worldFolder; private final ModuleGeneratorsConfigFields moduleGeneratorsConfigFields; - public ModulePasting(World world, File worldFolder, Deque WFCNodeDeque, String spawnPoolSuffix, Location startLocation, ModuleGeneratorsConfigFields moduleGeneratorsConfigFields) { + public ModulePasting(World world, + File worldFolder, + Deque WFCNodeDeque, + String spawnPoolSuffix, + Location startLocation, + ModuleGeneratorsConfigFields moduleGeneratorsConfigFields) { this.spawnPoolSuffix = spawnPoolSuffix; this.startLocation = startLocation; this.world = world; this.worldFolder = worldFolder; this.moduleGeneratorsConfigFields = moduleGeneratorsConfigFields; - // Check debug mode and modular world creation settings from first node WFCNode firstNode = WFCNodeDeque.peek(); - this.createModularWorld = firstNode != null && firstNode.getWfcGenerator() != null && - firstNode.getWfcGenerator().getModuleGeneratorsConfigFields().isWorldGeneration(); + this.createModularWorld = firstNode != null + && firstNode.getWfcGenerator() != null + && firstNode.getWfcGenerator().getModuleGeneratorsConfigFields().isWorldGeneration(); batchPaste(WFCNodeDeque, interpretedSigns); - createModularWorld(world, worldFolder); - - // Send notification to players - if (DefaultConfig.isNewBuildingWarn()) { - for (Player player : Bukkit.getOnlinePlayers()) { - if (player.hasPermission("betterstructures.warn")) { - player.spigot().sendMessage( - SpigotMessage.commandHoverMessage( - "[BetterStructures] New dungeon started generating! Do not stop your server now. Click to teleport. Do \"/betterstructures silent\" to stop getting warnings!", - "Click to teleport to " + startLocation.getWorld().getName() + ", " + - startLocation.getBlockX() + ", " + startLocation.getBlockY() + ", " + startLocation.getBlockZ(), - "/betterstructures teleport " + startLocation.getWorld().getName() + " " + - startLocation.getBlockX() + " " + startLocation.getBlockY() + " " + startLocation.getBlockZ()) - ); - } - } - } - } - - private static boolean isNbtRichMaterial(Material m) { - if (m == Material.CHEST || m == Material.TRAPPED_CHEST || m == Material.BARREL) return false; - if (m.name().endsWith("_SIGN") || m.name().endsWith("_WALL_SIGN") || m.name().endsWith("_HANGING_SIGN")) - return false; - - return switch (m) { - case SPAWNER, - DISPENSER, DROPPER, HOPPER, - BEACON, LECTERN, JUKEBOX, - COMMAND_BLOCK, REPEATING_COMMAND_BLOCK, CHAIN_COMMAND_BLOCK, - PLAYER_HEAD, PLAYER_WALL_HEAD, - SCULK_CATALYST, SCULK_SHRIEKER -> true; - default -> false; - }; + notifyPlayers(); } + /** + * Small explicit module paste. It still uses the FAWE provider and never falls back + * to Bukkit/NMS block writes. + */ public static void paste(Clipboard clipboard, Location location, Integer rotation) { - if (rotation == null) { - return; - } + if (clipboard == null || rotation == null || location.getWorld() == null) return; - // Transform the clipboard using the same approach as batch paste AffineTransform transform = new AffineTransform().rotateY(normalizeRotation(rotation)); Clipboard transformedClipboard; try { @@ -125,47 +100,27 @@ public static void paste(Clipboard clipboard, Location location, Integer rotatio throw new RuntimeException(e); } - // Get dimensions and calculate proper center BlockVector3 minPoint = transformedClipboard.getMinimumPoint(); - - World world = location.getWorld(); - int baseX = location.getBlockX(); - int baseY = location.getBlockY(); - int baseZ = location.getBlockZ(); - - // Create edit session for actual placement - com.sk89q.worldedit.world.World adaptedWorld = BukkitAdapter.adapt(world); + com.sk89q.worldedit.world.World adaptedWorld = BukkitAdapter.adapt(location.getWorld()); try (EditSession editSession = WorldEdit.getInstance().newEditSession(adaptedWorld)) { editSession.setTrackingHistory(false); editSession.setSideEffectApplier(SideEffectSet.none()); - // Process each block using calculated center point as reference - transformedClipboard.getRegion().forEach(blockPos -> { - try { - BaseBlock baseBlock = transformedClipboard.getFullBlock(blockPos); - - // Skip air blocks - if (baseBlock.getBlockType().getMaterial().isAir()) return; - - // Calculate world coordinates relative to center point - int worldX = baseX + (blockPos.x() - minPoint.x()); - int worldY = baseY + (blockPos.y() - minPoint.y()); - int worldZ = baseZ + (blockPos.z() - minPoint.z()); + for (BlockVector3 blockPos : transformedClipboard.getRegion()) { + BaseBlock baseBlock = transformedClipboard.getFullBlock(blockPos); + if (baseBlock.getBlockType().getMaterial().isAir()) continue; - // Place the block - BlockVector3 worldPos = BlockVector3.at(worldX, worldY, worldZ); - editSession.setBlock(worldPos, baseBlock); - - } catch (WorldEditException e) { - Logger.warn("Failed to place block at " + blockPos + ": " + e.getMessage()); - } - }); + BlockVector3 worldPos = BlockVector3.at( + location.getBlockX() + (blockPos.x() - minPoint.x()), + location.getBlockY() + (blockPos.y() - minPoint.y()), + location.getBlockZ() + (blockPos.z() - minPoint.z())); + editSession.setBlock(worldPos, baseBlock); + } pasteArmorStands(transformedClipboard, location, rotation); - } catch (Exception e) { - Logger.warn("Failed to paste structure: " + e.getMessage()); + Logger.warn("Failed to paste module through FAWE: " + e.getMessage()); throw new RuntimeException(e); } } @@ -178,72 +133,54 @@ public static void pasteArmorStands(Clipboard clipboard, Location location, Inte if (rotation == null) rotation = 0; AffineTransform transform = new AffineTransform().rotateY(normalizeRotation(rotation)); - Clipboard transformedClipboard; try { - transformedClipboard = clipboard.transform(transform); + Clipboard transformedClipboard = clipboard.transform(transform); + WorldEditUtils.pasteArmorStandsOnlyFromTransformed(transformedClipboard, location); } catch (WorldEditException e) { Logger.warn("Failed to transform clipboard for entities: " + e.getMessage()); - return; } - - WorldEditUtils.pasteArmorStandsOnlyFromTransformed(transformedClipboard, location); } - private List generatePasteMeList(Clipboard clipboard, - Location worldPasteOriginLocation, - Integer rotation, - List interpretedSigns, - ModulesConfigFields modulesConfigFields) { - List pasteableList = new ArrayList<>(); - - // Apply rotation transformation + private List generatePlacementPlan(Clipboard clipboard, + Location worldPasteOriginLocation, + Integer rotation, + List interpretedSigns, + ModulesConfigFields modulesConfigFields) { + List placements = new ArrayList<>(); AffineTransform transform = new AffineTransform().rotateY(normalizeRotation(rotation)); - Clipboard transformedClipboard; + + final Clipboard transformedClipboard; try { transformedClipboard = clipboard.transform(transform); } catch (WorldEditException e) { throw new RuntimeException(e); } - // Get the minimum point of the transformed clipboard to use as reference BlockVector3 minPoint = transformedClipboard.getMinimumPoint(); - - World world = worldPasteOriginLocation.getWorld(); int baseX = worldPasteOriginLocation.getBlockX(); int baseY = worldPasteOriginLocation.getBlockY(); int baseZ = worldPasteOriginLocation.getBlockZ(); - // Process each block in the transformed clipboard - transformedClipboard.getRegion().forEach(blockPos -> { + for (BlockVector3 blockPos : transformedClipboard.getRegion()) { BaseBlock baseBlock = transformedClipboard.getFullBlock(blockPos); BlockState blockState = baseBlock.toImmutableState(); - // Air must still be pasted when generating into an existing world, as it is what - // carves the walkable interiors out of the terrain. Only void worlds can skip it. - if (createModularWorld && WorldEditUtils.isAir(blockState)) return; - // Calculate world coordinates relative to the minimum point + if (createModularWorld && WorldEditUtils.isAir(blockState)) continue; + int worldX = baseX + (blockPos.x() - minPoint.x()); int worldY = baseY + (blockPos.y() - minPoint.y()); int worldZ = baseZ + (blockPos.z() - minPoint.z()); - + BlockVector3 worldPosition = BlockVector3.at(worldX, worldY, worldZ); Location pasteLocation = new Location(world, worldX, worldY, worldZ); Material material = WorldEditUtils.adaptMaterial(blockState); - // Skip barriers - if (material == Material.BARRIER) return; + if (material == Material.BARRIER) continue; - BlockData blockData = material == null ? null : WorldEditUtils.createBlockDataOrNull(baseBlock); - if (blockData == null) { - nbtToPlace.add(new NbtPlacement(pasteLocation, baseBlock)); - return; - } - - // Handle signs - collect instructions then turn into AIR - if (blockData.getMaterial().toString().toLowerCase().contains("sign")) { + if (isControlSign(material)) { List lines = getLines(baseBlock); interpretedSigns.add(new InterpretedSign(pasteLocation, lines)); - // Parse sign content for special markers + Material replacement = Material.AIR; for (String line : lines) { if (line.contains("[spawn]") && lines.size() > 1) { try { @@ -253,37 +190,39 @@ private List generatePasteMeList(Clipboard clipboard, Logger.warn("Invalid entity type in sign: " + lines.get(1)); } } else if (line.contains("[chest]")) { - chestsToPlace.add(new ChestPlacement(pasteLocation, Material.CHEST, rotation)); + replacement = Material.CHEST; + chestsToFill.add(new ChestPlacement(pasteLocation, Material.CHEST)); } else if (line.contains("[trapped_chest]")) { - chestsToPlace.add(new ChestPlacement(pasteLocation, Material.TRAPPED_CHEST, rotation)); + replacement = Material.TRAPPED_CHEST; + chestsToFill.add(new ChestPlacement(pasteLocation, Material.TRAPPED_CHEST)); } } - // Replace sign with air in the paste list so it won't be deferred as NBT-rich - blockData = Material.AIR.createBlockData(); - } - - // Convert bedrock to stone (unless replacing a solid block) - if (blockData.getMaterial().equals(Material.BEDROCK)) { - if (pasteLocation.getBlock().getType().isSolid()) return; - blockData = Material.STONE.createBlockData(); + placements.add(FawePlacement.replacement(worldPosition, replacement)); + continue; } - // Defer complex NBT blocks (dispensers, spawners, etc.) for post-processing via BaseBlock - if (isNbtRichMaterial(blockData.getMaterial())) { - nbtToPlace.add(new NbtPlacement(pasteLocation, baseBlock)); // keep full NBT - return; // do NOT add to normal paste list + if (material == Material.BEDROCK) { + placements.add(FawePlacement.bedrockFiller(worldPosition)); + continue; } - if (blockData.getMaterial() == Material.BARREL) { + if (material == Material.BARREL) { barrelsToFill.add(new BarrelPlacement(pasteLocation, modulesConfigFields)); } - // Normal placement path - pasteableList.add(new Pasteable(pasteLocation, blockData)); - }); + // BaseBlock keeps all block state and NBT. No separate Bukkit slow path or + // NBT repair pass is necessary with FAWE. + placements.add(FawePlacement.baseBlock(worldPosition, baseBlock)); + } + + return placements; + } - return pasteableList; + private static boolean isControlSign(Material material) { + if (material == null) return false; + String name = material.name(); + return name.endsWith("_SIGN") || name.endsWith("_WALL_SIGN") || name.endsWith("_HANGING_SIGN"); } private List getLines(BaseBlock baseBlock) { @@ -297,144 +236,117 @@ private List getLines(BaseBlock baseBlock) { } public List batchPaste(Deque WFCNodeDeque, List interpretedSigns) { - List pasteableList = new ArrayList<>(); - - // Collect entity paste info while processing blocks + List placements = new ArrayList<>(); List entityPasteInfos = new ArrayList<>(); while (!WFCNodeDeque.isEmpty()) { - WFCNode WFCNode = WFCNodeDeque.poll(); - if (WFCNode == null || WFCNode.getModulesContainer() == null) continue; - Clipboard clipboard = WFCNode.getModulesContainer().getClipboard(); + WFCNode node = WFCNodeDeque.poll(); + if (node == null || node.getModulesContainer() == null) continue; + + Clipboard clipboard = node.getModulesContainer().getClipboard(); if (clipboard == null) continue; - // Process blocks - ModulesConfigFields modulesConfigField = WFCNode.getModulesContainer().getModulesConfigField(); - pasteableList.addAll(generatePasteMeList(clipboard, WFCNode.getRealLocation(startLocation), - WFCNode.getModulesContainer().getRotation(), interpretedSigns, modulesConfigField)); + ModulesConfigFields modulesConfigField = node.getModulesContainer().getModulesConfigField(); + Integer rotation = node.getModulesContainer().getRotation(); + Location realLocation = node.getRealLocation(startLocation); - // Store entity paste info for later - WITH TRANSFORMED CLIPBOARD - AffineTransform transform = new AffineTransform().rotateY(normalizeRotation(WFCNode.getModulesContainer().getRotation())); + placements.addAll(generatePlacementPlan( + clipboard, realLocation, rotation, interpretedSigns, modulesConfigField)); + + AffineTransform transform = new AffineTransform().rotateY(normalizeRotation(rotation)); try { Clipboard transformedClipboard = clipboard.transform(transform); - entityPasteInfos.add(new EntityPasteInfo(transformedClipboard, WFCNode.getRealLocation(startLocation), - WFCNode.getModulesContainer().getRotation())); + entityPasteInfos.add(new EntityPasteInfo(transformedClipboard, realLocation)); } catch (WorldEditException e) { - Logger.warn("Failed to transform clipboard for entities: " + e.getMessage()); + Logger.warn("Failed to transform module clipboard for entities: " + e.getMessage()); } } - List slowBlocks = new ArrayList<>(); - WorkloadRunnable pasteMeRunnable = new WorkloadRunnable(.1, () -> { - WorkloadRunnable vanillaPlacementRunnable = new WorkloadRunnable(.1, () -> { - postPasteProcessing(entityPasteInfos); - }); - - for (Pasteable slowBlock : slowBlocks) - vanillaPlacementRunnable.addWorkload(() -> { - slowBlock.location.getBlock().setBlockData(slowBlock.blockData, false); + FaweEditQueue.submit( + "module batch at " + startLocation.getBlockX() + "," + + startLocation.getBlockY() + "," + startLocation.getBlockZ(), + () -> executeFaweBatch(placements, entityPasteInfos), + failure -> { + if (failure != null) { + Logger.warn("Module FAWE batch failed: " + failure.getMessage()); + failure.printStackTrace(); + return; + } + postPasteProcessing(); }); - vanillaPlacementRunnable.runTaskTimer(MetadataHandler.PLUGIN, 0, 1); - }); - List freshlyInterpretedSigns = new ArrayList<>(); + return interpretedSigns; + } - // Enable fast path only for world-based generation - final boolean fastPathEnabled = this.createModularWorld; + private void executeFaweBatch(List placements, + List entityPasteInfos) throws Exception { + com.sk89q.worldedit.world.World adaptedWorld = BukkitAdapter.adapt(world); - for (Pasteable pasteable : pasteableList) { - if (!fastPathEnabled) { - // Not world-based generation: force slow placement for EVERYTHING - slowBlocks.add(pasteable); - continue; - } + try (EditSession editSession = WorldEdit.getInstance().newEditSession(adaptedWorld)) { + editSession.setTrackingHistory(false); + editSession.setSideEffectApplier(SideEffectSet.none()); - // World-based generation: keep original split between fast/slow - if (pasteable.blockData.getLightEmission() > 0 - || pasteable.blockData instanceof Directional - || pasteable.blockData instanceof Rail - || pasteable.blockData instanceof Sign) { - slowBlocks.add(pasteable); - } else { - pasteMeRunnable.addWorkload(() -> { - NMSManager.getAdapter().setBlockInNativeDataPalette( - pasteable.location.getWorld(), - pasteable.location.getBlockX(), - pasteable.location.getBlockY(), - pasteable.location.getBlockZ(), - pasteable.blockData, - true); - }); + for (FawePlacement placement : placements) { + if (placement.bedrockFiller()) { + if (editSession.getBlock(placement.position()) + .getBlockType().getMaterial().isSolid()) { + continue; + } + editSession.setBlock(placement.position(), + BukkitAdapter.adapt(Material.STONE.createBlockData())); + } else if (placement.replacementMaterial() != null) { + editSession.setBlock(placement.position(), + BukkitAdapter.adapt(placement.replacementMaterial().createBlockData())); + } else if (placement.baseBlock() != null) { + editSession.setBlock(placement.position(), placement.baseBlock()); + } } } - pasteMeRunnable.runTaskTimer(MetadataHandler.PLUGIN, 0, 1); - - return freshlyInterpretedSigns; + // Entity clipboard operations remain in the same serialized asynchronous FAWE + // lane instead of running during the Bukkit completion phase. + for (EntityPasteInfo info : entityPasteInfos) { + WorldEditUtils.pasteArmorStandsOnlyFromTransformed(info.clipboard(), info.location()); + } } - private void postPasteProcessing(List entityPasteInfos) { + /** + * Bukkit-only completion phase: inventory APIs, plugin events, and entity spawns. + * There are intentionally no block mutation calls here. + */ + private void postPasteProcessing() { if (createModularWorld) { createModularWorld(world, worldFolder); modularWorld.spawnOtherEntities(); } - // 1) Paste deferred NBT-rich blocks (dispenser, spawner, etc.) with WE so NBT is preserved - if (!nbtToPlace.isEmpty()) { - com.sk89q.worldedit.world.World adaptedWorld = BukkitAdapter.adapt(world); - try (EditSession editSession = WorldEdit.getInstance().newEditSession(adaptedWorld)) { - editSession.setTrackingHistory(false); - editSession.setSideEffectApplier(SideEffectSet.none()); - - for (NbtPlacement np : nbtToPlace) { - BlockVector3 wp = BlockVector3.at( - np.location().getBlockX(), - np.location().getBlockY(), - np.location().getBlockZ() - ); - try { - editSession.setBlock(wp, np.baseBlock()); // BaseBlock carries NBT - } catch (WorldEditException e) { - Logger.warn("Failed to set NBT block at " + np.location() + ": " + e.getMessage()); - } - } - } catch (Exception e) { - Logger.warn("Failed NBT post-paste session: " + e.getMessage()); - } - } + for (ChestPlacement chestPlacement : chestsToFill) { + Block block = chestPlacement.location().getBlock(); + if (block.getType() != chestPlacement.material()) continue; + if (!(block.getState() instanceof Container container)) continue; - // 2) Paste entities from schematics (armor stands, etc.) - pasteArmorStandsForBatch(entityPasteInfos); - - for (ChestPlacement chestPlacement : chestsToPlace) { - Block block = chestPlacement.location.getBlock(); - block.setType(chestPlacement.material); - - if (block.getBlockData() instanceof Chest chest) { - block.setBlockData(chest, false); - - String treasureFilename = moduleGeneratorsConfigFields.getTreasureFile(); - TreasureConfigFields treasureConfigFields = TreasureConfig.getConfigFields(treasureFilename); - if (treasureConfigFields != null) { - ChestContents chestContents = new ChestContents(treasureConfigFields); - Container container = (Container) block.getState(); - chestContents.rollChestContents(container); - ChestFillEvent chestFillEvent = new ChestFillEvent(container, treasureFilename); - Bukkit.getServer().getPluginManager().callEvent(chestFillEvent); - if (!chestFillEvent.isCancelled()) - container.update(true); - } - } + String treasureFilename = moduleGeneratorsConfigFields.getTreasureFile(); + TreasureConfigFields treasureConfigFields = TreasureConfig.getConfigFields(treasureFilename); + if (treasureConfigFields == null) continue; + + ChestContents chestContents = new ChestContents(treasureConfigFields); + chestContents.rollChestContents(container); + ChestFillEvent chestFillEvent = new ChestFillEvent(container, treasureFilename); + Bukkit.getServer().getPluginManager().callEvent(chestFillEvent); + if (!chestFillEvent.isCancelled()) container.update(true); } if (moduleGeneratorsConfigFields.isGenerateLootInBarrels() && !barrelsToFill.isEmpty()) { Map contentsByTreasure = new HashMap<>(); Set warnedMissingTreasures = new HashSet<>(); + for (BarrelPlacement bp : barrelsToFill) { ModulesConfigFields modConfig = bp.modulesConfigFields(); if (modConfig != null && !modConfig.isGenerateLootInBarrels()) continue; - String treasureFilename = (modConfig != null && modConfig.getBarrelTreasureFilename() != null && !modConfig.getBarrelTreasureFilename().isEmpty()) + String treasureFilename = (modConfig != null + && modConfig.getBarrelTreasureFilename() != null + && !modConfig.getBarrelTreasureFilename().isEmpty()) ? modConfig.getBarrelTreasureFilename() : moduleGeneratorsConfigFields.getBarrelTreasureFilename(); if (treasureFilename == null || treasureFilename.isEmpty()) continue; @@ -445,9 +357,12 @@ private void postPasteProcessing(List entityPasteInfos) { barrelContents = barrelTreasureFields != null ? new ChestContents(barrelTreasureFields) : null; contentsByTreasure.put(treasureFilename, barrelContents); } + if (barrelContents == null) { if (warnedMissingTreasures.add(treasureFilename)) { - Logger.warn("Module generator " + moduleGeneratorsConfigFields.getFilename() + " has barrels referencing barrelTreasureFilename '" + treasureFilename + "' but it did not resolve to a valid treasure config. Affected barrels will be empty."); + Logger.warn("Module generator " + moduleGeneratorsConfigFields.getFilename() + + " has barrels referencing barrelTreasureFilename '" + treasureFilename + + "' but it did not resolve to a valid treasure config. Affected barrels will be empty."); } continue; } @@ -459,47 +374,51 @@ private void postPasteProcessing(List entityPasteInfos) { barrelContents.rollChestContents(container); ChestFillEvent chestFillEvent = new ChestFillEvent(container, treasureFilename); Bukkit.getServer().getPluginManager().callEvent(chestFillEvent); - if (!chestFillEvent.isCancelled()) { - container.update(true); - } + if (!chestFillEvent.isCancelled()) container.update(true); } } - // 4) Spawn entities last for (EntitySpawn entitySpawn : entitiesToSpawn) { try { - LivingEntity entity = (LivingEntity) world.spawnEntity(entitySpawn.location, entitySpawn.entityType); + LivingEntity entity = (LivingEntity) world.spawnEntity(entitySpawn.location(), entitySpawn.entityType()); entity.setRemoveWhenFarAway(false); entity.setPersistent(true); } catch (Exception e) { - Logger.warn("Failed to spawn entity of type " + entitySpawn.entityType + " at " + entitySpawn.location); + Logger.warn("Failed to spawn entity of type " + entitySpawn.entityType() + + " at " + entitySpawn.location()); } } } - // Helper method to paste entities for all collected clipboards - private void pasteArmorStandsForBatch(List entityPasteInfos) { - for (EntityPasteInfo info : entityPasteInfos) { - try { - WorldEditUtils.pasteArmorStandsOnlyFromTransformed(info.clipboard, info.location); - } catch (Exception e) { - Logger.warn("Failed to paste entities for batch operation at " + info.location + ": " + e.getMessage()); + private void notifyPlayers() { + if (!DefaultConfig.isNewBuildingWarn()) return; + + Runnable notifier = () -> { + for (Player player : Bukkit.getOnlinePlayers()) { + if (!player.hasPermission("betterstructures.warn")) continue; + player.spigot().sendMessage( + SpigotMessage.commandHoverMessage( + "[BetterStructures] New dungeon started generating! Click to teleport. Do \"/betterstructures silent\" to stop getting warnings!", + "Click to teleport to " + startLocation.getWorld().getName() + ", " + + startLocation.getBlockX() + ", " + startLocation.getBlockY() + ", " + startLocation.getBlockZ(), + "/betterstructures teleport " + startLocation.getWorld().getName() + " " + + startLocation.getBlockX() + " " + startLocation.getBlockY() + " " + startLocation.getBlockZ()) + ); } - } + }; + + if (Bukkit.isPrimaryThread()) notifier.run(); + else Bukkit.getScheduler().runTask(MetadataHandler.PLUGIN, notifier); } private void createModularWorld(World world, File worldFolder) { modularWorld = new ModularWorld(world, worldFolder, interpretedSigns); } - private record NbtPlacement(Location location, BaseBlock baseBlock) { + private record EntityPasteInfo(Clipboard clipboard, Location location) { } - // Record to hold entity paste information - now with transformed clipboard - private record EntityPasteInfo(Clipboard clipboard, Location location, Integer rotation) { - } - - private record ChestPlacement(Location location, Material material, Integer rotation) { + private record ChestPlacement(Location location, Material material) { } private record BarrelPlacement(Location location, ModulesConfigFields modulesConfigFields) { @@ -508,9 +427,25 @@ private record BarrelPlacement(Location location, ModulesConfigFields modulesCon private record EntitySpawn(Location location, EntityType entityType) { } - public record InterpretedSign(Location location, List text) { + private record FawePlacement( + BlockVector3 position, + BaseBlock baseBlock, + Material replacementMaterial, + boolean bedrockFiller) { + + private static FawePlacement baseBlock(BlockVector3 position, BaseBlock baseBlock) { + return new FawePlacement(position, baseBlock, null, false); + } + + private static FawePlacement replacement(BlockVector3 position, Material material) { + return new FawePlacement(position, null, material, false); + } + + private static FawePlacement bedrockFiller(BlockVector3 position) { + return new FawePlacement(position, null, null, true); + } } - private record Pasteable(Location location, BlockData blockData) { + public record InterpretedSign(Location location, List text) { } } From 6023189acdf4396cfa3e60940b337cb47fbef90c Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:45:20 -0400 Subject: [PATCH 41/47] Route modular chest and barrel placement through FAWE --- .../modules/ModularWorld.java | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/modules/ModularWorld.java b/src/main/java/com/magmaguy/betterstructures/modules/ModularWorld.java index de201a3..f8612c6 100644 --- a/src/main/java/com/magmaguy/betterstructures/modules/ModularWorld.java +++ b/src/main/java/com/magmaguy/betterstructures/modules/ModularWorld.java @@ -11,6 +11,11 @@ import com.magmaguy.elitemobs.mobconstructor.custombosses.InstancedBossEntity; import com.magmaguy.magmacore.instance.MatchInstance; import com.magmaguy.magmacore.util.Logger; +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.util.SideEffectSet; import lombok.Getter; import org.bukkit.*; import org.bukkit.block.Block; @@ -101,23 +106,39 @@ private void processExitLocations(ModulePasting.InterpretedSign interpretedSign) } public List spawnChests() { + placeLocationsWithFawe(chestLocations, Material.CHEST); List chests = new ArrayList<>(); for (Location chestLocation : chestLocations) { - chestLocation.getBlock().setType(Material.CHEST); chests.add(chestLocation.getBlock()); } return chests; } public List spawnBarrels() { + placeLocationsWithFawe(barrelLocations, Material.BARREL); List barrels = new ArrayList<>(); for (Location barrelLocation : barrelLocations) { - barrelLocation.getBlock().setType(Material.BARREL); barrels.add(barrelLocation.getBlock()); } return barrels; } + private void placeLocationsWithFawe(List locations, Material material) { + if (locations.isEmpty()) return; + try (EditSession editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(world))) { + editSession.setTrackingHistory(false); + editSession.setSideEffectApplier(SideEffectSet.none()); + for (Location location : locations) { + editSession.setBlock( + BlockVector3.at(location.getBlockX(), location.getBlockY(), location.getBlockZ()), + BukkitAdapter.adapt(material.createBlockData())); + } + } catch (Exception exception) { + Logger.warn("Failed to place modular " + material + " blocks through FAWE: " + exception.getMessage()); + exception.printStackTrace(); + } + } + //todo: maybe this should go into extractioncraft later public List spawnInaccessibleExitLocations() { List randomizedLocations = new ArrayList<>(); @@ -168,7 +189,6 @@ public void run() { scheduledInstancedEntities.add(new ScheduledInstancedEntity(otherLocation.location(), customBossesConfigFields, parsedString, spawnPoolsConfigFields.getMinLevel(), spawnPoolsConfigFields.getMaxLevel())); } } - //got to keep the memory clear for this one, unfortunately otherLocations.clear(); generationFinished(); } @@ -178,15 +198,14 @@ public void run() { public List spawnInstancedEntities(MatchInstance matchInstance) { List instancedBossEntities = new ArrayList<>(); for (ScheduledInstancedEntity scheduledInstancedEntity : scheduledInstancedEntities) { - int totalRadius = 2 * 128 + 64;//todo this is just a placeholder for now that hardcodes the radius - Vector2i center = new Vector2i(64, 64); //todo this is just a placeholder for now that hardcodes the center + int totalRadius = 2 * 128 + 64; + Vector2i center = new Vector2i(64, 64); Vector2i entityLocation = new Vector2i(scheduledInstancedEntity.location.getBlockX(), scheduledInstancedEntity.location.getBlockZ()); double distance = center.distance(entityLocation); double percentageDistance = distance / totalRadius; int level = (int) Math.round((1.0 - percentageDistance) * scheduledInstancedEntity.maxLevel + percentageDistance * scheduledInstancedEntity.minLevel); InstancedBossEntity instancedBossEntity = new InstancedBossEntity(scheduledInstancedEntity.configFields, scheduledInstancedEntity.location, matchInstance, level); -// InstancedBossEntity instancedBossEntity = new InstancedBossEntity(scheduledInstancedEntity.configFields, scheduledInstancedEntity.location, matchInstance, 10);//todo: level is just a placeholder for now instancedBossEntity.spawn(true); instancedBossEntity.addCustomData(new NamespacedKey("betterstructures", "spawnpool"), scheduledInstancedEntity.originalSpawnPool); instancedBossEntities.add(instancedBossEntity); From 8d0ea1c0dd253a3bc00c18820598786cbe28e697 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:47:49 -0400 Subject: [PATCH 42/47] Move WFC debug block writes to FAWE --- .../betterstructures/modules/WFCNode.java | 111 +++++++----------- 1 file changed, 42 insertions(+), 69 deletions(-) diff --git a/src/main/java/com/magmaguy/betterstructures/modules/WFCNode.java b/src/main/java/com/magmaguy/betterstructures/modules/WFCNode.java index 0a78f08..3eeeff2 100644 --- a/src/main/java/com/magmaguy/betterstructures/modules/WFCNode.java +++ b/src/main/java/com/magmaguy/betterstructures/modules/WFCNode.java @@ -2,6 +2,11 @@ import com.magmaguy.betterstructures.MetadataHandler; import com.magmaguy.magmacore.util.Logger; +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.util.SideEffectSet; import lombok.Getter; import org.bukkit.*; import org.bukkit.entity.Display; @@ -44,7 +49,7 @@ public class WFCNode { * @param nodeMap The global node map reference */ public WFCNode(Vector3i nodePosition, World world, WFCLattice lattice, Map nodeMap, WFCGenerator wfcGenerator) { - this.nodePosition = new Vector3i(nodePosition); // Defensive copy + this.nodePosition = new Vector3i(nodePosition); this.world = world; this.lattice = lattice; this.nodeMap = nodeMap; @@ -80,26 +85,16 @@ public boolean isBoundary() { /** * Gets a safe copy of the cell location. - * - * @return A new Vector3i containing the cell location */ public Vector3i getCellLocation() { return new Vector3i(nodePosition); } - /** - * Updates the possible states for this node based on its adjacent nodes. - */ public void updatePossibleStates() { possibleStates = ModulesContainer.getValidModulesFromSurroundings(this); showDebugTextDisplays(); } - /** - * Gets the count of valid module options for this cell. - * - * @return The number of valid options, or 0 if none are available - */ public int getValidOptionCount() { if (possibleStates == null) { updatePossibleStates(); @@ -111,20 +106,10 @@ public int getValidOptionCount() { return possibleStates.size(); } - /** - * Gets a map of neighboring cells in each direction. - * - * @return Map of Direction to WFCNode for each neighbor - */ public Map getOrientedNeighbors() { return adjacentNodes; } - /** - * Gets the possible states for this node. - * - * @return Set of possible module states for this node - */ public HashSet getValidOptions() { if (possibleStates == null) { updatePossibleStates(); @@ -132,11 +117,6 @@ public HashSet getValidOptions() { return possibleStates; } - /** - * Gets the real world location of this cell's origin point. - * - * @return Location object representing the cell's origin in the world - */ public Location getRealLocation(Location startLocation) { Vector3i worldCoord; if (startLocation != null) @@ -146,15 +126,12 @@ public Location getRealLocation(Location startLocation) { return new Location(world, worldCoord.x, worldCoord.y, worldCoord.z); } - /** - * Creates debug text displays showing cell information. - */ public void showDebugTextDisplays() { if (!wfcGenerator.getModuleGeneratorsConfigFields().isDebug()) return; new BukkitRunnable() { @Override public void run() { - if (textDisplays != null && !textDisplays.isEmpty()) clearDebugDisplays(); + if (textDisplays != null && !textDisplays.isEmpty()) clearDebugDisplays(); textDisplays = new ArrayList<>(); if (modulesContainer == null) { @@ -207,7 +184,6 @@ private void displayBorderInfo(Location centerLocation, Color color) { Location tagLocation = centerLocation.clone().add(offset.x, offset.y, offset.z); spawnDebugText(tagLocation, entry.getKey().name(), color, 1); - displayNeighborTags(tagLocation, entry.getValue(), color); } } @@ -234,7 +210,7 @@ private void spawnDebugText(Location location, String text, Color color, float s new BukkitRunnable() { @Override public void run() { - Location adjustedLocation = location.clone().subtract(new Vector(0,textDisplays.size()/2d,0)); + Location adjustedLocation = location.clone().subtract(new Vector(0, textDisplays.size() / 2d, 0)); TextDisplay textDisplay = (TextDisplay) world.spawnEntity(adjustedLocation, EntityType.TEXT_DISPLAY); configureTextDisplay(textDisplay, text, color, scale); textDisplays.add(textDisplay); @@ -256,23 +232,14 @@ private void configureTextDisplay(TextDisplay display, String text, Color color, display.setViewRange(1); } - /** - * Checks if this cell has been generated. - * - * @return true if the cell has a module container - */ public boolean isCollapsed() { return modulesContainer != null; } - public boolean isNothing(){ + public boolean isNothing() { return modulesContainer != null && modulesContainer.isNothing(); } - /** - * Resets this cell's data. - * - */ public void resetState() { if (isInitialNode() || isBoundary()) return; @@ -283,14 +250,10 @@ public void resetState() { } } - public boolean isInitialNode() { return new Vector3i().equals(nodePosition); } - /** - * Clears generation data for this cell. - */ public void clearGenerationData() { clearDebugDisplays(); possibleStates = null; @@ -303,34 +266,43 @@ private void clearDebugDisplays() { } } + /** + * Debug lattice wireframe placement. Debug blocks are still world edits, so Albion's + * FAWE-native rule applies here too rather than falling back to Bukkit block writes. + */ private void placeMaterial(Location startLocation, Material material) { int sizeXZ = wfcGenerator.getModuleGeneratorsConfigFields().getModuleSizeXZ(); int sizeY = wfcGenerator.getModuleGeneratorsConfigFields().getModuleSizeY(); - - for (int x = 0; x < sizeXZ; x++) { - for (int y = 0; y < sizeY; y++) { - for (int z = 0; z < sizeXZ; z++) { - Location blockLocation = startLocation.clone().add(x, y, z); - - // Check if block is on an edge (intersection of at least 2 faces) - boolean isOnXEdge = (x == 0 || x == sizeXZ - 1); - boolean isOnYEdge = (y == 0 || y == sizeY - 1); - boolean isOnZEdge = (z == 0 || z == sizeXZ - 1); - - // Count how many edges this block touches - int edgeCount = 0; - if (isOnXEdge) edgeCount++; - if (isOnYEdge) edgeCount++; - if (isOnZEdge) edgeCount++; - - // Place material only if block is on at least 2 edges (true edge/corner) - if (edgeCount >= 2) { - blockLocation.getBlock().setType(material); - } else { - blockLocation.getBlock().setType(Material.AIR); + int baseX = startLocation.getBlockX(); + int baseY = startLocation.getBlockY(); + int baseZ = startLocation.getBlockZ(); + + try (EditSession editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(world))) { + editSession.setTrackingHistory(false); + editSession.setSideEffectApplier(SideEffectSet.none()); + + for (int x = 0; x < sizeXZ; x++) { + for (int y = 0; y < sizeY; y++) { + for (int z = 0; z < sizeXZ; z++) { + boolean isOnXEdge = x == 0 || x == sizeXZ - 1; + boolean isOnYEdge = y == 0 || y == sizeY - 1; + boolean isOnZEdge = z == 0 || z == sizeXZ - 1; + + int edgeCount = 0; + if (isOnXEdge) edgeCount++; + if (isOnYEdge) edgeCount++; + if (isOnZEdge) edgeCount++; + + Material targetMaterial = edgeCount >= 2 ? material : Material.AIR; + editSession.setBlock( + BlockVector3.at(baseX + x, baseY + y, baseZ + z), + BukkitAdapter.adapt(targetMaterial.createBlockData())); } } } + } catch (Exception exception) { + Logger.warn("Failed to paste WFC debug lattice through FAWE: " + exception.getMessage()); + exception.printStackTrace(); } } @@ -353,7 +325,8 @@ public void run() { try { Thread.sleep(50); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); throw new RuntimeException(e); } } -} \ No newline at end of file +} From 3996d1c986f44aaef13cf665a629fa0c19287b63 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:50:13 -0400 Subject: [PATCH 43/47] Document Albion 1.1.1 FAWE-native architecture --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7021a68..c62c462 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,43 @@ BetterStructures was created by **MagmaGuy**. The original concept, structure sy This project is **not intended to replace, rebrand, or compete with the original BetterStructures project**. Credit for the plugin belongs with MagmaGuy and the upstream contributors. The original GPLv3 license is retained. -## Purpose of this fork +## Albion 1.1.1 — FAWE Native -This branch is maintained specifically for **AlbionMC.com** with these goals: +**FastAsyncWorldEdit is the required world-edit engine for this fork.** Standard WorldEdit is not a supported runtime backend. + +FAWE intentionally implements the WorldEdit API, so source imports such as `com.sk89q.worldedit.*` remain normal and expected. Those packages are the API surface used by FAWE; Albion builds require FastAsyncWorldEdit at runtime. + +The 1.1.1 goal is simple: **BetterStructures itself does not perform direct Bukkit/NMS block mutations.** Structure, module, cleanup, marker, and debug block writes are routed through FAWE. + +### FAWE-native block paths + +- natural surface, underground, sky, liquid, and other schematic structures; +- schematic air carving and NBT-rich `BaseBlock` placement; +- BetterStructures bedrock/pedestal filler behavior; +- pedestal construction below natural structures; +- tree/foliage cleanup above surface structures; +- vanilla, EliteMobs, and MythicMobs marker-block removal; +- modular/WFC dungeon batches; +- module directional/light blocks and NBT blocks without a Bukkit slow path; +- modular chest/barrel block placement; +- WFC debug lattice block placement; +- component/elevator schematic pastes. + +CI enforces this rule by scanning production Java sources and failing if direct Bukkit `setType`, `setBlockData`, or the old native-palette block-write path is reintroduced. + +## Resource-world performance design + +This fork is maintained specifically for **AlbionMC.com** with these goals: - preserve the gameplay, structures, content packages, and visual identity of BetterStructures 2.6.3; - reduce MSPT/TPS spikes while players explore and generate new chunks, especially in resource worlds; -- use **FastAsyncWorldEdit (FAWE)** as the required WorldEdit backend; - queue structure-generation work instead of allowing bursts of expensive fitting work during new-chunk events; - pause ordinary player-driven structure generation when server MSPT/TPS indicates the main thread is under pressure; -- serialize the expensive fit/load/paste path so several large structures do not compete at once; -- prepare required structure chunks through Paper's async chunk API in small batches; -- perform the normal structure block loop in an asynchronous FAWE `EditSession`, while returning Bukkit-only preparation and completion work to the main thread; +- serialize heavy BetterStructures FAWE edits through one global edit lane instead of allowing several large structures or dungeon batches to compete at once; +- prepare required natural-structure chunks through Paper's async chunk API in small batches; +- keep structure chunk tickets until the FAWE structure and FAWE cleanup phases are complete; - prevent BetterStructures' own internal chunk loads from recursively generating more BetterStructures structures; +- keep Bukkit primary-thread work for APIs that require it, such as terrain sampling reads, inventory/loot handling, plugin events, and entity spawning; - target **Paper 1.21.11 and newer only**. Older Minecraft/Paper compatibility is intentionally out of scope. ## Performance defaults @@ -30,7 +54,8 @@ Player-driven structure generation is guarded by conservative load thresholds fo - pause around **42 MSPT** or **18.5 TPS**; - resume after recovery around **32 MSPT** and **19.5 TPS**; - admit expensive fit jobs separately instead of allowing a burst from fast resource-world exploration; -- load structure chunks in small batches before starting the FAWE edit. +- load structure chunks in small batches before starting the FAWE edit; +- allow only one heavy BetterStructures FAWE edit at a time. Existing configuration values are preserved when upgrading. These defaults are intended as a safe starting point and can be tuned from real AlbionMC spark profiles after runtime testing. @@ -41,11 +66,11 @@ Existing configuration values are preserved when upgrading. These defaults are i - FastAsyncWorldEdit **2.14.3+** - For newer Minecraft versions, use a FAWE release that explicitly supports that server version. -FastAsyncWorldEdit provides the WorldEdit API used by BetterStructures and is intentionally a hard dependency in this fork. +Do **not** install a separate WorldEdit JAR alongside FAWE for this fork. FastAsyncWorldEdit supplies the WorldEdit API/runtime provider BetterStructures uses. ## Fork versioning -Albion fork releases use their own version line beginning at **1.1.0**. The upstream source baseline remains BetterStructures **2.6.3**. +Albion fork releases use their own version line beginning at **1.1.0**. The current FAWE-native milestone is **1.1.1**. The upstream source baseline remains BetterStructures **2.6.3**. Release JARs are named: @@ -53,6 +78,12 @@ Release JARs are named: BetterStructures-1.1.x.jar ``` +The 1.1.1 test build is: + +```text +BetterStructures-1.1.1.jar +``` + ## Building ```bash @@ -65,7 +96,7 @@ The shaded plugin JAR is written to: build/libs/BetterStructures-1.1.x.jar ``` -GitHub tagged releases (`v1.1.x`) publish that JAR directly as a **raw release asset**, rather than requiring server owners to download an Actions ZIP. +GitHub branch test builds publish the JAR directly as a prerelease asset. Tagged releases (`v1.1.x`) also publish the JAR directly as a **raw release asset**, rather than requiring server owners to download an Actions ZIP. ## Upstream updates From e4cd8499915d826c9f034d90e6a3e3f14f7400fc Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:53:01 -0400 Subject: [PATCH 44/47] Keep branch-test release tag on exact green head --- .github/workflows/gradle.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index ce2e660..aaf4507 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -62,11 +62,17 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail VERSION="$(sed -n "s/^version = '\([^']*\)'.*/\1/p" build.gradle)" JAR="build/libs/BetterStructures-${VERSION}.jar" TAG="albion-${VERSION}-test" test -f "$JAR" + # The test release is intentionally reusable. Move its tag to the exact + # commit that just passed audit/build/tests before replacing the raw JAR. + git tag -f "$TAG" "$GITHUB_SHA" + git push origin "refs/tags/$TAG" --force + if gh release view "$TAG" >/dev/null 2>&1; then gh release upload "$TAG" "$JAR" --clobber gh release edit "$TAG" \ @@ -75,7 +81,6 @@ jobs: --prerelease else gh release create "$TAG" "$JAR" \ - --target "$GITHUB_SHA" \ --title "BetterStructures Albion ${VERSION} Test Build" \ --notes "AlbionMC development test build from commit ${GITHUB_SHA}. This is a prerelease for resource-world runtime testing before the final v${VERSION} release." \ --prerelease From 7c0cce8c1bfc45f2549d11f1e420c279528f3b70 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Tue, 11 Aug 2026 23:59:46 -0400 Subject: [PATCH 45/47] Show BetterStructures JAR in workflow outputs --- .github/workflows/gradle.yml | 39 ++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index aaf4507..35d1f45 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -51,10 +51,26 @@ jobs: - name: Verify versioned JAR shell: bash run: | + set -euo pipefail VERSION="$(sed -n "s/^version = '\([^']*\)'.*/\1/p" build.gradle)" JAR="build/libs/BetterStructures-${VERSION}.jar" test -f "$JAR" echo "Built $JAR" + echo "BS_VERSION=$VERSION" >> "$GITHUB_ENV" + echo "BS_JAR=$JAR" >> "$GITHUB_ENV" + + # This makes the JAR visible in the workflow run's Artifacts section. + # GitHub Actions artifact downloads are always wrapped by GitHub as a ZIP, + # so the raw, unzipped JAR is also published below as a release asset. + - name: Upload JAR to workflow artifacts + id: upload-jar + uses: actions/upload-artifact@v4 + with: + name: BetterStructures-${{ env.BS_VERSION }}.jar + path: ${{ env.BS_JAR }} + if-no-files-found: error + compression-level: 0 + retention-days: 30 - name: Publish raw branch-test JAR if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/agent/') @@ -63,8 +79,8 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - VERSION="$(sed -n "s/^version = '\([^']*\)'.*/\1/p" build.gradle)" - JAR="build/libs/BetterStructures-${VERSION}.jar" + VERSION="$BS_VERSION" + JAR="$BS_JAR" TAG="albion-${VERSION}-test" test -f "$JAR" @@ -85,3 +101,22 @@ jobs: --notes "AlbionMC development test build from commit ${GITHUB_SHA}. This is a prerelease for resource-world runtime testing before the final v${VERSION} release." \ --prerelease fi + + - name: Add JAR links to workflow summary + if: always() && env.BS_VERSION != '' + shell: bash + env: + ARTIFACT_URL: ${{ steps.upload-jar.outputs.artifact-url }} + run: | + { + echo "## BetterStructures ${BS_VERSION}" + echo + if [[ -n "${ARTIFACT_URL:-}" ]]; then + echo "- [Workflow artifact: BetterStructures-${BS_VERSION}.jar](${ARTIFACT_URL})" + fi + if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == refs/heads/agent/* ]]; then + echo "- [Direct raw JAR: BetterStructures-${BS_VERSION}.jar](https://github.com/${GITHUB_REPOSITORY}/releases/download/albion-${BS_VERSION}-test/BetterStructures-${BS_VERSION}.jar)" + fi + echo + echo "The workflow artifact is shown in GitHub Actions; the release link is the raw .jar with no ZIP wrapper." + } >> "$GITHUB_STEP_SUMMARY" From 5bb426d9bdb98cea79fb0253a33cf9edfe862b95 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Wed, 12 Aug 2026 00:01:42 -0400 Subject: [PATCH 46/47] Add automatic milestone release notes --- .github/workflows/gradle.yml | 58 ++++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 35d1f45..8940c5a 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -20,6 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Set up Java 21 uses: actions/setup-java@v5 @@ -55,13 +57,16 @@ jobs: VERSION="$(sed -n "s/^version = '\([^']*\)'.*/\1/p" build.gradle)" JAR="build/libs/BetterStructures-${VERSION}.jar" test -f "$JAR" + SHA256="$(sha256sum "$JAR" | awk '{print $1}')" echo "Built $JAR" + echo "SHA-256: $SHA256" echo "BS_VERSION=$VERSION" >> "$GITHUB_ENV" echo "BS_JAR=$JAR" >> "$GITHUB_ENV" + echo "BS_SHA256=$SHA256" >> "$GITHUB_ENV" - # This makes the JAR visible in the workflow run's Artifacts section. - # GitHub Actions artifact downloads are always wrapped by GitHub as a ZIP, - # so the raw, unzipped JAR is also published below as a release asset. + # GitHub's Actions artifact system displays the build on the workflow run. + # GitHub wraps artifact downloads in a ZIP; the raw JAR is also published + # automatically as the milestone prerelease asset below. - name: Upload JAR to workflow artifacts id: upload-jar uses: actions/upload-artifact@v4 @@ -72,7 +77,7 @@ jobs: compression-level: 0 retention-days: 30 - - name: Publish raw branch-test JAR + - name: Publish milestone prerelease and raw JAR if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/agent/') shell: bash env: @@ -82,23 +87,49 @@ jobs: VERSION="$BS_VERSION" JAR="$BS_JAR" TAG="albion-${VERSION}-test" + NOTES="release-notes-${VERSION}.md" test -f "$JAR" - # The test release is intentionally reusable. Move its tag to the exact - # commit that just passed audit/build/tests before replacing the raw JAR. + PREVIOUS_SHA="" + if git rev-parse -q --verify "refs/tags/${TAG}^{commit}" >/dev/null 2>&1; then + PREVIOUS_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")" + fi + + { + echo "## BetterStructures Albion ${VERSION} — Milestone Build" + echo + echo "AlbionMC development build from commit \`${GITHUB_SHA}\`." + echo + echo "- **JAR:** \`BetterStructures-${VERSION}.jar\`" + echo "- **SHA-256:** \`${BS_SHA256}\`" + echo "- **CI:** ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + echo "- **Runtime target:** Paper 1.21.11+ with FastAsyncWorldEdit" + echo + echo "### Changes in this milestone build" + if [[ -n "$PREVIOUS_SHA" && "$PREVIOUS_SHA" != "$GITHUB_SHA" ]]; then + git log --no-merges --max-count=25 --pretty='- %s (`%h`)' "${PREVIOUS_SHA}..${GITHUB_SHA}" + else + git log --no-merges --max-count=25 --pretty='- %s (`%h`)' "origin/master..${GITHUB_SHA}" + fi + echo + echo "This is an automatically maintained prerelease for AlbionMC runtime testing. After successful live testing, the same version can be promoted to the final \`v${VERSION}\` release." + } > "$NOTES" + + # Keep the reusable milestone tag on the exact commit that passed the + # audit, tests and JAR verification before publishing the asset. git tag -f "$TAG" "$GITHUB_SHA" git push origin "refs/tags/$TAG" --force if gh release view "$TAG" >/dev/null 2>&1; then gh release upload "$TAG" "$JAR" --clobber gh release edit "$TAG" \ - --title "BetterStructures Albion ${VERSION} Test Build" \ - --notes "AlbionMC development test build from commit ${GITHUB_SHA}. This is a prerelease for resource-world runtime testing before the final v${VERSION} release." \ + --title "BetterStructures Albion ${VERSION} Milestone Build" \ + --notes-file "$NOTES" \ --prerelease else gh release create "$TAG" "$JAR" \ - --title "BetterStructures Albion ${VERSION} Test Build" \ - --notes "AlbionMC development test build from commit ${GITHUB_SHA}. This is a prerelease for resource-world runtime testing before the final v${VERSION} release." \ + --title "BetterStructures Albion ${VERSION} Milestone Build" \ + --notes-file "$NOTES" \ --prerelease fi @@ -111,12 +142,15 @@ jobs: { echo "## BetterStructures ${BS_VERSION}" echo + echo "**SHA-256:** \`${BS_SHA256}\`" + echo if [[ -n "${ARTIFACT_URL:-}" ]]; then - echo "- [Workflow artifact: BetterStructures-${BS_VERSION}.jar](${ARTIFACT_URL})" + echo "- [Actions build artifact: BetterStructures-${BS_VERSION}.jar](${ARTIFACT_URL})" fi if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == refs/heads/agent/* ]]; then echo "- [Direct raw JAR: BetterStructures-${BS_VERSION}.jar](https://github.com/${GITHUB_REPOSITORY}/releases/download/albion-${BS_VERSION}-test/BetterStructures-${BS_VERSION}.jar)" + echo "- [Milestone release notes](https://github.com/${GITHUB_REPOSITORY}/releases/tag/albion-${BS_VERSION}-test)" fi echo - echo "The workflow artifact is shown in GitHub Actions; the release link is the raw .jar with no ZIP wrapper." + echo "The Actions artifact is visible on this run. For server use, the milestone release link provides the raw .jar with no ZIP wrapper." } >> "$GITHUB_STEP_SUMMARY" From 0404e62875a27f95303b46414f9502c8c0717763 Mon Sep 17 00:00:00 2001 From: wickidcow Date: Wed, 12 Aug 2026 00:19:45 -0400 Subject: [PATCH 47/47] Publish only raw JAR release assets --- .github/workflows/gradle.yml | 47 +++++++++++------------------------- 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 8940c5a..21dbc04 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -47,37 +47,22 @@ jobs: fi echo "FAWE-native audit passed: no direct Bukkit/NMS block writes found." - - name: Build and test - run: ./gradlew clean test shadowJar - - - name: Verify versioned JAR + - name: Build and verify raw JAR shell: bash run: | set -euo pipefail + ./gradlew clean test shadowJar VERSION="$(sed -n "s/^version = '\([^']*\)'.*/\1/p" build.gradle)" JAR="build/libs/BetterStructures-${VERSION}.jar" test -f "$JAR" SHA256="$(sha256sum "$JAR" | awk '{print $1}')" - echo "Built $JAR" + echo "Built raw JAR: $JAR" echo "SHA-256: $SHA256" echo "BS_VERSION=$VERSION" >> "$GITHUB_ENV" echo "BS_JAR=$JAR" >> "$GITHUB_ENV" echo "BS_SHA256=$SHA256" >> "$GITHUB_ENV" - # GitHub's Actions artifact system displays the build on the workflow run. - # GitHub wraps artifact downloads in a ZIP; the raw JAR is also published - # automatically as the milestone prerelease asset below. - - name: Upload JAR to workflow artifacts - id: upload-jar - uses: actions/upload-artifact@v4 - with: - name: BetterStructures-${{ env.BS_VERSION }}.jar - path: ${{ env.BS_JAR }} - if-no-files-found: error - compression-level: 0 - retention-days: 30 - - - name: Publish milestone prerelease and raw JAR + - name: Publish raw JAR milestone release if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/agent/') shell: bash env: @@ -100,7 +85,7 @@ jobs: echo echo "AlbionMC development build from commit \`${GITHUB_SHA}\`." echo - echo "- **JAR:** \`BetterStructures-${VERSION}.jar\`" + echo "- **Raw JAR:** \`BetterStructures-${VERSION}.jar\`" echo "- **SHA-256:** \`${BS_SHA256}\`" echo "- **CI:** ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" echo "- **Runtime target:** Paper 1.21.11+ with FastAsyncWorldEdit" @@ -112,11 +97,9 @@ jobs: git log --no-merges --max-count=25 --pretty='- %s (`%h`)' "origin/master..${GITHUB_SHA}" fi echo - echo "This is an automatically maintained prerelease for AlbionMC runtime testing. After successful live testing, the same version can be promoted to the final \`v${VERSION}\` release." + echo "This prerelease contains the raw server-ready JAR. No Actions artifact ZIP is created." } > "$NOTES" - # Keep the reusable milestone tag on the exact commit that passed the - # audit, tests and JAR verification before publishing the asset. git tag -f "$TAG" "$GITHUB_SHA" git push origin "refs/tags/$TAG" --force @@ -133,24 +116,22 @@ jobs: --prerelease fi - - name: Add JAR links to workflow summary + - name: Add raw JAR link to workflow summary if: always() && env.BS_VERSION != '' shell: bash - env: - ARTIFACT_URL: ${{ steps.upload-jar.outputs.artifact-url }} run: | { echo "## BetterStructures ${BS_VERSION}" echo echo "**SHA-256:** \`${BS_SHA256}\`" echo - if [[ -n "${ARTIFACT_URL:-}" ]]; then - echo "- [Actions build artifact: BetterStructures-${BS_VERSION}.jar](${ARTIFACT_URL})" - fi if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == refs/heads/agent/* ]]; then - echo "- [Direct raw JAR: BetterStructures-${BS_VERSION}.jar](https://github.com/${GITHUB_REPOSITORY}/releases/download/albion-${BS_VERSION}-test/BetterStructures-${BS_VERSION}.jar)" - echo "- [Milestone release notes](https://github.com/${GITHUB_REPOSITORY}/releases/tag/albion-${BS_VERSION}-test)" + echo "### [Download raw BetterStructures-${BS_VERSION}.jar](https://github.com/${GITHUB_REPOSITORY}/releases/download/albion-${BS_VERSION}-test/BetterStructures-${BS_VERSION}.jar)" + echo + echo "[Milestone release notes](https://github.com/${GITHUB_REPOSITORY}/releases/tag/albion-${BS_VERSION}-test)" + echo + echo "This is the actual .jar file. No extraction is required." + else + echo "Raw JAR built successfully at \`${BS_JAR}\`." fi - echo - echo "The Actions artifact is visible on this run. For server use, the milestone release link provides the raw .jar with no ZIP wrapper." } >> "$GITHUB_STEP_SUMMARY"