diff --git a/build.gradle.kts b/build.gradle.kts
index 2425b24..8b636dd 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -10,7 +10,7 @@ plugins {
}
group = "org.inventivetalent.tessera"
-version = "26.6.1"
+version = "26.7.0-SNAPSHOT"
java {
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
diff --git a/docs/api.md b/docs/api.md
index 555ba14..dcab0f6 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -136,6 +136,69 @@ public void onBaked(TesseraBlockBakedEvent event) {
}
```
+## Replacing the break animation (API v2)
+
+A plugin can take over the animation Tessera plays when a block breaks by
+registering a **break-effect provider**. Tessera still does everything up to
+that moment — gating, variant rotation, lattice spawning, the progress-driven
+mining wave, per-viewer transport — and then offers the surviving fragments to
+your provider instead of running its built-in collapse:
+
+```java
+TesseraApi tessera = Tessera.api();
+if (tessera == null || tessera.apiVersion() < 2) {
+ getLogger().severe("Tessera with API v2 required");
+ return;
+}
+tessera.registerBreakEffectProvider(this, new BreakEffectProvider() {
+ @Override
+ public boolean onBlockShatter(ShatterHandle handle, ShatterContext ctx) {
+ for (ShatterChunk chunk : handle.chunks()) {
+ // animate: chunk.setPose(centerOffset, tumble, scale, delay, interp)
+ }
+ // you now own the fragments — despawn when the effect is done:
+ Bukkit.getScheduler().runTaskLater(plugin, handle::despawn, 40L);
+ return true; // claimed; false = let the built-in effect run
+ }
+
+ @Override
+ public boolean wantsInteriorFill() {
+ return true; // solid lattice even if animation.fillInterior is off
+ }
+});
+```
+
+The contract, briefly:
+
+- **One slot, first-wins.** A second plugin's registration fails with a log
+ line; two providers fighting over the same break can't be reconciled. The
+ provider is detached automatically when its plugin disables.
+- **Claim = own the despawn.** Return `true` and you must eventually call
+ `handle.despawn()` — that removes the fragments and frees Tessera's
+ concurrency slot. As a backstop, Tessera force-despawns a claimed break
+ after `limits.providerMaxEffectDurationMs` (config, default 10s);
+ `despawn()` is idempotent so a well-behaved provider never notices.
+- **Decline = built-in effect.** Return `false` (or throw — logged once) and
+ Tessera animates the break exactly as if no provider were registered.
+- **Progress mode hands over the remainder.** With the default
+ `animation.mode: progress`, the mining wave consumes fragments while the
+ player digs; on the real break your provider receives only what survived,
+ positioned and scaled mid-wave. `ShatterContext.mode()` tells you which
+ path you're on.
+- **Pose, don't recompute.** `ShatterChunk.setPose(centerOffset, tumble,
+ scale, delayTicks, durationTicks)` places a fragment's cube center relative
+ to `handle.origin()` with a world-space rotation on top of the blockstate
+ orientation. The player-head model's render-offset compensation is
+ re-solved internally — with the raw `setTransformation` escape hatch,
+ rotating the left rotation makes fragments orbit off-center. A positive
+ `durationTicks` rides the client's display interpolation, so a handful of
+ poses per second animates smoothly with no per-tick server work.
+- **Main thread only**, like the rest of the break pipeline. Fragments are
+ visible only to the breaker (`ShatterContext.breaker()`).
+
+In-game check: `/tessera test stone` routes through the registered provider;
+`/tessera test stone builtin` forces the built-in effect for comparison.
+
## Versioning
`TesseraApi.VERSION` (and `apiVersion()`) is bumped on incompatible changes. The
diff --git a/src/main/java/org/inventivetalent/tessera/assemble/BlockGeometry.java b/src/main/java/org/inventivetalent/tessera/assemble/BlockGeometry.java
index 0ed977f..762be26 100644
--- a/src/main/java/org/inventivetalent/tessera/assemble/BlockGeometry.java
+++ b/src/main/java/org/inventivetalent/tessera/assemble/BlockGeometry.java
@@ -142,4 +142,33 @@ public Vector3f translationFor(ChunkCoord coord, Quaternionf faceRot, float over
public Quaternionf blockRotation() {
return new Quaternionf(blockRotation);
}
+
+ /**
+ * Translation that puts a chunk cube's geometric center at
+ * {@code worldCenterOffset} (relative to the display's entity location)
+ * for an arbitrary left rotation — unlike
+ * {@link #translationFor(ChunkCoord, Quaternionf, float)}, which assumes
+ * the left rotation is this geometry's block rotation and derives the
+ * center from the grid cell.
+ *
+ *
{@code worldVertex = entityLocation + T + L*S*R*v}, so the rendered
+ * cube center sits at {@code T + L*S*R*CUBE_CENTER_PRE}. Solving for the
+ * requested center: {@code T = worldCenterOffset - L*(R*CUBE_CENTER_PRE)*s}.
+ * Callers animating a chunk (moving or spinning it after spawn) must
+ * recompute {@code T} with this whenever {@code L} or the scale changes,
+ * or the cube orbits its own center instead of rotating in place.
+ *
+ *
Pure function of its arguments plus the tunable
+ * {@link #cubeCenterPre()}; safe from any thread that isn't concurrently
+ * re-tuning the center via {@code /tessera debug center}.
+ */
+ public static Vector3f poseTranslation(Vector3f worldCenterOffset,
+ Quaternionf leftRotation,
+ Quaternionf faceRot,
+ float scale) {
+ Vector3f disp = new Quaternionf(faceRot).transform(new Vector3f(CUBE_CENTER_PRE));
+ disp.mul(scale);
+ new Quaternionf(leftRotation).transform(disp);
+ return new Vector3f(worldCenterOffset).sub(disp);
+ }
}
diff --git a/src/main/java/org/inventivetalent/tessera/core/FakeBlock.java b/src/main/java/org/inventivetalent/tessera/core/FakeBlock.java
index b352ce2..a845af9 100644
--- a/src/main/java/org/inventivetalent/tessera/core/FakeBlock.java
+++ b/src/main/java/org/inventivetalent/tessera/core/FakeBlock.java
@@ -21,6 +21,7 @@ public final class FakeBlock {
private final List chunks;
private final Quaternionf blockRotation;
private final TransportSession session;
+ private final List despawnCallbacks = new ArrayList<>(2);
private boolean despawned = false;
public FakeBlock(Location origin, BlockKey blockKey, int gridN, List chunks,
@@ -53,10 +54,39 @@ public FakeBlock(Location origin, BlockKey blockKey, int gridN, List c
*/
public Quaternionf blockRotation() { return new Quaternionf(blockRotation); }
+ /**
+ * Runs {@code callback} when {@link #despawn()} executes — the hook for
+ * anything whose lifetime is tied to this FakeBlock (concurrency-slot
+ * release, bookkeeping). Runs immediately if already despawned. Callbacks
+ * fire exactly once, after the session closes, each guarded so one
+ * failure can't starve the rest. Main thread only.
+ */
+ public void onDespawn(Runnable callback) {
+ if (despawned) {
+ callback.run();
+ return;
+ }
+ despawnCallbacks.add(callback);
+ }
+
/** Removes every spawned display handle. Idempotent. Must be called on the main thread. */
public void despawn() {
if (despawned) return;
despawned = true;
- session.close();
+ try {
+ session.close();
+ } finally {
+ // Callbacks run even when the transport teardown throws —
+ // whatever is keyed to this FakeBlock's lifetime (concurrency
+ // slot, registries) must not leak on a failed close.
+ for (Runnable r : despawnCallbacks) {
+ try {
+ r.run();
+ } catch (RuntimeException ignored) {
+ // A misbehaving callback must not block the others.
+ }
+ }
+ despawnCallbacks.clear();
+ }
}
}
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakListener.java b/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakListener.java
index 2297731..b45e96f 100644
--- a/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakListener.java
+++ b/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakListener.java
@@ -4,6 +4,7 @@
import org.inventivetalent.tessera.core.BakeKey;
import org.inventivetalent.tessera.core.BlockKey;
import org.inventivetalent.tessera.core.FakeBlock;
+import org.inventivetalent.tessera.api.effect.ShatterContext;
import org.inventivetalent.tessera.core.VariantKey;
import org.inventivetalent.tessera.effect.EffectContext;
import org.inventivetalent.tessera.effect.builtin.DirectionalShrinkEffect;
@@ -129,19 +130,27 @@ private void spawn(Player viewer, BakeKey bakeKey, BlockData blockData,
String matchedKey = VariantKey.pickMatching(fullStateKey, registry.variantsFor(key).keySet());
Quaternionf blockRotation = registry.rotationFor(key, matchedKey);
+ boolean fillInterior = cfg.fillInterior()
+ || plugin.breakEffectProviders().wantsInteriorFill();
FakeBlock fb;
try {
- fb = factory.create(viewer, breakLoc, bakeKey, blockRotation, cfg.fillInterior(), eyeDir);
+ fb = factory.create(viewer, breakLoc, bakeKey, blockRotation, fillInterior, eyeDir);
} catch (RuntimeException re) {
active.decrementAndGet();
plugin.getLogger().warning("Failed to spawn FakeBlock for " + bakeKey + ": " + re.getMessage());
return;
}
+ // The concurrency slot is held for exactly the FakeBlock's lifetime:
+ // whoever despawns it (the effect's scheduled cleanup, a break-effect
+ // provider, plugin shutdown, ...) releases the slot, so effects of any
+ // duration can't wedge the cap or release it early.
+ fb.onDespawn(active::decrementAndGet);
+
+ ShatterContext shatterContext = new ShatterContext(
+ viewer, eyeDir, blockData, ShatterContext.Mode.POST_BREAK);
+ if (plugin.breakEffectProviders().dispatch(fb, shatterContext)) return;
EffectContext ctx = new EffectContext(eyeDir, System.currentTimeMillis(), cfg.effectDurationMs(), plugin);
new DirectionalShrinkEffect(cfg.collapseStyle()).applyTimed(fb, ctx);
-
- long despawnTicks = (cfg.effectDurationMs() / 50L) + 5L;
- plugin.getServer().getScheduler().runTaskLater(plugin, active::decrementAndGet, despawnTicks);
}
}
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakProgressListener.java b/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakProgressListener.java
index 6233138..55bb69f 100644
--- a/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakProgressListener.java
+++ b/src/main/java/org/inventivetalent/tessera/plugin/BlockBreakProgressListener.java
@@ -1,5 +1,6 @@
package org.inventivetalent.tessera.plugin;
+import org.inventivetalent.tessera.api.effect.ShatterContext;
import org.inventivetalent.tessera.assemble.BlockGeometry;
import org.inventivetalent.tessera.assemble.FakeBlockFactory;
import org.inventivetalent.tessera.core.*;
@@ -267,7 +268,7 @@ public void onInteract(PlayerInteractEvent event) {
FakeBlockFactory.PreloadPlan plan;
try {
plan = factory.completePlan(breakLoc, preload.bakeKey(),
- preload.blockRotation(), cfg.fillInterior(), eyeDir,
+ preload.blockRotation(), effectiveFillInterior(cfg), eyeDir,
preload.prespawnedChunks(), preload.session());
} catch (RuntimeException re) {
preload.session().close();
@@ -279,6 +280,7 @@ public void onInteract(PlayerInteractEvent event) {
Math.floor(breakLoc.getX()), Math.floor(breakLoc.getY()), Math.floor(breakLoc.getZ()));
FakeBlock fb = new FakeBlock(origin, preload.bakeKey().block(), gridN,
new ArrayList<>(plan.frontRefs().values()), preload.blockRotation(), plan.session());
+ fb.onDespawn(active::decrementAndGet);
TrackedBreak tb = buildTrackedBreak(player.getUniqueId(),
preload.bakeKey().block(), fb, block.getBlockData(), eyeDir, plan);
tracker.put(posKey, tb);
@@ -366,6 +368,16 @@ private static boolean tooFast(Block block, Player player, TesseraConfig cfg) {
return min > 0 && estimateBreakDurationMs(block, player) < min;
}
+ /**
+ * Interior fill applies when the server config asks for it or a
+ * registered break-effect provider requests it (a provider that animates
+ * the whole block wants a solid lattice even on servers that keep the
+ * built-in effect hollow).
+ */
+ private boolean effectiveFillInterior(TesseraConfig cfg) {
+ return cfg.fillInterior() || plugin.breakEffectProviders().wantsInteriorFill();
+ }
+
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
UUID id = event.getPlayer().getUniqueId();
@@ -413,7 +425,7 @@ private void spawnAndRegister(Player player, Block block, Location breakLoc,
FakeBlockFactory.PreloadPlan plan;
try {
plan = factory.preloadAndPending(player, breakLoc, bakeKey, blockRotation,
- cfg.fillInterior(), eyeDir);
+ effectiveFillInterior(cfg), eyeDir);
} catch (RuntimeException re) {
active.decrementAndGet();
plugin.getLogger().warning("Failed to spawn FakeBlock for " + bakeKey + ": " + re.getMessage());
@@ -425,6 +437,7 @@ private void spawnAndRegister(Player player, Block block, Location breakLoc,
Math.floor(breakLoc.getX()), Math.floor(breakLoc.getY()), Math.floor(breakLoc.getZ()));
FakeBlock fb = new FakeBlock(origin, key, gridN,
new ArrayList<>(plan.frontRefs().values()), blockRotation, plan.session());
+ fb.onDespawn(active::decrementAndGet);
TrackedBreak tb = buildTrackedBreak(player.getUniqueId(), key, fb, blockData, eyeDir, plan);
tracker.put(posKey, tb);
@@ -629,6 +642,8 @@ void onRealBreak(BlockPosKey posKey) {
TrackedBreak tb = tracker.remove(posKey);
if (tb == null) return;
cancelReverseTask(tb);
+ cancelSmoothTask(tb);
+ cancelPendingSpawnTask(tb);
if (tb.barrierSent) {
Player p = Bukkit.getPlayer(tb.currentPlayerId);
if (p != null) {
@@ -641,12 +656,92 @@ void onRealBreak(BlockPosKey posKey) {
}
tb.barrierSent = false;
}
- disposeImmediate(tb, /*restoreBlock=*/ false);
clearPreloadsAt(posKey);
+ if (handOffToProvider(tb)) {
+ if (plugin.tesseraConfig().debug()) plugin.getLogger().info(
+ "[" + ts() + "] [debug-progress] real-break-handoff " + tb.key + " at " + posKey
+ + " chunks=" + tb.fakeBlock.chunks().size());
+ return;
+ }
+ disposeImmediate(tb, /*restoreBlock=*/ false);
if (plugin.tesseraConfig().debug()) plugin.getLogger().info(
"[" + ts() + "] [debug-progress] real-break " + tb.key + " at " + posKey);
}
+ /**
+ * Offer the surviving lattice to a registered break-effect provider
+ * instead of disposing it. The wave consumed the near-side chunks; what
+ * the provider receives is the material that was still un-mined at the
+ * moment the block actually broke:
+ *
+ * - all still-pending chunks (back faces + interior) are spawned in
+ * one batch, at the shell factor matching the current lattice;
+ * - the spawn-time shell compression is undone if it hadn't been —
+ * the real block is gone at this instant, so there is nothing to
+ * z-fight with. Rescaling reads the actual per-chunk transform
+ * scales (not the wave-tracking arrays, whose freshly-spawned
+ * interior slots hold a sentinel);
+ * - chunks the wave shrank to invisibility but hadn't culled yet are
+ * despawned, so mined-away material stays gone.
+ *
+ * Returns false (leaving teardown to the caller) when no provider is
+ * registered or the provider declines.
+ */
+ private boolean handOffToProvider(TrackedBreak tb) {
+ BreakEffectProviders providers = plugin.breakEffectProviders();
+ if (!providers.hasProvider() || tb.fakeBlock.despawned()) return false;
+
+ materializeRemainder(tb);
+
+ if (!tb.shellExpanded) {
+ float[] actual = DirectionalShrinkEffect.captureBaseScales(tb.fakeBlock);
+ DirectionalShrinkEffect.rescaleShell(tb.fakeBlock, actual, actual.clone(),
+ 1f / FakeBlockFactory.INITIAL_SHELL_COMPRESSION, 0, null);
+ tb.shellExpanded = true;
+ }
+
+ float minScale = (float) plugin.tesseraConfig().progressMinDelta();
+ for (ChunkRef ref : tb.fakeBlock.chunks()) {
+ if (ref.handle().isAlive()
+ && ref.handle().getTransformation().getScale().x() < minScale) {
+ ref.handle().despawn();
+ }
+ }
+
+ ShatterContext context = new ShatterContext(
+ Bukkit.getPlayer(tb.currentPlayerId), tb.eyeDir,
+ tb.originalBlockData, ShatterContext.Mode.PROGRESS);
+ return providers.dispatch(tb.fakeBlock, context);
+ }
+
+ /**
+ * Spawn every remaining pending chunk of {@code tb} in one batch,
+ * appending to the FakeBlock's chunk list. Sized for the worst case of
+ * gridN³ chunks; at the default gridN=4 that's at most 64. The
+ * wave-tracking arrays are deliberately not updated — after a handoff the
+ * progress machinery never touches this tracker again.
+ */
+ private void materializeRemainder(TrackedBreak tb) {
+ List pending = tb.pendingChunks;
+ if (pending == null || pending.isEmpty()) return;
+ tb.pendingChunks = new ArrayList<>();
+
+ float shellFactor = tb.shellExpanded ? 1.0f : FakeBlockFactory.INITIAL_SHELL_COMPRESSION;
+ FakeBlockFactory.PendingChunkSpec donor = null;
+ for (FakeBlockFactory.PendingChunkSpec s : pending) {
+ if (s.interior()) { donor = s; break; }
+ }
+ FakeBlockFactory.PendingSpawnContext ctx = factory.beginPendingBatch(tb.fakeBlock, donor);
+ for (FakeBlockFactory.PendingChunkSpec spec : pending) {
+ try {
+ tb.fakeBlock.chunks().add(factory.spawnPendingChunk(ctx, spec, shellFactor));
+ } catch (RuntimeException re) {
+ plugin.getLogger().warning(
+ "handoff spawn failed for " + spec.coord() + ": " + re.getMessage());
+ }
+ }
+ }
+
public boolean isTracked(BlockPosKey posKey) {
return tracker.get(posKey) != null;
}
@@ -700,7 +795,10 @@ private void disposeImmediate(TrackedBreak tb, boolean restoreBlock) {
} catch (RuntimeException re) {
plugin.getLogger().warning("despawn FakeBlock failed: " + re.getMessage());
}
- active.decrementAndGet();
+ // The concurrency slot is released by the FakeBlock's onDespawn
+ // callback (registered at spawn), not here — despawn() is idempotent
+ // and the callback fires exactly once regardless of which teardown
+ // path got there first.
}
private static void cancelReverseTask(TrackedBreak tb) {
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/BreakEffectProviders.java b/src/main/java/org/inventivetalent/tessera/plugin/BreakEffectProviders.java
new file mode 100644
index 0000000..4662cc7
--- /dev/null
+++ b/src/main/java/org/inventivetalent/tessera/plugin/BreakEffectProviders.java
@@ -0,0 +1,161 @@
+package org.inventivetalent.tessera.plugin;
+
+import org.inventivetalent.tessera.api.effect.BreakEffectProvider;
+import org.inventivetalent.tessera.api.effect.ShatterContext;
+import org.inventivetalent.tessera.core.FakeBlock;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.server.PluginDisableEvent;
+import org.bukkit.plugin.Plugin;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.Set;
+import java.util.function.LongSupplier;
+import java.util.logging.Logger;
+
+/**
+ * Single-slot registry and dispatcher for {@link BreakEffectProvider}s.
+ *
+ * Both break listeners route through {@link #dispatch} at the moment they
+ * would run the built-in collapse effect; a registered provider gets first
+ * refusal. Claimed FakeBlocks are tracked so they can be force-despawned when
+ * the provider unregisters (or its plugin disables) mid-effect, and each claim
+ * schedules a safety despawn at {@code limits.providerMaxEffectDurationMs} so
+ * a provider that forgets its despawn obligation can't hold concurrency slots
+ * forever — {@code FakeBlock.despawn()} is idempotent, so the backstop is a
+ * no-op for well-behaved providers.
+ *
+ *
Main-thread only, like everything else in the break pipeline.
+ */
+public final class BreakEffectProviders implements Listener {
+
+ /** Schedules the safety-despawn backstop; injected so tests run scheduler-free. */
+ @FunctionalInterface
+ interface DelayedRunner {
+ void runLater(Runnable task, long delayTicks);
+ }
+
+ private record Registration(Plugin owner, BreakEffectProvider provider) {}
+
+ private final Logger logger;
+ private final LongSupplier maxEffectDurationMs;
+ private final DelayedRunner scheduler;
+
+ private Registration current;
+ /** True after the current provider's first thrown exception was logged. */
+ private boolean throwLogged;
+ private final Set outstanding =
+ Collections.newSetFromMap(new IdentityHashMap<>());
+
+ BreakEffectProviders(Logger logger, LongSupplier maxEffectDurationMs, DelayedRunner scheduler) {
+ this.logger = logger;
+ this.maxEffectDurationMs = maxEffectDurationMs;
+ this.scheduler = scheduler;
+ }
+
+ /** First-wins single slot; re-registering the same provider instance is a no-op success. */
+ public boolean register(Plugin owner, BreakEffectProvider provider) {
+ if (current != null) {
+ if (current.provider() == provider) return true;
+ logger.warning("Break-effect provider from " + owner.getName()
+ + " rejected: " + current.owner().getName()
+ + " already registered one. Only a single provider can drive break effects.");
+ return false;
+ }
+ current = new Registration(owner, provider);
+ throwLogged = false;
+ logger.info("Break-effect provider registered by " + owner.getName());
+ return true;
+ }
+
+ /** Removes {@code provider} if registered; force-despawns any breaks it still owns. */
+ public boolean unregister(BreakEffectProvider provider) {
+ if (current == null || current.provider() != provider) return false;
+ logger.info("Break-effect provider from " + current.owner().getName() + " unregistered");
+ current = null;
+ despawnOutstanding();
+ return true;
+ }
+
+ /**
+ * True while a provider is registered. Callers with expensive handoff prep
+ * (materializing pending chunks, decompressing the shell) check this first
+ * so the work only happens when someone can claim it.
+ */
+ public boolean hasProvider() {
+ return current != null;
+ }
+
+ /** True when the registered provider requests interior fill for spawned lattices. */
+ public boolean wantsInteriorFill() {
+ Registration reg = current;
+ if (reg == null) return false;
+ try {
+ return reg.provider().wantsInteriorFill();
+ } catch (Throwable t) {
+ logThrow(reg, t);
+ return false;
+ }
+ }
+
+ /**
+ * Offer {@code fakeBlock} to the registered provider. Returns true iff the
+ * provider claimed it — the caller must then skip the built-in effect. On
+ * false (no provider, decline, or provider threw) the caller runs the
+ * built-in effect exactly as before.
+ */
+ public boolean dispatch(FakeBlock fakeBlock, ShatterContext context) {
+ Registration reg = current;
+ if (reg == null || fakeBlock.despawned()) return false;
+
+ boolean claimed;
+ try {
+ claimed = reg.provider().onBlockShatter(new ShatterHandleImpl(fakeBlock), context);
+ } catch (Throwable t) {
+ logThrow(reg, t);
+ return false;
+ }
+ if (!claimed) return false;
+
+ outstanding.add(fakeBlock);
+ fakeBlock.onDespawn(() -> outstanding.remove(fakeBlock));
+ scheduler.runLater(fakeBlock::despawn, maxEffectDurationMs.getAsLong() / 50L + 5L);
+ return true;
+ }
+
+ @EventHandler
+ public void onPluginDisable(PluginDisableEvent event) {
+ if (current != null && current.owner() == event.getPlugin()) {
+ unregister(current.provider());
+ }
+ }
+
+ /** Plugin shutdown: drop the registration and despawn everything still owned. */
+ public void shutdown() {
+ current = null;
+ despawnOutstanding();
+ }
+
+ private void despawnOutstanding() {
+ // Despawn callbacks mutate `outstanding`; iterate a copy.
+ for (FakeBlock fb : new ArrayList<>(outstanding)) {
+ try {
+ fb.despawn();
+ } catch (RuntimeException re) {
+ logger.warning("despawn of provider-owned FakeBlock failed: " + re.getMessage());
+ }
+ }
+ outstanding.clear();
+ }
+
+ private void logThrow(Registration reg, Throwable t) {
+ if (throwLogged) return;
+ throwLogged = true;
+ logger.warning("Break-effect provider from " + reg.owner().getName()
+ + " threw " + t.getClass().getSimpleName() + " (" + t.getMessage()
+ + "); treating as decline. Further exceptions from this provider"
+ + " are suppressed from the log.");
+ }
+}
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/ShatterChunkImpl.java b/src/main/java/org/inventivetalent/tessera/plugin/ShatterChunkImpl.java
new file mode 100644
index 0000000..b4a38b0
--- /dev/null
+++ b/src/main/java/org/inventivetalent/tessera/plugin/ShatterChunkImpl.java
@@ -0,0 +1,102 @@
+package org.inventivetalent.tessera.plugin;
+
+import org.inventivetalent.tessera.api.effect.ShatterChunk;
+import org.inventivetalent.tessera.assemble.BlockGeometry;
+import org.inventivetalent.tessera.core.ChunkCoord;
+import org.inventivetalent.tessera.core.ChunkRef;
+import org.inventivetalent.tessera.core.FaceDir;
+import org.bukkit.util.Transformation;
+import org.joml.Quaternionf;
+import org.joml.Vector3f;
+
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * {@link ShatterChunk} adapter over a {@link ChunkRef}. The canonical face
+ * rotation is captured from the chunk's spawn transformation (it is the same
+ * {@code Ry(180°)} for every chunk of every FakeBlock — see the invariant on
+ * {@code FakeBlockFactory}) so {@link #setPose} can rebuild transformations
+ * without reaching into the assemble layer's rotation tables.
+ */
+final class ShatterChunkImpl implements ShatterChunk {
+
+ private final ChunkRef ref;
+ private final Quaternionf blockRotation;
+ private final Quaternionf canonicalRotation;
+ private final Vector3f initialCenter;
+ private final Set outwardFaces;
+
+ ShatterChunkImpl(ChunkRef ref, Quaternionf blockRotation) {
+ this.ref = ref;
+ this.blockRotation = new Quaternionf(blockRotation);
+ this.canonicalRotation = new Quaternionf(
+ ref.handle().getTransformation().getRightRotation());
+ Vector3f blockCenter = new Vector3f(0.5f, 0.5f, 0.5f);
+ this.initialCenter = new Vector3f(blockCenter)
+ .add(new Quaternionf(blockRotation)
+ .transform(ref.localCenter().sub(blockCenter)));
+ this.outwardFaces = Collections.unmodifiableSet(ref.outwardFaces());
+ }
+
+ @Override
+ public ChunkCoord coord() {
+ return ref.coord();
+ }
+
+ @Override
+ public Vector3f localCenter() {
+ return ref.localCenter();
+ }
+
+ @Override
+ public Set outwardFaces() {
+ return outwardFaces;
+ }
+
+ @Override
+ public boolean interior() {
+ return outwardFaces.isEmpty();
+ }
+
+ @Override
+ public Vector3f initialCenter() {
+ return new Vector3f(initialCenter);
+ }
+
+ @Override
+ public float currentScale() {
+ return ref.handle().getTransformation().getScale().x();
+ }
+
+ @Override
+ public boolean isAlive() {
+ return ref.handle().isAlive();
+ }
+
+ @Override
+ public void setPose(Vector3f centerOffset, Quaternionf tumble, float scale,
+ int delayTicks, int durationTicks) {
+ Quaternionf left = new Quaternionf(tumble).mul(blockRotation);
+ Vector3f translation = BlockGeometry.poseTranslation(
+ centerOffset, left, canonicalRotation, scale);
+ ref.handle().setTransformation(new Transformation(
+ translation, left, new Vector3f(scale, scale, scale),
+ new Quaternionf(canonicalRotation)), delayTicks, durationTicks);
+ }
+
+ @Override
+ public Transformation getTransformation() {
+ return ref.handle().getTransformation();
+ }
+
+ @Override
+ public void setTransformation(Transformation transformation, int delayTicks, int durationTicks) {
+ ref.handle().setTransformation(transformation, delayTicks, durationTicks);
+ }
+
+ @Override
+ public void despawn() {
+ ref.handle().despawn();
+ }
+}
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/ShatterHandleImpl.java b/src/main/java/org/inventivetalent/tessera/plugin/ShatterHandleImpl.java
new file mode 100644
index 0000000..e510e7f
--- /dev/null
+++ b/src/main/java/org/inventivetalent/tessera/plugin/ShatterHandleImpl.java
@@ -0,0 +1,71 @@
+package org.inventivetalent.tessera.plugin;
+
+import org.inventivetalent.tessera.api.effect.ShatterChunk;
+import org.inventivetalent.tessera.api.effect.ShatterHandle;
+import org.inventivetalent.tessera.core.BlockKey;
+import org.inventivetalent.tessera.core.ChunkRef;
+import org.inventivetalent.tessera.core.FakeBlock;
+import org.bukkit.Location;
+import org.joml.Quaternionf;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * {@link ShatterHandle} adapter over a {@link FakeBlock}. The chunk list is
+ * snapshotted at construction (handoff time) from the alive refs only, so a
+ * provider never sees fragments the progress wave already consumed.
+ */
+final class ShatterHandleImpl implements ShatterHandle {
+
+ private final FakeBlock fakeBlock;
+ private final List chunks;
+
+ ShatterHandleImpl(FakeBlock fakeBlock) {
+ this.fakeBlock = fakeBlock;
+ Quaternionf blockRotation = fakeBlock.blockRotation();
+ List alive = new ArrayList<>(fakeBlock.chunks().size());
+ for (ChunkRef ref : fakeBlock.chunks()) {
+ if (ref.handle().isAlive()) {
+ alive.add(new ShatterChunkImpl(ref, blockRotation));
+ }
+ }
+ this.chunks = Collections.unmodifiableList(alive);
+ }
+
+ @Override
+ public Location origin() {
+ return fakeBlock.origin();
+ }
+
+ @Override
+ public BlockKey blockKey() {
+ return fakeBlock.blockKey();
+ }
+
+ @Override
+ public int gridN() {
+ return fakeBlock.gridN();
+ }
+
+ @Override
+ public Quaternionf blockRotation() {
+ return fakeBlock.blockRotation();
+ }
+
+ @Override
+ public List chunks() {
+ return chunks;
+ }
+
+ @Override
+ public boolean despawned() {
+ return fakeBlock.despawned();
+ }
+
+ @Override
+ public void despawn() {
+ fakeBlock.despawn();
+ }
+}
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/TesseraApiImpl.java b/src/main/java/org/inventivetalent/tessera/plugin/TesseraApiImpl.java
index 6f19da6..bf3a45b 100644
--- a/src/main/java/org/inventivetalent/tessera/plugin/TesseraApiImpl.java
+++ b/src/main/java/org/inventivetalent/tessera/plugin/TesseraApiImpl.java
@@ -1,10 +1,12 @@
package org.inventivetalent.tessera.plugin;
import org.bukkit.block.data.BlockData;
+import org.bukkit.plugin.Plugin;
import org.inventivetalent.tessera.api.BakeOutcome;
import org.inventivetalent.tessera.api.ChunkLayout;
import org.inventivetalent.tessera.api.SkinPayload;
import org.inventivetalent.tessera.api.TesseraApi;
+import org.inventivetalent.tessera.api.effect.BreakEffectProvider;
import org.inventivetalent.tessera.assemble.FakeBlockFactory;
import org.inventivetalent.tessera.core.BakeKey;
import org.inventivetalent.tessera.core.BlockKey;
@@ -108,4 +110,14 @@ public boolean canBakeNewBlocks() {
TesseraConfig cfg = plugin.tesseraConfig();
return cfg.hasLicense() || !cfg.mineskinApiKey().isBlank();
}
+
+ @Override
+ public boolean registerBreakEffectProvider(Plugin owner, BreakEffectProvider provider) {
+ return plugin.breakEffectProviders().register(owner, provider);
+ }
+
+ @Override
+ public boolean unregisterBreakEffectProvider(BreakEffectProvider provider) {
+ return plugin.breakEffectProviders().unregister(provider);
+ }
}
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/TesseraCommand.java b/src/main/java/org/inventivetalent/tessera/plugin/TesseraCommand.java
index bcd7b4c..5676ffa 100644
--- a/src/main/java/org/inventivetalent/tessera/plugin/TesseraCommand.java
+++ b/src/main/java/org/inventivetalent/tessera/plugin/TesseraCommand.java
@@ -44,7 +44,10 @@
* v1 command surface for testing the splitting/effect pipeline. Subcommands:
*
*
- * /tessera test [material] [static] bake (if needed) + spawn FakeBlock; "static" = no shrink
+ * /tessera test [material] [static|builtin] bake (if needed) + spawn FakeBlock; "static" = no
+ * effect (linger for inspection), "builtin" = force the
+ * built-in shrink even when a break-effect provider is
+ * registered
* /tessera bake <material> [tint:#RRGGBB]
* bake without spawning; reports upload count + completion.
* Tint is required for biome-tinted blocks (grass, leaves,
@@ -130,7 +133,7 @@ public final class TesseraCommand implements CommandExecutor, TabCompleter {
private static final List PERM_KINDS = List.of("all", "head", "source", "tile");
private static final List CENTER_HINTS = List.of("-0.5", "0", "0.5", "reset");
private static final List FLOAT_HINTS = List.of("-90", "0", "90", "180", "270");
- private static final List STATIC_FLAG = List.of("static");
+ private static final List TEST_FLAGS = List.of("static", "builtin");
private static final List TINT_HINT = List.of("tint:#");
private static final List HEAD_FACES = lower(HeadFace.values());
@@ -207,19 +210,21 @@ private boolean handleTest(CommandSender sender, String[] args) {
sender.sendMessage("§cUnknown material: " + args[1]);
return true;
}
- // Optional trailing "static" flag → spawn without the shrink effect.
- // Useful when debugging texture / rotation - the FakeBlock lingers
- // for STATIC_LIFETIME_TICKS so you can rotate around and inspect.
+ // Optional trailing flags:
+ // "static" → spawn without any effect and linger for inspection.
+ // "builtin" → force the built-in shrink effect even when an
+ // external break-effect provider is registered.
boolean staticMode = false;
+ boolean builtinMode = false;
for (int i = 2; i < args.length; i++) {
- if (args[i].equalsIgnoreCase("static")) {
- staticMode = true;
- break;
- }
+ if (args[i].equalsIgnoreCase("static")) staticMode = true;
+ if (args[i].equalsIgnoreCase("builtin")) builtinMode = true;
}
BlockKey key = blockKeyOf(mat);
Location target = pickTargetLocation(p);
boolean st = staticMode;
+ boolean bi = builtinMode;
+ org.bukkit.block.data.BlockData testData = mat.createBlockData();
if (!registry.has(key)) {
if (plugin.tesseraConfig().mineskinApiKey().isBlank()) {
@@ -238,24 +243,30 @@ private boolean handleTest(CommandSender sender, String[] args) {
return;
}
sender.sendMessage("§aBake complete; spawning" + (st ? " (static)" : "") + ".");
- spawnTest(sender, p, key, target, st);
+ spawnTest(sender, p, key, testData, target, st, bi);
}));
return true;
}
- spawnTest(sender, p, key, target, st);
+ spawnTest(sender, p, key, testData, target, st, bi);
return true;
}
/**
* If {@code staticMode}, spawn and let it linger for ~30s without running
* any effect — useful when debugging texture/rotation issues without
- * being rushed by the shrink animation.
+ * being rushed by the shrink animation. Otherwise the spawn goes through
+ * the same effect selection as a real break — a registered break-effect
+ * provider gets first refusal unless {@code forceBuiltin}.
*/
- private void spawnTest(CommandSender sender, Player p, BlockKey key, Location target, boolean staticMode) {
+ private void spawnTest(CommandSender sender, Player p, BlockKey key,
+ org.bukkit.block.data.BlockData blockData, Location target,
+ boolean staticMode, boolean forceBuiltin) {
org.bukkit.util.Vector eyeDir = p.getEyeLocation().getDirection();
+ boolean fillInterior = plugin.tesseraConfig().fillInterior()
+ || (!staticMode && !forceBuiltin && plugin.breakEffectProviders().wantsInteriorFill());
FakeBlock fb = factory.create(p, target, key, new org.joml.Quaternionf(),
- plugin.tesseraConfig().fillInterior(), eyeDir);
+ fillInterior, eyeDir);
if (fb.chunks().isEmpty()) {
sender.sendMessage("§cFakeBlock has 0 chunks - heads.json entry for " + key + " is empty.");
return;
@@ -271,6 +282,14 @@ private void spawnTest(CommandSender sender, Player p, BlockKey key, Location ta
Bukkit.getScheduler().runTaskLater(plugin, fb::despawn, 5L * 60L * 20L);
return;
}
+ if (!forceBuiltin && plugin.breakEffectProviders().dispatch(fb,
+ new org.inventivetalent.tessera.api.effect.ShatterContext(
+ p, eyeDir, blockData,
+ org.inventivetalent.tessera.api.effect.ShatterContext.Mode.POST_BREAK))) {
+ sender.sendMessage("§aSpawned FakeBlock for " + key + " at " + formatLoc(target)
+ + " §7(external break-effect provider)");
+ return;
+ }
EffectContext ctx = new EffectContext(
p.getEyeLocation().getDirection(),
System.currentTimeMillis(),
@@ -1261,7 +1280,7 @@ public List onTabComplete(@NotNull CommandSender sender, @NotNull Comman
return switch (sub) {
case "test" -> {
if (args.length == 2) yield matchMaterials(args[1]);
- if (args.length == 3) yield match(args[2], STATIC_FLAG);
+ if (args.length == 3) yield match(args[2], TEST_FLAGS);
yield Collections.emptyList();
}
case "bake" -> {
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/TesseraConfig.java b/src/main/java/org/inventivetalent/tessera/plugin/TesseraConfig.java
index 14f882f..c3fa3fb 100644
--- a/src/main/java/org/inventivetalent/tessera/plugin/TesseraConfig.java
+++ b/src/main/java/org/inventivetalent/tessera/plugin/TesseraConfig.java
@@ -25,6 +25,7 @@ public record TesseraConfig(
Set enabledWorlds,
Set disabledWorlds,
int maxConcurrentFakeBlocks,
+ long providerMaxEffectDurationMs,
AnimationMode animationMode,
CollapseStyle collapseStyle,
int effectDurationMs,
@@ -101,6 +102,9 @@ public static TesseraConfig from(FileConfiguration cfg) {
normalizeWorlds(readStringList(cfg, "worlds.enabled", "enabledWorlds", List.of("*"))),
normalizeWorlds(readStringList(cfg, "worlds.disabled", "disabledWorlds", List.of())),
readInt(cfg, "limits.maxConcurrentFakeBlocks", "maxConcurrentFakeBlocks", 8),
+ // Ceiling for externally provided break effects (api break-effect
+ // providers); the safety despawn fires this long after a claim.
+ cfg.getLong("limits.providerMaxEffectDurationMs", 10_000L),
mode,
style,
readInt(cfg, "animation.durationMs", "effectDurationMs", 600),
diff --git a/src/main/java/org/inventivetalent/tessera/plugin/TesseraPlugin.java b/src/main/java/org/inventivetalent/tessera/plugin/TesseraPlugin.java
index 38a0d76..2ee4c39 100644
--- a/src/main/java/org/inventivetalent/tessera/plugin/TesseraPlugin.java
+++ b/src/main/java/org/inventivetalent/tessera/plugin/TesseraPlugin.java
@@ -61,6 +61,7 @@ public final class TesseraPlugin extends JavaPlugin {
private ProgressSource progressBridge;
private BackendClient backendClient;
private Path addonsDir;
+ private BreakEffectProviders breakEffectProviders;
@Override
public void onEnable() {
@@ -173,6 +174,16 @@ public void onEnable() {
this.bakerExecutor = Executors.newFixedThreadPool(2, named("Tessera-Baker"));
this.baker = new BlockBaker(getLogger(), () -> this.config.debug(), assets, mcVersion, registry, uploader, diskCache, pngDir, bakerExecutor);
+ // Break-effect provider slot (extension API v2): a third-party plugin
+ // may register a provider that replaces the built-in collapse effect;
+ // both break listeners consult this before running it. Registered as
+ // a Listener for PluginDisableEvent so a dying provider plugin is
+ // detached (and its claimed breaks despawned) automatically.
+ this.breakEffectProviders = new BreakEffectProviders(getLogger(),
+ () -> this.config.providerMaxEffectDurationMs(),
+ (task, delayTicks) -> getServer().getScheduler().runTaskLater(this, task, delayTicks));
+ getServer().getPluginManager().registerEvents(breakEffectProviders, this);
+
// Public extension API: let third-party plugins read pre-baked block
// data and request bakes (see org.inventivetalent.tessera.api). Exposed
// via Bukkit's ServicesManager — consumers load TesseraApi.class. The
@@ -224,6 +235,7 @@ public void onEnable() {
public void onDisable() {
if (progressBridge != null) progressBridge.shutdown();
progressBridge = null;
+ if (breakEffectProviders != null) breakEffectProviders.shutdown();
if (progressListener != null) progressListener.shutdown();
if (itemFactory != null) itemFactory.clear();
if (uploader != null) uploader.cancelAll();
@@ -310,6 +322,9 @@ public void reloadTesseraConfig() {
public HeadsRegistry registry() { return registry; }
+ /** Break-effect provider slot; both break listeners consult it before the built-in effect. */
+ public BreakEffectProviders breakEffectProviders() { return breakEffectProviders; }
+
/**
* Stable per-install identifier sent as {@code X-Tessera-Server-Id} so
* the backend can flag a single license seen across many distinct
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
index c72fef7..ac862e6 100644
--- a/src/main/resources/config.yml
+++ b/src/main/resources/config.yml
@@ -89,6 +89,11 @@ limits:
# Hard cap on concurrent FakeBlocks (sub-block effect entities).
# Excess block-break events fall back to the vanilla animation.
maxConcurrentFakeBlocks: 8
+ # Ceiling (ms) for break effects run by an external break-effect provider
+ # (extension API). If a provider hasn't despawned a claimed break by then,
+ # Tessera despawns it to reclaim the concurrency slot. Irrelevant unless a
+ # plugin registers a provider.
+ providerMaxEffectDurationMs: 10000
animation:
# When the chunked animation plays.
diff --git a/src/test/java/org/inventivetalent/tessera/assemble/BlockGeometryTest.java b/src/test/java/org/inventivetalent/tessera/assemble/BlockGeometryTest.java
index c70669e..21cd018 100644
--- a/src/test/java/org/inventivetalent/tessera/assemble/BlockGeometryTest.java
+++ b/src/test/java/org/inventivetalent/tessera/assemble/BlockGeometryTest.java
@@ -55,4 +55,61 @@ void rejectsZeroOrNegativeGridN() {
assertThrows(IllegalArgumentException.class, () -> new BlockGeometry(0, new Quaternionf()));
assertThrows(IllegalArgumentException.class, () -> new BlockGeometry(-1, new Quaternionf()));
}
+
+ @ParameterizedTest
+ @ValueSource(ints = {1, 2, 4, 8})
+ void poseTranslationReproducesSpawnTranslation(int gridN) {
+ // translationFor derives the cube center from the grid cell and
+ // assumes L = blockRotation; poseTranslation takes the center and L
+ // explicitly. Feeding it the spawn pose must give bit-for-bit the
+ // same T, otherwise a chunk re-posed at its spawn position would
+ // visibly jump.
+ Quaternionf blockRot = new Quaternionf().rotateY((float) Math.toRadians(90));
+ Quaternionf faceRot = new Quaternionf().rotateY((float) Math.PI);
+ BlockGeometry geom = new BlockGeometry(gridN, blockRot);
+ float scale = geom.chunkScale();
+ Vector3f blockCenter = new Vector3f(0.5f, 0.5f, 0.5f);
+ for (int x = 0; x < gridN; x++)
+ for (int y = 0; y < gridN; y++)
+ for (int z = 0; z < gridN; z++) {
+ ChunkCoord c = new ChunkCoord(x, y, z);
+ Vector3f cell = geom.chunkLocalCenter(c).sub(blockCenter);
+ Vector3f center = new Vector3f(blockCenter)
+ .add(new Quaternionf(blockRot).transform(cell));
+ Vector3f expected = geom.translationFor(c, faceRot, scale);
+ Vector3f actual = BlockGeometry.poseTranslation(center, blockRot, faceRot, scale);
+ assertEquals(expected.x, actual.x, EPS);
+ assertEquals(expected.y, actual.y, EPS);
+ assertEquals(expected.z, actual.z, EPS);
+ }
+ }
+
+ @Test
+ void poseTranslationPinsCenterUnderArbitraryRotation() {
+ // The whole point of the compensation: for any left rotation the
+ // rendered cube center (T + L*S*R*CUBE_CENTER_PRE) must land exactly
+ // on the requested center — a spinning chunk rotates in place rather
+ // than orbiting.
+ java.util.Random rng = new java.util.Random(424242);
+ Quaternionf faceRot = new Quaternionf().rotateY((float) Math.PI);
+ for (int i = 0; i < 200; i++) {
+ Quaternionf left = new Quaternionf().rotateXYZ(
+ (float) (rng.nextDouble() * Math.PI * 2),
+ (float) (rng.nextDouble() * Math.PI * 2),
+ (float) (rng.nextDouble() * Math.PI * 2));
+ float scale = 0.05f + rng.nextFloat() * 2f;
+ Vector3f center = new Vector3f(
+ rng.nextFloat() * 8f - 4f,
+ rng.nextFloat() * 8f - 4f,
+ rng.nextFloat() * 8f - 4f);
+ Vector3f t = BlockGeometry.poseTranslation(center, left, faceRot, scale);
+ Vector3f rendered = new Quaternionf(faceRot).transform(BlockGeometry.cubeCenterPre());
+ rendered.mul(scale);
+ new Quaternionf(left).transform(rendered);
+ rendered.add(t);
+ assertEquals(center.x, rendered.x, 1e-4f);
+ assertEquals(center.y, rendered.y, 1e-4f);
+ assertEquals(center.z, rendered.z, 1e-4f);
+ }
+ }
}
diff --git a/src/test/java/org/inventivetalent/tessera/plugin/BreakEffectProvidersTest.java b/src/test/java/org/inventivetalent/tessera/plugin/BreakEffectProvidersTest.java
new file mode 100644
index 0000000..9a413de
--- /dev/null
+++ b/src/test/java/org/inventivetalent/tessera/plugin/BreakEffectProvidersTest.java
@@ -0,0 +1,235 @@
+package org.inventivetalent.tessera.plugin;
+
+import be.seeseemelk.mockbukkit.MockBukkit;
+import org.inventivetalent.tessera.api.effect.BreakEffectProvider;
+import org.inventivetalent.tessera.api.effect.ShatterContext;
+import org.inventivetalent.tessera.api.effect.ShatterHandle;
+import org.inventivetalent.tessera.core.BlockKey;
+import org.inventivetalent.tessera.core.ChunkCoord;
+import org.inventivetalent.tessera.core.ChunkRef;
+import org.inventivetalent.tessera.core.FaceDir;
+import org.inventivetalent.tessera.core.FakeBlock;
+import org.inventivetalent.tessera.transport.DisplayHandle;
+import org.inventivetalent.tessera.transport.TransportSession;
+import org.bukkit.Location;
+import org.bukkit.event.server.PluginDisableEvent;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.util.Transformation;
+import org.bukkit.util.Vector;
+import org.joml.Quaternionf;
+import org.joml.Vector3f;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.logging.Logger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The provider slot's contract — single registration, decline/throw fallback,
+ * claim bookkeeping, and the safety-despawn backstop — exercised without a
+ * scheduler: the {@code DelayedRunner} seam records tasks so tests fire the
+ * backstop deterministically. MockBukkit supplies the {@code Plugin} handles
+ * and event plumbing only.
+ */
+class BreakEffectProvidersTest {
+
+ private static Plugin ownerA;
+ private static Plugin ownerB;
+
+ @BeforeAll
+ static void boot() {
+ MockBukkit.mock();
+ ownerA = MockBukkit.createMockPlugin("OwnerA");
+ ownerB = MockBukkit.createMockPlugin("OwnerB");
+ }
+
+ @AfterAll
+ static void shutdown() {
+ MockBukkit.unmock();
+ }
+
+ /** Recorded instead of scheduled; {@code runAll()} = the backstop firing. */
+ private static final class RecordingRunner implements BreakEffectProviders.DelayedRunner {
+ final List tasks = new ArrayList<>();
+ final List delays = new ArrayList<>();
+
+ @Override
+ public void runLater(Runnable task, long delayTicks) {
+ tasks.add(task);
+ delays.add(delayTicks);
+ }
+
+ void runAll() {
+ tasks.forEach(Runnable::run);
+ }
+ }
+
+ private static final class FakeHandle implements DisplayHandle {
+ boolean alive = true;
+ Transformation tx = new Transformation(
+ new Vector3f(), new Quaternionf(),
+ new Vector3f(0.5f, 0.5f, 0.5f),
+ new Quaternionf().rotateY((float) Math.PI));
+
+ @Override public boolean isAlive() { return alive; }
+ @Override public Transformation getTransformation() { return tx; }
+ @Override public void setTransformation(Transformation t, int delay, int duration) { tx = t; }
+ @Override public void despawn() { alive = false; }
+ }
+
+ private RecordingRunner runner;
+ private BreakEffectProviders providers;
+
+ @BeforeEach
+ void setUp() {
+ runner = new RecordingRunner();
+ providers = new BreakEffectProviders(Logger.getLogger("test"), () -> 10_000L, runner);
+ }
+
+ private static FakeBlock fakeBlock(FakeHandle... handles) {
+ List refs = new ArrayList<>();
+ int i = 0;
+ for (FakeHandle h : handles) {
+ refs.add(new ChunkRef(h, new ChunkCoord(i++, 0, 0),
+ new Vector3f(0.125f, 0.125f, 0.125f), EnumSet.of(FaceDir.DOWN)));
+ }
+ TransportSession session = new TransportSession() {
+ @Override public DisplayHandle spawn(Location o, ItemStack s, Transformation t, float v) {
+ throw new UnsupportedOperationException();
+ }
+ @Override public void close() {
+ for (FakeHandle h : handles) h.alive = false;
+ }
+ };
+ return new FakeBlock(new Location(null, 0, 64, 0), BlockKey.of("minecraft:stone"),
+ 4, refs, new Quaternionf(), session);
+ }
+
+ private static ShatterContext context() {
+ return new ShatterContext(null, new Vector(0, 0, 1), null,
+ ShatterContext.Mode.POST_BREAK);
+ }
+
+ @Test
+ void slotIsFirstWins() {
+ BreakEffectProvider first = (h, c) -> true;
+ BreakEffectProvider second = (h, c) -> true;
+ assertTrue(providers.register(ownerA, first));
+ assertFalse(providers.register(ownerB, second));
+ // Same instance re-registering is a harmless success, not a conflict.
+ assertTrue(providers.register(ownerA, first));
+ assertFalse(providers.unregister(second));
+ assertTrue(providers.unregister(first));
+ // Slot is free again after unregister.
+ assertTrue(providers.register(ownerB, second));
+ }
+
+ @Test
+ void dispatchWithoutProviderDeclines() {
+ FakeBlock fb = fakeBlock(new FakeHandle());
+ assertFalse(providers.dispatch(fb, context()));
+ assertFalse(fb.despawned());
+ assertTrue(runner.tasks.isEmpty());
+ }
+
+ @Test
+ void decliningProviderFallsThrough() {
+ providers.register(ownerA, (h, c) -> false);
+ FakeBlock fb = fakeBlock(new FakeHandle());
+ assertFalse(providers.dispatch(fb, context()));
+ assertFalse(fb.despawned());
+ assertTrue(runner.tasks.isEmpty());
+ }
+
+ @Test
+ void throwingProviderCountsAsDecline() {
+ providers.register(ownerA, (h, c) -> { throw new IllegalStateException("boom"); });
+ FakeBlock fb = fakeBlock(new FakeHandle());
+ assertFalse(providers.dispatch(fb, context()));
+ assertFalse(fb.despawned());
+ assertTrue(runner.tasks.isEmpty());
+ }
+
+ @Test
+ void handleExposesAliveChunksOnly() {
+ FakeHandle dead = new FakeHandle();
+ dead.alive = false;
+ FakeHandle live = new FakeHandle();
+ List seen = new ArrayList<>();
+ providers.register(ownerA, (h, c) -> { seen.add(h); return true; });
+
+ assertTrue(providers.dispatch(fakeBlock(dead, live), context()));
+ assertEquals(1, seen.getFirst().chunks().size());
+ }
+
+ @Test
+ void claimSchedulesSafetyDespawn() {
+ providers.register(ownerA, (h, c) -> true);
+ FakeBlock fb = fakeBlock(new FakeHandle());
+
+ assertTrue(providers.dispatch(fb, context()));
+ assertEquals(1, runner.tasks.size());
+ assertEquals(10_000L / 50L + 5L, runner.delays.getFirst());
+
+ runner.runAll();
+ assertTrue(fb.despawned());
+ }
+
+ @Test
+ void safetyDespawnIsNoopAfterProviderDespawns() {
+ List handles = new ArrayList<>();
+ providers.register(ownerA, (h, c) -> { handles.add(h); return true; });
+ FakeBlock fb = fakeBlock(new FakeHandle());
+
+ assertTrue(providers.dispatch(fb, context()));
+ handles.getFirst().despawn();
+ assertTrue(fb.despawned());
+ runner.runAll(); // idempotent — must not throw
+ assertTrue(fb.despawned());
+ }
+
+ @Test
+ void unregisterDespawnsOutstandingClaims() {
+ BreakEffectProvider provider = (h, c) -> true;
+ providers.register(ownerA, provider);
+ FakeBlock fb = fakeBlock(new FakeHandle());
+ assertTrue(providers.dispatch(fb, context()));
+
+ assertTrue(providers.unregister(provider));
+ assertTrue(fb.despawned());
+ }
+
+ @Test
+ void ownerDisableDetachesProviderAndDespawns() {
+ providers.register(ownerA, (h, c) -> true);
+ FakeBlock fb = fakeBlock(new FakeHandle());
+ assertTrue(providers.dispatch(fb, context()));
+
+ providers.onPluginDisable(new PluginDisableEvent(ownerA));
+ assertTrue(fb.despawned());
+ // Slot is free again.
+ assertTrue(providers.register(ownerB, (h, c) -> true));
+ }
+
+ @Test
+ void interiorFillFollowsProviderPreference() {
+ assertFalse(providers.wantsInteriorFill());
+ BreakEffectProvider wants = new BreakEffectProvider() {
+ @Override public boolean onBlockShatter(ShatterHandle h, ShatterContext c) { return true; }
+ @Override public boolean wantsInteriorFill() { return true; }
+ };
+ providers.register(ownerA, wants);
+ assertTrue(providers.wantsInteriorFill());
+ providers.unregister(wants);
+ assertFalse(providers.wantsInteriorFill());
+ }
+}
diff --git a/src/test/java/org/inventivetalent/tessera/plugin/TesseraConfigTest.java b/src/test/java/org/inventivetalent/tessera/plugin/TesseraConfigTest.java
index 9b060b1..d2c19e6 100644
--- a/src/test/java/org/inventivetalent/tessera/plugin/TesseraConfigTest.java
+++ b/src/test/java/org/inventivetalent/tessera/plugin/TesseraConfigTest.java
@@ -50,6 +50,7 @@ void emptyConfigYieldsAllDefaults() {
TesseraConfig cfg = parse("");
assertEquals(4, cfg.chunkGridSize());
assertEquals(8, cfg.maxConcurrentFakeBlocks());
+ assertEquals(10_000L, cfg.providerMaxEffectDurationMs());
assertEquals(AnimationMode.PROGRESS, cfg.animationMode());
assertEquals(CollapseStyle.POP, cfg.collapseStyle());
assertEquals(600, cfg.effectDurationMs());
diff --git a/tessera-api/src/main/java/org/inventivetalent/tessera/api/TesseraApi.java b/tessera-api/src/main/java/org/inventivetalent/tessera/api/TesseraApi.java
index 78e9e8f..4efb6d0 100644
--- a/tessera-api/src/main/java/org/inventivetalent/tessera/api/TesseraApi.java
+++ b/tessera-api/src/main/java/org/inventivetalent/tessera/api/TesseraApi.java
@@ -1,6 +1,8 @@
package org.inventivetalent.tessera.api;
import org.bukkit.block.data.BlockData;
+import org.bukkit.plugin.Plugin;
+import org.inventivetalent.tessera.api.effect.BreakEffectProvider;
import org.inventivetalent.tessera.core.BakeKey;
import org.inventivetalent.tessera.core.BlockKey;
import org.inventivetalent.tessera.core.ChunkCoord;
@@ -39,8 +41,15 @@
*/
public interface TesseraApi {
- /** Current API contract version. Bumped on incompatible changes. */
- int VERSION = 1;
+ /**
+ * Current API contract version. Bumped on incompatible changes.
+ *
+ * - v1 — read-only bake data: queries, layouts, on-demand bakes.
+ * - v2 — break-effect providers: plugins can replace the built-in
+ * break animation ({@link #registerBreakEffectProvider}).
+ *
+ */
+ int VERSION = 2;
/** @return {@link #VERSION} of the running implementation. */
int apiVersion();
@@ -102,4 +111,28 @@ public interface TesseraApi {
* {@link BakeOutcome#NOT_CONFIGURED}.
*/
boolean canBakeNewBlocks();
+
+ /**
+ * Register {@code provider} to replace Tessera's built-in break animation
+ * (see {@link BreakEffectProvider} for the contract). One slot,
+ * first-wins: registration fails (with a log line naming both plugins) if
+ * a different provider is already installed — two plugins fighting over
+ * the same break can't be reconciled. The provider is unregistered
+ * automatically when {@code owner} disables.
+ *
+ * Main thread only.
+ *
+ * @param owner the registering plugin, used for lifecycle + diagnostics
+ * @param provider the provider to install
+ * @return true if installed (or already installed by this exact provider)
+ */
+ boolean registerBreakEffectProvider(Plugin owner, BreakEffectProvider provider);
+
+ /**
+ * Remove {@code provider} if it is the currently registered one. Any
+ * breaks it still owns are despawned. Main thread only.
+ *
+ * @return true if it was the registered provider
+ */
+ boolean unregisterBreakEffectProvider(BreakEffectProvider provider);
}
diff --git a/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/BreakEffectProvider.java b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/BreakEffectProvider.java
new file mode 100644
index 0000000..da83894
--- /dev/null
+++ b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/BreakEffectProvider.java
@@ -0,0 +1,56 @@
+package org.inventivetalent.tessera.api.effect;
+
+import org.inventivetalent.tessera.api.TesseraApi;
+import org.bukkit.plugin.Plugin;
+
+/**
+ * Extension point for replacing Tessera's built-in break animation with a
+ * custom effect. Register via
+ * {@link TesseraApi#registerBreakEffectProvider(Plugin, BreakEffectProvider)}.
+ *
+ *
Tessera keeps full ownership of everything up to the moment the collapse
+ * animation would play: gating (material/world lists, concurrency cap, break
+ * timing), blockstate variant rotation, lattice spawning, the progress-driven
+ * mining wave, and transport. When the block actually breaks, the registered
+ * provider is offered the surviving fragments instead of the built-in effect.
+ *
+ *
In progress mode (the default) the shrink wave has usually consumed part
+ * of the lattice by the time the block breaks — the provider receives only the
+ * remaining fragments, already positioned and scaled mid-wave.
+ *
+ *
All calls happen on the main server thread.
+ */
+public interface BreakEffectProvider {
+
+ /**
+ * Offered a freshly broken block's fragment lattice.
+ *
+ *
Return {@code true} to claim the break: the provider now owns the
+ * animation and must eventually call {@link ShatterHandle#despawn()}
+ * — that releases the fragments and Tessera's concurrency slot. Tessera
+ * schedules a safety despawn after {@code limits.providerMaxEffectDurationMs}
+ * (config, default 10s) as a backstop; {@code despawn()} is idempotent, so
+ * a well-behaved provider is unaffected by it.
+ *
+ *
Return {@code false} to decline — Tessera's built-in collapse effect
+ * runs as if no provider were registered. A thrown exception is treated as
+ * a decline (and logged once per provider).
+ *
+ * @param handle the live fragment lattice; alive fragments only
+ * @param context who broke what, and how the handoff happened
+ * @return true if the provider claims the break and owns despawn
+ */
+ boolean onBlockShatter(ShatterHandle handle, ShatterContext context);
+
+ /**
+ * Consulted before the lattice spawns (both animation modes).
+ * When {@code true}, Tessera populates interior grid cells (its donor-skin
+ * interior fill) even if the server's {@code animation.fillInterior}
+ * config is off, so a claimed break can work with a solid block rather
+ * than a hollow shell. Only effective for {@code chunkGridSize >= 3};
+ * interior cell count grows cubically with the grid size.
+ */
+ default boolean wantsInteriorFill() {
+ return false;
+ }
+}
diff --git a/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterChunk.java b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterChunk.java
new file mode 100644
index 0000000..938b117
--- /dev/null
+++ b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterChunk.java
@@ -0,0 +1,95 @@
+package org.inventivetalent.tessera.api.effect;
+
+import org.inventivetalent.tessera.core.ChunkCoord;
+import org.inventivetalent.tessera.core.FaceDir;
+import org.bukkit.util.Transformation;
+import org.joml.Quaternionf;
+import org.joml.Vector3f;
+
+import java.util.Set;
+
+/**
+ * One live fragment of a {@link ShatterHandle} — a single display entity in
+ * the broken block's lattice. Main-thread only.
+ *
+ *
Posing fragments. Prefer {@link #setPose}: it thinks in "fragment
+ * center + orientation + size" and internally re-solves the player-head
+ * model's render-offset compensation. The raw
+ * {@link #setTransformation(Transformation, int, int)} escape hatch skips that
+ * compensation — with it, changing the left rotation makes the cube orbit its
+ * render offset instead of spinning in place (see the transformation notes on
+ * {@code ChunkLayout}).
+ *
+ *
Both mutators drive the client's display interpolation: a positive
+ * {@code durationTicks} makes the client lerp from the fragment's current pose
+ * to the new one over that many ticks, which is how effects animate smoothly
+ * without per-tick server work.
+ */
+public interface ShatterChunk {
+
+ /** Grid cell of this fragment (0-indexed, pre-rotation block-local). */
+ ChunkCoord coord();
+
+ /** Fragment center in block-local [0,1]³ space, before block rotation. */
+ Vector3f localCenter();
+
+ /**
+ * The block faces this fragment touches, in block-local directions
+ * (empty for interior fragments). Map through
+ * {@link ShatterHandle#blockRotation()} for world-space normals.
+ */
+ Set outwardFaces();
+
+ /** True for interior fragments (no outward face). */
+ boolean interior();
+
+ /**
+ * The fragment's cube center at handoff, relative to
+ * {@link ShatterHandle#origin()}, block rotation applied:
+ * {@code (0.5,0.5,0.5) + blockRotation · (localCenter − (0.5,0.5,0.5))}.
+ * The natural starting point for {@link #setPose} offsets.
+ */
+ Vector3f initialCenter();
+
+ /**
+ * The fragment's current uniform scale (from the last-set transformation).
+ * In progress mode the mining wave may have shrunk a fragment below its
+ * spawn scale by handoff time.
+ */
+ float currentScale();
+
+ /** False once this fragment's display entity has been removed. */
+ boolean isAlive();
+
+ /**
+ * Pose this fragment as: cube centered at {@code origin() + centerOffset},
+ * spun by the world-space {@code tumble} on top of the block's variant
+ * rotation, at uniform {@code scale}. The render-offset compensation is
+ * re-solved internally so the cube stays pinned to the requested center
+ * under any rotation.
+ *
+ * @param centerOffset target cube center relative to {@link ShatterHandle#origin()}
+ * @param tumble world-space rotation applied on top of the variant
+ * rotation; identity for "no spin". Keep successive
+ * poses less than 180° apart — the client lerps
+ * rotations along the shortest path.
+ * @param scale uniform scale ({@code 2/gridN} is full fragment size)
+ * @param delayTicks ticks before the client starts interpolating (0 = now)
+ * @param durationTicks client-side interpolation length in ticks; 0 snaps
+ */
+ void setPose(Vector3f centerOffset, Quaternionf tumble, float scale,
+ int delayTicks, int durationTicks);
+
+ /** The last transformation set on this fragment's display. */
+ Transformation getTransformation();
+
+ /**
+ * Raw transformation write with interpolation timing. No render-offset
+ * compensation is applied — prefer {@link #setPose} unless you are only
+ * touching scale or reusing components read from {@link #getTransformation()}.
+ */
+ void setTransformation(Transformation transformation, int delayTicks, int durationTicks);
+
+ /** Remove just this fragment's display entity. Idempotent. */
+ void despawn();
+}
diff --git a/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterContext.java b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterContext.java
new file mode 100644
index 0000000..44dc06e
--- /dev/null
+++ b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterContext.java
@@ -0,0 +1,52 @@
+package org.inventivetalent.tessera.api.effect;
+
+import org.bukkit.block.data.BlockData;
+import org.bukkit.entity.Player;
+import org.bukkit.util.Vector;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Break metadata passed to {@link BreakEffectProvider#onBlockShatter}.
+ *
+ * @param breaker the player who broke the block; the fragments are only
+ * visible to them. May be null if they logged off in the
+ * same tick.
+ * @param breakerEyeDir the breaker's normalized view direction at break time —
+ * the natural axis for directional effects (defensive
+ * copy, already normalized)
+ * @param blockData the broken block's full state (for sounds, particles,
+ * material checks)
+ * @param mode which of Tessera's two break paths handed the lattice
+ * over
+ */
+public record ShatterContext(
+ @Nullable Player breaker,
+ Vector breakerEyeDir,
+ BlockData blockData,
+ Mode mode) {
+
+ /** How the break reached the provider. */
+ public enum Mode {
+ /**
+ * The block broke without a progress-driven wave (post-break
+ * animation mode, or an effectively instant break). The lattice is
+ * complete and at full scale.
+ */
+ POST_BREAK,
+ /**
+ * The progress-driven mining wave ran first (default mode). Fragments
+ * the wave consumed are gone; the handle carries the remainder.
+ */
+ PROGRESS
+ }
+
+ public ShatterContext {
+ breakerEyeDir = breakerEyeDir.clone();
+ if (breakerEyeDir.lengthSquared() > 0) breakerEyeDir.normalize();
+ }
+
+ @Override
+ public Vector breakerEyeDir() {
+ return breakerEyeDir.clone();
+ }
+}
diff --git a/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterHandle.java b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterHandle.java
new file mode 100644
index 0000000..74270d4
--- /dev/null
+++ b/tessera-api/src/main/java/org/inventivetalent/tessera/api/effect/ShatterHandle.java
@@ -0,0 +1,55 @@
+package org.inventivetalent.tessera.api.effect;
+
+import org.inventivetalent.tessera.core.BlockKey;
+import org.bukkit.Location;
+import org.joml.Quaternionf;
+
+import java.util.List;
+
+/**
+ * A broken block's live fragment lattice, handed to a
+ * {@link BreakEffectProvider} when it claims a break. Wraps Tessera's internal
+ * runtime handle; all methods are main-thread only.
+ *
+ * Every fragment display entity shares one entity location —
+ * {@link #origin()}, the block's integer-floored lower north-west-down corner.
+ * Fragments are positioned purely through their transformation, which is what
+ * {@link ShatterChunk#setPose} manipulates.
+ */
+public interface ShatterHandle {
+
+ /** The shared entity location: the block's floored NW-down corner. Cloned. */
+ Location origin();
+
+ /** The broken block's type. */
+ BlockKey blockKey();
+
+ /** Lattice density N (the block was split into an N×N×N grid). */
+ int gridN();
+
+ /**
+ * The blockstate-variant rotation applied to the block as a whole
+ * (identity for blocks rendered in their canonical orientation). Already
+ * baked into every fragment's pose; exposed so providers can map
+ * block-local directions (e.g. {@link ShatterChunk#outwardFaces()}) into
+ * world space. Fresh copy.
+ */
+ Quaternionf blockRotation();
+
+ /**
+ * The fragments that were alive at handoff, immutable. In progress mode,
+ * fragments consumed by the mining wave are not included. Individual
+ * fragments may still die during the effect ({@link ShatterChunk#isAlive}).
+ */
+ List chunks();
+
+ /** True once {@link #despawn()} has run. */
+ boolean despawned();
+
+ /**
+ * Remove every remaining fragment display and release the break's
+ * concurrency slot. The claiming provider must call this when its effect
+ * finishes. Idempotent; main thread only.
+ */
+ void despawn();
+}
diff --git a/src/main/java/org/inventivetalent/tessera/core/FaceDir.java b/tessera-api/src/main/java/org/inventivetalent/tessera/core/FaceDir.java
similarity index 80%
rename from src/main/java/org/inventivetalent/tessera/core/FaceDir.java
rename to tessera-api/src/main/java/org/inventivetalent/tessera/core/FaceDir.java
index 2276632..d729eb8 100644
--- a/src/main/java/org/inventivetalent/tessera/core/FaceDir.java
+++ b/tessera-api/src/main/java/org/inventivetalent/tessera/core/FaceDir.java
@@ -3,12 +3,17 @@
import org.joml.Vector3i;
/**
- * One of the six block-local face directions. Distinct from {@link HeadFace},
- * which is the player-head's UV layout — the FaceDir → HeadFace mapping is
- * what the assemble layer's right-rotation table picks.
+ * One of the six block-local face directions. Distinct from the plugin's
+ * internal {@code HeadFace}, which is the player-head's UV layout — the
+ * FaceDir → HeadFace mapping is what the assemble layer's right-rotation
+ * table picks.
*
* Axis convention matches Minecraft block-space: +X = east, +Y = up,
* +Z = south. {@link #DOWN} corresponds to NBT {@code "down"}, etc.
+ *
+ *
{@link #shade()} and {@link #jsonName()} exist for the plugin's bake
+ * pipeline; API consumers normally only need {@link #normal()} and
+ * {@link #isOutwardAt(int, int, int, int)}.
*/
public enum FaceDir {
DOWN ( 0, -1, 0),
diff --git a/website/src/commands.md b/website/src/commands.md
index cda3049..e0b647f 100644
--- a/website/src/commands.md
+++ b/website/src/commands.md
@@ -17,15 +17,20 @@ subcommands — anyone with `tessera.command` can run every subcommand,
including the bake-time tuners that invalidate the registry. Restrict
the node to operators or a tightly-scoped admin group accordingly.
-## `/tessera test [material] [static]`
+## `/tessera test [material] [static|builtin]`
Bake the requested material if it isn't already in the registry, then
spawn a `FakeBlock` at the cell you're looking at.
- `material` — defaults to `stone`. With or without `minecraft:` prefix.
-- `static` — optional flag. Spawns the FakeBlock without the shrink
- animation and keeps it alive for five minutes so you can walk around
- and inspect it.
+- `static` — optional flag. Spawns the FakeBlock without any animation
+ and keeps it alive for five minutes so you can walk around and
+ inspect it.
+- `builtin` — optional flag. Forces the built-in shrink animation even
+ when another plugin has registered a
+ [break-effect provider](/extension-api#replace-the-break-animation-api-v2);
+ without it the test goes through the same effect selection as a real
+ break.
If the material isn't in `heads.json` and `mineskin.apiKey` is unset,
the command refuses with a hint to configure the key first.
diff --git a/website/src/extension-api.md b/website/src/extension-api.md
index 932ce12..3f0074e 100644
--- a/website/src/extension-api.md
+++ b/website/src/extension-api.md
@@ -140,6 +140,69 @@ public void onBaked(TesseraBlockBakedEvent event) {
}
```
+## Replace the break animation (API v2)
+
+A plugin can take over the animation Tessera plays when a block breaks by
+registering a **break-effect provider**. Tessera still does everything up to
+that moment — gating, variant rotation, lattice spawning, the progress-driven
+mining wave, per-viewer transport — and then offers the surviving fragments to
+your provider instead of running its built-in collapse:
+
+```java
+TesseraApi tessera = Tessera.api();
+if (tessera == null || tessera.apiVersion() < 2) {
+ getLogger().severe("Tessera with API v2 required");
+ return;
+}
+tessera.registerBreakEffectProvider(this, new BreakEffectProvider() {
+ @Override
+ public boolean onBlockShatter(ShatterHandle handle, ShatterContext ctx) {
+ for (ShatterChunk chunk : handle.chunks()) {
+ // animate: chunk.setPose(centerOffset, tumble, scale, delay, interp)
+ }
+ // you now own the fragments — despawn when the effect is done:
+ Bukkit.getScheduler().runTaskLater(plugin, handle::despawn, 40L);
+ return true; // claimed; false = let the built-in effect run
+ }
+
+ @Override
+ public boolean wantsInteriorFill() {
+ return true; // solid lattice even if animation.fillInterior is off
+ }
+});
+```
+
+The contract, briefly:
+
+- **One slot, first-wins.** A second plugin's registration fails with a log
+ line; two providers fighting over the same break can't be reconciled. The
+ provider is detached automatically when its plugin disables.
+- **Claim = own the despawn.** Return `true` and you must eventually call
+ `handle.despawn()` — that removes the fragments and frees Tessera's
+ concurrency slot. As a backstop, Tessera force-despawns a claimed break
+ after `limits.providerMaxEffectDurationMs` (config, default 10s);
+ `despawn()` is idempotent so a well-behaved provider never notices.
+- **Decline = built-in effect.** Return `false` (or throw — logged once) and
+ Tessera animates the break exactly as if no provider were registered.
+- **Progress mode hands over the remainder.** With the default
+ `animation.mode: progress`, the mining wave consumes fragments while the
+ player digs; on the real break your provider receives only what survived,
+ positioned and scaled mid-wave. `ShatterContext.mode()` tells you which
+ path you're on.
+- **Pose, don't recompute.** `ShatterChunk.setPose(centerOffset, tumble,
+ scale, delayTicks, durationTicks)` places a fragment's cube center relative
+ to `handle.origin()` with a world-space rotation on top of the blockstate
+ orientation. The player-head model's render-offset compensation is
+ re-solved internally — with the raw `setTransformation` escape hatch,
+ rotating the left rotation makes fragments orbit off-center. A positive
+ `durationTicks` rides the client's display interpolation, so a handful of
+ poses per second animates smoothly with no per-tick server work.
+- **Main thread only**, like the rest of the break pipeline. Fragments are
+ visible only to the breaker (`ShatterContext.breaker()`).
+
+In-game check: `/tessera test stone` routes through the registered provider;
+`/tessera test stone builtin` forces the built-in effect for comparison.
+
## Versioning
`TesseraApi.VERSION` (and `apiVersion()`) is bumped on incompatible changes. The