--- port-only/Speedmine.java 2026-11-03 01:40:49.053466706 +0000 +++ fixed/Speedmine.java 2026-11-03 01:40:49.054470620 +0000 @@ -17,7 +17,20 @@ * 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. - * The known defects of the original are NOT fixed here; see ../fixed/Speedmine.java for that. + * 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; @@ -336,6 +349,11 @@ /** 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; @@ -516,10 +534,13 @@ BlockState state = mc.world.getBlockState(event.blockPos); if (!BlockUtils.canBreak(event.blockPos, state)) return; if (outOfRange(event.blockPos)) return; - event.cancel(); - if (!isMining(event.blockPos)) { - handleBlockClick(event.blockPos, state); + // 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 @@ -564,7 +585,7 @@ else if (heldSlot != -1 && !pendingBreaks.isEmpty()) holdTool(heldSlot); } - if (primary != null && !primary.started && !shouldRemove(primary.pos)) sendStart(primary); + if (primary != null && !shouldRemove(primary, now) && !primary.started) sendStart(primary); tickAutoMine(); rebreakReplacement(); @@ -616,7 +637,9 @@ if (secondary != null) renderMineContext(event, secondary); if (primary != null) renderMineContext(event, primary); - if (lastBrokenPos != null && autoRebreak.get() && !mc.world.getBlockState(lastBrokenPos).isAir()) { + // 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); } } @@ -645,24 +668,26 @@ if (finishBreak(ctx, silentSwap.get()) && !strong && tpsSync.get()) startCredit = Math.max(0, startCredit - 1); } - private void handleBlockClick(BlockPos pos, BlockState state) { - if (isMining(pos)) return; + /** @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; + return true; } - if (shouldPauseMining()) return; + if (shouldPauseMining()) return false; if (hasPendingNormalBreak()) { - if (queueEnabled.get()) queue.addLast(pos.toImmutable()); - return; + if (!queueEnabled.get()) return false; + queue.addLast(pos.toImmutable()); + return true; } if (!canStart()) { queue.addLast(pos.toImmutable()); - return; + return true; } boolean canAddSecondary = secondary == null && doubleBreak.get(); @@ -670,23 +695,30 @@ if (primary == null) { primary = new MineContext(pos, state, true); sendStart(primary); + return true; } else if (canAddSecondary) { if (!stopWithTool(primary, silentSwap.get())) { - if (queueEnabled.get() && !queue.contains(pos)) queue.addLast(pos.toImmutable()); - return; + 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 { - if (queueEnabled.get() && !queue.contains(pos)) queue.addLast(pos); + // 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() { - if (primary != null && shouldRemove(primary.pos)) primary = null; - if (secondary != null && shouldRemove(secondary.pos)) secondary = null; + long now = System.currentTimeMillis(); + if (primary != null && shouldRemove(primary, now)) primary = null; + if (secondary != null && shouldRemove(secondary, now)) secondary = null; queue.removeIf(this::shouldRemove); } @@ -694,12 +726,24 @@ 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)); + handleBlockClick(pos, mc.world.getBlockState(pos)); // return value is not needed here } } @@ -761,7 +805,7 @@ lastBrokenPos = ctx.pos; if (ctx.rebreak) { rebreakPending = true; - rebreakDeadlineMs = System.currentTimeMillis() + 2000; + rebreakDeadlineMs = System.currentTimeMillis() + REBREAK_TIMEOUT_MS; packetReplacement = null; if (debugRebreak.get()) rebreakConfirmations.sent(ctx.pos, System.nanoTime(), lastBreakConfirmed); } @@ -875,9 +919,13 @@ */ private int findBestHotbarSlot(BlockState state) { if (mc.player == null) return -1; - int best = -1; - double bestSpeed = -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; } } @@ -934,16 +982,18 @@ /** Every valid target around nearby enemies, nearest first. */ private List findAutoTargets() { List enemies = new ArrayList<>(); - int friends = 0, distant = 0; + 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).", friends, distant); + "No eligible players (friends=%d, distant=%d, dead=%d).", friends, distant, dead); return List.of(); } @@ -1006,8 +1056,12 @@ 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(), (int) Math.round(enemy.getY()), enemy.getBlockZ()); + 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. */ @@ -1144,7 +1198,9 @@ public void requestBreak(BlockPos pos) { if (mc.world == null || mc.player == null) return; BlockState state = mc.world.getBlockState(pos); - if (state.isAir()) return; + // 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); } @@ -1273,7 +1329,7 @@ // A late initial air response must not count as a successful Strong retry. if (!initialConfirmed) return; expire(now); - awaiting.putIfAbsent(pos.toImmutable(), now + 2_000_000_000L); + awaiting.putIfAbsent(pos.toImmutable(), now + REBREAK_TIMEOUT_NS); } boolean confirm(BlockPos pos, long now) {