/* * Ported into QuinnAddon from THM Addons — https://github.com/Magnus-Krill/THM-Addons * Source file: src/main/java/xyz/thm/addon/modules/Speedmine.java (branch 26.2). * The original header reads: "Copyright (c) THM Addons contributors. Credit the devs, keep the link. * By using this code you agree to the license terms and to keep your repo public." * Credit stays with the THM Addons contributors; thanks to mushek, and to BlackOut for the * Auto Mine target order this is based on. * * PORT NOTES — behaviour is kept, the plumbing is not. QuinnAddon does not have THM's support * classes, so each one is replaced by the nearest thing Meteor already ships: * Mojang mappings -> Yarn mappings (mc.level -> mc.world, Serverbound* -> *C2SPacket). * THM InventoryManager -> a local server-slot tracker (UpdateSelectedSlotC2SPacket). * THM RangeUtils -> isInRange() / closestFace() below. * THM RenderUtilsTHM -> Render3DEvent.renderer.box(). * THM hand-written mining formula -> Meteor BlockUtils.getBreakDelta(slot, state) (same maths). * THM ClientLevel mixin hook -> Meteor BlockUpdateEvent. * THM sequenced-packet accessor -> an invoker mixin on ClientPlayerInteractionManager. * PaketLimiter / ThmMembers / THMSystem / DescribedOption -> dropped, they are THM-only. * fromTag() settings migration -> dropped, QuinnAddon has no legacy Speedmine settings. * FIXES APPLIED ON TOP OF THE ORIGINAL (THM 26.2 behaves differently here): * 1. The break event is only cancelled when this module actually takes the block, so a click * that the module declines falls back to vanilla mining instead of doing nothing. * 2. requestBreak() validates the block, and a context that cannot make progress is dropped after * STALL_TIMEOUT_MS instead of occupying the primary slot forever. * 3. Every queue insert stores an immutable BlockPos. * 4. A context whose block changed to a different block is dropped instead of mining the old one. * 5. The enemy's feet use the floored Y, so a half-slab or snow layer no longer shifts the * surround ring one block up. * 6. Dead players are not targeted. * 7. The tool search starts from the slot already selected, so an equally good held tool is kept * instead of swapping to a lower hotbar index. * 8. The rebreak timeout is one constant instead of the same value written in ms and in ns. * 9. The "last broken" outline respects rebreak-mode Off. */ package com.example.addon.modules; import com.example.addon.QuinnAddon; import com.example.addon.mixin.ClientPlayerInteractionManagerAccessor; import com.example.addon.mixin.ClientWorldAccessor; import com.example.addon.mixin.PlayerMoveC2SPacketAccessor; import meteordevelopment.meteorclient.events.entity.player.StartBreakingBlockEvent; import meteordevelopment.meteorclient.events.game.GameLeftEvent; import meteordevelopment.meteorclient.events.packets.PacketEvent; import meteordevelopment.meteorclient.events.render.Render3DEvent; import meteordevelopment.meteorclient.events.world.BlockUpdateEvent; import meteordevelopment.meteorclient.events.world.TickEvent; import meteordevelopment.meteorclient.renderer.ShapeMode; import meteordevelopment.meteorclient.settings.*; import meteordevelopment.meteorclient.systems.friends.Friends; import meteordevelopment.meteorclient.systems.modules.Module; import meteordevelopment.meteorclient.utils.misc.Keybind; import meteordevelopment.meteorclient.utils.player.Rotations; import meteordevelopment.meteorclient.utils.render.color.Color; import meteordevelopment.meteorclient.utils.render.color.SettingColor; import meteordevelopment.meteorclient.utils.world.BlockUtils; import meteordevelopment.meteorclient.utils.world.TickRate; import meteordevelopment.orbit.EventHandler; import meteordevelopment.orbit.EventPriority; import net.minecraft.block.Block; import net.minecraft.block.BlockState; import net.minecraft.block.Blocks; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.network.packet.c2s.play.PlayerActionC2SPacket; import net.minecraft.network.packet.c2s.play.PlayerMoveC2SPacket; import net.minecraft.network.packet.c2s.play.UpdateSelectedSlotC2SPacket; import net.minecraft.util.Hand; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Box; import net.minecraft.util.math.Direction; import net.minecraft.util.math.Vec3d; import net.minecraft.world.WorldEvents; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Comparator; import java.util.Deque; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.function.Function; /** Packet miner with server-timed progress and sequenced client prediction. */ public class Speedmine extends Module { private final SettingGroup sgMine = settings.getDefaultGroup(); private final SettingGroup sgAuto = settings.createGroup("Auto Mine", false); private final SettingGroup sgRender = settings.createGroup("Render"); private final SettingGroup sgAir = settings.createGroup("Air Miner", false); // ── Air Miner ───────────────────────────────────────────────────────────── private final Setting airMine = sgAir.add(new BoolSetting.Builder() .name("air-mine") .description("Mine at ground speed while airborne.") .defaultValue(false) .onChanged(enabled -> { if (!enabled) restoreAirGround(); }) .build()); private final Setting airWhileGliding = sgAir.add(new BoolSetting.Builder() .name("air-while-gliding") .description("Allow airborne mining while gliding.") .defaultValue(false) .visible(airMine::get) .build()); private final Setting airMaxSpeed = sgAir.add(new DoubleSetting.Builder() .name("air-max-speed") .description("Maximum horizontal speed for ground claims.") .defaultValue(1).min(0).sliderRange(0, 3) .visible(airMine::get) .build()); private final Setting airClaimWindow = sgAir.add(new IntSetting.Builder() .name("air-claim-window") .description("Ticks to retain the mining ground claim.") .defaultValue(2).min(1).sliderRange(1, 10) .visible(airMine::get) .build()); private int airClaimLeft; private long airClaimTick = -1; private boolean airInjecting; // ── Auto Mine (target selection, ported from BlackOut's AutoMine) ───────── public final Setting autoMine = sgAuto.add(new BoolSetting.Builder() .name("auto-mine") .description("Automatically pick blocks to break around nearby enemies.") .defaultValue(false) .build()); public final Setting autoMineBind = sgAuto.add(new KeybindSetting.Builder() .name("auto-mine-bind") .description("Hold this to auto-mine. Leave unbound to have auto-mine run whenever the module is on.") .defaultValue(Keybind.none()) .visible(autoMine::get) .build()); private final Setting debugAutoMine = sgAuto.add(new BoolSetting.Builder() .name("debug-auto-mine") .description("Report Auto Mine targets and idle reasons in chat.") .defaultValue(false) .onChanged(enabled -> { this.lastAutoMineStatus = null; this.nextAutoMineReportTick = 0; }) .visible(autoMine::get) .build()); public final Setting autoMineOnly = sgAuto.add(new BoolSetting.Builder() .name("auto-mine-only") .description("Ignore blocks you click yourself — only auto-mine targets get broken.") .defaultValue(false) .visible(autoMine::get) .build()); public final Setting feetFirst = sgAuto.add(new BoolSetting.Builder() .name("feet-first") .description("Break the lowest targets first, so an enemy's feet go before their head. Applies to bedrock too.") .defaultValue(false) .visible(autoMine::get) .build()); public final Setting enemyRange = sgAuto.add(new DoubleSetting.Builder() .name("enemy-range") .description("How far away an enemy can be to be considered.") .defaultValue(10).min(1).max(20).sliderRange(1, 20) .visible(autoMine::get) .build()); public final Setting antiPhase = sgAuto.add(new BoolSetting.Builder() .name("anti-phase") .description("Mine the blocks an enemy is standing inside.") .defaultValue(true) .visible(autoMine::get) .build()); public final Setting antiSurround = sgAuto.add(new BoolSetting.Builder() .name("anti-surround") .description("Mine the blocks boxing an enemy in.") .defaultValue(true) .visible(autoMine::get) .build()); public final Setting neverMineOwn = sgAuto.add(new BoolSetting.Builder() .name("never-mine-own") .description("Never auto-mine blocks touching you.") .defaultValue(true) .visible(autoMine::get) .build()); public final Setting autoDoubleMine = sgAuto.add(new BoolSetting.Builder() .name("auto-double-mine") .description("Break two auto-mine targets at once, using double-break.") .defaultValue(true) .visible(autoMine::get) .build()); public final Setting mineBedrock = sgAuto.add(new BoolSetting.Builder() .name("mine-bedrock") .description("Also target bedrock, broken vanilla-style with hand swings instead of packets.") .defaultValue(false) .visible(autoMine::get) .build()); public final Setting bedrockOnly = sgAuto.add(new BoolSetting.Builder() .name("bedrock-only") .description("Only break bedrock, ignore every other block.") .defaultValue(false) .visible(() -> autoMine.get() && mineBedrock.get()) .build()); public final Setting bedrockWhileEating = sgAuto.add(new BoolSetting.Builder() .name("bedrock-while-eating") .description("Continue mining bedrock while eating.") .defaultValue(false) .visible(() -> autoMine.get() && mineBedrock.get()) .build()); public final Setting bedrockRotate = sgAuto.add(new BoolSetting.Builder() .name("bedrock-rotate") .description("Silently look at the bedrock before breaking it. Usually not needed.") .defaultValue(false) .visible(() -> autoMine.get() && mineBedrock.get()) .build()); // ── Mining ──────────────────────────────────────────────────────────────── public final Setting rotate = sgMine.add(new BoolSetting.Builder() .name("rotate") .description("Silently look at each block before mining it. Bedrock has its own toggle.") .defaultValue(false) .build()); public final Setting grimBypass = sgMine.add(new BoolSetting.Builder() .name("grim-bypass") .description("Send STOP_DESTROY_BLOCK before START to bypass Grim's sequence check.") .defaultValue(true) .build()); public final Setting doubleBreak = sgMine.add(new BoolSetting.Builder() .name("double-break") .description("Track a primary and secondary block simultaneously.") .defaultValue(true) .build()); public final Setting queueEnabled = sgMine.add(new BoolSetting.Builder() .name("queue") .description("Queue extra blocks when both break slots are occupied.") .defaultValue(true) .build()); public final Setting breakThreshold = sgMine.add(new DoubleSetting.Builder() .name("break-threshold") .description("Mining progress needed before sending STOP.") .defaultValue(0.7).min(0.1).max(1.0).decimalPlaces(2) .build()); public final Setting clientPrediction = sgMine.add(new BoolSetting.Builder() .name("client-prediction") .description("Predict completed breaks locally, including rebreaks.") .defaultValue(true) .build()); public final Setting autoRebreak = sgMine.add(new BoolSetting.Builder() .name("auto-rebreak") .description("Rebreak the last position if a block reappears there.") .defaultValue(true) .build()); public final Setting rebreakMode = sgMine.add(new EnumSetting.Builder() .name("rebreak-mode") .description("How instant rebreaks wait for server confirmation. Off: no instant rebreaks. Strict: wait for server confirmation between rebreaks. Strong: skip confirmation waits and TPS start delays. Bypass: Strict until a rebreak is confirmed, then Strong.") .defaultValue(RebreakMode.Strong) .visible(autoRebreak::get) .build()); public final Setting rebreakTrigger = sgMine.add(new EnumSetting.Builder() .name("rebreak-trigger") .description("Use client-world checks, optionally with early packet triggers. OnPacket: rebreak on server updates and client-world replacements. OnClientWorld: check for replacement blocks each client tick.") .defaultValue(RebreakTrigger.OnPacket) .visible(autoRebreak::get) .build()); private final Setting debugRebreak = sgMine.add(new BoolSetting.Builder() .name("debug-rebreak") .description("Show server-confirmed rebreaks per second.") .defaultValue(false) .onChanged(enabled -> resetRebreakMonitor()) .build()); public final Setting silentSwap = sgMine.add(new BoolSetting.Builder() .name("silent-swap") .description("Swap to the best tool via packet without visually changing your held item.") .defaultValue(true) .build()); public final Setting swapMode = sgMine.add(new EnumSetting.Builder() .name("swap-mode") .description("Swap timing for instant breaks and rebreaks. Keep: hold the tool through the next tick and consecutive rebreaks. SameTick: restore the previous slot immediately after mining packets. EndOfTick: restore the selected slot at the end of the tick. ToolHold: hold the tool through server validation and three idle ticks.") .defaultValue(SwapMode.SameTick) .visible(silentSwap::get) .onChanged(mode -> releaseHeldSlot()) .build()); public final Setting tpsSync = sgMine.add(new BoolSetting.Builder() .name("tps-sync") .description("Match mining progress and start rate to server TPS.") .defaultValue(false) .build()); public final Setting multitask = sgMine.add(new BoolSetting.Builder() .name("multitask") .description("Continue mining while using items.") .defaultValue(false) .build()); public final Setting range = sgMine.add(new DoubleSetting.Builder() .name("range") .description("Maximum block-breaking distance.") .defaultValue(5.2).min(1).max(6).decimalPlaces(1) .build()); // ── Render ──────────────────────────────────────────────────────────────── private final Setting renderColor = sgRender.add(new ColorSetting.Builder() .name("color") .defaultValue(new SettingColor(225, 25, 25, 255)) .build()); private final Setting bedrockColor = sgRender.add(new ColorSetting.Builder() .name("bedrock-color") .description("Color of the bedrock blocks currently being broken.") .defaultValue(new SettingColor(255, 70, 70, 255)) .visible(mineBedrock::get) .build()); private final Setting bedrockShape = sgRender.add(new EnumSetting.Builder() .name("bedrock-shape") .description("How the bedrock being broken is drawn.") .defaultValue(ShapeMode.Both) .visible(mineBedrock::get) .build()); // ── State ───────────────────────────────────────────────────────────────── public static Speedmine INSTANCE; /** Ticks with nothing left to mine before the slot is handed back to the client's real one. */ private static final int IDLE_RELEASE_TICKS = 3; /** How long a context may make no progress before it is dropped (fix 2). */ private static final long STALL_TIMEOUT_MS = 3000; /** Fix 8: one timeout, two units derived from it. */ private static final long REBREAK_TIMEOUT_MS = 2000; private static final long REBREAK_TIMEOUT_NS = REBREAK_TIMEOUT_MS * 1_000_000L; private MineContext primary; private MineContext secondary; public BlockPos lastBrokenPos; private boolean lastBreakConfirmed; private boolean rebreakPending; private boolean bypassConfirmed; private long rebreakDeadlineMs; private BlockState packetReplacement; private long rebreakMonitorStartNs; private int rebreakCount; private String rebreakRate = "0.0"; private final RebreakConfirmations rebreakConfirmations = new RebreakConfirmations(); private BlockPos lastStartedPos; public final Deque queue = new ArrayDeque<>(); /** Hotbar slot this miner is holding on the server; -1 when it holds none. */ private int heldSlot = -1; /** What the server currently believes is selected; -1 when it matches the client. */ private int serverSlot = -1; private int idleTicks = 0; private final List pendingBreaks = new ArrayList<>(3); private long lastStartCreditMs; private double startCredit = 1; private long clientTick; private long lastMiningActionTick; private BlockPos bedrockPos; private String autoMineStatus; private String lastAutoMineStatus; private long nextAutoMineReportTick; private int autoAirRejected, autoSelfRejected, autoRangeRejected, autoDisabledRejected, autoUnbreakableRejected; // ── Constructor ─────────────────────────────────────────────────────────── public Speedmine() { super(QuinnAddon.CATEGORY, "speedmine", "Grim-safe packet miner with queue and double break. Ported from THM Addons, credit to the THM devs."); INSTANCE = this; } // ── Module lifecycle ────────────────────────────────────────────────────── @Override public void onActivate() { airClaimLeft = 0; airClaimTick = -1; lastStartCreditMs = System.currentTimeMillis(); startCredit = 1; clientTick = 0; lastMiningActionTick = 0; autoMineStatus = lastAutoMineStatus = null; nextAutoMineReportTick = 0; resetRebreakMonitor(); } @Override public void onDeactivate() { restoreAirGround(); releaseHeldSlot(); primary = null; secondary = null; lastBrokenPos = null; lastBreakConfirmed = false; rebreakPending = false; bypassConfirmed = false; packetReplacement = null; resetRebreakMonitor(); lastStartedPos = null; bedrockPos = null; pendingBreaks.clear(); queue.clear(); } @EventHandler private void onGameLeft(GameLeftEvent event) { heldSlot = -1; serverSlot = -1; onDeactivate(); } // ── Air Miner ───────────────────────────────────────────────────────────── public boolean liftsAirMiningPenalty() { return isActive() && airMine.get() && mc.player != null && mc.getNetworkHandler() != null && canClaimAir(mc.player.isOnGround(), mc.player.isGliding(), airWhileGliding.get(), mc.player.isCreative(), mc.player.isSpectator(), mc.player.getVelocity().horizontalLength(), airMaxSpeed.get()); } static boolean canClaimAir(boolean onGround, boolean gliding, boolean allowGliding, boolean creative, boolean spectator, double speed, double maximum) { return !onGround && (!gliding || allowGliding) && !creative && !spectator && speed <= maximum; } @EventHandler(priority = EventPriority.LOWEST) private void onPacketSend(PacketEvent.Send event) { if (event.isCancelled() || mc.player == null || mc.getNetworkHandler() == null) return; // Keep our idea of the server's selected slot in sync with whatever actually goes out. if (event.packet instanceof UpdateSelectedSlotC2SPacket slot) { serverSlot = slot.getSelectedSlot(); return; } if (airInjecting) return; if (event.packet instanceof PlayerActionC2SPacket action && (action.getAction() == PlayerActionC2SPacket.Action.START_DESTROY_BLOCK || action.getAction() == PlayerActionC2SPacket.Action.STOP_DESTROY_BLOCK) && liftsAirMiningPenalty()) { if (airClaimTick != clientTick) { sendAirGround(true); airClaimTick = clientTick; } airClaimLeft = airClaimWindow.get(); } else if (event.packet instanceof PlayerMoveC2SPacket move && airClaimLeft > 0 && liftsAirMiningPenalty()) { ((PlayerMoveC2SPacketAccessor) move).quinn$setOnGround(true); } } private void sendAirGround(boolean onGround) { if (mc.player == null || mc.getNetworkHandler() == null) return; airInjecting = true; try { mc.getNetworkHandler().sendPacket(new PlayerMoveC2SPacket.Full( mc.player.getX(), mc.player.getY(), mc.player.getZ(), mc.player.getYaw(), mc.player.getPitch(), onGround, mc.player.horizontalCollision)); } finally { airInjecting = false; } } private void restoreAirGround() { boolean claimed = airClaimLeft > 0 || airClaimTick != -1; airClaimLeft = 0; airClaimTick = -1; if (claimed && mc.player != null && mc.getNetworkHandler() != null) sendAirGround(mc.player.isOnGround()); } // ── Server block updates (THM used a ClientLevel mixin; Meteor fires this for us) ── @EventHandler private void onBlockUpdate(BlockUpdateEvent event) { if (mc.world == null || mc.player == null) return; BlockPos pos = event.pos; BlockState state = event.newState; confirmBreak(pos, state); if (!pos.equals(lastBrokenPos)) return; packetReplacement = state.isAir() ? null : state; if (rebreakTrigger.get() == RebreakTrigger.OnPacket && packetReplacement != null) { tryRebreak(pos, packetReplacement, true); } } private void confirmBreak(BlockPos pos, BlockState state) { if (!state.isAir()) return; if (rebreakConfirmations.confirm(pos, System.nanoTime())) rebreakCount++; if (pos.equals(lastStartedPos) && (pos.equals(lastBrokenPos) || (primary != null && pos.equals(primary.pos)))) { lastBrokenPos = pos.toImmutable(); lastBreakConfirmed = true; if (rebreakPending) bypassConfirmed = true; rebreakPending = false; } pendingBreaks.removeIf(pending -> pending.pos().equals(pos)); if (primary != null && pos.equals(primary.pos)) primary = null; if (secondary != null && pos.equals(secondary.pos)) secondary = null; } // ── Events ──────────────────────────────────────────────────────────────── @EventHandler private void onStartBreaking(StartBreakingBlockEvent event) { if (mc.world == null || mc.player == null) return; // Hands manual clicks straight back to vanilla — auto-mine owns the module if (autoMine.get() && autoMineOnly.get()) return; BlockState state = mc.world.getBlockState(event.blockPos); if (!BlockUtils.canBreak(event.blockPos, state)) return; if (outOfRange(event.blockPos)) return; // Fix 1: this block is already ours, so vanilla must not dig it, but there is nothing to start. if (isMining(event.blockPos)) { event.cancel(); return; } // Fix 1: only take the click away from vanilla if the module actually accepted it. if (handleBlockClick(event.blockPos, state)) event.cancel(); } @EventHandler private void onTick(TickEvent.Pre event) { if (mc.world == null || mc.player == null) return; clientTick++; if (airClaimLeft > 0 && (--airClaimLeft == 0 || !liftsAirMiningPenalty())) restoreAirGround(); long now = System.currentTimeMillis(); startCredit = replenishStartCredit(startCredit, now - lastStartCreditMs, effectiveTps()); lastStartCreditMs = now; pendingBreaks.removeIf(pending -> now >= pending.deadlineMs()); if (rebreakPending && now >= rebreakDeadlineMs) rebreakPending = false; updateRebreakMonitor(); boolean paused = shouldPauseMining(); long progressNow = System.nanoTime(); if (primary != null) primary.advance(progressNow, paused); if (secondary != null) secondary.advance(progressNow, paused); if (debugAutoMine.get()) { autoMineStatus = !autoMine.get() ? "Disabled." : !autoMineHeld() ? "Waiting for auto-mine-bind." : "Paused while using an item or eating."; } if (paused) { releaseHeldSlot(); if (bedrockWhileEating.get() && isEating()) tickAutoMine(); else bedrockPos = null; return; } int validationSlot = validationToolSlot(); if (!silentSwap.get()) { releaseHeldSlot(); if (validationSlot != -1) selectSlot(validationSlot); } else if (validationSlot != -1) holdTool(validationSlot); else if (swapMode.get() == SwapMode.SameTick) releaseHeldSlot(); else if (swapMode.get() != SwapMode.EndOfTick) { if (primary != null) holdTool(primary.startSlot); else if (secondary != null) holdTool(secondary.startSlot); else if (heldSlot != -1 && !pendingBreaks.isEmpty()) holdTool(heldSlot); } if (primary != null && !shouldRemove(primary, now) && !primary.started) sendStart(primary); tickAutoMine(); rebreakReplacement(); pruneCompletedOrInvalid(); if (secondary != null && secondary.progress() >= 1.0) finishBreak(secondary, silentSwap.get()); if (primary != null && primary.progress() >= 1.0) finishBreak(primary, silentSwap.get()); drainQueue(); } @EventHandler(priority = EventPriority.LOWEST) private void onTickPost(TickEvent.Post event) { if (mc.player == null || mc.world == null) return; if (debugAutoMine.get() && autoMineStatus != null && !autoMineStatus.equals(lastAutoMineStatus) && clientTick >= nextAutoMineReportTick) { info("Auto Mine: %s", autoMineStatus); lastAutoMineStatus = autoMineStatus; nextAutoMineReportTick = clientTick + 40; } // Placers can refill predicted air after our Pre handler. rebreakReplacement(); if (heldSlot == -1) return; if (!silentSwap.get() || shouldPauseMining()) { releaseHeldSlot(); return; } boolean mining = primary != null || secondary != null || !queue.isEmpty(); boolean replacement = autoRebreak.get() && rebreakMode.get() != RebreakMode.Off && rebreakMode.get().isPrimed(lastBreakConfirmed) && lastBrokenPos != null && lastBrokenPos.equals(lastStartedPos) && !outOfRange(lastBrokenPos) && BlockUtils.canBreak(lastBrokenPos, mc.world.getBlockState(lastBrokenPos)); boolean pending = !pendingBreaks.isEmpty(); idleTicks = mining || pending ? 0 : Math.min(IDLE_RELEASE_TICKS, idleTicks + 1); // Check after this tick's placements before releasing the keep-mode hold. SwapMode mode = validationToolSlot() != -1 ? SwapMode.ToolHold : swapMode.get(); if (mode.shouldRelease(clientTick - lastMiningActionTick, mining, replacement, pending, idleTicks)) releaseHeldSlot(); } @EventHandler private void onRender(Render3DEvent event) { if (mc.world == null || mc.player == null) return; for (BlockPos pos : queue) renderBox(event, pos, 1, renderColor.get(), ShapeMode.Lines); if (bedrockPos != null) { renderBox(event, bedrockPos, 1, bedrockColor.get(), bedrockShape.get()); } if (secondary != null) renderMineContext(event, secondary); if (primary != null) renderMineContext(event, primary); // Fix 9: every other rebreak check also tests the mode, so the outline should too. if (lastBrokenPos != null && autoRebreak.get() && rebreakMode.get() != RebreakMode.Off && !mc.world.getBlockState(lastBrokenPos).isAir()) { renderBox(event, lastBrokenPos, 1, renderColor.get(), ShapeMode.Lines); } } // ── Core break logic ────────────────────────────────────────────────────── private void rebreakReplacement() { if (lastBrokenPos == null) return; boolean packet = rebreakTrigger.get() == RebreakTrigger.OnPacket && packetReplacement != null; tryRebreak(lastBrokenPos, packet ? packetReplacement : mc.world.getBlockState(lastBrokenPos), packet); } private void tryRebreak(BlockPos pos, BlockState state, boolean packet) { boolean strong = rebreakMode.get().isStrong(bypassConfirmed); if (mc.interactionManager == null || !autoRebreak.get() || !pos.equals(lastBrokenPos) || !pos.equals(lastStartedPos) || !rebreakMode.get().canRebreak(lastBreakConfirmed, rebreakPending, bypassConfirmed) || primary != null || secondary != null || hasPendingNormalBreak(pendingBreaks, strong ? pos : null) || outOfRange(pos) || state.isAir() || !BlockUtils.canBreak(pos, state)) return; if (shouldPauseMining()) return; if (!strong && !canStart()) return; MineContext ctx = new MineContext(pos, state, false); ctx.rebreak = true; ctx.packetRebreak = packet; if (rotate.get()) lookAt(pos); if (finishBreak(ctx, silentSwap.get()) && !strong && tpsSync.get()) startCredit = Math.max(0, startCredit - 1); } /** @return true when this module took ownership of {@code pos} (mining it, or queueing it). */ private boolean handleBlockClick(BlockPos pos, BlockState state) { if (isMining(pos)) return false; if (autoRebreak.get() && rebreakMode.get() != RebreakMode.Off && rebreakMode.get().isPrimed(lastBreakConfirmed) && pos.equals(lastBrokenPos) && pos.equals(lastStartedPos)) { boolean packet = rebreakTrigger.get() == RebreakTrigger.OnPacket; if (packet && packetReplacement != null) tryRebreak(pos, packetReplacement, true); else tryRebreak(pos, state, false); return true; } if (shouldPauseMining()) return false; if (hasPendingNormalBreak()) { if (!queueEnabled.get()) return false; queue.addLast(pos.toImmutable()); return true; } if (!canStart()) { queue.addLast(pos.toImmutable()); return true; } boolean canAddSecondary = secondary == null && doubleBreak.get(); if (primary == null) { primary = new MineContext(pos, state, true); sendStart(primary); return true; } else if (canAddSecondary) { if (!stopWithTool(primary, silentSwap.get())) { if (!queueEnabled.get()) return false; if (!queue.contains(pos)) queue.addLast(pos.toImmutable()); return true; } secondary = primary; secondary.isPrimary = false; primary = new MineContext(pos, state, true); sendStart(primary); return true; } else { // Fix 3: this insert was the one place the original stored a possibly mutable BlockPos. if (!queueEnabled.get()) return false; if (!queue.contains(pos)) queue.addLast(pos.toImmutable()); return true; } } private void pruneCompletedOrInvalid() { long now = System.currentTimeMillis(); if (primary != null && shouldRemove(primary, now)) primary = null; if (secondary != null && shouldRemove(secondary, now)) secondary = null; queue.removeIf(this::shouldRemove); } private boolean shouldRemove(BlockPos pos) { return mc.world.getBlockState(pos).isAir() || outOfRange(pos); } /** * Fix 4: the context carries the state it started on, so a block that was swapped for a * different one must not keep being mined with the old hardness and the old particle id. * Fix 2: a context that cannot make progress at all (hardness below zero, no usable tool) would * otherwise never satisfy shouldRemove and would hold the primary slot forever. */ private boolean shouldRemove(MineContext ctx, long now) { if (shouldRemove(ctx.pos)) return true; if (mc.world.getBlockState(ctx.pos) != ctx.state) return true; return ctx.tickDamage <= 0 && now - ctx.startMs > STALL_TIMEOUT_MS; } private void drainQueue() { if (hasPendingNormalBreak() || queue.isEmpty() || !canStart()) return; if (primary == null || (doubleBreak.get() && secondary == null)) { BlockPos pos = queue.pollFirst(); handleBlockClick(pos, mc.world.getBlockState(pos)); // return value is not needed here } } // ── Packet building ─────────────────────────────────────────────────────── private void sendStart(MineContext ctx) { if (ctx.started || !ctx.active) return; if (rotate.get()) lookAt(ctx.pos); withMiningTool(ctx, silentSwap.get(), () -> { if (grimBypass.get() && !sendSequencedAction(PlayerActionC2SPacket.Action.STOP_DESTROY_BLOCK, ctx, false)) return; if (!sendSequencedAction(PlayerActionC2SPacket.Action.START_DESTROY_BLOCK, ctx, false)) return; ctx.started = true; ctx.instaBreak = ctx.tickDamage >= 1.0; ctx.timing.start(System.nanoTime(), ctx.tickDamage, effectiveTps()); rebreakPending = false; bypassConfirmed = false; packetReplacement = null; lastBreakConfirmed = false; lastStartedPos = ctx.pos; if (tpsSync.get()) startCredit = Math.max(0, startCredit - 1); if (ctx.instaBreak) finishBreak(ctx, silentSwap.get()); }); } /** STOP must use the same tool as START. */ private boolean stopWithTool(MineContext ctx, boolean silent) { if (!ctx.started || mc.world == null || mc.player == null) return false; withMiningTool(ctx, silent, () -> ctx.stopSent = sendSequencedAction(PlayerActionC2SPacket.Action.STOP_DESTROY_BLOCK, ctx, false)); return ctx.stopSent; } private boolean finishBreak(MineContext ctx, boolean silent) { if (!ctx.active || (!ctx.started && !ctx.rebreak) || mc.world == null || mc.player == null || mc.interactionManager == null) return false; boolean clientRemove = clientPrediction.get(); // Instant STARTs do not prime the server's target for subsequent STOPs. PlayerActionC2SPacket.Action action = ctx.rebreak && ctx.instaBreak ? PlayerActionC2SPacket.Action.START_DESTROY_BLOCK : PlayerActionC2SPacket.Action.STOP_DESTROY_BLOCK; withMiningTool(ctx, silent, () -> ctx.stopSent = sendSequencedAction(action, ctx, clientRemove)); if (!ctx.stopSent) return false; if (ctx.requiresValidation() || (silent && swapMode.get() == SwapMode.ToolHold)) { double ticksPerMs = serverTicks(1, TickRate.INSTANCE.getTickRate()) * ctx.tickDamage; long deadline = System.currentTimeMillis() + (long) clamp( ticksPerMs > 0 ? 1 / ticksPerMs + 1000 : 30000, 2000, 30000); // At most two normal validations and the latest instant/rebreak hold. pendingBreaks.removeIf(pending -> !pending.normal() || pending.pos().equals(ctx.pos)); pendingBreaks.add(new PendingBreak(ctx.pos, ctx.startSlot, ctx.requiresValidation(), !ctx.isPrimary, deadline)); } if (clientRemove) { mc.world.syncWorldEvent(WorldEvents.BLOCK_BROKEN, ctx.pos, Block.getRawIdFromState(ctx.state)); } if (!ctx.pos.equals(lastBrokenPos)) lastBreakConfirmed = false; lastBrokenPos = ctx.pos; if (ctx.rebreak) { rebreakPending = true; rebreakDeadlineMs = System.currentTimeMillis() + REBREAK_TIMEOUT_MS; packetReplacement = null; if (debugRebreak.get()) rebreakConfirmations.sent(ctx.pos, System.nanoTime(), lastBreakConfirmed); } ctx.active = false; if (ctx == primary) primary = null; else if (ctx == secondary) secondary = null; if (silent && hasPendingNormalBreak()) holdTool(validationToolSlot()); return true; } /** * Sends one dig action inside the client's own prediction sequence, through vanilla's * sendSequencedPacket. Any block change made inside the callback is recorded against that * sequence, so a wrong guess is rolled back by the server's acknowledgement instead of leaving a * ghost block. The call needs an invoker mixin: the method is private and the pending-update * manager behind it is package-private. */ private boolean sendSequencedAction(PlayerActionC2SPacket.Action action, MineContext ctx, boolean predict) { if (mc.world == null || mc.player == null || mc.interactionManager == null) return false; ((ClientPlayerInteractionManagerAccessor) mc.interactionManager).quinn$sendSequencedPacket(mc.world, sequence -> { if (predict) { // The replacement may still read as air locally, so tell the prediction bookkeeping // what the server really has before predicting air over it. if (ctx.packetRebreak) { ((ClientWorldAccessor) mc.world).quinn$getPendingUpdateManager() .addPendingUpdate(ctx.pos, ctx.state, mc.player); } mc.world.setBlockState(ctx.pos, Blocks.AIR.getDefaultState(), Block.NOTIFY_ALL); } return new PlayerActionC2SPacket(action, ctx.pos, Direction.DOWN, sequence); }); return true; } // ── Silent swap ─────────────────────────────────────────────────────────── private void withMiningTool(MineContext ctx, boolean silent, Runnable action) { SwapMode mode = swapMode.get().forBreak(ctx.instaBreak, ctx.rebreak); if (validationToolSlot() != -1) mode = SwapMode.ToolHold; if (silent && mode == SwapMode.SameTick) { releaseHeldSlot(); withSilentTool(ctx.state, action); } else { if (silent) holdTool(ctx.startSlot); else equipBestTool(ctx.state); action.run(); } int validationSlot = validationToolSlot(); if (validationSlot != -1) { if (silent) holdTool(validationSlot); else selectSlot(validationSlot); } lastMiningActionTick = clientTick; } /** Restores the previous server slot, including an existing silent hold. */ public void withSilentTool(BlockState state, Runnable action) { if (mc.player == null) { action.run(); return; } int best = findBestHotbarSlot(state); int prev = currentServerSlot(); boolean swap = best != -1 && best != prev; if (swap) sendSlotPacket(best); try { action.run(); } finally { if (swap && currentServerSlot() != prev) sendSlotPacket(prev); } } private int currentServerSlot() { if (serverSlot != -1) return serverSlot; return mc.player == null ? 0 : mc.player.getInventory().getSelectedSlot(); } private void sendSlotPacket(int slot) { if (slot < 0 || mc.getNetworkHandler() == null) return; if (currentServerSlot() == slot) return; mc.getNetworkHandler().sendPacket(new UpdateSelectedSlotC2SPacket(slot)); serverSlot = slot; } private void holdTool(int slot) { if (slot < 0 || mc.player == null) return; sendSlotPacket(slot); heldSlot = slot; } private void releaseHeldSlot() { if (heldSlot != -1 && mc.player != null && currentServerSlot() == heldSlot) { sendSlotPacket(mc.player.getInventory().getSelectedSlot()); } heldSlot = -1; idleTicks = 0; } /** Non-silent swap: move the client's own selection too. */ private void selectSlot(int slot) { if (slot < 0 || mc.player == null) return; mc.player.getInventory().setSelectedSlot(slot); sendSlotPacket(slot); } private void equipBestTool(BlockState state) { int slot = findBestHotbarSlot(state); if (slot != -1) selectSlot(slot); } /** * THM re-implemented vanilla's mining formula here. Meteor already has it, including efficiency, * haste, mining fatigue, water and the airborne penalty, so the port calls that instead. */ private int findBestHotbarSlot(BlockState state) { if (mc.player == null) return -1; // Fix 7: start from the slot already selected, so an equally fast tool that is in hand wins // the tie. The original started at -1, which both made the "no tool" result unreachable and // swapped to the lowest matching hotbar index on every tie. int best = mc.player.getInventory().getSelectedSlot(); double bestSpeed = BlockUtils.getBreakDelta(best, state); for (int i = 0; i < 9; i++) { if (i == best) continue; double speed = BlockUtils.getBreakDelta(i, state); if (speed > bestSpeed) { bestSpeed = speed; best = i; } } return best; } // ── Auto Mine ───────────────────────────────────────────────────────────── /** * Picks blocks to break around nearby enemies, in BlackOut AutoMine's priority order, and hands * them to the normal packet miner via {@link #requestBreak(BlockPos)}. Bedrock can't be * packet-mined, so it goes down the vanilla progress+swing path instead. */ private void tickAutoMine() { if (!autoMine.get() || !autoMineHeld()) { bedrockPos = null; if (debugAutoMine.get()) autoMineStatus = !autoMine.get() ? "Disabled." : "Waiting for auto-mine-bind."; return; } bedrockPos = null; int budget = autoDoubleMine.get() && doubleBreak.get() ? 2 : 1; List targets = findAutoTargets(); // Auto-mine outranks the queue: drop the backlog instead of mining it first if (!targets.isEmpty() && !shouldPauseMining()) queue.clear(); for (BlockPos target : targets) { if (mc.world.getBlockState(target).getBlock() == Blocks.BEDROCK) { // Bedrock is mined one at a time — it's a vanilla progress bar, not a packet break if (bedrockPos == null) mineBedrock(target); continue; } if (budget-- <= 0) break; // Both break slots busy: skip rather than queue — auto targets never enter the queue if (primary != null && (secondary != null || !doubleBreak.get())) continue; requestBreak(target); } if (debugAutoMine.get() && !targets.isEmpty()) { if (bedrockPos != null) autoMineStatus = "Targeting bedrock at " + bedrockPos.toShortString(); else if (primary != null) autoMineStatus = "Targeting block at " + primary.pos.toShortString(); else if (secondary != null) autoMineStatus = "Targeting block at " + secondary.pos.toShortString(); else if (hasPendingNormalBreak()) autoMineStatus = "Waiting for server break confirmation."; else if (!canStart()) autoMineStatus = "Waiting for TPS mining credit."; else autoMineStatus = "Targets selected; waiting for mining slots."; } } /** A bound key is hold-to-mine; an unbound one means "always on". */ private boolean autoMineHeld() { return !autoMineBind.get().isSet() || autoMineBind.get().isPressed(); } /** Every valid target around nearby enemies, nearest first. */ private List findAutoTargets() { List enemies = new ArrayList<>(); int friends = 0, distant = 0, dead = 0; for (PlayerEntity player : mc.world.getPlayers()) { if (player == mc.player || player.isSpectator()) continue; // Fix 6: a corpse still sits in the player list for the whole death animation. if (!player.isAlive()) { dead++; continue; } if (Friends.get().isFriend(player)) { friends++; continue; } if (player.distanceTo(mc.player) > enemyRange.get()) { distant++; continue; } enemies.add(player); } if (enemies.isEmpty()) { if (debugAutoMine.get()) autoMineStatus = String.format(Locale.ROOT, "No eligible players (friends=%d, distant=%d, dead=%d).", friends, distant, dead); return List.of(); } // Digging an enemy out beats chipping at their surround autoAirRejected = autoSelfRejected = autoRangeRejected = autoDisabledRejected = autoUnbreakableRejected = 0; List targets = new ArrayList<>(); if (antiPhase.get()) targets.addAll(collect(enemies, this::phaseBlocks)); if (targets.isEmpty() && antiSurround.get()) targets.addAll(collect(enemies, this::surroundBlocks)); if (debugAutoMine.get() && targets.isEmpty()) { autoMineStatus = !antiPhase.get() && !antiSurround.get() ? "Both anti-phase and anti-surround are disabled." : String.format(Locale.ROOT, "No target blocks (air=%d, own=%d, range=%d, disabled=%d, unbreakable=%d).", autoAirRejected, autoSelfRejected, autoRangeRejected, autoDisabledRejected, autoUnbreakableRejected); } return targets; } private List collect(List enemies, Function> candidates) { List out = new ArrayList<>(); for (PlayerEntity enemy : enemies) { for (BlockPos pos : candidates.apply(enemy)) { if (pos == null) continue; if (outOfRange(pos)) { autoRangeRejected++; continue; } if (!out.contains(pos)) out.add(pos); } } Comparator byDistance = Comparator.comparingDouble(pos -> mc.player.getEyePos().squaredDistanceTo(Vec3d.ofCenter(pos))); // Absolute Y, not per-enemy feet level — right for one enemy, good enough for a pile of them out.sort(feetFirst.get() ? Comparator.comparingInt(BlockPos::getY).thenComparing(byDistance) : byDistance); return out; } /** Every block the enemy's hitbox overlaps. */ private List phaseBlocks(PlayerEntity enemy) { List out = new ArrayList<>(); Box box = enemy.getBoundingBox(); for (BlockPos pos : BlockPos.iterate( BlockPos.ofFloored(box.minX, box.minY, box.minZ), BlockPos.ofFloored(box.maxX, box.maxY, box.maxZ))) { if (mineable(pos)) out.add(pos.toImmutable()); } return out; } /** Everything boxing the enemy in: surround ring, head-level ring, and the block above their head. */ private List surroundBlocks(PlayerEntity enemy) { BlockPos feet = feet(enemy); BlockPos head = new BlockPos(enemy.getBlockX(), (int) Math.floor(enemy.getBoundingBox().maxY), enemy.getBlockZ()); List out = new ArrayList<>(); for (Direction dir : Direction.Type.HORIZONTAL) { out.add(feet.offset(dir)); out.add(head.offset(dir)); } out.add(head.up()); List mineable = new ArrayList<>(); for (BlockPos pos : out) if (mineable(pos)) mineable.add(pos); return mineable; } /** * Fix 5: the original rounded the Y, which lands one block too high whenever the enemy stands on * anything that puts them on a fractional Y of .5 or more (slab, snow layer, soul sand). */ private BlockPos feet(PlayerEntity enemy) { return new BlockPos(enemy.getBlockX(), enemy.getBlockY(), enemy.getBlockZ()); } /** True if the block is inside or directly against our own hitbox — breaking it drops or exposes us. */ private boolean touchesSelf(BlockPos pos) { return mc.player.getBoundingBox().expand(1).intersects( pos.getX(), pos.getY(), pos.getZ(), pos.getX() + 1, pos.getY() + 1, pos.getZ() + 1); } private boolean mineable(BlockPos pos) { BlockState state = mc.world.getBlockState(pos); if (state.isAir()) { autoAirRejected++; return false; } if (neverMineOwn.get() && touchesSelf(pos)) { autoSelfRejected++; return false; } // Bedrock's hardness is -1, so BlockUtils.canBreak always rejects it — it has to be checked first if (state.isOf(Blocks.BEDROCK)) { if (!mineBedrock.get()) { autoDisabledRejected++; return false; } return true; } if (shouldPauseMining() || bedrockOnly.get()) { autoDisabledRejected++; return false; } if (!BlockUtils.canBreak(pos, state)) { autoUnbreakableRejected++; return false; } return true; } // ── Bedrock (vanilla progress + swing — packets can't break it) ─────────── /** * Bedrock is a server-side progress bar driven by holding the dig, not something a START/STOP * packet pair can pop, so it goes through updateBlockBreakingProgress — which tracks exactly one * position, hence one block at a time. No tool swap: bedrock breaks at the same speed with * anything, so whatever is held works. */ private void mineBedrock(BlockPos pos) { if (mc.interactionManager == null || mc.player == null) return; bedrockPos = pos; if (bedrockRotate.get()) lookAt(pos); mc.interactionManager.updateBlockBreakingProgress(pos, closestFace(pos)); mc.player.swingHand(Hand.MAIN_HAND); } // ── Pending validation ──────────────────────────────────────────────────── /** A delayed secondary completes in a server tick, using the tool held then. */ private int validationToolSlot() { return validationToolSlot( secondary != null && secondary.requiresValidation() ? secondary.startSlot : -1, pendingBreaks, primary != null && primary.requiresValidation() ? primary.startSlot : -1); } static int validationToolSlot(int secondarySlot, List pendingBreaks, int primarySlot) { for (PendingBreak pending : pendingBreaks) { if (pending.normal() && pending.secondary()) return pending.toolSlot(); } if (secondarySlot != -1) return secondarySlot; for (PendingBreak pending : pendingBreaks) { if (pending.normal()) return pending.toolSlot(); } return primarySlot; } private boolean hasPendingNormalBreak() { return hasPendingNormalBreak(pendingBreaks, null); } static boolean hasPendingNormalBreak(List pendingBreaks, BlockPos completedTarget) { return pendingBreaks.stream().anyMatch(pending -> pending.normal() && !pending.pos().equals(completedTarget)); } record PendingBreak(BlockPos pos, int toolSlot, boolean normal, boolean secondary, long deadlineMs) {} // ── Util ───────────────────────────────────────────────────────────────── private boolean isEating() { return mc.player != null && mc.player.isUsingItem(); } private boolean shouldPauseMining() { return shouldPauseMining(multitask.get(), mc.player != null && mc.player.isUsingItem()); } static boolean shouldPauseMining(boolean multitask, boolean usingItem) { return !multitask && usingItem; } private void resetRebreakMonitor() { rebreakMonitorStartNs = System.nanoTime(); rebreakCount = 0; rebreakRate = "0.0"; rebreakConfirmations.clear(); } private void updateRebreakMonitor() { if (!debugRebreak.get()) return; long now = System.nanoTime(); rebreakConfirmations.expire(now); long elapsed = now - rebreakMonitorStartNs; if (elapsed < 1_000_000_000L) return; rebreakRate = String.format(Locale.ROOT, "%.1f", rebreakCount * 1_000_000_000.0 / elapsed); info("Rebreaks/s: %s confirmed", rebreakRate); rebreakMonitorStartNs = now; rebreakCount = 0; } @Override public String getInfoString() { return debugRebreak.get() ? rebreakRate + " rebreaks/s" : null; } private float effectiveTps() { return tpsSync.get() ? TickRate.INSTANCE.getTickRate() : 20; } private boolean canStart() { return canStart(tpsSync.get(), effectiveTps(), startCredit); } static boolean canStart(boolean sync, float tps, double credit) { return !sync || !Float.isFinite(tps) || tps >= 20 || credit >= 1; } static double replenishStartCredit(double credit, long elapsedMillis, float tps) { // Retain fractional overflow so tick jitter does not discard earned starts. return Math.min(2, credit + serverTicks(elapsedMillis, tps)); } static double serverTicks(long elapsedMillis, float tps) { return Math.max(0, elapsedMillis) / 1000.0 * clamp(Float.isFinite(tps) ? tps : 20, 0, 20); } static double clamp(double value, double min, double max) { return Math.max(min, Math.min(max, value)); } public void requestBreak(BlockPos pos) { if (mc.world == null || mc.player == null) return; BlockState state = mc.world.getBlockState(pos); // Fix 2: the original only checked for air, so an unbreakable block handed in by another // module (bedrock, barrier) produced a context that could never finish. if (state.isAir() || outOfRange(pos) || !BlockUtils.canBreak(pos, state)) return; if (!isMining(pos)) handleBlockClick(pos, state); } public boolean isMining(BlockPos pos) { return (primary != null && primary.pos.equals(pos)) || (secondary != null && secondary.pos.equals(pos)) || pendingBreaks.stream().anyMatch(pending -> pending.normal() && pending.pos().equals(pos)) || queue.contains(pos); } /** Server-side only look at {@code pos} — the camera doesn't move. */ private void lookAt(BlockPos pos) { Rotations.rotate(Rotations.getYaw(pos), Rotations.getPitch(pos)); } public boolean outOfRange(BlockPos pos) { return !isInRange(range.get(), pos); } /** Distance from the eyes to the nearest point of the block, like the server measures it. */ private boolean isInRange(double maximum, BlockPos pos) { if (mc.player == null) return false; Vec3d eyes = mc.player.getEyePos(); double dx = Math.max(0, Math.max(pos.getX() - eyes.x, eyes.x - (pos.getX() + 1))); double dy = Math.max(0, Math.max(pos.getY() - eyes.y, eyes.y - (pos.getY() + 1))); double dz = Math.max(0, Math.max(pos.getZ() - eyes.z, eyes.z - (pos.getZ() + 1))); return dx * dx + dy * dy + dz * dz <= maximum * maximum; } /** The block face pointing closest to our eyes, used for the bedrock dig. */ private Direction closestFace(BlockPos pos) { if (mc.player == null) return Direction.UP; Vec3d center = Vec3d.ofCenter(pos); Vec3d eyes = mc.player.getEyePos(); Direction best = Direction.UP; double bestDot = -Double.MAX_VALUE; Vec3d toEyes = eyes.subtract(center).normalize(); for (Direction dir : Direction.values()) { double dot = toEyes.x * dir.getOffsetX() + toEyes.y * dir.getOffsetY() + toEyes.z * dir.getOffsetZ(); if (dot > bestDot) { bestDot = dot; best = dir; } } return best; } // ── Rendering ───────────────────────────────────────────────────────────── private void renderMineContext(Render3DEvent event, MineContext ctx) { double fraction = ctx.timing.renderFraction(System.nanoTime(), ctx.isPrimary ? breakThreshold.get() : 1); renderBox(event, ctx.pos, fraction, renderColor.get(), ShapeMode.Lines); } /** Draws the block shrunk toward its centre by {@code fraction}, as a progress indicator. */ private void renderBox(Render3DEvent event, BlockPos pos, double fraction, SettingColor color, ShapeMode shape) { double shrink = (1 - clamp(fraction, 0, 1)) * 0.5; // THM fills the side at a third of the outline's alpha; Lines mode ignores it either way. Color side = new Color(color.r, color.g, color.b, color.a / 3); event.renderer.box( pos.getX() + shrink, pos.getY() + shrink, pos.getZ() + shrink, pos.getX() + 1 - shrink, pos.getY() + 1 - shrink, pos.getZ() + 1 - shrink, side, color, shape, 0); } // ── Enums ──────────────────────────────────────────────────────────────── public enum RebreakTrigger { OnPacket, OnClientWorld } public enum RebreakMode { Off, Strict, Strong, Bypass; boolean isStrong(boolean confirmedRebreak) { return this == Strong || (this == Bypass && confirmedRebreak); } boolean isPrimed(boolean confirmedBreak) { // A break and replacement in one server tick can omit the air update. return confirmedBreak || this == Strong; } boolean canRebreak(boolean primed, boolean waiting, boolean confirmedRebreak) { return this != Off && isPrimed(primed) && (!waiting || isStrong(confirmedRebreak)); } } public enum SwapMode { Keep("Keep for Next Tick"), SameTick("Same Tick"), EndOfTick("End of Tick"), ToolHold("Tool Hold"); private final String title; SwapMode(String title) { this.title = title; } @Override public String toString() { return title; } SwapMode forBreak(boolean instant, boolean primedRebreak) { return instant || primedRebreak ? this : ToolHold; } boolean shouldRelease(long ticksSinceAction, boolean mining, boolean replacement, boolean pending, int idleTicks) { return switch (this) { case SameTick, EndOfTick -> true; case Keep -> !mining && !replacement && ticksSinceAction >= 1; case ToolHold -> !mining && !pending && idleTicks >= IDLE_RELEASE_TICKS; }; } } // ── Rebreak monitor ────────────────────────────────────────────────────── static final class RebreakConfirmations { private final Map awaiting = new HashMap<>(); void sent(BlockPos pos, long now, boolean initialConfirmed) { // A late initial air response must not count as a successful Strong retry. if (!initialConfirmed) return; expire(now); awaiting.putIfAbsent(pos.toImmutable(), now + REBREAK_TIMEOUT_NS); } boolean confirm(BlockPos pos, long now) { Long deadline = awaiting.remove(pos); return deadline != null && now < deadline; } void expire(long now) { awaiting.values().removeIf(deadline -> now >= deadline); } void clear() { awaiting.clear(); } } // ── Mining progress ────────────────────────────────────────────────────── static final class MiningProgress { private long lastNs; private double damage; private double rate; private boolean started; void start(long now, double delta, float tps) { lastNs = now; damage = Double.isFinite(delta) ? Math.max(0, delta) : 1; rate = damage * serverTicks(1000, tps) / 1_000_000_000.0; started = true; } void advance(long now, double delta, float tps, boolean paused) { if (!started) return; damage += Math.max(0, now - lastNs) * rate; lastNs = Math.max(lastNs, now); rate = paused || !Double.isFinite(delta) ? 0 : Math.max(0, delta) * serverTicks(1000, tps) / 1_000_000_000.0; } double fraction(double threshold) { return clamp(damage / threshold, 0, 1); } double renderFraction(long now, double threshold) { // Extrapolate at most one client tick without changing mining work. return clamp((damage + clamp(now - lastNs, 0, 50_000_000L) * rate) / threshold, 0, 1); } } // ── MineContext ────────────────────────────────────────────────────────── public class MineContext { public final BlockPos pos; public final BlockState state; public final long startMs; public boolean isPrimary; public boolean instaBreak; /** Hotbar slot holding the tool this block was started with; -1 if none. */ public final int startSlot; public boolean active = true; private boolean rebreak; /** True when this rebreak was triggered by a server update the client world has not shown yet. */ private boolean packetRebreak; private boolean started; private boolean stopSent; private double tickDamage; private final MiningProgress timing = new MiningProgress(); public MineContext(BlockPos pos, BlockState state, boolean isPrimary) { this.pos = pos.toImmutable(); this.state = state; this.isPrimary = isPrimary; this.startSlot = findBestHotbarSlot(state); this.startMs = System.currentTimeMillis(); this.tickDamage = calcDelta(); this.instaBreak = tickDamage >= 1.0; } private boolean requiresValidation() { return !instaBreak && !rebreak; } public double progress() { return timing.fraction(isPrimary ? breakThreshold.get() : 1); } private void advance(long now, boolean paused) { tickDamage = paused ? 0 : calcDelta(); timing.advance(now, tickDamage, effectiveTps(), paused); } /** * Per-tick mining damage for the tool this context started with. Meteor's helper already does * the whole vanilla formula; the only thing added here is cancelling its airborne penalty * when the ground claim is active. */ private double calcDelta() { if (mc.player == null || mc.world == null) return 0; int slot = startSlot < 0 ? mc.player.getInventory().getSelectedSlot() : startSlot; double delta = BlockUtils.getBreakDelta(slot, state); if (!Double.isFinite(delta)) return 1; if (delta <= 0) return 0; if (!mc.player.isOnGround() && liftsAirMiningPenalty()) delta *= 5; return delta; } } }