chunkCaching = new Setting<>(true);
/**
- * On save, delete from RAM any cached regions that are more than 1024 blocks away from the player
+ * Evict cached regions from RAM. The cache maintenance thread drops a region once it has been idle (not read
+ * by pathing or scanning, not written by the packer) for longer than {@link #cachedRegionsIdleSeconds}, and
+ * drops the least recently used regions whenever the resident cache exceeds {@link #cachedRegionsRamBudgetMB}.
+ * Regions with unsaved changes are written to disk first and are never evicted before that write succeeded,
+ * unless {@link #chunkCaching} is off and nothing will ever write them.
*
- * Temporarily disabled
- *
- * Temporarily reenabled
- *
- * @see Issue #248
+ * false keeps every region touched in this session in RAM.
*/
public final Setting pruneRegionsFromRAM = new Setting<>(true);
+ /**
+ * Upper bound for the cached regions kept in RAM, in MiB. A fully cached region costs about 27 MiB
+ * (1024 chunks of 24 KiB block data). The bound is soft by one maintenance tick.
+ */
+ public final Setting cachedRegionsRamBudgetMB = new Setting<>(192);
+
+ /**
+ * A cached region that nothing has read or written for this many seconds is evicted from RAM (after being
+ * saved if it changed). Regions come back from disk on demand.
+ */
+ public final Setting cachedRegionsIdleSeconds = new Setting<>(180);
+
+ /**
+ * How often changed regions are written to disk. The elytra nether pathfinder reads the region files, so this
+ * is also how long newly seen terrain takes to become visible to it.
+ */
+ public final Setting cachedRegionsSaveIntervalSeconds = new Setting<>(30);
+
+ /**
+ * When pathing asks for a region that is not in RAM, load it from disk in the background so the next path
+ * calculation sees terrain explored in earlier sessions. Without this only the packer (chunks around the
+ * player) and explicit scans (#find, explore) ever bring regions into RAM.
+ */
+ public final Setting cachedRegionsLoadOnDemand = new Setting<>(true);
+
+ /**
+ * Pack a chunk again when it unloads even if no block changed since it was packed on load. Off by default:
+ * the unload pack doubled the packer's work for an identical result. A chunk that changed while loaded, or
+ * whose load-time pack was dropped or has not run yet, is still packed on unload.
+ */
+ public final Setting repackUnchangedChunksOnUnload = new Setting<>(false);
+
/**
* The chunk packer queue can never grow to larger than this, if it does, the oldest chunks are discarded
*
@@ -1445,6 +1477,13 @@ public final class Settings {
*/
public final Setting elytraPitchRange = new Setting<>(25);
+ /**
+ * The number of path nodes ahead of the player that are considered as flight targets. Higher values let the bot
+ * commit to a further-away node and cut corners, which is smoother at firework speed, but are computationally
+ * more expensive.
+ */
+ public final Setting elytraPathLookahead = new Setting<>(20);
+
/**
* The minimum speed that the player can drop to (in blocks/tick) before a firework is automatically deployed.
*/
@@ -1490,6 +1529,12 @@ public final class Settings {
*/
public final Setting elytraAutoJump = new Setting<>(false);
+ /**
+ * When {@link #elytraAutoJump} can't find a ledge to walk off of, jump on the spot and light a firework the
+ * moment the elytra opens instead of giving up. Costs one firework per attempt.
+ */
+ public final Setting elytraStandingTakeoff = new Setting<>(true);
+
/**
* The seed used to generate chunks for long distance elytra path-finding in the nether.
* Defaults to 2b2t's nether seed.
diff --git a/src/api/java/baritone/api/pathing/goals/GoalGetToBlock.java b/src/api/java/baritone/api/pathing/goals/GoalGetToBlock.java
index 1c04f7c6d1..a0116be6f3 100644
--- a/src/api/java/baritone/api/pathing/goals/GoalGetToBlock.java
+++ b/src/api/java/baritone/api/pathing/goals/GoalGetToBlock.java
@@ -61,6 +61,13 @@ public double heuristic(int x, int y, int z) {
return GoalBlock.calculate(xDiff, yDiff < 0 ? yDiff + 1 : yDiff, zDiff);
}
+ @Override
+ public double heuristic() {
+ // the largest heuristic value that is still guaranteed to be inside the goal: the six positions
+ // adjacent to the block, of which the one directly above is the most expensive
+ return Math.max(GoalXZ.calculate(1, 0), Math.max(GoalYLevel.calculate(0, 1), GoalYLevel.calculate(0, -1)));
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
diff --git a/src/api/java/baritone/api/pathing/goals/GoalNear.java b/src/api/java/baritone/api/pathing/goals/GoalNear.java
index 351c080216..630856c539 100644
--- a/src/api/java/baritone/api/pathing/goals/GoalNear.java
+++ b/src/api/java/baritone/api/pathing/goals/GoalNear.java
@@ -29,20 +29,20 @@ public class GoalNear implements Goal, IGoalRenderPos {
private final int x;
private final int y;
private final int z;
- private final int rangeSq;
+ private final long rangeSq;
public GoalNear(BlockPos pos, int range) {
this.x = pos.getX();
this.y = pos.getY();
this.z = pos.getZ();
- this.rangeSq = range * range;
+ this.rangeSq = (long) range * range;
}
@Override
public boolean isInGoal(int x, int y, int z) {
- int xDiff = x - this.x;
- int yDiff = y - this.y;
- int zDiff = z - this.z;
+ long xDiff = (long) x - this.x;
+ long yDiff = (long) y - this.y;
+ long zDiff = (long) z - this.z;
return xDiff * xDiff + yDiff * yDiff + zDiff * zDiff <= rangeSq;
}
@@ -105,7 +105,7 @@ public boolean equals(Object o) {
@Override
public int hashCode() {
- return (int) BetterBlockPos.longHash(x, y, z) + rangeSq;
+ return (int) BetterBlockPos.longHash(x, y, z) + Long.hashCode(rangeSq);
}
@Override
diff --git a/src/api/java/baritone/api/pathing/goals/GoalRunAway.java b/src/api/java/baritone/api/pathing/goals/GoalRunAway.java
index 1a1b6e50c4..462968f1b4 100644
--- a/src/api/java/baritone/api/pathing/goals/GoalRunAway.java
+++ b/src/api/java/baritone/api/pathing/goals/GoalRunAway.java
@@ -34,7 +34,7 @@ public class GoalRunAway implements Goal {
private final BlockPos[] from;
- private final int distanceSq;
+ private final double distanceSq;
private final Integer maintainY;
@@ -47,7 +47,7 @@ public GoalRunAway(double distance, Integer maintainY, BlockPos... from) {
throw new IllegalArgumentException("Positions to run away from must not be empty");
}
this.from = from;
- this.distanceSq = (int) (distance * distance);
+ this.distanceSq = distance * distance;
this.maintainY = maintainY;
}
@@ -57,9 +57,9 @@ public boolean isInGoal(int x, int y, int z) {
return false;
}
for (BlockPos p : from) {
- int diffX = x - p.getX();
- int diffZ = z - p.getZ();
- int distSq = diffX * diffX + diffZ * diffZ;
+ double diffX = (double) x - p.getX();
+ double diffZ = (double) z - p.getZ();
+ double distSq = diffX * diffX + diffZ * diffZ;
if (distSq < distanceSq) {
return false;
}
@@ -96,9 +96,9 @@ public double heuristic() {// TODO less hacky solution
minX = Math.min(minX, p.getX() - distance);
minY = Math.min(minY, p.getY() - distance);
minZ = Math.min(minZ, p.getZ() - distance);
- maxX = Math.max(minX, p.getX() + distance);
- maxY = Math.max(minY, p.getY() + distance);
- maxZ = Math.max(minZ, p.getZ() + distance);
+ maxX = Math.max(maxX, p.getX() + distance);
+ maxY = Math.max(maxY, p.getY() + distance);
+ maxZ = Math.max(maxZ, p.getZ() + distance);
}
DoubleOpenHashSet maybeAlwaysInside = new DoubleOpenHashSet(); // see pull request #1978
double minOutside = Double.POSITIVE_INFINITY;
@@ -143,8 +143,8 @@ public boolean equals(Object o) {
@Override
public int hashCode() {
int hash = Arrays.hashCode(from);
- hash = hash * 1196803141 + distanceSq;
- hash = hash * -2053788840 + maintainY;
+ hash = hash * 1196803141 + Double.hashCode(distanceSq);
+ hash = hash * -2053788840 + Objects.hashCode(maintainY);
return hash;
}
diff --git a/src/api/java/baritone/api/pathing/movement/ActionCosts.java b/src/api/java/baritone/api/pathing/movement/ActionCosts.java
index 691d73b4d5..447ef7c1c3 100644
--- a/src/api/java/baritone/api/pathing/movement/ActionCosts.java
+++ b/src/api/java/baritone/api/pathing/movement/ActionCosts.java
@@ -87,7 +87,9 @@ static double distanceToTicks(double distance) {
double tmpDistance = distance;
int tickCount = 0;
while (true) {
- double fallDistance = velocity(tickCount);
+ // you have already been falling for one tick by the time you have moved at all; velocity(0) is 0,
+ // so indexing from tickCount would charge a phantom tick against every fall
+ double fallDistance = velocity(tickCount + 1);
if (tmpDistance <= fallDistance) {
return tickCount + tmpDistance / fallDistance;
}
diff --git a/src/api/java/baritone/api/utils/BlockOptionalMeta.java b/src/api/java/baritone/api/utils/BlockOptionalMeta.java
index a9cc2caa56..1505a448d5 100644
--- a/src/api/java/baritone/api/utils/BlockOptionalMeta.java
+++ b/src/api/java/baritone/api/utils/BlockOptionalMeta.java
@@ -59,7 +59,6 @@
import sun.misc.Unsafe;
import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.*;
@@ -281,9 +280,10 @@ public static ServerLevelStub fastCreate() {
@Override
public RegistryAccess registryAccess() {
- if (client.level != null) {
- return client.level.registryAccess();
- }
+ // Loot tables are populated only by the reloaded server registries built
+ // in load(). A client RegistryAccess never receives them, so sourcing the
+ // holder from client.level made every block resolve to zero drops, which
+ // silently broke #mine item pickup (regression from PR #5058).
return registryAccess.join();
}
@@ -305,22 +305,22 @@ public static CompletableFuture load() {
// Simplified from {@link net.minecraft.server.WorldLoader#load()}
CloseableResourceManager closeableResourceManager = new MultiPackResourceManager(
PackType.SERVER_DATA,
- List.of(ServerPacksSource.createVanillaPackSource())
+ List.of(ServerPacksSource.createVanillaPackSource().fullResources())
);
LayeredRegistryAccess baseLayeredRegistry = RegistryLayer.createRegistryAccess();
List> pendingTags = TagLoader.loadTagsForExistingRegistries(
closeableResourceManager, baseLayeredRegistry.getLayer(RegistryLayer.STATIC)
);
List> worldGenRegistryLookupList = TagLoader.buildUpdatedLookups(
- baseLayeredRegistry.getAccessForLoading(RegistryLayer.WORLDGEN),
+ baseLayeredRegistry.getAccessForLoading(RegistryLayer.WORLD),
pendingTags
);
LayeredRegistryAccess layeredRegistryAccess = baseLayeredRegistry.replaceFrom(
- RegistryLayer.WORLDGEN,
+ RegistryLayer.WORLD,
RegistryDataLoader.load(
closeableResourceManager,
worldGenRegistryLookupList,
- RegistryDataLoader.WORLDGEN_REGISTRIES,
+ RegistryDataLoader.WORLD_REGISTRIES,
ForkJoinPool.commonPool()
).join()
);
diff --git a/src/launch/java/baritone/launch/mixins/MixinClientPlayNetHandler.java b/src/launch/java/baritone/launch/mixins/MixinClientPlayNetHandler.java
index 1985b31517..729e397ff0 100644
--- a/src/launch/java/baritone/launch/mixins/MixinClientPlayNetHandler.java
+++ b/src/launch/java/baritone/launch/mixins/MixinClientPlayNetHandler.java
@@ -108,8 +108,8 @@ private void postHandleChunkData(ClientboundLevelChunkWithLightPacket packetIn,
new ChunkEvent(
EventState.POST,
!packetIn.isSkippable() ? ChunkEvent.Type.POPULATE_FULL : ChunkEvent.Type.POPULATE_PARTIAL,
- packetIn.getX(),
- packetIn.getZ()
+ packetIn.x(),
+ packetIn.z()
)
);
}
diff --git a/src/launch/java/baritone/launch/mixins/MixinMinecraft.java b/src/launch/java/baritone/launch/mixins/MixinMinecraft.java
index 37455dbd28..5762e05de3 100644
--- a/src/launch/java/baritone/launch/mixins/MixinMinecraft.java
+++ b/src/launch/java/baritone/launch/mixins/MixinMinecraft.java
@@ -114,7 +114,7 @@ private void postRunTick(CallbackInfo ci) {
method = "tick",
at = @At(
value = "INVOKE",
- target = "net/minecraft/client/multiplayer/ClientLevel.tickEntities()V",
+ target = "Lnet/minecraft/client/player/LocalPlayer;sendChanges()V",
shift = At.Shift.AFTER
)
)
diff --git a/src/launch/java/baritone/launch/mixins/MixinRenderPipelines.java b/src/launch/java/baritone/launch/mixins/MixinRenderPipelines.java
index f5a8a6e97e..4f07497a24 100644
--- a/src/launch/java/baritone/launch/mixins/MixinRenderPipelines.java
+++ b/src/launch/java/baritone/launch/mixins/MixinRenderPipelines.java
@@ -18,7 +18,7 @@
package baritone.launch.mixins;
import baritone.utils.accessor.IRenderPipelines;
-import com.mojang.blaze3d.pipeline.RenderPipeline;
+import com.mojang.renderpearl.api.pipeline.RenderPipeline;
import net.minecraft.client.renderer.RenderPipelines;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
diff --git a/src/launch/java/baritone/launch/mixins/MixinWorldRenderer.java b/src/launch/java/baritone/launch/mixins/MixinWorldRenderer.java
index 51bb7e3e83..ee93e521dc 100644
--- a/src/launch/java/baritone/launch/mixins/MixinWorldRenderer.java
+++ b/src/launch/java/baritone/launch/mixins/MixinWorldRenderer.java
@@ -20,13 +20,11 @@
import baritone.api.BaritoneAPI;
import baritone.api.IBaritone;
import baritone.api.event.events.RenderEvent;
-import com.mojang.blaze3d.buffers.GpuBufferSlice;
import com.mojang.blaze3d.resource.GraphicsResourceAllocator;
import com.mojang.blaze3d.vertex.PoseStack;
-import net.minecraft.client.DeltaTracker;
+import com.mojang.renderpearl.api.buffers.GpuBufferSlice;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.client.renderer.state.level.CameraRenderState;
-import org.joml.Matrix4fc;
import org.joml.Vector4f;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
@@ -44,11 +42,11 @@ public class MixinWorldRenderer {
method = "render",
at = @At("RETURN")
)
- private void onStartHand(final GraphicsResourceAllocator allocator, final DeltaTracker deltaTracker, final boolean outline, final CameraRenderState camera, final Matrix4fc modelViewMatrix, final GpuBufferSlice fog, final Vector4f fogColor, final boolean sky, final CallbackInfo ci) {
+ private void onStartHand(final GraphicsResourceAllocator resourceAllocator, final boolean renderOutline, final CameraRenderState cameraState, final GpuBufferSlice terrainFog, final Vector4f fogColor, final boolean shouldRenderSky, final boolean consistentDepthRequired, final CallbackInfo ci) {
for (IBaritone ibaritone : BaritoneAPI.getProvider().getAllBaritones()) {
PoseStack poseStack = new PoseStack();
- poseStack.mulPose(modelViewMatrix);
- ibaritone.getGameEventHandler().onRenderPass(new RenderEvent(deltaTracker.getGameTimeDeltaPartialTick(false), poseStack, camera.projectionMatrix));
+ poseStack.mulPose(cameraState.viewRotationMatrix);
+ ibaritone.getGameEventHandler().onRenderPass(new RenderEvent(cameraState.cameraEntityPartialTicks, poseStack, cameraState.projectionMatrix));
}
}
}
diff --git a/src/main/java/baritone/Baritone.java b/src/main/java/baritone/Baritone.java
index 53f2bd3e0a..6e1405606f 100755
--- a/src/main/java/baritone/Baritone.java
+++ b/src/main/java/baritone/Baritone.java
@@ -36,6 +36,7 @@
import baritone.utils.InputOverrideHandler;
import baritone.utils.PathingControlManager;
import baritone.utils.player.BaritonePlayerContext;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
import net.minecraft.client.Minecraft;
import java.io.IOException;
@@ -58,7 +59,13 @@ public class Baritone implements IBaritone {
private static final ThreadPoolExecutor threadPool;
static {
- threadPool = new ThreadPoolExecutor(4, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<>());
+ threadPool = new ThreadPoolExecutor(4, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<>(),
+ new ThreadFactoryBuilder()
+ .setNameFormat("baritone-pool-%d")
+ // as of mc 26.2, mc will await non-daemon threads exit on game stop - crash reporting if any non-daemon thread is still alive
+ .setDaemon(true)
+ .build()
+ );
}
private final Minecraft mc;
diff --git a/src/main/java/baritone/behavior/LookBehavior.java b/src/main/java/baritone/behavior/LookBehavior.java
index 98e12aeff3..bdb8d7e284 100644
--- a/src/main/java/baritone/behavior/LookBehavior.java
+++ b/src/main/java/baritone/behavior/LookBehavior.java
@@ -158,6 +158,19 @@ public void pig() {
}
}
+ /**
+ * The rotation this tick's movement packet is going to carry: the one {@link #onPlayerUpdate} applies for the
+ * current target, or the player's own if there is no target or the target leaves the rotation alone. Anything
+ * else sent this tick that carries a look of its own (a use packet) has to match it exactly, because the
+ * server-side movement checks compare the two.
+ */
+ public Rotation getRotationForThisTick() {
+ if (this.target == null || this.target.mode == Target.Mode.NONE) {
+ return new Rotation(ctx.player().getYRot(), ctx.player().getXRot());
+ }
+ return this.processor.peekRotation(this.target.rotation);
+ }
+
public Optional getEffectiveRotation() {
if (Baritone.settings().freeLook.value) {
return Optional.ofNullable(this.serverRotation);
diff --git a/src/main/java/baritone/behavior/PathingBehavior.java b/src/main/java/baritone/behavior/PathingBehavior.java
index 6ed20d50f2..70406117c7 100644
--- a/src/main/java/baritone/behavior/PathingBehavior.java
+++ b/src/main/java/baritone/behavior/PathingBehavior.java
@@ -139,13 +139,15 @@ private void tickPath() {
// we are calculating
// are we calculating the right thing though? 🤔
BetterBlockPos calcFrom = inProgress.getStart();
- Optional currentBest = inProgress.bestPathSoFar();
if ((current == null || !current.getPath().getDest().equals(calcFrom)) // if current ends in inProgress's start, then we're ok
&& !calcFrom.equals(ctx.playerFeet()) && !calcFrom.equals(expectedSegmentStart) // if current starts in our playerFeet or pathStart, then we're ok
- && (!currentBest.isPresent() || (!currentBest.get().positions().contains(ctx.playerFeet()) && !currentBest.get().positions().contains(expectedSegmentStart))) // if
) {
+ // only now is it worth reconstructing the best path so far, which allocates one BetterBlockPos per node
+ Optional currentBest = inProgress.bestPathSoFar();
// when it was *just* started, currentBest will be empty so we need to also check calcFrom since that's always present
- inProgress.cancel(); // cancellation doesn't dispatch any events
+ if (!currentBest.isPresent() || (!currentBest.get().positions().contains(ctx.playerFeet()) && !currentBest.get().positions().contains(expectedSegmentStart))) {
+ inProgress.cancel(); // cancellation doesn't dispatch any events
+ }
}
}
}
diff --git a/src/main/java/baritone/cache/CacheFiles.java b/src/main/java/baritone/cache/CacheFiles.java
new file mode 100644
index 0000000000..1230a5c3f6
--- /dev/null
+++ b/src/main/java/baritone/cache/CacheFiles.java
@@ -0,0 +1,42 @@
+/*
+ * This file is part of Baritone.
+ *
+ * Baritone is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Baritone is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Baritone. If not, see .
+ */
+
+package baritone.cache;
+
+import java.io.IOException;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+
+final class CacheFiles {
+
+ private CacheFiles() {}
+
+ /**
+ * Replaces {@code target} with a fully written {@code source} in one step where the file system allows it, so a
+ * save that gets cut off (a crash, or the JVM exiting while a worker thread is still writing) leaves the previous
+ * file intact instead of a truncated one.
+ */
+ static void moveIntoPlace(Path source, Path target) throws IOException {
+ try {
+ Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException e) {
+ Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+}
diff --git a/src/main/java/baritone/cache/CachedChunk.java b/src/main/java/baritone/cache/CachedChunk.java
index c49394d76e..c2d0c53d6e 100644
--- a/src/main/java/baritone/cache/CachedChunk.java
+++ b/src/main/java/baritone/cache/CachedChunk.java
@@ -153,6 +153,13 @@ public static int size(int dimension_height) {
return 2 * 16 * 16 * dimension_height;
}
+ /**
+ * Rough heap footprint: the 2-bit block data plus the overview, height map and special-block index.
+ */
+ public int approximateHeapBytes() {
+ return sizeInBytes + 2_560 + (special == null ? 0 : special.size() * 48);
+ }
+
public static int sizeInBytes(int size) {
return size / 8;
}
diff --git a/src/main/java/baritone/cache/CachedRegion.java b/src/main/java/baritone/cache/CachedRegion.java
index 38b5490101..312a056048 100644
--- a/src/main/java/baritone/cache/CachedRegion.java
+++ b/src/main/java/baritone/cache/CachedRegion.java
@@ -27,9 +27,11 @@
import net.minecraft.world.level.dimension.DimensionType;
import java.io.*;
+import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
@@ -72,6 +74,16 @@ public final class CachedRegion implements ICachedRegion {
*/
private boolean hasUnsavedChanges;
+ /**
+ * Wall-clock time of the last read or write through CachedWorld; drives idle and LRU eviction
+ */
+ private volatile long lastAccessMs = System.currentTimeMillis();
+
+ /**
+ * Set under the region lock when CachedWorld drops this instance from RAM; a late write must go to the live instance
+ */
+ private boolean evicted;
+
CachedRegion(int x, int z, DimensionType dimension, ResourceKey dimensionId) {
this.x = x;
this.z = z;
@@ -111,9 +123,54 @@ public final ArrayList getLocationsOf(String block) {
return res;
}
- public final synchronized void updateCachedChunk(int chunkX, int chunkZ, CachedChunk chunk) {
+ /**
+ * @return false when this instance was evicted from RAM before the write; the caller must look the region up again
+ */
+ public final synchronized boolean updateCachedChunk(int chunkX, int chunkZ, CachedChunk chunk) {
+ if (evicted) {
+ return false;
+ }
this.chunks[chunkX][chunkZ] = chunk;
hasUnsavedChanges = true;
+ lastAccessMs = System.currentTimeMillis();
+ return true;
+ }
+
+ final void touch() {
+ lastAccessMs = System.currentTimeMillis();
+ }
+
+ final long getLastAccessMs() {
+ return lastAccessMs;
+ }
+
+ final synchronized boolean hasUnsavedChanges() {
+ return hasUnsavedChanges;
+ }
+
+ /**
+ * Marks the region evicted unless it still has unsaved changes (the packer may have written since the last save).
+ * {@code discardUnsaved} drops them anyway, for when nothing is able to save them.
+ */
+ final synchronized boolean tryEvict(boolean discardUnsaved) {
+ if (hasUnsavedChanges && !discardUnsaved) {
+ return false;
+ }
+ evicted = true;
+ return true;
+ }
+
+ final long approximateHeapBytes() {
+ long total = 0;
+ for (int x = 0; x < 32; x++) {
+ for (int z = 0; z < 32; z++) {
+ CachedChunk chunk = this.chunks[x][z];
+ if (chunk != null) {
+ total += chunk.approximateHeapBytes();
+ }
+ }
+ }
+ return total;
}
@@ -130,61 +187,74 @@ public synchronized final void save(String directory) {
}
System.out.println("Saving region " + x + "," + z + " to disk " + path);
Path regionFile = getRegionFile(path, this.x, this.z);
- if (!Files.exists(regionFile)) {
- Files.createFile(regionFile);
- }
- try (
- FileOutputStream fileOut = new FileOutputStream(regionFile.toFile());
- GZIPOutputStream gzipOut = new GZIPOutputStream(fileOut, 16384);
- DataOutputStream out = new DataOutputStream(gzipOut)
- ) {
- out.writeInt(CACHED_REGION_MAGIC);
- for (int x = 0; x < 32; x++) {
- for (int z = 0; z < 32; z++) {
- CachedChunk chunk = this.chunks[x][z];
- if (chunk == null) {
- out.write(CHUNK_NOT_PRESENT);
- } else {
- out.write(CHUNK_PRESENT);
- byte[] chunkBytes = chunk.toByteArray();
- out.write(chunkBytes);
- // Messy, but fills the empty 0s that should be trailing to fill up the space.
- out.write(new byte[chunk.sizeInBytes - chunkBytes.length]);
+ // Write to a temp file and atomically rename it over the real one. The nether pathfinder
+ // (and a concurrently running load) reads these files while we save; an in-place rewrite
+ // hands them a truncated gzip stream and they treat the region as permanently unparseable.
+ // The pid keeps multiple game instances sharing this cache dir off each other's temp file;
+ // concurrent saves of the same region then resolve to last-complete-writer-wins.
+ Path tmpFile = regionFile.resolveSibling(regionFile.getFileName() + "." + ProcessHandle.current().pid() + ".tmp");
+ try {
+ try (
+ FileOutputStream fileOut = new FileOutputStream(tmpFile.toFile());
+ GZIPOutputStream gzipOut = new GZIPOutputStream(fileOut, 16384);
+ DataOutputStream out = new DataOutputStream(gzipOut)
+ ) {
+ out.writeInt(CACHED_REGION_MAGIC);
+ for (int x = 0; x < 32; x++) {
+ for (int z = 0; z < 32; z++) {
+ CachedChunk chunk = this.chunks[x][z];
+ if (chunk == null) {
+ out.write(CHUNK_NOT_PRESENT);
+ } else {
+ out.write(CHUNK_PRESENT);
+ byte[] chunkBytes = chunk.toByteArray();
+ out.write(chunkBytes);
+ // Messy, but fills the empty 0s that should be trailing to fill up the space.
+ out.write(new byte[chunk.sizeInBytes - chunkBytes.length]);
+ }
}
}
- }
- for (int x = 0; x < 32; x++) {
- for (int z = 0; z < 32; z++) {
- if (chunks[x][z] != null) {
- for (int i = 0; i < 256; i++) {
- out.writeUTF(BlockUtils.blockToString(chunks[x][z].getOverview()[i].getBlock()));
+ for (int x = 0; x < 32; x++) {
+ for (int z = 0; z < 32; z++) {
+ if (chunks[x][z] != null) {
+ for (int i = 0; i < 256; i++) {
+ out.writeUTF(BlockUtils.blockToString(chunks[x][z].getOverview()[i].getBlock()));
+ }
}
}
}
- }
- for (int x = 0; x < 32; x++) {
- for (int z = 0; z < 32; z++) {
- if (chunks[x][z] != null) {
- Map> locs = chunks[x][z].getRelativeBlocks();
- out.writeShort(locs.entrySet().size());
- for (Map.Entry> entry : locs.entrySet()) {
- out.writeUTF(entry.getKey());
- out.writeShort(entry.getValue().size());
- for (BlockPos pos : entry.getValue()) {
- out.writeByte((byte) (pos.getZ() << 4 | pos.getX()));
- out.writeInt(pos.getY()-dimension.minY());
+ for (int x = 0; x < 32; x++) {
+ for (int z = 0; z < 32; z++) {
+ if (chunks[x][z] != null) {
+ Map> locs = chunks[x][z].getRelativeBlocks();
+ out.writeShort(locs.entrySet().size());
+ for (Map.Entry> entry : locs.entrySet()) {
+ out.writeUTF(entry.getKey());
+ out.writeShort(entry.getValue().size());
+ for (BlockPos pos : entry.getValue()) {
+ out.writeByte((byte) (pos.getZ() << 4 | pos.getX()));
+ out.writeInt(pos.getY()-dimension.minY());
+ }
}
}
}
}
- }
- for (int x = 0; x < 32; x++) {
- for (int z = 0; z < 32; z++) {
- if (chunks[x][z] != null) {
- out.writeLong(chunks[x][z].cacheTimestamp);
+ for (int x = 0; x < 32; x++) {
+ for (int z = 0; z < 32; z++) {
+ if (chunks[x][z] != null) {
+ out.writeLong(chunks[x][z].cacheTimestamp);
+ }
}
}
}
+ try {
+ Files.move(tmpFile, regionFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (AtomicMoveNotSupportedException ex) {
+ Files.move(tmpFile, regionFile, StandardCopyOption.REPLACE_EXISTING);
+ }
+ } finally {
+ // nothing to delete once the move succeeded; this is the failed-write path
+ Files.deleteIfExists(tmpFile);
}
hasUnsavedChanges = false;
System.out.println("Saved region successfully");
@@ -360,7 +430,7 @@ public final int getZ() {
return this.z;
}
- private static Path getRegionFile(Path cacheDir, int regionX, int regionZ) {
+ static Path getRegionFile(Path cacheDir, int regionX, int regionZ) {
return Paths.get(cacheDir.toString(), "r." + regionX + "." + regionZ + ".bcr");
}
}
diff --git a/src/main/java/baritone/cache/CachedWorld.java b/src/main/java/baritone/cache/CachedWorld.java
index 037cc35f43..9d829b4d21 100644
--- a/src/main/java/baritone/cache/CachedWorld.java
+++ b/src/main/java/baritone/cache/CachedWorld.java
@@ -18,14 +18,15 @@
package baritone.cache;
import baritone.Baritone;
-import baritone.api.BaritoneAPI;
-import baritone.api.IBaritone;
import baritone.api.cache.ICachedWorld;
-import baritone.api.cache.IWorldData;
import baritone.api.utils.Helper;
import com.google.common.cache.CacheBuilder;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
+import it.unimi.dsi.fastutil.longs.LongList;
+import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
+import it.unimi.dsi.fastutil.longs.LongSet;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.ChunkPos;
@@ -35,13 +36,27 @@
import java.io.IOException;
import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
/**
+ * The RAM tier of the chunk cache. The disk tier (one .bcr per region) keeps everything ever seen; RAM holds
+ * only regions in use: written by the packer, read by pathing or scanning. One shared daemon thread writes
+ * changed regions every {@code cachedRegionsSaveIntervalSeconds} and evicts idle or over-budget regions
+ * ({@link RegionRetentionPolicy}); a region is never evicted while it has unsaved changes that can still be
+ * saved. Pathing misses pull regions back from disk in the background.
+ *
* @author Brady
* @since 8/4/2018
*/
@@ -52,16 +67,41 @@ public final class CachedWorld implements ICachedWorld, Helper {
*/
private static final int REGION_MAX = 30_000_000 / 512 + 1;
+ private static final ChunkPos PACKER_STOP = new ChunkPos(Integer.MIN_VALUE, Integer.MIN_VALUE);
+
+ private static final long MAINTENANCE_TICK_SECONDS = 5;
+
+ private static final int KNOWN_ABSENT_MAX = 65_536;
+
+ private static final int DROPPED_PACKS_MAX = 65_536;
+
+ private static final ScheduledExecutorService MAINTENANCE = Executors.newSingleThreadScheduledExecutor(
+ new ThreadFactoryBuilder().setNameFormat("baritone-cache-maintenance").setDaemon(true).build());
+
+ private static final ExecutorService LOADER = Executors.newSingleThreadExecutor(
+ new ThreadFactoryBuilder().setNameFormat("baritone-cache-loader").setDaemon(true).build());
+
+ private static final Set ACTIVE = ConcurrentHashMap.newKeySet();
+
+ static {
+ MAINTENANCE.scheduleWithFixedDelay(CachedWorld::maintainAll, MAINTENANCE_TICK_SECONDS, MAINTENANCE_TICK_SECONDS, TimeUnit.SECONDS);
+ }
+
/**
* A map of all of the cached regions.
*/
- private Long2ObjectMap cachedRegions = new Long2ObjectOpenHashMap<>();
+ private final Long2ObjectMap cachedRegions = new Long2ObjectOpenHashMap<>();
/**
* The directory that the cached region files are saved to
*/
private final String directory;
+ /**
+ * False when the directory could not be created; every disk access is skipped instead of throwing per region
+ */
+ private final boolean diskAvailable;
+
/**
* Queue of positions to pack. Refers to the toPackMap, in that every element of this queue will be a
* key in that map.
@@ -74,36 +114,72 @@ public final class CachedWorld implements ICachedWorld, Helper {
*/
private final Map toPackMap = CacheBuilder.newBuilder().softValues().build().asMap();
+ /**
+ * Chunks whose latest pack was dropped (packer queue over chunkPackerQueueMaxSize, or the soft reference
+ * collected first), so whatever the cache holds for them predates that load.
+ */
+ private final Set droppedPacks = ConcurrentHashMap.newKeySet();
+
+ /**
+ * Region ids with a disk load queued on the loader thread. Guarded by this.
+ */
+ private final LongSet loadsInFlight = new LongOpenHashSet();
+
+ /**
+ * Region ids known to have no file on disk, so pathing misses stop hitting the disk. Guarded by this.
+ */
+ private final LongSet knownAbsent = new LongOpenHashSet();
+
private final DimensionType dimension;
private final ResourceKey dimensionId;
+ private volatile boolean active;
+
+ private long lastSaveNanos = System.nanoTime();
+
CachedWorld(Path directory, DimensionType dimension, ResourceKey dimensionId) {
- if (!Files.exists(directory)) {
- try {
- Files.createDirectories(directory);
- } catch (IOException ignored) {
- }
+ boolean available = true;
+ try {
+ Files.createDirectories(directory);
+ } catch (IOException | InvalidPathException e) {
+ available = false;
+ System.out.println("Baritone cache directory " + directory + " cannot be created (" + e + "); disk caching is disabled for this world");
}
+ this.diskAvailable = available;
this.directory = directory.toString();
this.dimension = dimension;
this.dimensionId = dimensionId;
System.out.println("Cached world directory: " + directory);
+ activate();
+ }
+
+ /**
+ * Starts the packer for this world and puts it under maintenance. Idempotent.
+ */
+ final void activate() {
+ synchronized (this) {
+ if (active) {
+ return;
+ }
+ active = true;
+ }
+ ACTIVE.add(this);
Baritone.getExecutor().execute(new PackerThread());
- Baritone.getExecutor().execute(() -> {
- try {
- Thread.sleep(30000);
- while (true) {
- // since a region only saves if it's been modified since its last save
- // saving every 10 minutes means that once it's time to exit
- // we'll only have a couple regions to save
- save();
- Thread.sleep(600000);
- }
- } catch (InterruptedException e) {
- e.printStackTrace();
+ }
+
+ /**
+ * Called when the game leaves this world: the packer drains what is queued, then saves and evicts every region.
+ */
+ final void close() {
+ synchronized (this) {
+ if (!active) {
+ return;
}
- });
+ active = false;
+ }
+ ACTIVE.remove(this);
+ toPackQueue.add(PACKER_STOP);
}
@Override
@@ -115,15 +191,28 @@ public final void queueForPacking(LevelChunk chunk) {
@Override
public final boolean isCached(int blockX, int blockZ) {
- CachedRegion region = getRegion(blockX >> 9, blockZ >> 9);
+ CachedRegion region = peekRegion(blockX >> 9, blockZ >> 9);
if (region == null) {
return false;
}
return region.isCached(blockX & 511, blockZ & 511);
}
+ /**
+ * Whether the cache holds what the current load of this chunk packed: it is cached, the pack queued when it
+ * loaded was not dropped, and no pack of it is still waiting. A copy from an earlier load or session does not
+ * count.
+ */
+ public final boolean holdsPackOfCurrentLoad(int chunkX, int chunkZ) {
+ ChunkPos pos = new ChunkPos(chunkX, chunkZ);
+ if (toPackMap.containsKey(pos) || droppedPacks.contains(pos)) {
+ return false;
+ }
+ return isCached(chunkX << 4, chunkZ << 4);
+ }
+
public final boolean regionLoaded(int blockX, int blockZ) {
- return getRegion(blockX >> 9, blockZ >> 9) != null;
+ return peekRegion(blockX >> 9, blockZ >> 9) != null;
}
@Override
@@ -158,82 +247,136 @@ public final ArrayList getLocationsOf(String block, int maximum, int c
}
private void updateCachedChunk(CachedChunk chunk) {
- CachedRegion region = getOrCreateRegion(chunk.x >> 5, chunk.z >> 5);
- region.updateCachedChunk(chunk.x & 31, chunk.z & 31, chunk);
+ while (true) {
+ CachedRegion region = getOrCreateRegion(chunk.x >> 5, chunk.z >> 5);
+ if (region.updateCachedChunk(chunk.x & 31, chunk.z & 31, chunk)) {
+ return;
+ }
+ // evicted between the lookup and the write; both sides hold the region lock, so retry on the live instance
+ }
}
@Override
public final void save() {
if (!Baritone.settings().chunkCaching.value) {
System.out.println("Not saving to disk; chunk caching is disabled.");
- allRegions().forEach(region -> {
- if (region != null) {
- region.removeExpired();
- }
- }); // even if we aren't saving to disk, still delete expired old chunks from RAM
- prune();
return;
}
- long start = System.nanoTime() / 1000000L;
- allRegions().parallelStream().forEach(region -> {
- if (region != null) {
- region.save(this.directory);
- }
- });
- long now = System.nanoTime() / 1000000L;
- System.out.println("World save took " + (now - start) + "ms");
- prune();
+ int saved = saveDirtyRegions();
+ if (saved == 0) {
+ System.out.println("World save: nothing changed");
+ }
}
/**
- * Delete regions that are too far from the player
+ * @return how many regions had changes and were written
*/
- private synchronized void prune() {
- if (!Baritone.settings().pruneRegionsFromRAM.value) {
- return;
+ private int saveDirtyRegions() {
+ if (!diskAvailable) {
+ return 0;
}
- BlockPos pruneCenter = guessPosition();
+ List dirty = new ArrayList<>();
for (CachedRegion region : allRegions()) {
- if (region == null) {
- continue;
+ if (region != null && region.hasUnsavedChanges()) {
+ dirty.add(region);
}
- int distX = ((region.getX() << 9) + 256) - pruneCenter.getX();
- int distZ = ((region.getZ() << 9) + 256) - pruneCenter.getZ();
- double dist = Math.sqrt(distX * distX + distZ * distZ);
- if (dist > 1024) {
- logDebug("Deleting cached region from ram");
- cachedRegions.remove(getRegionID(region.getX(), region.getZ()));
+ }
+ if (dirty.isEmpty()) {
+ return 0;
+ }
+ long start = System.nanoTime() / 1000000L;
+ dirty.parallelStream().forEach(region -> region.save(this.directory));
+ long now = System.nanoTime() / 1000000L;
+ System.out.println("World save of " + dirty.size() + " regions took " + (now - start) + "ms");
+ return dirty.size();
+ }
+
+ private static void maintainAll() {
+ for (CachedWorld world : ACTIVE) {
+ try {
+ world.maintain();
+ } catch (Throwable th) {
+ th.printStackTrace();
}
}
}
+ private void maintain() {
+ long now = System.nanoTime();
+ if (now - lastSaveNanos >= Baritone.settings().cachedRegionsSaveIntervalSeconds.value * 1_000_000_000L) {
+ lastSaveNanos = now;
+ if (Baritone.settings().chunkCaching.value) {
+ saveDirtyRegions();
+ } else {
+ allRegions().forEach(region -> {
+ if (region != null) {
+ region.removeExpired();
+ }
+ }); // even if we aren't saving to disk, still delete expired old chunks from RAM
+ }
+ }
+ evict(false);
+ }
+
/**
- * If we are still in this world and dimension, return player feet, otherwise return most recently modified chunk
+ * Runs on the packer thread after it drained the queue for a closed world.
*/
- private BlockPos guessPosition() {
- for (IBaritone ibaritone : BaritoneAPI.getProvider().getAllBaritones()) {
- IWorldData data = ibaritone.getWorldProvider().getCurrentWorld();
- if (data != null && data.getCachedWorld() == this && ibaritone.getPlayerContext().player() != null) {
- return ibaritone.getPlayerContext().playerFeet();
- }
+ private void saveAndEvictAll() {
+ if (Baritone.settings().chunkCaching.value) {
+ saveDirtyRegions();
}
- CachedChunk mostRecentlyModified = null;
- for (CachedRegion region : allRegions()) {
- if (region == null) {
- continue;
- }
- CachedChunk ch = region.mostRecentlyModified();
- if (ch == null) {
- continue;
+ if (active) {
+ // re-entered this world while the old packer was still draining; the live packer's regions stay
+ return;
+ }
+ evict(true);
+ }
+
+ /**
+ * Drops regions per {@link RegionRetentionPolicy}. A region that turned dirty since the policy looked at it
+ * refuses the eviction under its own lock and stays until the next save.
+ */
+ private void evict(boolean everything) {
+ if (!everything && !Baritone.settings().pruneRegionsFromRAM.value) {
+ return;
+ }
+ // with chunk caching off or no cache directory nothing ever saves a region, so its unsaved changes would
+ // pin it in RAM for good; drop them instead, as the old distance prune did
+ boolean saveable = diskAvailable && Baritone.settings().chunkCaching.value;
+ List regions = allRegions();
+ List candidates = new ArrayList<>(regions.size());
+ for (CachedRegion region : regions) {
+ if (region != null) {
+ candidates.add(new RegionRetentionPolicy.Candidate(
+ getRegionID(region.getX(), region.getZ()),
+ region.getLastAccessMs(),
+ saveable && region.hasUnsavedChanges(),
+ region.approximateHeapBytes()));
}
- if (mostRecentlyModified == null || mostRecentlyModified.cacheTimestamp < ch.cacheTimestamp) {
- mostRecentlyModified = ch;
+ }
+ LongList victims = RegionRetentionPolicy.select(
+ candidates,
+ Baritone.settings().cachedRegionsRamBudgetMB.value * 1024L * 1024L,
+ Baritone.settings().cachedRegionsIdleSeconds.value * 1000L,
+ System.currentTimeMillis(),
+ everything);
+ if (victims.isEmpty()) {
+ return;
+ }
+ int evicted = 0;
+ synchronized (this) {
+ for (int i = 0; i < victims.size(); i++) {
+ long id = victims.getLong(i);
+ CachedRegion region = cachedRegions.get(id);
+ if (region != null && region.tryEvict(!saveable)) {
+ cachedRegions.remove(id);
+ evicted++;
+ }
}
}
- if (mostRecentlyModified == null) {
- return new BlockPos(0, 0, 0);
+ if (evicted > 0) {
+ logDebug("Evicted " + evicted + " cached regions from RAM");
}
- return new BlockPos((mostRecentlyModified.x << 4) + 8, 0, (mostRecentlyModified.z << 4) + 8);
}
private synchronized List allRegions() {
@@ -252,25 +395,89 @@ public final void reloadAllFromDisk() {
System.out.println("World load took " + (now - start) + "ms");
}
- @Override
- public final synchronized CachedRegion getRegion(int regionX, int regionZ) {
+ private synchronized CachedRegion peekRegion(int regionX, int regionZ) {
return cachedRegions.get(getRegionID(regionX, regionZ));
}
+ /**
+ * The region if it is in RAM. A miss queues a background load from disk, so a path calculation that reaches
+ * terrain explored in an earlier session sees it on its next attempt.
+ */
+ @Override
+ public final CachedRegion getRegion(int regionX, int regionZ) {
+ long id = getRegionID(regionX, regionZ);
+ synchronized (this) {
+ CachedRegion region = cachedRegions.get(id);
+ if (region != null) {
+ region.touch();
+ return region;
+ }
+ if (!diskAvailable
+ || !Baritone.settings().cachedRegionsLoadOnDemand.value
+ || knownAbsent.contains(id)
+ || !loadsInFlight.add(id)) {
+ return null;
+ }
+ }
+ LOADER.execute(() -> loadFromDisk(regionX, regionZ));
+ return null;
+ }
+
+ private void loadFromDisk(int regionX, int regionZ) {
+ long id = getRegionID(regionX, regionZ);
+ try {
+ if (!Files.exists(CachedRegion.getRegionFile(Paths.get(directory), regionX, regionZ))) {
+ synchronized (this) {
+ if (knownAbsent.size() >= KNOWN_ABSENT_MAX) {
+ knownAbsent.clear();
+ }
+ knownAbsent.add(id);
+ }
+ return;
+ }
+ CachedRegion region = new CachedRegion(regionX, regionZ, dimension, dimensionId);
+ region.load(this.directory);
+ synchronized (this) {
+ cachedRegions.putIfAbsent(id, region);
+ }
+ } catch (Throwable th) {
+ th.printStackTrace();
+ } finally {
+ synchronized (this) {
+ loadsInFlight.remove(id);
+ }
+ }
+ }
+
/**
* Returns the region at the specified region coordinates. If a
- * region is not found, then a new one is created.
+ * region is not found, then a new one is created (loaded from disk when a file exists).
+ * The disk read happens outside the world lock so pathing lookups are not stalled by it.
*
* @param regionX The region X coordinate
* @param regionZ The region Z coordinate
* @return The region located at the specified coordinates
*/
- private synchronized CachedRegion getOrCreateRegion(int regionX, int regionZ) {
- return cachedRegions.computeIfAbsent(getRegionID(regionX, regionZ), id -> {
- CachedRegion newRegion = new CachedRegion(regionX, regionZ, dimension, dimensionId);
- newRegion.load(this.directory);
- return newRegion;
- });
+ private CachedRegion getOrCreateRegion(int regionX, int regionZ) {
+ long id = getRegionID(regionX, regionZ);
+ synchronized (this) {
+ CachedRegion region = cachedRegions.get(id);
+ if (region != null) {
+ region.touch();
+ return region;
+ }
+ }
+ CachedRegion fresh = new CachedRegion(regionX, regionZ, dimension, dimensionId);
+ if (diskAvailable) {
+ fresh.load(this.directory);
+ }
+ synchronized (this) {
+ CachedRegion existing = cachedRegions.putIfAbsent(id, fresh);
+ knownAbsent.remove(id);
+ CachedRegion region = existing != null ? existing : fresh;
+ region.touch();
+ return region;
+ }
}
public void tryLoadFromDisk(int regionX, int regionZ) {
@@ -304,18 +511,35 @@ private boolean isRegionInWorld(int regionX, int regionZ) {
return regionX <= REGION_MAX && regionX >= -REGION_MAX && regionZ <= REGION_MAX && regionZ >= -REGION_MAX;
}
+ private void markDropped(ChunkPos pos) {
+ if (droppedPacks.size() >= DROPPED_PACKS_MAX) {
+ droppedPacks.clear();
+ }
+ droppedPacks.add(pos);
+ }
+
private class PackerThread implements Runnable {
public void run() {
while (true) {
try {
ChunkPos pos = toPackQueue.take();
+ if (pos == PACKER_STOP) {
+ saveAndEvictAll();
+ return;
+ }
LevelChunk chunk = toPackMap.remove(pos);
+ if (chunk == null) {
+ markDropped(pos); // soft reference collected before we got to it
+ continue;
+ }
if (toPackQueue.size() > Baritone.settings().chunkPackerQueueMaxSize.value) {
+ markDropped(pos);
continue;
}
CachedChunk cached = ChunkPacker.pack(chunk);
CachedWorld.this.updateCachedChunk(cached);
+ droppedPacks.remove(pos);
//System.out.println("Processed chunk at " + chunk.x + "," + chunk.z);
} catch (InterruptedException e) {
e.printStackTrace();
diff --git a/src/main/java/baritone/cache/ChunkPacker.java b/src/main/java/baritone/cache/ChunkPacker.java
index 78bca4b7a8..b5a6de007e 100644
--- a/src/main/java/baritone/cache/ChunkPacker.java
+++ b/src/main/java/baritone/cache/ChunkPacker.java
@@ -53,7 +53,9 @@ public static CachedChunk pack(LevelChunk chunk) {
LevelChunkSection[] chunkInternalStorageArray = chunk.getSections();
for (int y0 = 0; y0 < height / 16; y0++) {
LevelChunkSection extendedblockstorage = chunkInternalStorageArray[y0];
- if (extendedblockstorage == null) {
+ // modern chunks never have null sections; an all-air section still packs to zeros, so skip the
+ // 4096 block lookups (most of an overworld column above the surface, the nether above the roof)
+ if (extendedblockstorage == null || extendedblockstorage.hasOnlyAir()) {
// any 16x16x16 area that's all air will have null storage
// for example, in an ocean biome, with air from y=64 to y=256
// the first 4 extended blocks storages will be full
diff --git a/src/main/java/baritone/cache/RegionRetentionPolicy.java b/src/main/java/baritone/cache/RegionRetentionPolicy.java
new file mode 100644
index 0000000000..440008173c
--- /dev/null
+++ b/src/main/java/baritone/cache/RegionRetentionPolicy.java
@@ -0,0 +1,74 @@
+/*
+ * This file is part of Baritone.
+ *
+ * Baritone is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Baritone is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Baritone. If not, see .
+ */
+
+package baritone.cache;
+
+import it.unimi.dsi.fastutil.longs.LongArrayList;
+import it.unimi.dsi.fastutil.longs.LongList;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Which cached regions to drop from RAM. Pure so it can be unit tested; CachedWorld applies the result under
+ * its locks and re-checks the dirty flag there.
+ *
+ * A dirty region is never selected. Clean regions idle for longer than {@code idleMs} are always selected.
+ * If the resident bytes still exceed the budget, the least recently used clean regions go next, except those
+ * touched within {@link #GRACE_MS}: the packer or a path calculation is about to use them again.
+ */
+final class RegionRetentionPolicy {
+
+ static final long GRACE_MS = 2_000;
+
+ record Candidate(long id, long lastAccessMs, boolean dirty, long bytes) {}
+
+ private RegionRetentionPolicy() {}
+
+ static LongList select(List regions, long budgetBytes, long idleMs, long nowMs, boolean everything) {
+ LongList victims = new LongArrayList();
+ long resident = 0;
+ for (Candidate c : regions) {
+ resident += c.bytes();
+ }
+ List clean = new ArrayList<>();
+ for (Candidate c : regions) {
+ if (c.dirty()) {
+ continue;
+ }
+ if (everything || nowMs - c.lastAccessMs() > idleMs) {
+ victims.add(c.id());
+ resident -= c.bytes();
+ } else {
+ clean.add(c);
+ }
+ }
+ if (resident <= budgetBytes) {
+ return victims;
+ }
+ clean.sort(Comparator.comparingLong(Candidate::lastAccessMs));
+ for (Candidate c : clean) {
+ if (resident <= budgetBytes || nowMs - c.lastAccessMs() < GRACE_MS) {
+ break;
+ }
+ victims.add(c.id());
+ resident -= c.bytes();
+ }
+ return victims;
+ }
+}
diff --git a/src/main/java/baritone/cache/WaypointCollection.java b/src/main/java/baritone/cache/WaypointCollection.java
index 222adfdf66..ae22aa8103 100644
--- a/src/main/java/baritone/cache/WaypointCollection.java
+++ b/src/main/java/baritone/cache/WaypointCollection.java
@@ -93,8 +93,9 @@ private synchronized void load(Waypoint.Tag tag) {
private synchronized void save(Waypoint.Tag tag) {
Path fileName = this.directory.resolve(tag.name().toLowerCase() + ".mp4");
+ Path tempFile = fileName.resolveSibling(fileName.getFileName() + ".tmp");
try (
- FileOutputStream fileOut = new FileOutputStream(fileName.toFile());
+ FileOutputStream fileOut = new FileOutputStream(tempFile.toFile());
BufferedOutputStream bufOut = new BufferedOutputStream(fileOut);
DataOutputStream out = new DataOutputStream(bufOut)
) {
@@ -107,6 +108,12 @@ private synchronized void save(Waypoint.Tag tag) {
out.writeInt(waypoint.getLocation().getY());
out.writeInt(waypoint.getLocation().getZ());
}
+ } catch (IOException ex) {
+ ex.printStackTrace();
+ return;
+ }
+ try {
+ CacheFiles.moveIntoPlace(tempFile, fileName);
} catch (IOException ex) {
ex.printStackTrace();
}
diff --git a/src/main/java/baritone/cache/WorldData.java b/src/main/java/baritone/cache/WorldData.java
index 5ea9739f92..95710431bb 100644
--- a/src/main/java/baritone/cache/WorldData.java
+++ b/src/main/java/baritone/cache/WorldData.java
@@ -17,7 +17,6 @@
package baritone.cache;
-import baritone.Baritone;
import baritone.api.cache.ICachedWorld;
import baritone.api.cache.IWaypointCollection;
import baritone.api.cache.IWorldData;
@@ -48,10 +47,7 @@ public class WorldData implements IWorldData {
}
public void onClose() {
- Baritone.getExecutor().execute(() -> {
- System.out.println("Started saving the world in a new thread");
- cache.save();
- });
+ cache.close();
}
@Override
diff --git a/src/main/java/baritone/cache/WorldProvider.java b/src/main/java/baritone/cache/WorldProvider.java
index 28b2d94f11..b5e67e20eb 100644
--- a/src/main/java/baritone/cache/WorldProvider.java
+++ b/src/main/java/baritone/cache/WorldProvider.java
@@ -25,11 +25,11 @@
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.storage.LevelResource;
-import org.apache.commons.lang3.SystemUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
@@ -94,6 +94,8 @@ public final void initWorld(Level world) {
synchronized (worldCache) {
this.currentWorld = worldCache.computeIfAbsent(worldDataDir, d -> new WorldData(d, world.dimensionType(), world.dimension()));
}
+ // a WorldData is reused across visits; its cache holds no regions and no threads while closed
+ this.currentWorld.cache.activate();
this.mcWorld = ctx.world();
});
}
@@ -147,11 +149,8 @@ private Optional> getSaveDirectories(Level world) {
mcWorld = ctx.world();
return Optional.empty();
}
- if (SystemUtils.IS_OS_WINDOWS) {
- folderName = folderName.replace(":", "_");
- }
// TODO: This should probably be in "baritone/servers"
- worldDir = baritone.getDirectory().resolve(folderName);
+ worldDir = baritone.getDirectory().resolve(serverFolderName(baritone.getDirectory(), folderName));
// Just write the readme to the baritone directory instead of each server save in it
readmeDir = baritone.getDirectory();
}
@@ -159,6 +158,39 @@ private Optional> getSaveDirectories(Level world) {
return Optional.of(new Pair<>(worldDir, readmeDir));
}
+ /**
+ * Windows, and NTFS mounted with windows_names, reject {@code : * ? " < > |} in a file name, so a direct-IP
+ * server ("host:port") could never get a cache directory there and every region save and load threw. Names
+ * are sanitised on every platform now (":" becomes "_", the same name Windows always produced). A directory
+ * under the raw name that already exists, written by an earlier version on Linux, keeps being used.
+ */
+ static String serverFolderName(Path baritoneDir, String rawName) {
+ String sanitised = sanitizeFileName(rawName);
+ if (!sanitised.equals(rawName)) {
+ try {
+ if (Files.isDirectory(baritoneDir.resolve(rawName))) {
+ return rawName;
+ }
+ } catch (InvalidPathException ignored) {
+ // the raw name is not a legal path on this platform, so nothing can exist under it
+ }
+ }
+ return sanitised;
+ }
+
+ static String sanitizeFileName(String name) {
+ StringBuilder out = new StringBuilder(name.length());
+ for (int i = 0; i < name.length(); i++) {
+ char c = name.charAt(i);
+ out.append(c < 0x20 || "\\/:*?\"<>|".indexOf(c) >= 0 ? '_' : c);
+ }
+ int end = out.length();
+ while (end > 0 && (out.charAt(end - 1) == '.' || out.charAt(end - 1) == ' ')) {
+ end--;
+ }
+ return end == 0 ? "_" : out.substring(0, end);
+ }
+
/**
* Why does this exist instead of fixing the event? Some mods break the event. Lol.
*/
diff --git a/src/main/java/baritone/command/ExampleBaritoneControl.java b/src/main/java/baritone/command/ExampleBaritoneControl.java
index 82f1cc57c3..12d9149f2d 100644
--- a/src/main/java/baritone/command/ExampleBaritoneControl.java
+++ b/src/main/java/baritone/command/ExampleBaritoneControl.java
@@ -34,13 +34,14 @@
import baritone.command.argument.ArgConsumer;
import baritone.command.argument.CommandArguments;
import baritone.command.manager.CommandManager;
+import com.mojang.blaze3d.Blaze3D;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.HoverEvent;
import net.minecraft.network.chat.MutableComponent;
-import net.minecraft.util.Util;
+import java.net.URI;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;
@@ -96,7 +97,7 @@ public boolean runCommand(String msg) {
return false;
} else if (msg.trim().equalsIgnoreCase("orderpizza")) {
try {
- Util.getPlatform().openUri("https://www.dominos.com/en/pages/order/");
+ Blaze3D.openUri(URI.create("https://www.dominos.com/en/pages/order/"));
} catch (Exception ignored) {}
return false;
}
diff --git a/src/main/java/baritone/command/defaults/FindCommand.java b/src/main/java/baritone/command/defaults/FindCommand.java
index 75d55e94e0..8d5142e926 100644
--- a/src/main/java/baritone/command/defaults/FindCommand.java
+++ b/src/main/java/baritone/command/defaults/FindCommand.java
@@ -60,7 +60,7 @@ public void execute(String label, IArgConsumer args) throws CommandException {
BuiltInRegistries.BLOCK.getKey(block).getPath(),
Integer.MAX_VALUE,
origin.x,
- origin.y,
+ origin.z,
4
).stream()
)
diff --git a/src/main/java/baritone/event/GameEventHandler.java b/src/main/java/baritone/event/GameEventHandler.java
index 8bbdb52d67..db42a599e1 100644
--- a/src/main/java/baritone/event/GameEventHandler.java
+++ b/src/main/java/baritone/event/GameEventHandler.java
@@ -25,6 +25,7 @@
import baritone.api.utils.Helper;
import baritone.api.utils.Pair;
import baritone.cache.CachedChunk;
+import baritone.cache.CachedWorld;
import baritone.cache.WorldProvider;
import baritone.utils.BlockStateInterface;
import net.minecraft.world.level.ChunkPos;
@@ -101,6 +102,14 @@ public void onChunkEvent(ChunkEvent event) {
if (event.isPostPopulate() || isPreUnload) {
baritone.getWorldProvider().ifWorldLoaded(worldData -> {
LevelChunk chunk = world.getChunk(event.getX(), event.getZ());
+ if (isPreUnload
+ && !Baritone.settings().repackUnchangedChunksOnUnload.value
+ && !chunk.isUnsaved()
+ && ((CachedWorld) worldData.getCachedWorld()).holdsPackOfCurrentLoad(event.getX(), event.getZ())) {
+ // packed on load and no block changed since (the client marks a chunk unsaved on every
+ // setBlockState): the cache already holds this exact chunk
+ return;
+ }
worldData.getCachedWorld().queueForPacking(chunk);
});
}
diff --git a/src/main/java/baritone/pathing/calc/AStarPathFinder.java b/src/main/java/baritone/pathing/calc/AStarPathFinder.java
index 1c58cbf20e..f2ae74ad13 100644
--- a/src/main/java/baritone/pathing/calc/AStarPathFinder.java
+++ b/src/main/java/baritone/pathing/calc/AStarPathFinder.java
@@ -112,7 +112,7 @@ protected Optional calculate0(long primaryTimeout, long failureTimeout) {
if (!moves.dynamicXZ && !worldBorder.entirelyContains(newX, newZ)) {
continue;
}
- if (currentNode.y + moves.yOffset > height || currentNode.y + moves.yOffset < minY) {
+ if (currentNode.y + moves.yOffset > minY + height || currentNode.y + moves.yOffset < minY) {
continue;
}
res.reset();
diff --git a/src/main/java/baritone/pathing/calc/AbstractNodeCostSearch.java b/src/main/java/baritone/pathing/calc/AbstractNodeCostSearch.java
index 0bfb6ac3f2..a0b97800ec 100644
--- a/src/main/java/baritone/pathing/calc/AbstractNodeCostSearch.java
+++ b/src/main/java/baritone/pathing/calc/AbstractNodeCostSearch.java
@@ -58,7 +58,7 @@ public abstract class AbstractNodeCostSearch implements IPathFinder, Helper {
private volatile boolean isFinished;
- protected boolean cancelRequested;
+ protected volatile boolean cancelRequested;
/**
* This is really complicated and hard to explain. I wrote a comment in the old version of MineBot but it was so
diff --git a/src/main/java/baritone/pathing/movement/MovementHelper.java b/src/main/java/baritone/pathing/movement/MovementHelper.java
index 51a23577ba..1c1a1865ed 100644
--- a/src/main/java/baritone/pathing/movement/MovementHelper.java
+++ b/src/main/java/baritone/pathing/movement/MovementHelper.java
@@ -216,7 +216,7 @@ static boolean canWalkThroughPosition(BlockStateInterface bsi, int x, int y, int
return false;
}
// Everything after this point has to be a special case as it relies on the water not being flowing, which means a special case is needed.
- if (Baritone.settings().assumeWalkOnWater.value) {
+ if (bsi.assumeWalkOnWater) {
return false;
}
@@ -224,7 +224,9 @@ static boolean canWalkThroughPosition(BlockStateInterface bsi, int x, int y, int
if (!up.getFluidState().isEmpty() || up.getBlock() instanceof LilyPadBlock) {
return false;
}
- return fluidState.getType() instanceof WaterFluid;
+ // a waterlogged block (fence, wall, pane, bars, stairs, leaves...) keeps its own collision,
+ // so it's only passable if the block itself is
+ return fluidState.getType() instanceof WaterFluid && state.isPathfindable(PathComputationType.LAND);
}
return state.isPathfindable(PathComputationType.LAND);
@@ -235,6 +237,11 @@ static Ternary fullyPassableBlockState(BlockState state) {
if (block instanceof AirBlock) { // early return for most common case
return YES;
}
+ // the player hitbox is the same width in the air as on the ground, so anything we can't walk
+ // through we can't jump through either (powder snow, honey, berry bushes, dripstone, blocksToAvoid...)
+ if (canWalkThroughBlockState(state) == NO) {
+ return NO;
+ }
// exceptions - blocks that are isPassable true, but we can't actually jump through
if (block instanceof BaseFireBlock
|| block == Blocks.TRIPWIRE
@@ -458,11 +465,11 @@ static boolean canWalkOnPosition(BlockStateInterface bsi, int x, int y, int z, B
}
if (MovementHelper.isFlowing(x, y, z, state, bsi) || upState.getFluidState().getType() == Fluids.FLOWING_WATER) {
// the only scenario in which we can walk on flowing water is if it's under still water with jesus off
- return isWater(upState) && !Baritone.settings().assumeWalkOnWater.value;
+ return isWater(upState) && !bsi.assumeWalkOnWater;
}
// if assumeWalkOnWater is on, we can only walk on water if there isn't water above it
// if assumeWalkOnWater is off, we can only walk on water if there is water above it
- return isWater(upState) ^ Baritone.settings().assumeWalkOnWater.value;
+ return isWater(upState) ^ bsi.assumeWalkOnWater;
}
if (MovementHelper.isLava(state) && !MovementHelper.isFlowing(x, y, z, state, bsi) && Baritone.settings().assumeWalkOnLava.value) { // if we get here it means that assumeWalkOnLava must be true, so put it last
diff --git a/src/main/java/baritone/pathing/movement/movements/MovementDescend.java b/src/main/java/baritone/pathing/movement/movements/MovementDescend.java
index 0052efed2c..afc4863c6a 100644
--- a/src/main/java/baritone/pathing/movement/movements/MovementDescend.java
+++ b/src/main/java/baritone/pathing/movement/movements/MovementDescend.java
@@ -157,6 +157,8 @@ public static boolean dynamicFallCost(CalculationContext context, int x, int y,
BlockState ontoBlock = context.get(destX, newY, destZ);
int unprotectedFallHeight = fallHeight - (y - effectiveStartHeight); // equal to fallHeight - y + effectiveFallHeight, which is equal to -newY + effectiveFallHeight, which is equal to effectiveFallHeight - newY
double tentativeCost = WALK_OFF_BLOCK_COST + FALL_N_BLOCKS_COST[unprotectedFallHeight] + frontBreak + costSoFar;
+ // landing on top of a block ends the fall at newY + 1, so we only actually fall one block less than we probed
+ double tentativeCostOntoBlock = WALK_OFF_BLOCK_COST + FALL_N_BLOCKS_COST[unprotectedFallHeight - 1] + frontBreak + costSoFar;
if (reachedMinimum && MovementHelper.isWater(ontoBlock)) {
if (!MovementHelper.canWalkThrough(context, destX, newY, destZ, ontoBlock)) {
return false;
@@ -208,14 +210,14 @@ public static boolean dynamicFallCost(CalculationContext context, int x, int y,
res.x = destX;
res.y = newY + 1;
res.z = destZ;
- res.cost = tentativeCost;
+ res.cost = tentativeCostOntoBlock;
return false;
}
if (reachedMinimum && context.hasWaterBucket && unprotectedFallHeight <= context.maxFallHeightBucket + 1) {
res.x = destX;
res.y = newY + 1;// this is the block we're falling onto, so dest is +1
res.z = destZ;
- res.cost = tentativeCost + context.placeBucketCost();
+ res.cost = tentativeCostOntoBlock + context.placeBucketCost();
return true;
} else {
return false;
diff --git a/src/main/java/baritone/pathing/movement/movements/MovementDiagonal.java b/src/main/java/baritone/pathing/movement/movements/MovementDiagonal.java
index 8be1047582..edc1d579bf 100644
--- a/src/main/java/baritone/pathing/movement/movements/MovementDiagonal.java
+++ b/src/main/java/baritone/pathing/movement/movements/MovementDiagonal.java
@@ -166,7 +166,7 @@ public static void cost(CalculationContext context, int x, int y, int z, int des
return;
}
BlockState cuttingOver2 = context.get(destX, y - 1, z);
- if ((!context.allowWalkOnMagmaBlocks && cuttingOver1.is(Blocks.MAGMA_BLOCK)) || MovementHelper.isLava(cuttingOver2)) {
+ if ((!context.allowWalkOnMagmaBlocks && cuttingOver2.is(Blocks.MAGMA_BLOCK)) || MovementHelper.isLava(cuttingOver2)) {
return;
}
boolean water = false;
diff --git a/src/main/java/baritone/pathing/path/PathExecutor.java b/src/main/java/baritone/pathing/path/PathExecutor.java
index 04afddfcd7..d76ec17b0d 100644
--- a/src/main/java/baritone/pathing/path/PathExecutor.java
+++ b/src/main/java/baritone/pathing/path/PathExecutor.java
@@ -27,7 +27,6 @@
import baritone.api.utils.input.Input;
import baritone.behavior.PathingBehavior;
import baritone.pathing.calc.AbstractNodeCostSearch;
-import baritone.pathing.movement.CalculationContext;
import baritone.pathing.movement.Movement;
import baritone.pathing.movement.MovementHelper;
import baritone.pathing.movement.movements.*;
@@ -347,7 +346,7 @@ private boolean shouldSprintNextTick() {
behavior.baritone.getInputOverrideHandler().setInputForceState(Input.SPRINT, false);
// first and foremost, if allowSprint is off, or if we don't have enough hunger, don't try and sprint
- if (!new CalculationContext(behavior.baritone, false).canSprint) {
+ if (!Baritone.settings().allowSprint.value || ctx.player().getFoodData().getFoodLevel() <= 6) { // same as CalculationContext#canSprint, without building a whole context every tick
return false;
}
IMovement current = path.movements().get(pathPosition);
diff --git a/src/main/java/baritone/process/BuilderProcess.java b/src/main/java/baritone/process/BuilderProcess.java
index c1f275001e..2053712a23 100644
--- a/src/main/java/baritone/process/BuilderProcess.java
+++ b/src/main/java/baritone/process/BuilderProcess.java
@@ -519,7 +519,7 @@ public int lengthZ() {
}
// build repeat time
layer = 0;
- origin = new BlockPos(origin).offset(repeat);
+ origin = new BlockPos(origin.getX(), origin.getY(), origin.getZ()).offset(repeat);
if (!Baritone.settings().buildRepeatSneaky.value) {
schematic.reset();
}
@@ -1037,7 +1037,7 @@ private static boolean sameBlockstate(BlockState first, BlockState second) {
return first.equals(second); // early return if no properties are being ignored
}
for (Property> prop : first.getProperties()) {
- if (first.getValue(prop) != second.getValue(prop)
+ if (!Objects.equals(first.getValue(prop), second.getValue(prop))
&& !(ignoreDirection && ORIENTATION_PROPS.contains(prop))
&& !ignoredProps.contains(prop.getName())) {
return false;
diff --git a/src/main/java/baritone/process/ElytraProcess.java b/src/main/java/baritone/process/ElytraProcess.java
index 7ee911cded..d490c2bd3f 100644
--- a/src/main/java/baritone/process/ElytraProcess.java
+++ b/src/main/java/baritone/process/ElytraProcess.java
@@ -37,6 +37,7 @@
import baritone.api.utils.RotationUtils;
import baritone.api.utils.input.Input;
import baritone.pathing.movement.CalculationContext;
+import baritone.pathing.movement.MovementHelper;
import baritone.pathing.movement.movements.MovementFall;
import baritone.process.elytra.*;
import baritone.utils.BaritoneProcessHelper;
@@ -47,6 +48,7 @@
import net.minecraft.core.BlockPos;
import net.minecraft.core.NonNullList;
import net.minecraft.network.chat.Component;
+import net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
@@ -62,8 +64,10 @@
import net.minecraft.world.phys.Vec3;
import java.util.*;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import static baritone.api.pathing.movement.ActionCosts.COST_INF;
@@ -80,10 +84,70 @@ public class ElytraProcess extends BaritoneProcessHelper implements IBaritonePro
private boolean allowAboveBuildLimit;
private boolean allowAboveRoof;
private final Semaphore npfSema = new Semaphore(1);
+ private CompletableFuture npfDestroyDone = CompletableFuture.completedFuture(null);
+ /**
+ * Bumped by every {@link #onLostControl}; a deferred engage queued while the old context was
+ * tearing down only fires if nothing else (a cancel, another goal) happened in the meantime.
+ */
+ private int engageEpoch;
+ /**
+ * Destination of an engage that is waiting on {@link #npfDestroyDone} for the previous context's
+ * teardown (see {@link #pathTo0}); null when none is. Keeps the process active and its destination
+ * visible in the meantime, so the pathing manager keeps ticking us and callers polling for the goal
+ * don't re-issue it. Cleared wherever {@link #engageEpoch} is bumped, which is what drops the deferral.
+ */
+ private BlockPos deferredDestination;
+ // incremented from the pathfinder's worker threads, reset from wherever a path completes
+ private final AtomicInteger consecutivePathCalcFailures = new AtomicInteger();
+ private static final int PATH_CALC_FAILURES_BEFORE_RESET = 3;
private static final int SHORT_LANDING_COLUMN_HEIGHT = 15;
private static final int LONG_LANDING_COLUMN_HEIGHT = 39;
private static final long LANDING_SEARCH_BUDGET_NANOS = TimeUnit.MILLISECONDS.toNanos(25); // half a tick
+ /**
+ * The shortest drop {@link WalkOffCalculationContext} offers, and therefore the least we have to ask to descend
+ * for the walk off path to be forced to contain a {@link MovementFall} instead of a flight of stairs.
+ */
+ private static final int TAKEOFF_MIN_FALL_HEIGHT = 8;
+ /**
+ * How long to sit in a takeoff state with neither a path nor a calculation running before giving up. Both of
+ * those are dead ends: nothing in this process starts another calculation, so we would stand still forever.
+ */
+ private static final int TAKEOFF_STALL_TICKS = 60;
+ /**
+ * How long a standing takeoff gets to leave the ground and open the elytra before it is written off as a dud
+ * (a ceiling we didn't account for, knockback, a cobweb, ...).
+ */
+ private static final int STANDING_TAKEOFF_TIMEOUT_TICKS = 40;
+ /**
+ * How many standing takeoffs to attempt before giving up. Each one costs a firework, so if this many in a row
+ * have put us straight back on the ground then something about this spot is wrong and retrying just burns
+ * rockets.
+ */
+ private static final int MAX_STANDING_TAKEOFFS = 3;
+ /**
+ * How many ledges to walk towards before concluding that walking off one is never going to happen.
+ */
+ private static final int MAX_WALK_OFF_ATTEMPTS = 2;
+ /**
+ * How far above our feet to look for the first clear 4x4x4 cube when taking off from where we stand. A
+ * duration-1 rocket lifts a stationary player around 25 blocks straight up before it dies, so a cube any
+ * further up is out of reach of a vertical takeoff anyway.
+ */
+ private static final int TAKEOFF_MAX_EXIT_HEIGHT = 32;
+ private int takeoffStallTicks;
+ private int standingTakeoffs;
+ private int walkOffAttempts;
+ private int takeoffAirborneTicks;
+ private boolean walkOffImpossible;
+ private boolean takeoffBoostPending;
+ /**
+ * Ticks since a takeoff opened the elytra, or {@code -1} outside a takeoff. Only the server can shut an
+ * elytra again, so one that is shut while we are still in the air this soon after opening it is a takeoff
+ * the server refused, which is worth telling the user apart from one that merely didn't get anywhere.
+ */
+ private int takeoffOpenedTicksAgo = -1;
+ private boolean lavaPathRequested;
private int landingColumnHeight = SHORT_LANDING_COLUMN_HEIGHT;
private Set badLandingSpots = new HashSet<>();
private LandingSearchState landingSearchState;
@@ -94,15 +158,31 @@ public void onLostControl() {
}
public void onLostControl(boolean destroyNpf) {
+ // Path calculation whenComplete chains land here from the pathfinder's worker threads on
+ // failure; everything below (behavior handoff, semaphore, epoch) is render-thread state.
+ if (!ctx.minecraft().isSameThread()) {
+ ctx.minecraft().execute(() -> this.onLostControl(destroyNpf));
+ return;
+ }
+ this.engageEpoch++;
+ this.deferredDestination = null;
this.state = State.START_FLYING; // TODO: null state?
this.goingToLandingSpot = false;
this.landingSpot = null;
this.landingSearchState = null;
this.reachedGoal = false;
this.goal = null;
- destroyBehaviorAsync();
+ this.takeoffStallTicks = 0;
+ this.standingTakeoffs = 0;
+ this.walkOffAttempts = 0;
+ this.takeoffAirborneTicks = 0;
+ this.walkOffImpossible = false;
+ this.takeoffBoostPending = false;
+ this.takeoffOpenedTicksAgo = -1;
+ this.lavaPathRequested = false;
+ CompletableFuture behaviorGone = destroyBehaviorAsync();
if (destroyNpf) {
- destroyNpfContextAsync();
+ destroyNpfContextAsync(behaviorGone);
}
}
@@ -111,6 +191,41 @@ private ElytraProcess(Baritone baritone) {
baritone.getGameEventHandler().registerEventListener(this);
}
+ public void onPathCalculationSuccess() {
+ this.consecutivePathCalcFailures.set(0);
+ }
+
+ /**
+ * A pathfinder context that hit a transient failure while lazily parsing a cached region file
+ * never retries that region, so once one path calculation fails they usually all do. Persisting
+ * the context across new goals then turns any external re-command loop into an endless failure
+ * spam. After a few consecutive failures, throw the context away so the next engage starts clean.
+ */
+ public void onPathCalculationFailure() {
+ if (this.consecutivePathCalcFailures.incrementAndGet() < PATH_CALC_FAILURES_BEFORE_RESET) {
+ return;
+ }
+ if (ctx.player() != null && ctx.player().isFallFlying()) {
+ // resetting mid-air would disengage the elytra outright; if the failures persist,
+ // the grounded retries after landing will trip this
+ return;
+ }
+ this.consecutivePathCalcFailures.set(0);
+ logDirect("Path calculation failed " + PATH_CALC_FAILURES_BEFORE_RESET + " times in a row, discarding the pathfinder's chunk cache");
+ this.onLostControl();
+ }
+
+ /**
+ * Engaged, but without a behavior: {@link #pathTo0} queued its creation behind the previous pathfinder
+ * context's teardown, either because a setting change just reset us mid-tick or because we were
+ * re-engaged before that teardown was done. Hold control until the deferred engage lands instead of
+ * ticking a behavior we don't have, and let go of whatever keys the old one left forced.
+ */
+ private PathingCommand holdForDeferredEngage() {
+ baritone.getInputOverrideHandler().clearAllKeys();
+ return new PathingCommand(null, PathingCommandType.REQUEST_PAUSE);
+ }
+
public static IElytraProcess create(final Baritone baritone) {
return NetherPathfinderContext.isSupported()
? new ElytraProcess(baritone)
@@ -119,7 +234,7 @@ public static IElytraProcess create(final Baritone baritone) {
@Override
public boolean isActive() {
- return this.behavior != null;
+ return this.behavior != null || this.deferredDestination != null;
}
@Override
@@ -132,10 +247,14 @@ public void resetState() {
}
}
- private static final String AUTO_JUMP_FAILURE_MSG = "Failed to compute a walking path to a spot to jump off from. Consider starting from a higher location, near an overhang. Or, you can disable elytraAutoJump and just manually begin gliding.";
+ private static final String TAKEOFF_ADVICE_MSG = "Consider starting from a higher location, near an overhang. Or, you can disable elytraAutoJump and just manually begin gliding.";
+ private static final String AUTO_JUMP_FAILURE_MSG = "Failed to compute a walking path to a spot to jump off from. " + TAKEOFF_ADVICE_MSG;
@Override
public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
+ if (this.behavior == null) {
+ return holdForDeferredEngage();
+ }
try {
final long seedSetting = Baritone.settings().elytraNetherSeed.value;
if (seedSetting != this.behavior.npfContext.getSeed()) {
@@ -166,17 +285,31 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
logDirect(e.getMessage(), ChatFormatting.RED);
return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
}
+ if (this.behavior == null) {
+ // one of the resets above tore the context down and had to defer the re-engage
+ return holdForDeferredEngage();
+ }
this.behavior.onTick();
+ if (!ctx.player().isFallFlying() && ctx.player().isInLava()) {
+ return lavaTakeoff();
+ }
+
if (calcFailed) {
+ if (this.state == State.LOCATE_JUMP || this.state == State.GET_TO_JUMP) {
+ return standingTakeoff();
+ }
onLostControl();
logDirect(AUTO_JUMP_FAILURE_MSG);
return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
}
+ // No landing business while in lava. The solver is busy climbing out, and every path the landing search
+ // asks for from in here starts inside the pool and fails at once, which used to re-issue one every tick.
+ final boolean inLava = ctx.player().isInLava();
boolean safetyLanding = false;
- if (ctx.player().isFallFlying() && shouldLandForSafety()) {
+ if (ctx.player().isFallFlying() && !inLava && shouldLandForSafety()) {
if (Baritone.settings().elytraAllowEmergencyLand.value) {
logDirect("Emergency landing - almost out of elytra durability or fireworks");
safetyLanding = true;
@@ -184,7 +317,7 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
logDirect("almost out of elytra durability or fireworks, but I'm going to continue since elytraAllowEmergencyLand is false");
}
}
- if (ctx.player().isFallFlying() && this.state != State.LANDING && (this.behavior.pathManager.isComplete() || safetyLanding)) {
+ if (ctx.player().isFallFlying() && !inLava && this.state != State.LANDING && (this.behavior.pathManager.isComplete() || safetyLanding)) {
final BetterBlockPos last = this.behavior.pathManager.path.getLast();
if (last != null && (last.distToCenterSqr(ctx.player().position()) < (48 * 48) || safetyLanding) && (!goingToLandingSpot || (safetyLanding && this.landingSpot == null))) {
if (this.landingSearchState == null) {
@@ -240,6 +373,16 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
}
if (ctx.player().isFallFlying()) {
+ if (this.state == State.TAKEOFF_JUMP) {
+ // the elytra opened without us having asked for it, pick up from wherever that leaves us
+ this.state = State.START_FLYING;
+ this.takeoffStallTicks = 0;
+ this.takeoffBoostPending = true;
+ this.takeoffOpenedTicksAgo = 0;
+ }
+ if (this.takeoffOpenedTicksAgo >= 0) {
+ this.takeoffOpenedTicksAgo++;
+ }
behavior.landingMode = this.state == State.LANDING;
this.goal = null;
baritone.getInputOverrideHandler().clearAllKeys();
@@ -250,6 +393,12 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
this.behavior.npfContext.releaseReadLock();
}
}
+ if (this.takeoffBoostPending) {
+ // a takeoff boost that couldn't be used the moment the elytra opened. after behavior.tick() so
+ // that it doesn't fight the boost bookkeeping if the solver already decided to use one
+ this.takeoffBoostPending = false;
+ this.behavior.useFireworkForTakeoff();
+ }
return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
} else if (this.state == State.LANDING) {
if (ctx.playerMotion().multiply(1, 0, 1).length() > 0.001) {
@@ -263,10 +412,22 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
return new PathingCommand(null, PathingCommandType.REQUEST_PAUSE);
}
+ if (this.takeoffOpenedTicksAgo >= 0) {
+ // we opened the elytra and it is shut again: on the ground that is a takeoff that didn't get
+ // anywhere, in the air it is the server refusing the glide, and the takeoff rocket with it
+ if (!ctx.player().onGround()) {
+ logDirect("The server closed the elytra " + this.takeoffOpenedTicksAgo + " ticks after takeoff.");
+ }
+ this.takeoffOpenedTicksAgo = -1;
+ }
+
if (this.state == State.FLYING || this.state == State.START_FLYING) {
- this.state = ctx.player().onGround() && Baritone.settings().elytraAutoJump.value
- ? State.LOCATE_JUMP
- : State.START_FLYING;
+ if (ctx.player().onGround() && Baritone.settings().elytraAutoJump.value) {
+ this.state = State.LOCATE_JUMP;
+ this.takeoffStallTicks = 0;
+ } else {
+ this.state = State.START_FLYING;
+ }
}
if (this.state == State.LOCATE_JUMP) {
@@ -275,11 +436,24 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
onLostControl();
return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
}
+ if (this.walkOffImpossible) {
+ // there is nothing to walk off of around here, and we already spent a couple of seconds of
+ // pathfinding finding that out once
+ return standingTakeoff();
+ }
if (this.goal == null) {
- this.goal = new GoalYLevel(31);
+ this.goal = takeoffGoal();
}
- final IPathExecutor executor = baritone.getPathingBehavior().getCurrent();
- if (executor != null && executor.getPath().getGoal() == this.goal) {
+ IPathExecutor executor = baritone.getPathingBehavior().getCurrent();
+ if (executor != null && executor.getPath().getGoal() != this.goal) {
+ // a segment left over from whatever we were doing before takeoff. it can never finish while we're
+ // pausing pathing, and while it exists secretInternalSetGoalAndPath refuses to start the walk off
+ // calculation, so neither side of this ever moves unless we drop it here
+ baritone.getPathingBehavior().secretInternalSegmentCancel();
+ executor = null;
+ }
+ if (executor != null) {
+ this.takeoffStallTicks = 0;
final IMovement fall = executor.getPath().movements().stream()
.filter(movement -> movement instanceof MovementFall)
.findFirst().orElse(null);
@@ -299,10 +473,14 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
});
this.state = State.PAUSE;
} else {
- onLostControl();
- logDirect(AUTO_JUMP_FAILURE_MSG);
- return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
+ return standingTakeoff();
}
+ } else if (baritone.getPathingBehavior().getInProgress().isPresent()) {
+ this.takeoffStallTicks = 0;
+ } else if (++this.takeoffStallTicks > TAKEOFF_STALL_TICKS) {
+ // no path and nothing being calculated: SET_GOAL_AND_PAUSE has already decided it has nothing to
+ // do, so the executor we're waiting on is never going to appear
+ return standingTakeoff();
}
return new PathingCommandContext(this.goal, PathingCommandType.SET_GOAL_AND_PAUSE, new WalkOffCalculationContext(baritone));
}
@@ -312,6 +490,30 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
return new PathingCommand(null, PathingCommandType.REQUEST_PAUSE);
}
+ if (this.state == State.TAKEOFF_JUMP) {
+ if (++this.takeoffStallTicks > STANDING_TAKEOFF_TIMEOUT_TICKS) {
+ return abortTakeoff(ctx.player().onGround()
+ ? "Couldn't get off the ground. "
+ : "Got into the air but the elytra wouldn't open. ");
+ }
+ baritone.getInputOverrideHandler().clearAllKeys();
+ if (ctx.player().onGround()) {
+ // an ordinary jump. vanilla physics, and the movement packets it produces are what the server
+ // checks its own idea of onGround against before it will accept the request below
+ this.takeoffAirborneTicks = 0;
+ baritone.getInputOverrideHandler().setInputForceState(Input.JUMP, true);
+ } else if (this.takeoffAirborneTicks++ > 0) {
+ // the second press of the double tap. openElytra() is what actually opens it, but the client
+ // reports every change of the key to the server (ServerboundPlayerInputPacket), so the inputs it
+ // sees are the ones a player who opens an elytra sends: press, release, press
+ baritone.getInputOverrideHandler().setInputForceState(Input.JUMP, true);
+ if (openElytra()) {
+ this.state = State.START_FLYING;
+ }
+ }
+ return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
+ }
+
if (this.state == State.GET_TO_JUMP) {
final IPathExecutor executor = baritone.getPathingBehavior().getCurrent();
// TODO 1.21.5: replace `ctx.player().getDeltaMovement().y < -0.377` with `ctx.player().fallDistance > 1.0f`
@@ -321,8 +523,23 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
&& executor.getPath().movements().get(executor.getPosition()) instanceof MovementFall;
if (canStartFlying) {
+ this.takeoffStallTicks = 0;
this.state = State.START_FLYING;
} else {
+ if (executor != null || baritone.getPathingBehavior().getInProgress().isPresent()) {
+ this.takeoffStallTicks = 0;
+ } else if (++this.takeoffStallTicks > TAKEOFF_STALL_TICKS) {
+ // the walk off path ran out without us ever falling off it, either because we reached the goal
+ // by stepping down or because the segment got cancelled. PathingBehavior won't re-path once
+ // it considers the goal met, so go pick a new ledge instead of standing here
+ if (++this.walkOffAttempts >= MAX_WALK_OFF_ATTEMPTS) {
+ return standingTakeoff();
+ }
+ this.takeoffStallTicks = 0;
+ this.goal = null;
+ this.state = State.LOCATE_JUMP;
+ return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
+ }
return new PathingCommand(null, PathingCommandType.SET_GOAL_AND_PATH);
}
}
@@ -341,6 +558,190 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
}
+ /**
+ * Takes off from where we are standing: jump, open the elytra on the way up, and light a firework the moment
+ * it opens, since a glide that starts with no speed a block and a half off the floor only ends one way. This
+ * is what flat terrain leaves us with, where {@link WalkOffCalculationContext} has no ledge to offer and the
+ * alternative is standing around telling the user to go find a cliff.
+ */
+ private PathingCommand standingTakeoff() {
+ this.walkOffImpossible = true;
+ this.goal = null;
+ this.takeoffStallTicks = 0;
+ this.takeoffAirborneTicks = 0;
+ baritone.getPathingBehavior().secretInternalSegmentCancel();
+
+ if (!Baritone.settings().elytraStandingTakeoff.value) {
+ return abortTakeoff("There is no spot to jump off from, and elytraStandingTakeoff is off. ");
+ }
+ if (this.standingTakeoffs >= MAX_STANDING_TAKEOFFS) {
+ return abortTakeoff("Took off from here " + this.standingTakeoffs + " times and ended up back on the ground every time. ");
+ }
+ final BetterBlockPos feet = ctx.playerFeet();
+ if (!MovementHelper.fullyPassable(ctx, feet.above(2)) || !MovementHelper.fullyPassable(ctx, feet.above(3))) {
+ return abortTakeoff("There is no spot to jump off from, and not enough room above me to jump and open the elytra. ");
+ }
+ if (!this.behavior.selectFirework()) {
+ return abortTakeoff("There is no spot to jump off from, and no fireworks in my hotbar to take off from here with. ");
+ }
+ if (this.standingTakeoffs++ == 0) {
+ logDirect("No spot to jump off from, taking off from here instead.");
+ }
+ // The path starts from the first clear cube straight above us, which is where a climb on the takeoff
+ // rocket comes out. In a basalt delta crevice or the hollow beside a lava pond the walls are a couple of
+ // blocks away on every side, and up is the one direction that is reliably clear. Left to itself the
+ // pathfinder would start from the nearest clear cube to our feet, and its search for one ignores walls:
+ // from a hole that is very often a cube on the far side of one, which nothing here could ever reach.
+ final BetterBlockPos exit = takeoffExit(feet);
+ // the elytra path has to exist before we leave the ground: the behavior does nothing without one, and the
+ // handful of ticks between opening the elytra and hitting the ground is no time to compute one
+ this.state = State.PAUSE;
+ this.behavior.pathManager.pathToDestination(exit != null ? exit : feet).whenComplete((result, ex) -> {
+ if (ex == null) {
+ this.state = State.TAKEOFF_JUMP;
+ return;
+ }
+ onLostControl();
+ });
+ return new PathingCommand(null, PathingCommandType.REQUEST_PAUSE);
+ }
+
+ /**
+ * We are in lava and the elytra is not open. Whatever the ground states were doing can wait: an elytra opens
+ * in lava (vanilla only refuses in water), and a rocket lit looking straight up is what gets us out. Swimming
+ * is far too slow, and since vanilla moves us by the fluid rules while we are in one, glide or not, the
+ * rocket is the only thrust there is and keeps a fraction of its usual push. Pointing it at the pool's wall
+ * would spend all of that on basalt, so once the elytra is open {@link ElytraBehavior} aims straight up and
+ * keeps lighting rockets until we are out.
+ */
+ private PathingCommand lavaTakeoff() {
+ baritone.getPathingBehavior().secretInternalSegmentCancel();
+ baritone.getInputOverrideHandler().clearAllKeys();
+ // swimming up: slow, but it also lifts us off the pool floor, without which the elytra can't open
+ baritone.getInputOverrideHandler().setInputForceState(Input.JUMP, true);
+ if (this.behavior.pathManager.getPath().isEmpty() && !this.lavaPathRequested) {
+ // nothing to fly along once we're out; the takeoff states would have computed this
+ this.lavaPathRequested = true;
+ final BetterBlockPos feet = ctx.playerFeet();
+ final BetterBlockPos exit = takeoffExit(feet);
+ this.behavior.pathManager.pathToDestination(exit != null ? exit : feet);
+ }
+ if (!ctx.player().onGround() && openElytra()) {
+ this.state = State.START_FLYING;
+ this.takeoffStallTicks = 0;
+ }
+ return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
+ }
+
+ /**
+ * The centre of the first 4x4x4 cube of air straight above {@code feet}, aligned the way the elytra
+ * pathfinder aligns its nodes, or {@code null} if there is none within {@link #TAKEOFF_MAX_EXIT_HEIGHT}.
+ */
+ private BetterBlockPos takeoffExit(BetterBlockPos feet) {
+ final int minY = ctx.world().dimensionType().minY();
+ final int top = minY + this.behavior.npfContext.getMaxHeight();
+ final int limit = Math.min(feet.y + TAKEOFF_MAX_EXIT_HEIGHT, top - 4);
+ final int ox = feet.x & ~3;
+ final int oz = feet.z & ~3;
+ final BlockPos.MutableBlockPos mut = new BlockPos.MutableBlockPos();
+ cubes:
+ for (int oy = feet.y & ~3; oy <= limit; oy += 4) {
+ for (int x = ox; x < ox + 4; x++) {
+ for (int y = oy; y < oy + 4; y++) {
+ for (int z = oz; z < oz + 4; z++) {
+ if (!ctx.world().getBlockState(mut.set(x, y, z)).isAir()) {
+ continue cubes;
+ }
+ }
+ }
+ }
+ return new BetterBlockPos(ox + 2, oy + 2, oz + 2);
+ }
+ return null;
+ }
+
+ /**
+ * Opens the elytra mid jump, exactly the way {@link net.minecraft.client.player.LocalPlayer} does it when a
+ * player double taps jump: set the flag locally, then tell the server. Doing it here instead of by toggling
+ * the jump key means it doesn't hang on the key going up and back down on precisely the right ticks, and it
+ * is the same thing on the wire either way - the server has already been sent the movement packets from an
+ * ordinary jump, so it agrees we are off the ground by the time the request reaches it.
+ *
+ * @return {@code true} if the elytra is now open
+ */
+ private boolean openElytra() {
+ if (!ctx.player().tryToStartFallFlying()) {
+ return false;
+ }
+ ctx.player().connection.send(new ServerboundPlayerCommandPacket(ctx.player(), ServerboundPlayerCommandPacket.Action.START_FALL_FLYING));
+ // The rocket goes next tick, once the solver has aimed. A vanilla client cannot use an item in the tick
+ // it starts gliding (its use packets go out before its glide packet, while the elytra is still shut),
+ // and the use packet carries a look that has to match that tick's movement packet, which the solver
+ // has not decided yet. The tick costs nothing: the rocket takes a round trip to show up regardless.
+ this.takeoffBoostPending = true;
+ this.takeoffOpenedTicksAgo = 0;
+ return true;
+ }
+
+ private PathingCommand abortTakeoff(String reason) {
+ onLostControl();
+ logDirect(reason + TAKEOFF_ADVICE_MSG);
+ return new PathingCommand(null, PathingCommandType.CANCEL_AND_SET_GOAL);
+ }
+
+ /**
+ * Where to walk to in order to take off.
+ *
+ * {@link WalkOffCalculationContext} only offers single steps and falls of at least
+ * {@link #TAKEOFF_MIN_FALL_HEIGHT} as ways down, so asking to descend at least that far is what forces the
+ * path to contain a {@link MovementFall} at all. Aiming deeper than that is what makes the biggest reachable
+ * drop the cheapest route: a long fall costs a handful of ticks where the same descent by single steps costs
+ * roughly 8 ticks a block, so the pathfinder will happily walk a long way to an overhang. In the nether the
+ * lava ocean at y=31 is as deep as it is worth aiming, since anything that reaches it is already more runway
+ * than we need.
+ *
+ * The level is always strictly below our feet. A goal we are already standing in is never pathed to at all
+ * ({@link baritone.behavior.PathingBehavior#secretInternalSetGoalAndPath}), which used to leave the process
+ * paused forever waiting on an executor that nothing was calculating - most easily hit by landing at exactly
+ * y=31 in the nether, where the old fixed {@code GoalYLevel(31)} was already satisfied.
+ */
+ private Goal takeoffGoal() {
+ final int feetY = ctx.playerFeet().y;
+ final int minY = ctx.world().dimensionType().minY();
+ final int deepest = ctx.world().dimension() == Level.NETHER ? minY + 31 : minY;
+ final int level = Math.min(feetY - 1, Math.max(minY, Math.min(deepest, feetY - TAKEOFF_MIN_FALL_HEIGHT)));
+ return new GoalDescendTo(level);
+ }
+
+ /**
+ * {@link GoalYLevel} is an exact-level goal: it is satisfied only by standing at the level, and asks to climb
+ * back up when below it. For a takeoff all that matters is getting down, so anything at or below the level
+ * will do.
+ */
+ private static final class GoalDescendTo implements Goal {
+
+ private final int level;
+
+ private GoalDescendTo(int level) {
+ this.level = level;
+ }
+
+ @Override
+ public boolean isInGoal(int x, int y, int z) {
+ return y <= this.level;
+ }
+
+ @Override
+ public double heuristic(int x, int y, int z) {
+ return y > this.level ? GoalYLevel.calculate(this.level, y) : 0;
+ }
+
+ @Override
+ public String toString() {
+ return "GoalDescendTo{y<=" + this.level + "}";
+ }
+ }
+
public void landingSpotIsBad(BetterBlockPos endPos) {
badLandingSpots.add(endPos);
goingToLandingSpot = false;
@@ -349,14 +750,13 @@ public void landingSpotIsBad(BetterBlockPos endPos) {
this.state = State.FLYING;
}
- private void destroyBehaviorAsync() {
+ private CompletableFuture destroyBehaviorAsync() {
ElytraBehavior behavior = this.behavior;
- if (behavior != null) {
- this.behavior = null;
- Baritone.getExecutor().execute(() -> {
- behavior.destroy();
- });
+ if (behavior == null) {
+ return CompletableFuture.completedFuture(null);
}
+ this.behavior = null;
+ return CompletableFuture.runAsync(behavior::destroy, Baritone.getExecutor());
}
@Override
@@ -397,7 +797,7 @@ public void repackChunks() {
@Override
public BlockPos currentDestination() {
- return this.behavior != null ? this.behavior.destination : null;
+ return this.behavior != null ? this.behavior.destination : this.deferredDestination;
}
@Override
@@ -423,11 +823,37 @@ private void pathTo0(BlockPos destination, boolean appendDestination) {
return;
}
this.onLostControl(false);
+ if (this.npfContext == null) {
+ if (!this.npfSema.tryAcquire()) {
+ // The previous context is still tearing down on a worker thread, pinned by an
+ // uncancellable native path calculation. Blocking here would hang the render
+ // thread for its whole remaining duration, so finish this engage when the
+ // teardown completes instead - unless something else took over in the meantime.
+ final int epoch = this.engageEpoch;
+ this.deferredDestination = destination;
+ this.npfDestroyDone.whenCompleteAsync((v, ex) -> {
+ if (this.engageEpoch == epoch) {
+ this.pathTo0(destination, appendDestination);
+ }
+ }, ctx.minecraft()::execute);
+ return;
+ }
+ try {
+ this.npfContext = new NetherPathfinderContext(
+ Baritone.settings().elytraNetherSeed.value,
+ Baritone.settings().elytraUseCache.value ? baritone.getWorldProvider().getCurrentWorld().directory.resolve("cache") : null,
+ ctx.world()
+ );
+ } catch (Throwable t) {
+ this.npfSema.release();
+ throw t;
+ }
+ }
this.predictingTerrain = ctx.player().level().dimension() == Level.NETHER && Baritone.settings().elytraPredictTerrain.value;
this.allowTight = Baritone.settings().elytraAllowTightSpaces.value;
this.allowAboveBuildLimit = Baritone.settings().elytraAllowAboveBuildLimit.value;
this.allowAboveRoof = Baritone.settings().elytraAllowAboveRoof.value;
- this.behavior = new ElytraBehavior(this.baritone, this, getNpfContext(), destination, appendDestination);
+ this.behavior = new ElytraBehavior(this.baritone, this, this.npfContext, destination, appendDestination);
if (ctx.world() != null) {
this.repackChunks();
@@ -509,6 +935,7 @@ public enum State {
LOCATE_JUMP("Finding spot to jump off"),
PAUSE("Waiting for elytra path"),
GET_TO_JUMP("Walking to takeoff"),
+ TAKEOFF_JUMP("Taking off"),
START_FLYING("Begin flying"),
FLYING("Flying"),
LANDING("Landing");
@@ -528,7 +955,10 @@ public void onRenderPass(RenderEvent event) {
@Override
public void onWorldEvent(WorldEvent event) {
if (event.getWorld() != null && event.getState() == EventState.POST) {
- // Exiting the world, just destroy
+ // Exiting the world, just destroy. A world change also invalidates any engage that was
+ // deferred while the old context tore down - its destination belongs to the old world.
+ this.engageEpoch++;
+ this.deferredDestination = null;
destroyBehaviorAsync();
}
}
@@ -562,7 +992,7 @@ public static final class WalkOffCalculationContext extends CalculationContext {
public WalkOffCalculationContext(IBaritone baritone) {
super(baritone, true);
this.allowFallIntoLava = true;
- this.minFallHeight = 8;
+ this.minFallHeight = TAKEOFF_MIN_FALL_HEIGHT;
this.maxFallHeightNoWater = 10000;
}
@@ -770,26 +1200,22 @@ private BetterBlockPos advanceHeightmap(BetterBlockPos qPos) {
}
}
- private NetherPathfinderContext getNpfContext() {
- if(this.npfContext == null) {
- npfSema.acquireUninterruptibly();
- this.npfContext = new NetherPathfinderContext(
- Baritone.settings().elytraNetherSeed.value,
- Baritone.settings().elytraUseCache.value ? baritone.getWorldProvider().getCurrentWorld().directory.resolve("cache") : null,
- ctx.world()
- );
- }
- return this.npfContext;
- }
-
- private void destroyNpfContextAsync() {
+ private void destroyNpfContextAsync(CompletableFuture afterBehaviorGone) {
NetherPathfinderContext npf = this.npfContext;
if (npf != null) {
this.npfContext = null;
- Baritone.getExecutor().execute(() -> {
- npf.destroy();
- npfSema.release();
- });
+ // The solver and any still-queued path results keep making native calls until the
+ // behavior teardown completes, so the context must only be freed after it.
+ this.npfDestroyDone = afterBehaviorGone.handleAsync((v, ex) -> {
+ try {
+ npf.destroy();
+ } catch (Throwable t) {
+ t.printStackTrace();
+ } finally {
+ npfSema.release();
+ }
+ return null;
+ }, Baritone.getExecutor());
}
}
}
diff --git a/src/main/java/baritone/process/FarmProcess.java b/src/main/java/baritone/process/FarmProcess.java
index fbb7f66a2c..da2ff2fdd7 100644
--- a/src/main/java/baritone/process/FarmProcess.java
+++ b/src/main/java/baritone/process/FarmProcess.java
@@ -21,8 +21,8 @@
import baritone.api.BaritoneAPI;
import baritone.api.pathing.goals.Goal;
import baritone.api.pathing.goals.GoalBlock;
-import baritone.api.pathing.goals.GoalGetToBlock;
import baritone.api.pathing.goals.GoalComposite;
+import baritone.api.pathing.goals.GoalGetToBlock;
import baritone.api.process.IFarmProcess;
import baritone.api.process.PathingCommand;
import baritone.api.process.PathingCommandType;
@@ -42,16 +42,7 @@
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
-import net.minecraft.world.level.block.AirBlock;
-import net.minecraft.world.level.block.BambooStalkBlock;
-import net.minecraft.world.level.block.Block;
-import net.minecraft.world.level.block.Blocks;
-import net.minecraft.world.level.block.BonemealableBlock;
-import net.minecraft.world.level.block.CactusBlock;
-import net.minecraft.world.level.block.CocoaBlock;
-import net.minecraft.world.level.block.CropBlock;
-import net.minecraft.world.level.block.NetherWartBlock;
-import net.minecraft.world.level.block.SugarCaneBlock;
+import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;
@@ -267,7 +258,7 @@ public PathingCommand onTick(boolean calcFailed, boolean isSafeToCancel) {
}
if (state.getBlock() instanceof BonemealableBlock) {
BonemealableBlock ig = (BonemealableBlock) state.getBlock();
- if (ig.isValidBonemealTarget(ctx.world(), pos, state) && ig.isBonemealSuccess(ctx.world(), ctx.world().getRandom(), pos, state)) {
+ if (ig.isValidBonemealTarget(ctx.world(), pos, state, BonemealSource.INTERACTION) && ig.isBonemealSuccess(ctx.world(), ctx.world().getRandom(), pos, state, BonemealSource.INTERACTION)) {
bonemealable.add(pos);
}
}
diff --git a/src/main/java/baritone/process/elytra/BuildLimitPathFinder.java b/src/main/java/baritone/process/elytra/BuildLimitPathFinder.java
index 01624d730f..1961ed5d54 100644
--- a/src/main/java/baritone/process/elytra/BuildLimitPathFinder.java
+++ b/src/main/java/baritone/process/elytra/BuildLimitPathFinder.java
@@ -108,7 +108,8 @@ public Pair,Boolean> generateTransitionUp(BetterBlockPos st
final double deltaZ = destination.getZ() - start.getZ();
final double distance = Math.sqrt(deltaX * deltaX + deltaZ * deltaZ);
- final double scale = 8 / distance;
+ // a destination straight above/below would otherwise make the steps NaN
+ final double scale = distance > 1.0e-6 ? 8 / distance : 0;
final double stepX = deltaX * scale;
final double stepZ = deltaZ * scale;
@@ -234,7 +235,8 @@ public CompletableFuture pathFindAsync(BlockPos src, BlockPos d
boolean success = transition.second();
if(!success) {
- BetterBlockPos newDest = distanceXZ > 32 ? new BetterBlockPos(dst) : new BetterBlockPos(dst.getX(), playerCtx.world().getMaxY(), dst.getZ());
+ // distanceXZ is squared
+ BetterBlockPos newDest = distanceXZ > 32 * 32 ? new BetterBlockPos(dst) : new BetterBlockPos(dst.getX(), playerCtx.world().getMaxY(), dst.getZ());
var directPath = generateDirectPath(new BetterBlockPos(src), newDest, 0, 2);
return CompletableFuture.completedFuture(new UnpackedSegment(directPath.first().stream(), directPath.second()));
}
diff --git a/src/main/java/baritone/process/elytra/ElytraBehavior.java b/src/main/java/baritone/process/elytra/ElytraBehavior.java
index 7cc23498e1..10380263aa 100644
--- a/src/main/java/baritone/process/elytra/ElytraBehavior.java
+++ b/src/main/java/baritone/process/elytra/ElytraBehavior.java
@@ -40,7 +40,9 @@
import net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket;
import net.minecraft.util.Mth;
import net.minecraft.world.InteractionHand;
+import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.EquipmentSlot;
+import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
@@ -85,6 +87,7 @@ public final class ElytraBehavior implements Helper {
public final PathManager pathManager;
private final ElytraProcess process;
+ private static final int FIREWORK_COOLDOWN_TICKS = 10;
/**
* Remaining cool-down ticks between firework usage
*/
@@ -103,9 +106,11 @@ public final class ElytraBehavior implements Helper {
* Updated every time a firework is automatically used
*/
private int minimumBoostTicks;
-
- private boolean deployedFireworkLastTick;
- private final int[] nextTickBoostCounter;
+ /**
+ * Whether the entity of the last rocket we lit has been seen attached to us. Only for the verbose log:
+ * a rocket that never shows up is one the server ignored.
+ */
+ private boolean fireworkSeenSinceLit = true;
private BlockStateInterface bsi;
public final BetterBlockPos destination;
@@ -132,7 +137,6 @@ public ElytraBehavior(Baritone baritone, ElytraProcess process, NetherPathfinder
this.destination = new BetterBlockPos(destination);
this.appendDestination = appendDestination;
this.solverExecutor = Executors.newSingleThreadExecutor();
- this.nextTickBoostCounter = new int[2];
this.npfContext = npf;
@@ -194,6 +198,7 @@ public CompletableFuture pathToDestination(final BlockPos from) {
final long start = System.nanoTime();
return this.path0(from, destinationFixed(), UnaryOperator.identity())
.thenRun(() -> {
+ process.onPathCalculationSuccess();
final double distance = this.path.get(0).distanceTo(this.path.get(this.path.size() - 1));
if (this.completePath) {
logVerbose(String.format("Computed path (%.1f blocks in %.4f seconds)", distance, (System.nanoTime() - start) / 1e9d));
@@ -207,6 +212,7 @@ public CompletableFuture pathToDestination(final BlockPos from) {
final Throwable cause = ex.getCause();
if (cause instanceof PathCalculationException) {
logDirect("Failed to compute path to destination");
+ process.onPathCalculationFailure();
} else {
logUnhandledException(cause);
}
@@ -223,13 +229,25 @@ public CompletableFuture pathRecalcSegment(final OptionalInt upToIncl) {
final List after = upToIncl.isPresent() ? this.path.subList(upToIncl.getAsInt() + 1, this.path.size()) : Collections.emptyList();
final boolean complete = this.completePath;
- return this.path0(ctx.playerFeet(), upToIncl.isPresent() ? fixDestination(this.path.get(upToIncl.getAsInt())) : destinationFixed(), segment -> segment.append(after.stream(), complete || (segment.isFinished() && !upToIncl.isPresent())))
+ return this.path0(ctx.playerFeet(), upToIncl.isPresent() ? fixDestination(this.path.get(upToIncl.getAsInt())) : destinationFixed(), segment -> {
+ if (!upToIncl.isPresent() || !segment.isFinished()) {
+ // Either this went all the way to the destination and speaks for itself, or the
+ // search ran out of time short of the rejoin node. Tacking the old tail onto a
+ // segment that never reached it would leave a blind jump in the path, straight
+ // through whatever lies between, that no obstacle check can ever fix: every
+ // recomputation ends the same way, and the solver just circles the last node it
+ // can reach. Leave the path unfinished instead and continue from where it got to.
+ return segment;
+ }
+ return segment.append(after.stream(), complete);
+ })
.whenComplete((result, ex) -> {
this.recalculating = false;
if (ex != null) {
final Throwable cause = ex.getCause();
if (cause instanceof PathCalculationException) {
logDirect("Failed to recompute segment");
+ repackNearPlayer("segment recomputation failed");
} else {
logUnhandledException(cause);
}
@@ -264,6 +282,7 @@ public void pathNextSegment(final int afterIncl) {
final Throwable cause = ex.getCause();
if (cause instanceof PathCalculationException) {
logDirect("Failed to compute next segment");
+ repackNearPlayer("next segment computation failed");
if (pathStart.distToCenterSqr(ctx.player().position()) < 16 * 16) {
logVerbose("Player is near the segment start, therefore repeating this calculation is pointless. Marking as complete");
completePath = true;
@@ -336,8 +355,25 @@ private void pathfindAroundObstacles() {
}
final BetterBlockPos rangeStart = path.get(rangeStartIncl);
if (!ElytraBehavior.this.passable(rangeStart.x, rangeStart.y, rangeStart.z, false)) {
- // we're in a wall
- return; // previous iterations of this function SHOULD have fixed this by now :rage_cat:
+ // the node closest to us is now inside a block, so the terrain changed since this path was computed
+ final BetterBlockPos feet = ctx.playerFeet();
+ if (!ElytraBehavior.this.passable(feet.x, feet.y, feet.z, false)) {
+ // we're inside a block ourselves, recalculating from here can't succeed. while flying that
+ // means the cache is wrong about where we are, so refresh it
+ repackNearPlayer("the cache has us inside a block");
+ return;
+ }
+ int rejoin = rangeEndExcl - 1;
+ while (rejoin > rangeStartIncl && !ElytraBehavior.this.passable(path.get(rejoin).x, path.get(rejoin).y, path.get(rejoin).z, false)) {
+ rejoin--;
+ }
+ final var wallDest = destinationFixed();
+ final OptionalInt rejoinAt = rejoin > rangeStartIncl && path.get(rejoin).distanceSq(wallDest) < feet.distanceSq(wallDest)
+ ? OptionalInt.of(rejoin)
+ : OptionalInt.empty();
+ this.pathRecalcSegment(rejoinAt)
+ .thenRun(() -> logVerbose("Recalculated segment, path node was inside a block"));
+ return;
}
if (ElytraBehavior.this.process.state != ElytraProcess.State.LANDING && this.ticksNearUnchanged > 100) {
@@ -472,9 +508,53 @@ public void onRenderPass(RenderEvent event) {
public void onChunkEvent(ChunkEvent event) {
if (event.isPostPopulate() && this.npfContext != null) {
- final LevelChunk chunk = ctx.world().getChunk(event.getX(), event.getZ());
- npfContext.queueForPacking(chunk);
+ // A chunk packet the client discarded (outside its view range) still raises this event, and the
+ // world hands back its shared empty chunk for it. Packing that would record a chunk of pure air as
+ // real terrain, and the path would then be routed straight through whatever is actually there.
+ final LevelChunk chunk = ctx.world().getChunkSource().getChunk(event.getX(), event.getZ(), false);
+ if (chunk != null && !chunk.isEmpty()) {
+ npfContext.queueForPacking(chunk);
+ }
+ }
+ }
+
+ // Wall-clock so that it also bounds the repacks queued from the recomputation callbacks between ticks
+ private static final long REPACK_NEAR_COOLDOWN_MS = 2000;
+ private long lastRepackNearMs;
+
+ /**
+ * Re-reads the chunks around the player into the pathfinder's cache. Everything the solver and the
+ * obstacle checks do runs against that cache, so if it disagrees with the world (a chunk whose packing was
+ * missed, a stale region from the on-disk cache that the live chunk never replaced) the solver aims at
+ * terrain it thinks is air and nothing in here notices - the raytraces look at the same cache. This is the
+ * fix that cancelling and re-engaging used to apply by accident, through {@code repackChunks}.
+ */
+ private void repackNearPlayer(final String why) {
+ if (!ctx.minecraft().isSameThread()) {
+ ctx.minecraft().execute(() -> this.repackNearPlayer(why));
+ return;
+ }
+ if (ctx.player() == null || ctx.world() == null || this.npfContext == null) {
+ return;
+ }
+ final long now = System.currentTimeMillis();
+ if (now - this.lastRepackNearMs < REPACK_NEAR_COOLDOWN_MS) {
+ return;
+ }
+ this.lastRepackNearMs = now;
+ final ChunkPos center = ctx.player().chunkPosition();
+ final ChunkSource chunkSource = ctx.world().getChunkSource();
+ int packed = 0;
+ for (int dx = -1; dx <= 1; dx++) {
+ for (int dz = -1; dz <= 1; dz++) {
+ final LevelChunk chunk = chunkSource.getChunk(center.x() + dx, center.z() + dz, false);
+ if (chunk != null && !chunk.isEmpty()) {
+ npfContext.queueForPacking(chunk);
+ packed++;
+ }
+ }
}
+ logVerbose("Repacking " + packed + " chunks around the player: " + why);
}
public void onBlockChange(BlockChangeEvent event) {
@@ -546,11 +626,17 @@ private void onTick0() {
// Certified mojang employee incident
if (this.remainingFireworkTicks > 0) {
this.remainingFireworkTicks--;
+ if (this.remainingFireworkTicks == 0 && !this.fireworkSeenSinceLit) {
+ logVerbose("the rocket lit " + FIREWORK_COOLDOWN_TICKS + " ticks ago never showed up, the server ignored it");
+ }
}
if (this.remainingSetBackTicks > 0) {
this.remainingSetBackTicks--;
}
- if (!this.getAttachedFirework().isPresent()) {
+ if (this.getAttachedFirework().isPresent()) {
+ this.fireworkSeenSinceLit = true;
+ } else if (this.remainingFireworkTicks <= 0) {
+ // nothing burning, and nothing lit recently enough for its entity to still be on its way from the server
this.minimumBoostTicks = 0;
}
@@ -561,6 +647,11 @@ private void onTick0() {
this.simulationLine = null;
this.aimPos = null;
+ // ctx AND context???? :DDD
+ // Before the path check: everything the solver simulates goes through passable(), and the lava escape
+ // solves without a path
+ this.bsi = new BlockStateInterface(ctx);
+
final List path = this.pathManager.getPath();
if (path.isEmpty()) {
return;
@@ -569,8 +660,6 @@ private void onTick0() {
return;
}
- // ctx AND context???? :DDD
- this.bsi = new BlockStateInterface(ctx);
this.pathManager.tick();
final int playerNear = this.pathManager.getNear();
@@ -584,17 +673,24 @@ private void onTick0() {
* Called by {@link baritone.process.ElytraProcess#onTick(boolean, boolean)} when the process is in control and the player is flying
*/
public void tick() {
- if (this.pathManager.getPath().isEmpty()) {
+ final boolean inLava = ctx.player().isInLava();
+ if (this.pathManager.getPath().isEmpty() && !inLava) {
return;
}
trySwapElytra();
if (ctx.player().horizontalCollision) {
logVerbose("hbonk");
+ // the simulation never flies into anything it knows about, so this is something it didn't
+ repackNearPlayer("hit something the cache didn't know about");
}
if (ctx.player().verticalCollision) {
logVerbose("vbonk");
}
+ if (this.bsi == null) {
+ // onTick0 has not run yet (a path calculation has held the lock every tick so far), nothing to solve against
+ return;
+ }
final SolverContext solverContext = this.new SolverContext(false);
this.solveNextTick = true;
@@ -607,12 +703,6 @@ public void tick() {
solution = this.solveAngles(solverContext);
}
- if (this.deployedFireworkLastTick) {
- this.nextTickBoostCounter[solverContext.boost.isBoosted() ? 1 : 0]++;
- this.deployedFireworkLastTick = false;
- }
-
- final boolean inLava = ctx.player().isInLava();
if (inLava) {
baritone.getInputOverrideHandler().setInputForceState(Input.JUMP, true);
}
@@ -635,7 +725,8 @@ public void tick() {
solution.context.start,
solution.goingTo,
solution.context.boost.isBoosted(),
- solution.forceUseFirework || inLava
+ solution.forceUseFirework || inLava,
+ inLava
);
}
@@ -660,9 +751,13 @@ public void onPostTick(TickEvent event) {
// calls passable which requires a read lock
private Solution solveAngles(final SolverContext context) {
+ if (context.ignoreLava) {
+ return this.solveLavaEscape(context);
+ }
final NetherPath path = context.path;
final int playerNear = landingMode ? path.size() - 1 : context.playerNear;
final Vec3 start = context.start;
+ final int scanAhead = Math.max(1, Baritone.settings().elytraPathLookahead.value);
Solution solution = null;
for (int relaxation = 0; relaxation < 3; relaxation++) { // try for a strict solution first, then relax more and more (if we're in a corner or near some blocks, it will have to relax its constraints a bit)
@@ -671,7 +766,7 @@ private Solution solveAngles(final SolverContext context) {
//int minStep = Math.max(0, playerNear - relaxation);
int minStep = playerNear;
- for (int i = Math.min(playerNear + 20, path.size() - 1); i >= minStep; i--) {
+ for (int i = Math.min(playerNear + scanAhead, path.size() - 1); i >= minStep; i--) {
if (Thread.interrupted()) return null; // cancelled by the game thread
final List> candidates = new ArrayList<>();
for (int dy : heights) {
@@ -744,15 +839,118 @@ private Solution solveAngles(final SolverContext context) {
}
}
}
- return solution;
+ return this.solveSurvival(context, solution);
}
- private void tickUseFireworks(final Vec3 start, final Vec3 goingTo, final boolean isBoosted, final boolean forceUseFirework) {
- if (this.remainingSetBackTicks > 0) {
+ /**
+ * In lava the elytra is inert: vanilla moves us by the fluid rules whenever we are in one, glide or not, so
+ * the only thrust is the rocket, at a fraction of its usual push, and the pool is walled in a few blocks
+ * away on every side. Aiming at the path would spend the rocket pushing against basalt. The way out is up:
+ * the steepest climb the simulation keeps clear, and a rocket, every time. Works without a path, since a
+ * lava takeoff may still be waiting for its first one.
+ */
+ private Solution solveLavaEscape(final SolverContext context) {
+ final NetherPath path = context.path;
+ // yaw is irrelevant to a vertical rocket; face where we are going so that the climb comes out pointed
+ // along the path once the normal solver takes over
+ final Vec3 towards = path.isEmpty()
+ ? Vec3.atCenterOf(this.destination)
+ : path.getVec(Math.min(context.playerNear + 1, path.size() - 1));
+ final float yaw = RotationUtils.calcRotationFromVec3d(context.start, towards, ctx.playerRotations()).getYaw();
+ final int ticks = Math.max(5, Baritone.settings().elytraSimulationTicks.value);
+ // simulate with a rocket burning whether or not one is yet: the escape needs one, and tick() lights it
+ final int ticksBoosted = context.boost.isBoosted() ? Math.max(1, context.boost.getGuaranteedBoostTicks()) : 10;
+ final int ticksBoostDelay = context.boost.isBoosted() ? 0 : 2;
+ final PitchResult best = this.solveSurvivalPitch(context, yaw, ticks, ticksBoosted, ticksBoostDelay);
+ if (best == null) {
+ return null; // cancelled by the game thread
+ }
+ this.simulationLine = best.steps;
+ final Vec3 last = best.steps.get(best.steps.size() - 1);
+ return new Solution(context, new Rotation(yaw, best.pitch), context.start.add(last), true, true);
+ }
+
+ /**
+ * Last resort when no pitch reaches the path (e.g. the terrain changed and the goal is now walled off):
+ * fly the pitch that stays collision-free the longest, biased towards climbing, so that the path
+ * recalculation has time to catch up. If an impact is imminent and a firework would provably extend
+ * survival, one is forced.
+ */
+ private Solution solveSurvival(final SolverContext context, final Solution unsolved) {
+ if (landingMode) {
+ return unsolved;
+ }
+ final float yaw = unsolved != null ? unsolved.rotation.getYaw() : ctx.playerRotations().getYaw();
+ final int ticks = Math.max(5, Baritone.settings().elytraSimulationTicks.value);
+ final int ticksBoosted = context.boost.isBoosted() ? Math.max(1, context.boost.getGuaranteedBoostTicks()) : 0;
+
+ final PitchResult best = this.solveSurvivalPitch(context, yaw, ticks, ticksBoosted, 0);
+ if (best == null) { // cancelled by the game thread
+ return unsolved;
+ }
+
+ final int survivedTicks = best.steps.size() - 1;
+ // No pitch keeps us clear for the whole horizon, or we are in lava: if a rocket lit now would provably
+ // do better, light one. This used to wait until the impact was a dozen ticks out, which from the top of
+ // a climb out of a hole meant falling most of the way back in before doing anything about it.
+ final boolean impactAhead = survivedTicks < ticks;
+ if ((impactAhead || context.ignoreLava) && !context.boost.isBoosted() && context.hasFireworks) {
+ final PitchResult boosted = this.solveSurvivalPitch(context, yaw, ticks, 10, 2);
+ if (boosted != null && (boosted.steps.size() > best.steps.size() + 2
+ || (context.ignoreLava && boosted.steps.size() >= best.steps.size()))) {
+ final Vec3 last = boosted.steps.get(boosted.steps.size() - 1);
+ this.simulationLine = boosted.steps;
+ return new Solution(context, new Rotation(yaw, boosted.pitch), context.start.add(last), true, true);
+ }
+ }
+
+ this.simulationLine = best.steps;
+ return new Solution(context, new Rotation(yaw, best.pitch), null, false, false);
+ }
+
+ private PitchResult solveSurvivalPitch(final SolverContext context, final float yaw, final int ticks,
+ final int ticksBoosted, final int ticksBoostDelay) {
+ final float yawRadians = yaw * RotationUtils.DEG_TO_RAD_F;
+ // a goal far along the direction we are about to face, so that the simulated yaw stays fixed
+ final Vec3 direction = new Vec3(-Mth.sin(yawRadians) * 1e4, 0, Mth.cos(yawRadians) * 1e4);
+ final float currentPitch = ctx.playerRotations().getPitch();
+
+ PitchResult best = null;
+ // scan the climbing side (negative pitch) first; new walls are usually escapable from above. With a
+ // rocket burning, don't settle for the first pitch that survives: take the one that ends highest. The
+ // boost is the one chance to get above whatever is boxing us in, and straight up is where that is most
+ // likely to be clear (the hole we just took off from, a crevice, the hollow beside a lava pond).
+ final boolean preferHeight = ticksBoosted > 0;
+ for (float pitch = currentPitch; pitch >= -90; pitch -= 3) {
+ if (Thread.interrupted()) return null;
+ best = this.betterSurvival(context, direction, pitch, ticks, ticksBoosted, ticksBoostDelay, best);
+ if (!preferHeight && best != null && best.steps.size() - 1 >= ticks) return best;
+ }
+ if (best != null && best.steps.size() - 1 >= ticks) return best;
+ for (float pitch = currentPitch + 3; pitch <= 90; pitch += 3) {
+ if (Thread.interrupted()) return null;
+ best = this.betterSurvival(context, direction, pitch, ticks, ticksBoosted, ticksBoostDelay, best);
+ if (best != null && best.steps.size() - 1 >= ticks) return best;
+ }
+ return best;
+ }
+
+ private PitchResult betterSurvival(final SolverContext context, final Vec3 direction, final float pitch,
+ final int ticks, final int ticksBoosted, final int ticksBoostDelay,
+ final PitchResult best) {
+ final List steps = this.simulate(context, direction, pitch, ticks, ticksBoosted, ticksBoostDelay, true);
+ // longer survival wins; on equal survival prefer the trajectory that ends higher up
+ final double score = (steps.size() - 1) * 1000.0 + steps.get(steps.size() - 1).y;
+ return best == null || score > best.dot ? new PitchResult(pitch, score, steps) : best;
+ }
+
+ private void tickUseFireworks(final Vec3 start, final Vec3 goingTo, final boolean isBoosted, final boolean forceUseFirework, final boolean inLava) {
+ // neither the setback delay nor a landing in progress is a reason to keep burning
+ if (this.remainingSetBackTicks > 0 && !inLava) {
logDebug("waiting for elytraFireworkSetbackUseDelay: " + this.remainingSetBackTicks);
return;
}
- if (this.landingMode) {
+ if (this.landingMode && !inLava) {
return;
}
final boolean useOnDescend = !Baritone.settings().elytraConserveFireworks.value || ctx.player().position().y < goingTo.y + 5;
@@ -769,21 +967,71 @@ private void tickUseFireworks(final Vec3 start, final Vec3 goingTo, final boolea
&& (ctx.player().position().y < goingTo.y - 5 || start.distanceTo(new Vec3(goingTo.x + 0.5, ctx.player().position().y, goingTo.z + 0.5)) > 5) // UGH!!!!!!!
&& currentSpeed < elytraFireworkSpeed * elytraFireworkSpeed))
) {
- // Prioritize boosting fireworks over regular ones
- // TODO: Take the minimum boost time into account?
- if (!baritone.getInventoryBehavior().throwaway(true, ElytraBehavior::isBoostingFireworks) &&
- !baritone.getInventoryBehavior().throwaway(true, ElytraBehavior::isFireworks)) {
- logDirect("no fireworks");
- return;
- }
logVerbose("attempting to use firework" + (forceUseFirework ? " (forced)" : ""));
- ctx.playerController().processRightClick(ctx.player(), ctx.world(), InteractionHand.MAIN_HAND);
- this.minimumBoostTicks = 10 * (1 + getFireworkBoost(ctx.player().getItemInHand(InteractionHand.MAIN_HAND)).orElse(0));
- this.remainingFireworkTicks = 10;
- this.deployedFireworkLastTick = true;
+ this.useFirework();
}
}
+ /**
+ * Lights a firework to get off the ground, for an elytra that was opened from a standing jump instead of a
+ * fall. There is no speed and only a couple of blocks of air to work with, so this can't wait for the solver
+ * to decide that a boost is warranted.
+ *
+ * @return {@code true} if a firework is now burning, {@code false} if we have none to use
+ */
+ public boolean useFireworkForTakeoff() {
+ if (this.remainingFireworkTicks > 0 || this.getAttachedFirework().isPresent()) {
+ return true;
+ }
+ return this.useFirework();
+ }
+
+ /**
+ * @return {@code true} if a firework was used
+ */
+ private boolean useFirework() {
+ // the main hand is what processRightClick uses, and selectFirework settles for putting something harmless
+ // there when the only firework we have is in the off hand, which would right click nothing at all
+ if (!this.selectFirework() || !isFireworks(ctx.player().getItemInHand(InteractionHand.MAIN_HAND))) {
+ logDirect("no fireworks");
+ return false;
+ }
+ // The use packet carries a look of its own, read off the player as it is built, and the server-side
+ // movement check compares it with the look in this tick's movement packet: the rotation LookBehavior
+ // is about to apply for the solver's target, not what the player is facing right now. Face that for
+ // the duration of the click so the two agree; the look behavior sets or restores the real rotation
+ // later in the tick regardless.
+ final Rotation wire = this.baritone.getLookBehavior().getRotationForThisTick();
+ final float yaw = ctx.player().getYRot();
+ final float pitch = ctx.player().getXRot();
+ ctx.player().setYRot(wire.getYaw());
+ ctx.player().setXRot(wire.getPitch());
+ try {
+ ctx.playerController().processRightClick(ctx.player(), ctx.world(), InteractionHand.MAIN_HAND);
+ } finally {
+ ctx.player().setYRot(yaw);
+ ctx.player().setXRot(pitch);
+ }
+ this.minimumBoostTicks = 10 * (1 + getFireworkBoost(ctx.player().getItemInHand(InteractionHand.MAIN_HAND)).orElse(0));
+ this.remainingFireworkTicks = FIREWORK_COOLDOWN_TICKS;
+ this.fireworkSeenSinceLit = false;
+ return true;
+ }
+
+ /**
+ * Puts a firework in the main hand. Picking one that is already in the hotbar is instant, but bringing one up
+ * from the rest of the inventory is a window click, and those only happen while stationary, so a takeoff has
+ * to call this before leaving the ground.
+ *
+ * @return {@code true} if the main hand now holds a firework
+ */
+ public boolean selectFirework() {
+ // Prioritize boosting fireworks over regular ones
+ // TODO: Take the minimum boost time into account?
+ return baritone.getInventoryBehavior().throwaway(true, ElytraBehavior::isBoostingFireworks)
+ || baritone.getInventoryBehavior().throwaway(true, ElytraBehavior::isFireworks);
+ }
+
private final class SolverContext {
public final NetherPath path;
@@ -792,6 +1040,9 @@ private final class SolverContext {
public final Vec3 motion;
public final AABB boundingBox;
public final boolean ignoreLava;
+ public final double gravity;
+ public final boolean slowFalling;
+ public final boolean hasFireworks;
public final FireworkBoost boost;
public final IAimProcessor aimProcessor;
@@ -809,13 +1060,26 @@ public SolverContext(boolean async) {
this.motion = ctx.playerMotion();
this.boundingBox = ctx.player().getBoundingBox();
this.ignoreLava = ctx.player().isInLava();
-
- final Integer fireworkTicksExisted;
- if (async && ElytraBehavior.this.deployedFireworkLastTick) {
- final int[] counter = ElytraBehavior.this.nextTickBoostCounter;
- fireworkTicksExisted = counter[1] > counter[0] ? 0 : null;
- } else {
- fireworkTicksExisted = ElytraBehavior.this.getAttachedFirework().map(e -> e.tickCount).orElse(null);
+ this.gravity = ctx.player().getAttributeValue(Attributes.GRAVITY);
+ this.slowFalling = ctx.player().hasEffect(MobEffects.SLOW_FALLING);
+ boolean fireworks = false;
+ for (ItemStack stack : ctx.player().getInventory().getNonEquipmentItems()) {
+ if (isFireworks(stack)) {
+ fireworks = true;
+ break;
+ }
+ }
+ this.hasFireworks = fireworks;
+
+ Integer fireworkTicksExisted = ElytraBehavior.this.getAttachedFirework().map(e -> e.tickCount).orElse(null);
+ if (fireworkTicksExisted == null && ElytraBehavior.this.remainingFireworkTicks > 0) {
+ // A rocket we lit within the cooldown whose entity the server hasn't shown us yet: a round trip,
+ // which on a busy server is several ticks. Solve as though it had been burning since we lit it.
+ // The boost follows whatever we aim at once it arrives, and planning an unboosted trajectory in
+ // the meantime - a shallow glide, since without thrust nothing else gets anywhere - is what
+ // points a takeoff rocket at the nearest wall. If it never shows up the assumption expires with
+ // the cooldown. The end-of-tick solve is for the next tick, whose cooldown is one lower.
+ fireworkTicksExisted = FIREWORK_COOLDOWN_TICKS - ElytraBehavior.this.remainingFireworkTicks + (async ? 1 : 0);
}
this.boost = new FireworkBoost(fireworkTicksExisted, ElytraBehavior.this.minimumBoostTicks);
@@ -843,6 +1107,9 @@ public boolean equals(Object o) {
&& Objects.equals(this.motion, other.motion)
&& Objects.equals(this.boundingBox, other.boundingBox)
&& this.ignoreLava == other.ignoreLava
+ && this.gravity == other.gravity
+ && this.slowFalling == other.slowFalling
+ && this.hasFireworks == other.hasFireworks
&& Objects.equals(this.boost, other.boost);
}
}
@@ -1141,7 +1408,8 @@ private PitchResult solvePitch(final SolverContext context, final Vec3 goal, fin
pitch,
ticks,
ticksBoosted,
- ticksBoostDelay
+ ticksBoostDelay,
+ false
);
if (displacement == null) {
continue;
@@ -1181,7 +1449,7 @@ private PitchResult solvePitch(final SolverContext context, final Vec3 goal, fin
}
private List simulate(final SolverContext context, final Vec3 goalDelta, final float pitch, final int ticks,
- final int ticksBoosted, final int ticksBoostDelay) {
+ final int ticksBoosted, final int ticksBoostDelay, final boolean stopOnImpact) {
final ITickableAimProcessor aimProcessor = context.aimProcessor.fork();
Vec3 delta = goalDelta;
Vec3 motion = context.motion;
@@ -1201,7 +1469,7 @@ private List simulate(final SolverContext context, final Vec3 goalDelta, f
);
final Vec3 lookDirection = RotationUtils.calcLookDirectionFromRotation(rotation);
- motion = step(motion, lookDirection, rotation.getPitch());
+ motion = step(motion, lookDirection, rotation.getPitch(), context.gravity, context.slowFalling);
delta = delta.subtract(motion);
// Collision box while the player is in motion, with additional padding for safety
@@ -1220,7 +1488,8 @@ private List simulate(final SolverContext context, final Vec3 goalDelta, f
for (int y = ymin; y < ymax; y++) {
for (int z = zmin; z < zmax; z++) {
if (!this.passable(x, y, z, context.ignoreLava)) {
- return null;
+ // displacement only covers the ticks before the impact
+ return stopOnImpact ? displacement : null;
}
}
}
@@ -1242,7 +1511,16 @@ private List simulate(final SolverContext context, final Vec3 goalDelta, f
return displacement;
}
- private static Vec3 step(final Vec3 motion, final Vec3 lookDirection, final float pitch) {
+ /**
+ * A faithful reimplementation of {@code LivingEntity.updateFallFlyingMovement}. Every constant, guard and
+ * rounding step is kept identical to vanilla so that a simulated trajectory matches what the server will
+ * actually compute; any divergence here compounds over the simulated horizon and puts the bot into walls.
+ *
+ * @param gravity The player's {@code Attributes.GRAVITY} value, which is not always the 0.08 default
+ * @param slowFalling Whether the player has Slow Falling, which caps gravity while not ascending
+ */
+ private static Vec3 step(final Vec3 motion, final Vec3 lookDirection, final float pitch,
+ final double gravity, final boolean slowFalling) {
double motionX = motion.x;
double motionY = motion.y;
double motionZ = motion.z;
@@ -1250,20 +1528,19 @@ private static Vec3 step(final Vec3 motion, final Vec3 lookDirection, final floa
float pitchRadians = pitch * RotationUtils.DEG_TO_RAD_F;
double pitchBase2 = Math.sqrt(lookDirection.x * lookDirection.x + lookDirection.z * lookDirection.z);
double flatMotion = Math.sqrt(motionX * motionX + motionZ * motionZ);
- double thisIsAlwaysOne = lookDirection.length();
- float pitchBase3 = Mth.cos(pitchRadians);
- //System.out.println("always the same lol " + -pitchBase + " " + pitchBase3);
- //System.out.println("always the same lol " + Math.abs(pitchBase3) + " " + pitchBase2);
- //System.out.println("always 1 lol " + thisIsAlwaysOne);
- pitchBase3 = (float) ((double) pitchBase3 * (double) pitchBase3 * Math.min(1, thisIsAlwaysOne / 0.4));
- motionY += -0.08 + (double) pitchBase3 * 0.06;
+ // vanilla uses the exact Math.cos here and keeps the square in double precision, unlike the Mth table
+ double pitchBase3 = Mth.square(Math.cos((double) pitchRadians));
+ // LivingEntity.getEffectiveGravity
+ double effectiveGravity = slowFalling && motionY <= 0 ? Math.min(gravity, 0.01) : gravity;
+
+ motionY += effectiveGravity * (-1.0 + pitchBase3 * 0.75);
if (motionY < 0 && pitchBase2 > 0) {
- double speedModifier = motionY * -0.1 * (double) pitchBase3;
+ double speedModifier = motionY * -0.1 * pitchBase3;
motionY += speedModifier;
motionX += lookDirection.x * speedModifier / pitchBase2;
motionZ += lookDirection.z * speedModifier / pitchBase2;
}
- if (pitchRadians < 0) { // if you are looking down (below level)
+ if (pitchRadians < 0 && pitchBase2 > 0) { // if you are looking down (below level)
double anotherSpeedModifier = flatMotion * (double) (-Mth.sin(pitchRadians)) * 0.04;
motionY += anotherSpeedModifier * 3.2;
motionX -= lookDirection.x * anotherSpeedModifier / pitchBase2;
@@ -1273,10 +1550,9 @@ private static Vec3 step(final Vec3 motion, final Vec3 lookDirection, final floa
motionX += (lookDirection.x / pitchBase2 * flatMotion - motionX) * 0.1;
motionZ += (lookDirection.z / pitchBase2 * flatMotion - motionZ) * 0.1;
}
- motionX *= 0.99f;
- motionY *= 0.98f;
- motionZ *= 0.99f;
- //System.out.println(motionX + " " + motionY + " " + motionZ);
+ motionX *= 0.9900000095367432;
+ motionY *= 0.9800000190734863;
+ motionZ *= 0.9900000095367432;
return new Vec3(motionX, motionY, motionZ);
}
diff --git a/src/main/java/baritone/process/elytra/NetherPathfinderContext.java b/src/main/java/baritone/process/elytra/NetherPathfinderContext.java
index 3ed1e19155..4bb6d83fcd 100644
--- a/src/main/java/baritone/process/elytra/NetherPathfinderContext.java
+++ b/src/main/java/baritone/process/elytra/NetherPathfinderContext.java
@@ -40,6 +40,7 @@
import java.lang.ref.SoftReference;
import java.lang.reflect.Field;
import java.nio.file.Path;
+import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -63,7 +64,8 @@ public final class NetherPathfinderContext implements IElytraPathFinder {
}
}
private static final BlockState AIR_BLOCK_STATE = Blocks.AIR.defaultBlockState();
- private static final int SECTION_SIZE = 16;
+ // blocks per 16x16x16 section (replaces LevelChunkSection.SECTION_SIZE, removed from vanilla in 26.x)
+ private static final int SECTION_SIZE = 16 * 16 * 16;
// This lock must be held while there are active pointers to chunks in java,
// but we just hold it for the entire tick so we don't have to think much about it.
public final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
@@ -73,6 +75,9 @@ public final class NetherPathfinderContext implements IElytraPathFinder {
// Visible for access in BlockStateOctreeInterface
final long context;
+ // Set under the write lock before freeContext; every native call checks it under the read lock
+ // so nothing can touch the context pointer after it has been freed.
+ private volatile boolean destroyed;
private final long seed;
// write locked operations
private final ExecutorService writeExecutor = Executors.newSingleThreadExecutor();
@@ -98,11 +103,30 @@ public NetherPathfinderContext(long seed, Path cache, Level world) {
}
public boolean hasChunk(ChunkPos pos) {
- return NetherPathfinder.hasChunkFromJava(this.context, pos.x(), pos.z());
+ readLock.lock();
+ try {
+ if (this.destroyed) {
+ return false;
+ }
+ return NetherPathfinder.hasChunkFromJava(this.context, pos.x(), pos.z());
+ } finally {
+ readLock.unlock();
+ }
+ }
+
+ /**
+ * Submits to the write executor, dropping the task if the context is already being torn down.
+ */
+ private void executeWrite(Runnable task) {
+ try {
+ this.writeExecutor.execute(task);
+ } catch (java.util.concurrent.RejectedExecutionException ignored) {
+ // destroy() has shut the executor down; the cache no longer matters
+ }
}
public void queueCacheCulling(int chunkX, int chunkZ, int maxDistanceBlocks) {
- this.writeExecutor.execute(() -> {
+ this.executeWrite(() -> {
writeLock.lock();
try {
this.boi.chunkPtr = 0L;
@@ -115,7 +139,7 @@ public void queueCacheCulling(int chunkX, int chunkZ, int maxDistanceBlocks) {
public void queueForPacking(final LevelChunk chunkIn) {
final SoftReference ref = new SoftReference<>(chunkIn);
- this.writeExecutor.execute(() -> {
+ this.executeWrite(() -> {
// TODO: Prioritize packing recent chunks and/or ones that the path goes through,
// and prune the oldest chunks per chunkPackerQueueMaxSize
final LevelChunk chunk = ref.get();
@@ -134,7 +158,7 @@ public void queueForPacking(final LevelChunk chunkIn) {
}
public void queueBlockUpdate(BlockChangeEvent event) {
- this.writeExecutor.execute(() -> {
+ this.executeWrite(() -> {
ChunkPos chunkPos = event.getChunkPos();
// not inserting or deleting from the cache hashmap but it would still be bad for this function to race with itself
writeLock.lock();
@@ -157,31 +181,48 @@ public CompletableFuture pathFindAsync(final BlockPos src, fina
final BlockPos adjustedSrc = src.below(minY);
final BlockPos adjustedDst = dst.below(minY);
boolean generate = Baritone.settings().elytraPredictTerrain.value && this.dimension == Level.NETHER;
- Lock l = generate ? writeLock : readLock;
- ExecutorService exec = generate ? writeExecutor : readExecutor;
- return CompletableFuture.supplyAsync(() -> {
- l.lock();
- try {
- final PathSegment segment = NetherPathfinder.pathFind(
- this.context,
- adjustedSrc.getX(), adjustedSrc.getY(), adjustedSrc.getZ(),
- adjustedDst.getX(), adjustedDst.getY(), adjustedDst.getZ(),
- !Baritone.settings().elytraAllowTightSpaces.value, // atleastX4
- false, // refine
- 10000, // timeoutMs
- !generate, // useAirIfChunkNotLoaded
- // TODO: Determine appropriate cost value
- 8.0 // fakeChunkCost
- );
- if (segment == null) {
- throw new PathCalculationException("Path calculation failed");
- }
+ // pathFind is always a writer, regardless of terrain prediction: it lazily parses baritone cache
+ // regions into chunkCache (gated only by checkedRegions, see nether-pathfinder parseBaritoneRegion)
+ // and generates terrain. It therefore has to exclude the getChunkOrDefault/raytrace lookups the
+ // solvers run under the read lock. Running it under the read lock instead lets those inserts (and the
+ // rehash they trigger) race a concurrent lookup's bucket walk, corrupting the map -> SIGSEGV in
+ // getChunkOrDefault. The prediction flag now only selects useAirIfChunkNotLoaded.
+ Lock l = writeLock;
+ ExecutorService exec = writeExecutor;
+ try {
+ return CompletableFuture.supplyAsync(() -> {
+ l.lock();
+ try {
+ if (this.destroyed) {
+ throw new PathCalculationException("Pathfinder context destroyed");
+ }
+ final PathSegment segment = NetherPathfinder.pathFind(
+ this.context,
+ adjustedSrc.getX(), adjustedSrc.getY(), adjustedSrc.getZ(),
+ adjustedDst.getX(), adjustedDst.getY(), adjustedDst.getZ(),
+ !Baritone.settings().elytraAllowTightSpaces.value, // atleastX4
+ false, // refine
+ // The native cancel is a no-op (nether-pathfinder sets ctx->cancelFlag but the
+ // search loop polls an unrelated global), so this timeout is the only bound on
+ // how long destroy() can be stuck waiting for an in-flight search. Successful
+ // segments return ~500ms after first progress; only hopeless searches run it out.
+ 3000, // timeoutMs
+ !generate, // useAirIfChunkNotLoaded
+ // TODO: Determine appropriate cost value
+ 8.0 // fakeChunkCost
+ );
+ if (segment == null) {
+ throw new PathCalculationException("Path calculation failed");
+ }
- return new UnpackedSegment(UnpackedSegment.from(segment).collect().stream().map(pos -> pos.above(minY)), segment.finished);
- } finally {
- l.unlock();
- }
- }, exec);
+ return new UnpackedSegment(UnpackedSegment.from(segment).collect().stream().map(pos -> pos.above(minY)), segment.finished);
+ } finally {
+ l.unlock();
+ }
+ }, exec);
+ } catch (java.util.concurrent.RejectedExecutionException ex) {
+ return CompletableFuture.failedFuture(new PathCalculationException("Pathfinder context destroyed"));
+ }
}
/**
@@ -200,7 +241,15 @@ public boolean raytrace(final double startX, final double startY, final double s
final double endX, final double endY, final double endZ) {
final double adjustedStartY = startY - this.minY;
final double adjustedEndY = endY - this.minY;
- return NetherPathfinder.isVisible(this.context, NetherPathfinder.CACHE_MISS_SOLID, startX, adjustedStartY, startZ, endX, adjustedEndY, endZ);
+ readLock.lock();
+ try {
+ if (this.destroyed) {
+ return false;
+ }
+ return NetherPathfinder.isVisible(this.context, NetherPathfinder.CACHE_MISS_SOLID, startX, adjustedStartY, startZ, endX, adjustedEndY, endZ);
+ } finally {
+ readLock.unlock();
+ }
}
/**
@@ -214,7 +263,15 @@ public boolean raytrace(final double startX, final double startY, final double s
public boolean raytrace(final Vec3 start, final Vec3 end) {
final Vec3 adjustedStart = start.subtract(0, this.minY, 0);
final Vec3 adjustedEnd = end.subtract(0, this.minY, 0);
- return NetherPathfinder.isVisible(this.context, NetherPathfinder.CACHE_MISS_SOLID, adjustedStart.x, adjustedStart.y, adjustedStart.z, adjustedEnd.x, adjustedEnd.y, adjustedEnd.z);
+ readLock.lock();
+ try {
+ if (this.destroyed) {
+ return false;
+ }
+ return NetherPathfinder.isVisible(this.context, NetherPathfinder.CACHE_MISS_SOLID, adjustedStart.x, adjustedStart.y, adjustedStart.z, adjustedEnd.x, adjustedEnd.y, adjustedEnd.z);
+ } finally {
+ readLock.unlock();
+ }
}
public boolean raytrace(final int count, final double[] src, final double[] dst, final int visibility) {
@@ -227,15 +284,23 @@ public boolean raytrace(final int count, final double[] src, final double[] dst,
dst[i] -= this.minY;
}
- switch (visibility) {
- case Visibility.ALL:
- return NetherPathfinder.isVisibleMulti(this.context, NetherPathfinder.CACHE_MISS_SOLID, count, src, dst, false) == -1;
- case Visibility.NONE:
- return NetherPathfinder.isVisibleMulti(this.context, NetherPathfinder.CACHE_MISS_SOLID, count, src, dst, true) == -1;
- case Visibility.ANY:
- return NetherPathfinder.isVisibleMulti(this.context, NetherPathfinder.CACHE_MISS_SOLID, count, src, dst, true) != -1;
- default:
- throw new IllegalArgumentException("lol");
+ readLock.lock();
+ try {
+ if (this.destroyed) {
+ return false;
+ }
+ switch (visibility) {
+ case Visibility.ALL:
+ return NetherPathfinder.isVisibleMulti(this.context, NetherPathfinder.CACHE_MISS_SOLID, count, src, dst, false) == -1;
+ case Visibility.NONE:
+ return NetherPathfinder.isVisibleMulti(this.context, NetherPathfinder.CACHE_MISS_SOLID, count, src, dst, true) == -1;
+ case Visibility.ANY:
+ return NetherPathfinder.isVisibleMulti(this.context, NetherPathfinder.CACHE_MISS_SOLID, count, src, dst, true) != -1;
+ default:
+ throw new IllegalArgumentException("lol");
+ }
+ } finally {
+ readLock.unlock();
}
}
@@ -249,10 +314,27 @@ public void raytrace(final int count, final double[] src, final double[] dst, fi
dst[i] -= this.minY;
}
- NetherPathfinder.raytrace(this.context, NetherPathfinder.CACHE_MISS_SOLID, count, src, dst, hitsOut, hitPosOut);
+ readLock.lock();
+ try {
+ if (this.destroyed) {
+ Arrays.fill(hitsOut, true);
+ return;
+ }
+ NetherPathfinder.raytrace(this.context, NetherPathfinder.CACHE_MISS_SOLID, count, src, dst, hitsOut, hitPosOut);
+ } finally {
+ readLock.unlock();
+ }
}
+ /**
+ * Callers already hold the read lock (see {@link ElytraBehavior#solveAngles}) - this is the
+ * solver's innermost loop and taking it again per block would cost more than the call itself.
+ * {@link #destroyed} is set under the write lock, so it cannot flip while a caller is inside.
+ */
public boolean passable(int x, int y, int z) {
+ if (this.destroyed) {
+ return false;
+ }
return !this.boi.get0(x, y, z);
}
@@ -260,20 +342,48 @@ public void cancel() {
NetherPathfinder.cancel(this.context);
}
+ // Generous relative to the 3000ms native pathFind timeout, which does not bound findAir's BFS or the
+ // region-file IO a search can trigger. A normal teardown finishes well inside this; overshooting it
+ // means a search is genuinely wedged in native code and this context is abandoned rather than freed.
+ private static final long TEARDOWN_TIMEOUT_MS = 6000;
+
public void destroy() {
this.cancel();
// Ignore anything that was queued up, just shutdown the executor
this.readExecutor.shutdownNow();
this.writeExecutor.shutdownNow();
+ boolean terminated;
try {
- while (!this.readExecutor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS)) {}
- while (!this.writeExecutor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS)) {}
+ // The native cancel is a no-op, so shutdownNow only drops queued tasks; an in-flight pathFind
+ // runs to its own timeout. Bound the wait: a wedged native call must not pin this teardown
+ // forever, because that never releases npfSema and silently kills every later engage.
+ terminated = this.readExecutor.awaitTermination(TEARDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS)
+ & this.writeExecutor.awaitTermination(TEARDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
- e.printStackTrace();
+ Thread.currentThread().interrupt();
+ terminated = false;
+ }
+
+ if (!terminated) {
+ // A search is still running in native code. freeContext now would be a use-after-free, and the
+ // write lock is held by that same wedged search (pathFind is a writer), so taking it would
+ // deadlock. Abandon (leak) this context instead: mark it destroyed so nothing new calls in, and
+ // let the next engage build a fresh one. The leak is bounded to one context per wedged search.
+ this.destroyed = true;
+ return;
}
- NetherPathfinder.freeContext(this.context);
+ // The executors are drained, but the solver and render threads still raytrace through this
+ // context; the write lock keeps freeContext from pulling the memory out from under them.
+ writeLock.lock();
+ try {
+ this.destroyed = true;
+ this.boi.chunkPtr = 0L;
+ NetherPathfinder.freeContext(this.context);
+ } finally {
+ writeLock.unlock();
+ }
}
public long getSeed() {
diff --git a/src/main/java/baritone/utils/BlockBreakHelper.java b/src/main/java/baritone/utils/BlockBreakHelper.java
index 0c5cf6f000..2b1e10c8df 100644
--- a/src/main/java/baritone/utils/BlockBreakHelper.java
+++ b/src/main/java/baritone/utils/BlockBreakHelper.java
@@ -21,6 +21,7 @@
import baritone.api.utils.IPlayerContext;
import baritone.utils.accessor.IPlayerControllerMP;
import net.minecraft.world.InteractionHand;
+import net.minecraft.world.item.component.SwingAnimation;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;
@@ -62,10 +63,10 @@ public void tick(boolean isLeftClick) {
if (ctx.playerController().hasBrokenBlock()) {
ctx.playerController().syncHeldItem();
ctx.playerController().clickBlock(((BlockHitResult) trace).getBlockPos(), ((BlockHitResult) trace).getDirection());
- ctx.player().swing(InteractionHand.MAIN_HAND);
+ ctx.player().swing(InteractionHand.MAIN_HAND, SwingAnimation.DEFAULT, false);
} else {
if (ctx.playerController().onPlayerDamageBlock(((BlockHitResult) trace).getBlockPos(), ((BlockHitResult) trace).getDirection())) {
- ctx.player().swing(InteractionHand.MAIN_HAND);
+ ctx.player().swing(InteractionHand.MAIN_HAND, SwingAnimation.DEFAULT, false);
}
if (ctx.playerController().hasBrokenBlock()) { // block broken this tick
// break delay timer only applies for multi-tick block breaks like vanilla
diff --git a/src/main/java/baritone/utils/BlockPlaceHelper.java b/src/main/java/baritone/utils/BlockPlaceHelper.java
index ca8d505239..950b59f56e 100644
--- a/src/main/java/baritone/utils/BlockPlaceHelper.java
+++ b/src/main/java/baritone/utils/BlockPlaceHelper.java
@@ -21,6 +21,7 @@
import baritone.api.utils.IPlayerContext;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
+import net.minecraft.world.item.component.SwingAnimation;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;
@@ -47,7 +48,7 @@ public void tick(boolean rightClickRequested) {
rightClickTimer = Baritone.settings().rightClickSpeed.value - BASE_PLACE_DELAY;
for (InteractionHand hand : InteractionHand.values()) {
if (ctx.playerController().processRightClickBlock(ctx.player(), ctx.world(), hand, (BlockHitResult) mouseOver) == InteractionResult.SUCCESS) {
- ctx.player().swing(hand);
+ ctx.player().swing(hand, SwingAnimation.DEFAULT, false);
return;
}
if (!ctx.player().getItemInHand(hand).isEmpty() && ctx.playerController().processRightClick(ctx.player(), ctx.world(), hand) == InteractionResult.SUCCESS) {
diff --git a/src/main/java/baritone/utils/BlockStateInterface.java b/src/main/java/baritone/utils/BlockStateInterface.java
index 7a8420d8e2..afd4e5d756 100644
--- a/src/main/java/baritone/utils/BlockStateInterface.java
+++ b/src/main/java/baritone/utils/BlockStateInterface.java
@@ -53,6 +53,8 @@ public class BlockStateInterface {
private final boolean useTheRealWorld;
+ public final boolean assumeWalkOnWater;
+
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
public BlockStateInterface(IPlayerContext ctx) {
@@ -69,6 +71,7 @@ public BlockStateInterface(IPlayerContext ctx, boolean copyLoadedChunks) {
this.provider = (ClientChunkCache) world.getChunkSource();
}
this.useTheRealWorld = !Baritone.settings().pathThroughCachedOnly.value;
+ this.assumeWalkOnWater = Baritone.settings().assumeWalkOnWater.value;
if (!ctx.minecraft().isSameThread()) {
throw new IllegalStateException("BlockStateInterface must be constructed on the main thread");
}
diff --git a/src/main/java/baritone/utils/GuiClick.java b/src/main/java/baritone/utils/GuiClick.java
index fb8410134c..369104a136 100644
--- a/src/main/java/baritone/utils/GuiClick.java
+++ b/src/main/java/baritone/utils/GuiClick.java
@@ -22,6 +22,7 @@
import baritone.api.pathing.goals.GoalBlock;
import baritone.api.utils.BetterBlockPos;
import baritone.api.utils.Helper;
+import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.ChatFormatting;
@@ -92,7 +93,7 @@ public void extractBackground(GuiGraphicsExtractor grapics, int mouseX, int mous
@Override
public boolean mouseReleased(MouseButtonEvent event) {
if (currentMouseOver != null) { //Catch this, or else a click into void will result in a crash
- if (event.button() == 0) {
+ if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
if (clickStart != null && !clickStart.equals(currentMouseOver)) {
BaritoneAPI.getProvider().getPrimaryBaritone().getSelectionManager().removeAllSelections();
BaritoneAPI.getProvider().getPrimaryBaritone().getSelectionManager().addSelection(BetterBlockPos.from(clickStart), BetterBlockPos.from(currentMouseOver));
@@ -107,7 +108,7 @@ public boolean mouseReleased(MouseButtonEvent event) {
} else {
BaritoneAPI.getProvider().getPrimaryBaritone().getCustomGoalProcess().setGoalAndPath(new GoalBlock(currentMouseOver));
}
- } else if (event.button() == 1) {
+ } else if (event.button() == InputConstants.MOUSE_BUTTON_RIGHT) {
BaritoneAPI.getProvider().getPrimaryBaritone().getCustomGoalProcess().setGoalAndPath(new GoalBlock(currentMouseOver.above()));
}
}
diff --git a/src/main/java/baritone/utils/IRenderer.java b/src/main/java/baritone/utils/IRenderer.java
index 4cf39bdac0..59d8005a17 100644
--- a/src/main/java/baritone/utils/IRenderer.java
+++ b/src/main/java/baritone/utils/IRenderer.java
@@ -22,17 +22,14 @@
import baritone.utils.accessor.IEntityRenderManager;
import baritone.utils.accessor.IRenderPipelines;
import baritone.utils.accessor.IRenderType;
-import com.mojang.blaze3d.pipeline.BlendFunction;
-import com.mojang.blaze3d.pipeline.ColorTargetState;
-import com.mojang.blaze3d.pipeline.DepthStencilState;
-import com.mojang.blaze3d.pipeline.RenderPipeline;
-import com.mojang.blaze3d.platform.CompareOp;
-import com.mojang.blaze3d.platform.BlendFactor;
-import com.mojang.blaze3d.PrimitiveTopology;
-import com.mojang.blaze3d.vertex.*;
+import com.mojang.blaze3d.systems.RenderSystem;
+import com.mojang.blaze3d.vertex.BufferBuilder;
+import com.mojang.blaze3d.vertex.DefaultVertexFormat;
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.renderpearl.api.pipeline.*;
import net.minecraft.client.Minecraft;
-import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.BindGroupLayouts;
+import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.StagedVertexBuffer;
import net.minecraft.client.renderer.blockentity.BeaconRenderer;
import net.minecraft.client.renderer.rendertype.RenderSetup;
@@ -45,6 +42,8 @@
import net.minecraft.world.phys.Vec3;
import java.awt.*;
+import java.util.Optional;
+import java.util.OptionalDouble;
import java.util.function.BiFunction;
public interface IRenderer {
@@ -73,6 +72,7 @@ public interface IRenderer {
RenderPipeline BEACON_BEAM_OPAQUE = ((IRenderPipelines) new RenderPipelines()).baritone$registerPipeline(RenderPipeline.builder(BARITONE_BEACON_BEAM_SNIPPET)
.withLocation("pipeline/baritone_beacon_beam_opaque")
+ .withColorTargetState(new ColorTargetState(BlendFunction.TRANSLUCENT))
.withDepthStencilState(new DepthStencilState(CompareOp.ALWAYS_PASS, false))
.withCull(true)
.build());
@@ -145,7 +145,17 @@ static void endBuffer(BufferBuilder bufferBuilder, RenderType renderType) {
stagedVertexBuffer.upload();
StagedVertexBuffer.ExecuteInfo info = stagedVertexBuffer.getExecuteInfo(draw[0]);
if (info != null) {
- renderType.prepare().drawFromBuffer(info);
+ var preparedRenderType = renderType.prepare();
+ var renderTarget = Minecraft.getInstance().gameRenderer.mainRenderTarget();
+ try (var renderPass = RenderSystem.getDevice().createCommandEncoder().createRenderPass(
+ () -> "Baritone Render Pass",
+ renderTarget.getColorTextureView(),
+ Optional.empty(),
+ renderTarget.getDepthTextureView(),
+ OptionalDouble.empty())
+ ) {
+ preparedRenderType.drawFromBuffer(info, renderPass);
+ }
}
stagedVertexBuffer.endFrame();
}
diff --git a/src/main/java/baritone/utils/PathRenderer.java b/src/main/java/baritone/utils/PathRenderer.java
index 6339303e05..3cc75fb7fc 100644
--- a/src/main/java/baritone/utils/PathRenderer.java
+++ b/src/main/java/baritone/utils/PathRenderer.java
@@ -359,7 +359,7 @@ private static void renderGoalXZBeaconLayer(PoseStack stack, double height, floa
stack.translate(0.5D, 0.0D, 0.5D);
if (!translucent) {
stack.pushPose();
- stack.mulPose(Axis.YP.rotationDegrees(time * 2.25F - 45.0F));
+ stack.rotateDegrees(Axis.YP, time * 2.25F - 45.0F);
}
float v0 = -1.0F + scroll;
diff --git a/src/main/java/baritone/utils/ToolSet.java b/src/main/java/baritone/utils/ToolSet.java
index eca79d90bf..5743aed3a9 100644
--- a/src/main/java/baritone/utils/ToolSet.java
+++ b/src/main/java/baritone/utils/ToolSet.java
@@ -35,9 +35,9 @@
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
@@ -60,6 +60,14 @@ public class ToolSet {
private final LocalPlayer player;
+ /**
+ * Snapshot of the hotbar taken on construction. The pathing thread must see one fixed cost function for
+ * the whole calculation, and it must not touch the live inventory off the main thread.
+ */
+ private final ItemStack[] hotbar = new ItemStack[9];
+
+ private final int selectedSlot;
+
/**
* Used for evaluating the material cost of a tool.
* see {@link #getMaterialCost(ItemStack)}
@@ -75,8 +83,12 @@ public class ToolSet {
);
public ToolSet(LocalPlayer player) {
- breakStrengthCache = new HashMap<>();
+ breakStrengthCache = new ConcurrentHashMap<>();
this.player = player;
+ for (int i = 0; i < 9; i++) {
+ hotbar[i] = player.getInventory().getItem(i);
+ }
+ this.selectedSlot = player.getInventory().getSelectedSlot();
if (Baritone.settings().considerPotionEffects.value) {
double amplifier = potionAmplifier();
@@ -143,7 +155,7 @@ public int getBestSlot(Block b, boolean preferSilkTouch, boolean pathingCalculat
possible, this lets us make pathing depend on the actual tool to be used (if auto tool is disabled)
*/
if (!Baritone.settings().autoTool.value && pathingCalculation) {
- return player.getInventory().getSelectedSlot();
+ return selectedSlot;
}
int best = 0;
@@ -152,7 +164,7 @@ possible, this lets us make pathing depend on the actual tool to be used (if aut
boolean bestSilkTouch = false;
BlockState blockState = b.defaultBlockState();
for (int i = 0; i < 9; i++) {
- ItemStack itemStack = player.getInventory().getItem(i);
+ ItemStack itemStack = hotbar[i];
if (!Baritone.settings().useSwordToMine.value && itemStack.is(ItemTags.SWORDS)) {
continue;
}
@@ -188,7 +200,7 @@ possible, this lets us make pathing depend on the actual tool to be used (if aut
* @return A double containing the destruction ticks with the best tool
*/
private double getBestDestructionTime(Block b) {
- ItemStack stack = player.getInventory().getItem(getBestSlot(b, false, true));
+ ItemStack stack = hotbar[getBestSlot(b, false, true)];
return calculateSpeedVsBlock(stack, b.defaultBlockState()) * avoidanceMultiplier(b);
}
diff --git a/src/main/java/baritone/utils/accessor/IRenderPipelines.java b/src/main/java/baritone/utils/accessor/IRenderPipelines.java
index 35ebca33b9..6ef174f140 100644
--- a/src/main/java/baritone/utils/accessor/IRenderPipelines.java
+++ b/src/main/java/baritone/utils/accessor/IRenderPipelines.java
@@ -17,7 +17,7 @@
package baritone.utils.accessor;
-import com.mojang.blaze3d.pipeline.RenderPipeline;
+import com.mojang.renderpearl.api.pipeline.RenderPipeline;
public interface IRenderPipelines {
RenderPipeline.Snippet getLinesSnippet();
diff --git a/src/main/java/baritone/utils/pathing/Avoidance.java b/src/main/java/baritone/utils/pathing/Avoidance.java
index dcaf6e8a57..5e5f171cb5 100644
--- a/src/main/java/baritone/utils/pathing/Avoidance.java
+++ b/src/main/java/baritone/utils/pathing/Avoidance.java
@@ -23,7 +23,7 @@
import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.Mob;
-import net.minecraft.world.entity.monster.EnderMan;
+import net.minecraft.world.entity.monster.Enderman;
import net.minecraft.world.entity.monster.spider.Spider;
import net.minecraft.world.entity.monster.zombie.ZombifiedPiglin;
@@ -76,7 +76,7 @@ public static List create(IPlayerContext ctx) {
.filter(entity -> entity instanceof Mob)
.filter(entity -> (!(entity instanceof Spider)) || ctx.player().getLightLevelDependentMagicValue() < 0.5)
.filter(entity -> !(entity instanceof ZombifiedPiglin) || ((ZombifiedPiglin) entity).getLastHurtByMob() != null)
- .filter(entity -> !(entity instanceof EnderMan) || ((EnderMan) entity).isCreepy())
+ .filter(entity -> !(entity instanceof Enderman) || ((Enderman) entity).isCreepy())
.forEach(entity -> res.add(new Avoidance(entity.blockPosition(), mobCoeff, Baritone.settings().mobAvoidanceRadius.value)));
}
return res;
diff --git a/src/main/java/baritone/utils/pathing/Favoring.java b/src/main/java/baritone/utils/pathing/Favoring.java
index bbc23c7ff5..f4e62cff1d 100644
--- a/src/main/java/baritone/utils/pathing/Favoring.java
+++ b/src/main/java/baritone/utils/pathing/Favoring.java
@@ -39,7 +39,7 @@ public Favoring(IPlayerContext ctx, IPath previous, CalculationContext context)
public Favoring(IPath previous, CalculationContext context) { // create one just from previous path, no mob avoidances
favorings = new Long2DoubleOpenHashMap();
favorings.defaultReturnValue(1.0D);
- double coeff = context.backtrackCostFavoringCoefficient;
+ double coeff = Math.max(context.backtrackCostFavoringCoefficient, 0.01); // a coefficient of 0 or below would make actionCost non-positive, which A* rejects
if (coeff != 1D && previous != null) {
previous.positions().forEach(pos -> favorings.put(BetterBlockPos.longHash(pos), coeff));
}
diff --git a/src/main/java/baritone/utils/schematic/litematica/LitematicaHelper.java b/src/main/java/baritone/utils/schematic/litematica/LitematicaHelper.java
index a4b806f15b..b8e1e69813 100644
--- a/src/main/java/baritone/utils/schematic/litematica/LitematicaHelper.java
+++ b/src/main/java/baritone/utils/schematic/litematica/LitematicaHelper.java
@@ -26,7 +26,6 @@
import fi.dy.masa.litematica.schematic.placement.SchematicPlacement;
import fi.dy.masa.litematica.schematic.placement.SubRegionPlacement;
import fi.dy.masa.litematica.world.SchematicWorldHandler;
-import fi.dy.masa.litematica.world.WorldSchematic;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Vec3i;
import net.minecraft.world.level.Level;
@@ -37,6 +36,7 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
+import java.util.function.Supplier;
/**
* Helper class that provides access or processes data related to Litmatica schematics.
@@ -99,7 +99,11 @@ public static Pair getSchematic(int i) {
int minY = Integer.MAX_VALUE;
int minZ = Integer.MAX_VALUE;
HashMap subRegions = new HashMap<>();
- Level schematicWorld = SchematicWorldHandler.getSchematicWorld();
+
+ // doing this the normal way class loaded WorldSchematic too early
+ Supplier supplier = SchematicWorldHandler::getSchematicWorld;
+ Level schematicWorld = supplier.get();
+
for (Map.Entry entry : placement.getEnabledRelativeSubRegionPlacements().entrySet()) {
SubRegionPlacement subPlacement = entry.getValue();
Vec3i pos = transform(subPlacement.getPos(), placement.getMirror(), placement.getRotation());
diff --git a/src/test/java/baritone/cache/CacheFilesTest.java b/src/test/java/baritone/cache/CacheFilesTest.java
new file mode 100644
index 0000000000..201f5f230f
--- /dev/null
+++ b/src/test/java/baritone/cache/CacheFilesTest.java
@@ -0,0 +1,82 @@
+/*
+ * This file is part of Baritone.
+ *
+ * Baritone is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Baritone is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Baritone. If not, see .
+ */
+
+package baritone.cache;
+
+import baritone.api.cache.IWaypoint;
+import baritone.api.cache.Waypoint;
+import baritone.api.utils.BetterBlockPos;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import static org.junit.Assert.*;
+
+public class CacheFilesTest {
+
+ @Rule
+ public TemporaryFolder folder = new TemporaryFolder();
+
+ @Test
+ public void moveIntoPlaceReplacesExistingFile() throws Exception {
+ Path target = folder.getRoot().toPath().resolve("r.0.0.bcr");
+ Path temp = folder.getRoot().toPath().resolve("r.0.0.bcr.tmp");
+ Files.write(target, "old".getBytes(StandardCharsets.UTF_8));
+ Files.write(temp, "new".getBytes(StandardCharsets.UTF_8));
+
+ CacheFiles.moveIntoPlace(temp, target);
+
+ assertEquals("new", new String(Files.readAllBytes(target), StandardCharsets.UTF_8));
+ assertFalse(Files.exists(temp));
+ }
+
+ @Test
+ public void moveIntoPlaceCreatesMissingFile() throws Exception {
+ Path target = folder.getRoot().toPath().resolve("home.mp4");
+ Path temp = folder.getRoot().toPath().resolve("home.mp4.tmp");
+ Files.write(temp, "new".getBytes(StandardCharsets.UTF_8));
+
+ CacheFiles.moveIntoPlace(temp, target);
+
+ assertEquals("new", new String(Files.readAllBytes(target), StandardCharsets.UTF_8));
+ assertFalse(Files.exists(temp));
+ }
+
+ @Test
+ public void waypointsSurviveSaveAndLoad() throws Exception {
+ Path dir = folder.getRoot().toPath().resolve("waypoints");
+ Waypoint home = new Waypoint("base", IWaypoint.Tag.HOME, new BetterBlockPos(1, 64, -2), 1234L);
+
+ new WaypointCollection(dir).addWaypoint(home);
+ Set loaded = new WaypointCollection(dir).getByTag(IWaypoint.Tag.HOME);
+
+ assertEquals(1, loaded.size());
+ IWaypoint waypoint = loaded.iterator().next();
+ assertEquals("base", waypoint.getName());
+ assertEquals(new BetterBlockPos(1, 64, -2), waypoint.getLocation());
+ assertEquals(1234L, waypoint.getCreationTimestamp());
+ try (Stream files = Files.list(dir)) {
+ assertTrue("temporary file left behind", files.noneMatch(p -> p.toString().endsWith(".tmp")));
+ }
+ }
+}
diff --git a/src/test/java/baritone/cache/RegionRetentionPolicyTest.java b/src/test/java/baritone/cache/RegionRetentionPolicyTest.java
new file mode 100644
index 0000000000..58b6584cc2
--- /dev/null
+++ b/src/test/java/baritone/cache/RegionRetentionPolicyTest.java
@@ -0,0 +1,92 @@
+/*
+ * This file is part of Baritone.
+ *
+ * Baritone is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Baritone is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Baritone. If not, see .
+ */
+
+package baritone.cache;
+
+import baritone.cache.RegionRetentionPolicy.Candidate;
+import it.unimi.dsi.fastutil.longs.LongList;
+import org.junit.Test;
+
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class RegionRetentionPolicyTest {
+
+ private static final long MB = 1024 * 1024;
+ private static final long NOW = 1_000_000_000L;
+ private static final long IDLE = 180_000;
+
+ @Test
+ public void dirtyRegionsAreNeverSelected() {
+ List regions = List.of(
+ new Candidate(1, NOW - 10 * IDLE, true, 27 * MB),
+ new Candidate(2, NOW - 10 * IDLE, true, 27 * MB));
+ assertTrue(RegionRetentionPolicy.select(regions, 0, IDLE, NOW, false).isEmpty());
+ assertTrue(RegionRetentionPolicy.select(regions, 0, IDLE, NOW, true).isEmpty());
+ }
+
+ @Test
+ public void idleCleanRegionsGoRegardlessOfBudget() {
+ List regions = List.of(
+ new Candidate(1, NOW - IDLE - 1, false, 27 * MB),
+ new Candidate(2, NOW - 1_000, false, 27 * MB));
+ LongList victims = RegionRetentionPolicy.select(regions, 1024 * MB, IDLE, NOW, false);
+ assertEquals(1, victims.size());
+ assertEquals(1, victims.getLong(0));
+ }
+
+ @Test
+ public void overBudgetEvictsLeastRecentlyUsedFirstUntilUnderBudget() {
+ List regions = List.of(
+ new Candidate(1, NOW - 60_000, false, 27 * MB),
+ new Candidate(2, NOW - 30_000, false, 27 * MB),
+ new Candidate(3, NOW - 90_000, false, 27 * MB),
+ new Candidate(4, NOW - 5_000, false, 27 * MB));
+ LongList victims = RegionRetentionPolicy.select(regions, 60 * MB, IDLE, NOW, false);
+ assertEquals(List.of(3L, 1L), victims);
+ }
+
+ @Test
+ public void regionsTouchedWithinGraceStayEvenWhenOverBudget() {
+ List regions = List.of(
+ new Candidate(1, NOW - 500, false, 27 * MB),
+ new Candidate(2, NOW - 100, false, 27 * MB));
+ assertTrue(RegionRetentionPolicy.select(regions, 0, IDLE, NOW, false).isEmpty());
+ }
+
+ @Test
+ public void dirtyBytesStillCountAgainstTheBudget() {
+ List regions = List.of(
+ new Candidate(1, NOW - 10_000, true, 100 * MB),
+ new Candidate(2, NOW - 10_000, false, 27 * MB));
+ LongList victims = RegionRetentionPolicy.select(regions, 110 * MB, IDLE, NOW, false);
+ assertEquals(List.of(2L), victims);
+ assertFalse(victims.contains(1L));
+ }
+
+ @Test
+ public void everythingSelectsEveryCleanRegion() {
+ List regions = List.of(
+ new Candidate(1, NOW, false, 27 * MB),
+ new Candidate(2, NOW, true, 27 * MB),
+ new Candidate(3, NOW - 1, false, 27 * MB));
+ assertEquals(List.of(1L, 3L), RegionRetentionPolicy.select(regions, 1024 * MB, IDLE, NOW, true));
+ }
+}
diff --git a/src/test/java/baritone/cache/WorldProviderFolderNameTest.java b/src/test/java/baritone/cache/WorldProviderFolderNameTest.java
new file mode 100644
index 0000000000..56a7e7511c
--- /dev/null
+++ b/src/test/java/baritone/cache/WorldProviderFolderNameTest.java
@@ -0,0 +1,66 @@
+/*
+ * This file is part of Baritone.
+ *
+ * Baritone is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Baritone is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Baritone. If not, see .
+ */
+
+package baritone.cache;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.Assert.assertEquals;
+
+public class WorldProviderFolderNameTest {
+
+ @Test
+ public void hostnamesAreUntouched() {
+ assertEquals("2b2t.org", WorldProvider.sanitizeFileName("2b2t.org"));
+ assertEquals("realms", WorldProvider.sanitizeFileName("realms"));
+ }
+
+ @Test
+ public void directIpMatchesWhatWindowsAlwaysProduced() {
+ assertEquals("217.77.15.230_25571", WorldProvider.sanitizeFileName("217.77.15.230:25571"));
+ }
+
+ @Test
+ public void everyWindowsReservedCharacterIsReplaced() {
+ assertEquals("a_b_c_d_e_f_g_h_i", WorldProvider.sanitizeFileName("a\\b/c:d*e?f\"gi"));
+ assertEquals("x_y", WorldProvider.sanitizeFileName("x|y"));
+ assertEquals("tab_", WorldProvider.sanitizeFileName("tab\t"));
+ }
+
+ @Test
+ public void trailingDotsAndSpacesAreStripped() {
+ assertEquals("host_1", WorldProvider.sanitizeFileName("host:1. "));
+ assertEquals("_", WorldProvider.sanitizeFileName("..."));
+ }
+
+ @Test
+ public void existingRawDirectoryKeepsBeingUsed() throws IOException {
+ Path dir = Files.createTempDirectory("baritone-folder-test");
+ try {
+ assertEquals("host_25565", WorldProvider.serverFolderName(dir, "host:25565"));
+ Files.createDirectory(dir.resolve("host:25565"));
+ assertEquals("host:25565", WorldProvider.serverFolderName(dir, "host:25565"));
+ } finally {
+ Files.deleteIfExists(dir.resolve("host:25565"));
+ Files.delete(dir);
+ }
+ }
+}
diff --git a/src/test/java/baritone/pathing/goals/GoalDistanceTest.java b/src/test/java/baritone/pathing/goals/GoalDistanceTest.java
new file mode 100644
index 0000000000..00ba3013a2
--- /dev/null
+++ b/src/test/java/baritone/pathing/goals/GoalDistanceTest.java
@@ -0,0 +1,68 @@
+package baritone.pathing.goals;
+
+import baritone.api.pathing.goals.GoalNear;
+import baritone.api.pathing.goals.GoalRunAway;
+import net.minecraft.core.BlockPos;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class GoalDistanceTest {
+
+ @Test
+ public void nearDoesNotAcceptDistantPositionsAfterIntegerOverflow() {
+ GoalNear goal = new GoalNear(new BlockPos(0, 64, 0), 2);
+ assertTrue(goal.isInGoal(2, 64, 0));
+ assertFalse(goal.isInGoal(3, 64, 0));
+ for (int distance : new int[]{50_000, 65_536, 30_000_000}) {
+ assertFalse(goal.isInGoal(distance, 64, 0));
+ assertFalse(goal.isInGoal(0, 64, -distance));
+ }
+ }
+
+ @Test
+ public void nearSupportsRadiiWhoseSquaresExceedAnInteger() {
+ GoalNear goal = new GoalNear(new BlockPos(-1_000_000, 64, 1_000_000), 50_000);
+ assertTrue(goal.isInGoal(-1_000_000, 64, 1_000_000));
+ assertTrue(goal.isInGoal(-950_000, 64, 1_000_000));
+ assertFalse(goal.isInGoal(-949_999, 64, 1_000_000));
+ }
+
+ @Test
+ public void runawayDistanceWorksAcrossTheWorld() {
+ GoalRunAway goal = new GoalRunAway(100, new BlockPos(0, 64, 0));
+ assertFalse(goal.isInGoal(99, 64, 0));
+ assertTrue(goal.isInGoal(100, 64, 0));
+ assertTrue(goal.isInGoal(50_000, 64, 0));
+ assertTrue(goal.isInGoal(0, 64, -65_536));
+ assertTrue(new GoalRunAway(100, new BlockPos(-30_000_000, 64, 0))
+ .isInGoal(30_000_000, 64, 0));
+ }
+
+ @Test
+ public void runawayPreservesLargeAndFractionalRequestedDistances() {
+ GoalRunAway goal = new GoalRunAway(100_000, new BlockPos(0, 64, 0));
+ assertFalse(goal.isInGoal(99_999, 64, 0));
+ assertTrue(goal.isInGoal(100_000, 64, 0));
+ assertFalse(new GoalRunAway(1.1, new BlockPos(0, 64, 0)).isInGoal(1, 64, 0));
+ }
+
+ @Test
+ public void runawayStillChecksEveryOriginAndRequestedHeight() {
+ GoalRunAway goal = new GoalRunAway(10, Integer.valueOf(70),
+ new BlockPos(0, 64, 0), new BlockPos(100, 64, 0));
+ assertFalse(goal.isInGoal(95, 70, 0));
+ assertFalse(goal.isInGoal(50, 69, 0));
+ assertTrue(goal.isInGoal(50, 70, 0));
+ }
+
+ @Test
+ public void equalRunawayGoalsWithoutHeightHaveEqualHashes() {
+ GoalRunAway first = new GoalRunAway(1.1, new BlockPos(0, 64, 0));
+ GoalRunAway second = new GoalRunAway(1.1, new BlockPos(0, 64, 0));
+ assertEquals(first, second);
+ assertEquals(first.hashCode(), second.hashCode());
+ }
+}
diff --git a/src/test/java/baritone/pathing/movement/ActionCostsTest.java b/src/test/java/baritone/pathing/movement/ActionCostsTest.java
index a6d7e3e720..4cae886678 100644
--- a/src/test/java/baritone/pathing/movement/ActionCostsTest.java
+++ b/src/test/java/baritone/pathing/movement/ActionCostsTest.java
@@ -31,8 +31,8 @@ public void testFallNBlocksCost() {
double blocks = ticksToBlocks(FALL_N_BLOCKS_COST[i]);
assertEquals(blocks, i, 0.00000000001); // If you add another 0 the test fails at i=989 LOL
}
- assertEquals(FALL_1_25_BLOCKS_COST, 6.2344, 0.00001);
- assertEquals(FALL_0_25_BLOCKS_COST, 3.0710, 0.00001);
+ assertEquals(FALL_1_25_BLOCKS_COST, 5.2344, 0.00001);
+ assertEquals(FALL_0_25_BLOCKS_COST, 2.0710, 0.00001);
assertEquals(JUMP_ONE_BLOCK_COST, 3.1634, 0.00001);
}
@@ -40,10 +40,10 @@ public double ticksToBlocks(double ticks) {
double fallDistance = 0;
int integralComponent = (int) Math.floor(ticks);
for (int tick = 0; tick < integralComponent; tick++) {
- fallDistance += velocity(tick);
+ fallDistance += velocity(tick + 1);
}
double partialTickComponent = ticks - Math.floor(ticks);
- double finalPartialTickVelocity = velocity(integralComponent);
+ double finalPartialTickVelocity = velocity(integralComponent + 1);
double finalPartialTickDistance = finalPartialTickVelocity * partialTickComponent;
fallDistance += finalPartialTickDistance;
return fallDistance;