Skip to content

26.3 - #17

Open
dekrom wants to merge 61 commits into
MeteorDevelopment:26.2from
dekrom:26.3
Open

26.3#17
dekrom wants to merge 61 commits into
MeteorDevelopment:26.2from
dekrom:26.3

Conversation

@dekrom

@dekrom dekrom commented Oct 7, 2026

Copy link
Copy Markdown

cabaletta's 26.3 branch (cabaletta#5130) with the Meteor changes commit cherry-picked on top, same layout as the 26.2 and 26.1 branches here. The only conflict was mod_version, now 26.3-SNAPSHOT.

Opened against 26.2 because there is no 26.3 branch in this repo yet; feel free to retarget once it exists.

Built with ./gradlew build, published to mavenLocal and tested in a 26.3 dev client together with the meteor-client 26.3 update (PR follows): Baritone loads, the mixin audit passes, and it initialises in a world and saves its cache.

dekrom and others added 30 commits August 25, 2026 13:30
The 26.2 port replaced the removed vanilla constant
LevelChunkSection.SECTION_SIZE (4096, blocks per section) with a local
SECTION_SIZE = 16, so the all-solid fast path memset 2 bytes at offset
y0*2 instead of 512 bytes at y0*512 (SIZEOF_X16). Every section without
air in its palette was handed to nether-pathfinder as open air, which
made elytra path through solid terrain and fail constantly.
As of mc 26.2 the game awaits non-daemon threads on stop and crash
reports if any are still alive.
velocity(0) is 0, so the first iteration of fallTicks always consumed a tick
without covering any distance. Every fall was priced one tick (and the test's
own reimplementation one tick of distance) too expensive, which made falling
look worse than it is relative to walking down.
The fall ends at newY + 1 when there is a block to land on, so the drop is one
block shorter than the one that was probed. Both the plain landing and the
water bucket variant were charged for the extra block.
The max corner was folded against the running min instead of against itself,
so with more than one position the box collapsed towards the minimum corner
and isInGoal reported positions outside it as inside.
The second corner was tested for lava but its magma check looked at the first
corner again, so a diagonal could cut over a magma block with
allowWalkOnMagmaBlocks off.
height() is the number of blocks in the dimension, not the top y level. In the
overworld (minY -64, height 384) the ceiling check let the search climb to
y=384, 64 blocks above the build limit, and every node up there was wasted
work. minY + height is the actual top.
A source water block reached the "it is just water, walk through it" return
without ever asking the block itself. Waterlogged fences, walls, panes, bars,
stairs and leaves all keep their own collision, so the pathfinder walked into
them.
fullyPassable listed its exceptions one block at a time, so anything that
blocks walking but is not in that list - powder snow, honey, berry bushes,
dripstone, whatever the user put in blocksToAvoid - was still considered
jumpable. The hitbox is the same width in the air as on the ground.
heuristic() is what estimatedTicksToGoal treats as "we are there", and the
default of 0 is wrong for a goal whose own heuristic never reaches 0: standing
on top of the block still scores a full jump. The reported ETA never counted
down to zero and kept ticking after arrival.
A coefficient of 0 or less multiplies out to a non positive action cost, which
A* rejects outright, so the whole calculation fails instead of the setting just
not favoring anything.
ToolSet read the live inventory and cached break speeds in a HashMap, and
MovementHelper read assumeWalkOnWater straight off the settings, all from the
calculation thread. Reading the inventory off thread is a data race against the
game thread, and a setting that changes mid calculation makes the cost function
inconsistent with the nodes already expanded.

The hotbar and the selected slot are now copied on construction, the break
speed cache is concurrent, and assumeWalkOnWater is read once per
BlockStateInterface.
It is set by the game thread and read by the calculation thread, so without it
there is nothing that says the search ever has to notice the cancellation.
bestPathSoFar allocates a BetterBlockPos per node and was called on every tick
that a calculation was in progress, before the cheap checks that almost always
decide the outcome. PathExecutor built an entire CalculationContext per tick to
read two fields off it.
Finding the nearest node buried in terrain used to just return and hope a later
tick had fixed it, which nothing does, so the solver kept aiming at a node
inside a wall until it flew into one. Recalculate instead, rejoining the
existing path at the last node that is still in open air and still closer to
the destination than we are.
The simulated step had drifted from LivingEntity.updateFallFlyingMovement:

- gravity was the hardcoded 0.08 rather than the player's GRAVITY attribute,
  and Slow Falling was not accounted for at all
- the cosine term came from Mth's lookup table and was multiplied by a
  min(1, lookDirection.length() / 0.4) factor that vanilla does not have
- the "looking down" branch divided by pitchBase2 without vanilla's guard that
  it is greater than zero, so looking straight up or down produced infinities
- drag used float literals, which round differently from the doubles vanilla
  actually multiplies by

Every tick of error compounds over the simulated horizon, and the horizon is
what the solver picks a pitch from, so this is the difference between clearing
a ridge and flying into it.
When solveAngles runs out of relaxations it returned a solution that only had a
yaw, leaving the pitch at whatever it happened to be, which in practice means
holding the last pitch straight into the terrain. That is the case that matters
most: it is what happens when the world changed under a path, when a wall
appeared, or when the bot got knocked off course.

Fall back to simulating the pitch range and flying the trajectory that stays
clear the longest, preferring the one that ends higher on a tie, so the segment
recalculation has time to land. If nothing clears more than a dozen ticks and a
firework provably buys more than two ticks, force one.
How far ahead of the player the solver looks for a node to aim at was a bare 20
in the middle of solveAngles. At firework speed 20 nodes is not much runway, and
raising it is the knob that decides how much the bot cuts corners, so it belongs
in the settings.
elytraAutoJump only knew how to walk to an overhang and step off it. Everywhere
that has no overhang within reach - which on 2b2t is most of the overworld and
most of a nether highway - it spent a couple of seconds pathfinding and then
told the user to go find a cliff. It also had three ways to end up standing
still forever: a leftover path segment that blocks the walk off calculation from
ever starting, a walk off path that reaches its goal by stepping down instead of
falling, and a GoalYLevel that is already satisfied where we stand (landing at
exactly y=31 in the nether hit this every time).

Add a standing takeoff: jump, open the elytra on the way up the same way the
vanilla double tap does, and light a firework the moment it opens, because a
glide that starts with no speed a block and a half off the floor only ends one
way. The elytra path is computed before leaving the ground, since the handful of
ticks between opening the elytra and hitting the floor is no time to compute
one. Guarded by elytraStandingTakeoff, capped at three attempts so a bad spot
cannot burn a stack of rockets, and each of the stall cases now has a way out.
The README was still upstream's, down to the dead Travis badge and download
links for Minecraft versions this fork does not build. Replace it with what
this fork actually is, which versions it covers and what it changes.

The release workflow named every jar <mc>-SNAPSHOT regardless of the tag, so
two releases of the same Minecraft version shipped identically named files.
Take the version from the tag, the way the CI build already does, and fail the
release rather than publishing an empty one if the build produced no jars.
Releases run the tests now.
PR cabaletta#5058 sourced ServerLevelStub's RegistryAccess from client.level so that
modded loot tables would resolve. However, loot tables are populated only by
the ReloadableServerRegistries reload performed in load(); a client
RegistryAccess never receives them. In game (client.level != null) every loot
table lookup therefore threw, and the catch (Throwable) widened by that same
PR silently swallowed it, leaving an empty drops list. The visible effect was
that #mine stopped picking up items entirely.

Revert that half so drops resolve again. The widened catch is kept, so modded
blocks whose tables are absent degrade to no known drops instead of crashing,
which preserves what cabaletta#5058 set out to fix.

Reported-by: ZacSharp
Co-Authored-By: Claude <noreply@anthropic.com>
dekrom and others added 20 commits September 1, 2026 19:55
generateDirectPath divides 8 by the horizontal distance to the destination to
get its step vector, so a destination directly above or below the player made
every step NaN and the whole path with it.

The fallback for a failed transition compares distanceXZ against 32 to decide
whether to aim at the destination itself or at the build limit above it.
distanceXZ is a squared distance, so anything past 6 blocks took the first
branch and the roof route was never used.
destroy() shuts the read and write executors down and waits for them, then calls
freeContext. That covers path calculation and chunk packing, and nothing else.
The solver thread raytraces through the context outside those executors, and so
does the game thread - setPath's clearView runs on whichever thread the path
future completed on, with no lock at all. Freeing the context out from under one
of those is a use-after-free on native memory, and
BlockStateOctreeInterface.chunkPtr caches a raw pointer into it besides.

Take the write lock around freeContext, drop the cached chunk pointer, and set a
destroyed flag under the same lock. Every native entry point now checks it under
the read lock and fails safe: nothing is visible, nothing is passable, every
raytrace hits. passable() is the solver's innermost loop and its callers already
hold the read lock, so it reads the flag without taking it again.

Queueing work after destroy() rejects instead of throwing out of
queueForPacking/queueBlockUpdate/queueCacheCulling, and pathFindAsync returns a
failed future rather than propagating RejectedExecutionException to a caller that
expects a future.

Also cut the native path calculation timeout from 10s to 3s. NetherPathfinder's
cancel() is a no-op - the JNI sets ctx->cancelFlag while the search loop polls an
unrelated file scope global - so that timeout is the only bound on how long
destroy() can sit waiting for a search that is never going to finish. Searches
that succeed return within a few hundred ms of first progress; only hopeless ones
run the clock out.
Cancelling #elytra tears the behavior and the pathfinder context down on the
executor and hands the semaphore back when the context is freed. Both halves
were wrong.

The two teardowns were submitted independently, so the context could be freed
while the behavior's solver and its still-queued path futures were still calling
into it. Chain them: the context is destroyed only after the behavior is gone.

Worse, the next engage went through getNpfContext, which blocks on that semaphore
uninterruptibly - on the game thread. A path calculation cannot be cancelled and
runs to its timeout, so anything that re-issued a goal shortly after a cancel
froze the client for the rest of it. pathTo0 now tries the semaphore, and if the
old context is still going down it defers the engage onto the teardown's
completion instead of waiting for it. An epoch counter, bumped by every
onLostControl and by leaving the world, drops that deferred engage if anything
else has happened in the meantime.

onLostControl itself is called from the path future's failure handler, which is a
pathfinder worker thread, and touches nothing but game thread state. It
marshals itself back now.

Finally, a context that fails a path calculation usually keeps failing: a region
file it could not parse is never retried, so the hole stays. Combined with a
caller that re-issues its goal on a timer, that is an endless stream of "Failed
to compute path to destination" and no way out short of a relog. Three
consecutive failures now discard the context so the next attempt starts from a
clean cache - skipped while flying, since resetting mid air would drop the elytra
outright.
pathFindAsync picked its lock and executor by elytraPredictTerrain: the write
lock when the search may generate terrain, the read lock otherwise, on the
theory that a search that only reads the chunk cache can share it with the
solvers. It cannot. With elytraUseCache on, pathFind parses Baritone's region
files into the cache the first time a search touches them, prediction or not,
and every insert can rehash the map. The two solvers run getChunkOrDefault under
the same read lock, and on the native side that call is a plain find with no
mutex of its own. A rehash under a bucket walk is heap corruption, and it showed
up as a SIGSEGV in getChunkOrDefault on the render thread, mid flight, with
terrain prediction off - the one configuration that used the read lock.

A path calculation is always a writer. The prediction flag now only decides
whether an unloaded chunk counts as air. The cost is that the render thread's
solver skips its tick while a recomputation runs, which is what the prediction
path has always done.
destroy() waited for the executors without limit. The native cancel is a no-op,
so an in-flight search runs to its own timeout, and that timeout does not cover
findAir's breadth-first search for a start cube or the region-file parsing a
search can trigger. A search wedged in either pinned the teardown forever, and
the teardown is what hands npfSema back: every later engage found the semaphore
taken, deferred itself onto a completion that never came, and was dropped by the
next onLostControl. One hung teardown was #elytra silently dead for the rest of
the session, with nothing in the log to say so.

Wait six seconds per executor - twice the native timeout - and if a search is
still running after that, abandon the context: mark it destroyed so nothing new
calls in and return without freeContext, which would be a use-after-free under
the running search, and without taking the write lock, which that search holds.
The leak is one context per wedged search.
pathRecalcSegment recomputes the path from the player up to a rejoin node and
appends the rest of the old path after it. It did that whether or not the
search reached the rejoin node. The native search gives up 500ms after its last
progress, and a recomputation around an obstacle is often that kind of search:
when it stopped short, the result was the new segment, then a straight jump to
the rejoin node through whatever lay between, and the old path from there. No
obstacle check catches that jump - both of its ends are in open air - so every
recomputation ended the same way, and the solver circled the last node it could
reach, next to a path drawn through solid ground.

An unfinished segment now replaces the path on its own. It is marked incomplete,
so attemptNextSegment picks it up from where the search got to, and a
recomputation all the way to the destination no longer inherits the previous
path's completeness either.
Everything the solver does runs against the pathfinder's own copy of the world:
the pitch simulation, the hitbox raytraces, the obstacle checks along the path.
If that copy disagrees with the client's, the solver aims at terrain it believes
is air and nothing notices, because the raytraces that would catch it look at
the same copy. Cancelling and re-engaging fixed it, by way of repackChunks, and
that was the only thing that did.

Two sources of disagreement. A chunk packet the client discards, because it is
outside the view distance, still raises the chunk event, and the world hands
back its shared empty chunk for it; packing that recorded a chunk of pure air as
real terrain, and the path went straight through whatever is actually there.
Skip those. The other is the mundane one, a chunk whose packing was missed or a
section the block update path never saw, and the fix for that is to notice and
re-read: on a horizontal collision, which the simulation never plans, when the
cache says we are inside a block while we are plainly flying, and when a
recomputation fails, the 3x3 chunks around the player are queued for packing
again, at most once every two seconds.
Where a nether flight ends when the rockets run out is where the next one has
to start, and that is a basalt delta crevice or the hollow beside a lava pond
as often as not: walls a couple of blocks away on every side, and open sky
straight up. The standing takeoff asked for a path from our feet. The
pathfinder starts every path from the nearest 4x4x4 cube of air, and its search
for one walks straight through walls, so from a hole the first node was very
often on the far side of one. The takeoff rocket flew at it, hit the wall,
dropped back into the hole, and did that three times before giving up.

The path now starts from the first clear cube directly above us, up to 32
blocks up, which is about what a rocket lit straight up is good for. The
survival solver is changed to match. With a rocket burning it no longer settles
for the first pitch that survives the horizon but takes the one that ends
highest, since 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. And it forces a
rocket whenever an impact is anywhere inside the horizon instead of within
twelve ticks of it, which from the top of a climb out of a hole meant falling
most of the way back in first.
The standing takeoff lit its rocket in the same tick it opened the elytra, on
the grounds that a glide starting from a standstill a block and a half up has
no time to spare. Logs from 2b2t show what that got us: every standing takeoff
opened the elytra and was back on the ground within half a second, with no push
from the rocket at all. The server never accepted it. 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
rotation of its own, read off the player as the packet is built, which was the
camera's, while the movement packet in the same tick carried the takeoff aim.
Two different looks in one tick is exactly what the server's movement checks
compare, and the rocket went the way of anything that fails them.

So the rocket goes the tick after, once the solver has aimed, and that costs
nothing: the rocket takes a round trip to show up regardless. Every use packet
now carries the rotation the movement packet is about to, via the new
LookBehavior.getRotationForThisTick, by facing it for the duration of the click.

The solver had a rule for that round trip which made this worse. A rocket whose
entity had not arrived yet counted as no rocket, so the tick after lighting one
it planned an unboosted trajectory: a shallow glide, since nothing else goes
anywhere without thrust, pointed at the nearest wall. It now assumes a rocket
it lit within the last ten ticks has been burning since it lit it, and drops
the assumption with the cooldown if the entity never comes. The old two-tick
special case and its boost counter are gone with it; they were shorter than
2b2t's round trip anyway. A rocket that never shows up is logged, and so is an
elytra the server shuts again while we are still in the air right after a
takeoff, since that is the server refusing the glide and worth telling apart
from a takeoff that merely went nowhere.
An elytra opens in lava - vanilla only refuses in water - but it does nothing
there: while we are in a fluid, glide or not, vanilla moves us by the fluid
rules, so the wings are inert and a rocket is the only thrust, at a fraction of
its usual push. Two things went wrong with that. In the air the solver aimed at
the path, and from a lava pond the path leads through the pond's basalt wall,
so the rocket spent itself pushing against rock. And on the ground the process
had no idea it was in lava at all: the landing logic saw a complete path and
went looking for a landing spot, every path it asked for from inside the pool
failed on the spot, and it asked for another one next tick, twenty times a
second, each one a whole new ElytraBehavior.

In lava, everything else waits. ElytraProcess swims up, opens the elytra as
soon as we are off the pool floor, and asks for a path from the first clear
cube above us if there is none yet. The solver, when its context says we are
in lava, skips the path and solves for the steepest climb the simulation keeps
clear, with a rocket assumed to be burning and a rocket forced every time; the
setback delay and landing mode no longer hold that rocket back, since neither
is a reason to keep sitting in lava. The landing search is skipped while we are
in it. The BlockStateInterface the solver reads through is now created before
the empty-path return in onTick0, since the lava escape solves without a path,
and tick() bails if it has not been created yet rather than dereferencing it.
PR cabaletta#5058 sourced ServerLevelStub's RegistryAccess from client.level so that
modded loot tables would resolve. However, loot tables are populated only by
the ReloadableServerRegistries reload performed in load(); a client
RegistryAccess never receives them. In game (client.level != null) every loot
table lookup therefore threw, and the catch (Throwable) widened by that same
PR silently swallowed it, leaving an empty drops list. The visible effect was
that #mine stopped picking up items entirely.

Revert that half so drops resolve again. The widened catch is kept, so modded
blocks whose tables are absent degrade to no known drops instead of crashing,
which preserves what cabaletta#5058 set out to fix.

Reported-by: ZacSharp
Co-Authored-By: Claude <noreply@anthropic.com>
dekrom and others added 8 commits November 4, 2026 14:45
A region only turns clean when it is saved, and with chunkCaching off, or no cache directory, nothing
ever saves it. Every region the packer wrote stayed dirty, the retention policy skips dirty regions,
and the RAM tier grew for the whole session, where the old distance prune dropped them regardless.
Unsaved changes only hold a region in RAM now while they can still be written.
The unload repack was skipped for any chunk the cache held, but that copy can be one loaded from disk
from an earlier session: when the load-time pack is dropped (packer queue over
chunkPackerQueueMaxSize, or the soft reference collected first) the stale terrain stayed cached.
The skip now also needs this load's pack to have run, and none to be still waiting.
(cherry picked from commit 5f73685)
sameBlockstate compares each property's value with !=. The values come out of
BlockState.getValues() as boxed Comparable, so this is reference identity. It
is correct for enum and Boolean properties, whose values are singletons, and
for an IntegerProperty it happens to work today because every vanilla integer
property stays inside the -128..127 range the JVM's Integer cache interns
(the largest, age, tops out at 25). Nothing in the comparison guarantees that,
and identity is not what is meant here; equality is.

Objects.equals to match the three other comparisons in this file. Plain
equals would also be correct: both states share one StateDefinition, so the
lookups cannot return null.

(cherry picked from commit 4aa223d)
CachedRegion and WaypointCollection opened the real file for writing,
which truncates it before the new contents arrive, so a save that does
not finish leaves a half-written file behind. Waypoint loading catches
the resulting exception and ignores it, silently dropping every waypoint
of that tag, and a region load logs a stack trace and loses the cache.

Write to a sibling .tmp file and move it into place instead, so an
interrupted save leaves the previous file intact. The empty region file
that save created up front is gone too, since it would itself be left
behind as a corrupt file if a first save were cut off.

(cherry picked from commit ffa180e)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants