Repository navigation
Compatibility
Notes on interactions with Sable (the sub-level physics dependency that replaced Valkyrien Skies in v0.83.0) and any non-obvious 1.21.1 behaviour we've hit. This page is a running log — add an entry each time something bites us.
For the archived Forge 1.20.1 / VS 2.4.11 notes, see Compatibility (VS legacy).
All entries below apply to dev.ryanhcode:sable:2.0.2+mc1.21.1 running on NeoForge 1.21.1 (21.1.228). (Upgraded from 1.2.1 in DT v0.319.0; the public API DT consumes is unchanged, so the earlier notes still apply.)
Sable shipped a major upstream release (1.2.1 → 2.0.0 → 2.0.2). DT bumped sable_version to 2.0.2+mc1.21.1. Upstream notes: https://github.com/ryanhcode/sable/releases. Changes relevant to DT: reworked sub-level load/unload, fixed POIs not updating on assembly, fixed item-dup on assembly, a client render-pose caching fix, graceful Rapier-panic handling, and a new force-load ticket API (ServerSubLevelContainer.addForceLoadTicket).
API compatibility — verified by bytecode diff of all 15 Sable classes DT imports: every difference is additive (new methods/config only); no method DT calls was removed, renamed, or had its signature changed. The bundled sable-companion math jar is byte-identical (1.6.0 in both versions). The NeoForge floor [21.1.228,) is exactly DT's version, and create/flywheel are optional deps, so the "DT + Sable" modpack is unaffected. → DT compiles and runs against 2.0.2 with zero source changes.
Functional tests (DT v0.319.0):
- Build clean; unit tests pass.
- Runtime: DT + Sable 2.0.2 + Sable Companion 1.6.0 load, all Sable mixin configs apply, and the train assembles and runs (dedicated server and client).
-
Save & Quit — the old Sable #679 hang (
ChunkMap.hasWork()never clears while leakedPlotChunkHolders sit inupdatingChunkMap) no longer fires: a clean world exit took ~0.4 s with 0 leaked PlotChunkHolders swept. Sable 2.0.0's sub-level-unload rework fixes the leak upstream, soShipShutdownEvents' PlotChunkHolder sweep is now a no-op for that path (its separate vanilla-ticket drain still runs).
Performance A/B — server tick time, 2.0.2 vs 1.2.1: headless dedicated server, fixed seed 1234567890, DT preset, one riding player keeping a ~10–12-carriage train loaded, simulation-distance 10. 4 runs/version × 8 tick query samples after a 30 s warmup = 32 samples each; machine otherwise idle.
| Metric | 2.0.2 (new) | 1.2.1 (old) | Δ |
|---|---|---|---|
| Mean avg MSPT | 18.6 ms | 18.2 ms | +1.7% (≈ equal) |
| Mean P95 | 25.8 ms | 29.9 ms | −13.7% |
| Peak P99 | 115.8 ms | 264.2 ms | −56% |
| MSPT / carriage | 1.69 | 1.78 | −5.3% |
Read: average tick time is statistically unchanged (within run-to-run noise, sd ≈ 4 ms), and 2.0.2 roughly halves the worst-case lag spikes (peak P99 116 ms vs 264 ms) — consistent with the sub-level / render-pose reworks. 2.0.2 even carried slightly more carriages per run, so the comparison mildly understates it. Server-side physics only; client FPS not measured.
VS 2 upstream has not shipped a NeoForge 1.21.1 build (as of 2026-04). Sable is actively maintained, ships 1.21.1 builds, and exposes a similar primitive — a "sub-level": a kinematically-driven volume of blocks, separate from the main world chunk grid, that the player can walk around inside while it moves. The migration is one-file behind a Shipyard abstraction (games.brennan.dungeontrain.ship) so swapping physics backends remains a one-file change for everything else in the codebase.
Cosmetic: Sable's coordinate system has a per-ServerLevel "shipyard" region (millions of blocks from origin) where each sub-level's blocks actually live. The TrainTransformProvider driver translates between shipyard space and world space.
Symptom: A Trains.byTrainId(level) call right after TrainAssembler.spawnGroup(...) returns an empty map (or a map missing the just-spawned group) for one to several server ticks afterward.
Cause: Sable registers new sub-levels on the next tick after assembly, not synchronously. SubLevelContainer.getAllSubLevels() returns a snapshot that excludes the freshly-created sub-level until Sable's manager pumps.
Fix patterns:
- For the bootstrap → first-login race: a deferred-retry queue keyed by player UUID, drained on each
LevelTickEvent.Post. SeePlayerJoinEvents.onLevelTickfor the canonical implementation. Capped atMAX_RETRY_TICKS = 100so a stuck race never hangs the player forever. - For the per-tick appender: an authoritative
Trains.SPAWNED_GROUPSregistry — populated synchronously byTrainAssembler.spawnGroup— is the source of truth for "what anchors does this train own?", regardless of whether Sable'sfindAll()has pumped yet. The throttle inTrainCarriageAppendercaps spawns at 1 group per tick to avoid bursting past the lazy load.
Symptom: First-time players spawn 5–10 blocks off the placement that TrainBootstrapEvents carefully cached at server start, leaving a visible jump as our login retry teleports them back.
Cause: ServerPlayer.adjustSpawnLocation(level, sharedSpawnPos) (the 1.21.1 successor to 1.20.1's fudgeSpawnLocation) searches a spawnRadius=10-block square around sharedSpawnPos via PlayerRespawnLogic.getOverworldRespawnPos, which uses the MOTION_BLOCKING heightmap and stops on leaves. Our findGroundY descends through leaves to find the trunk top, so the two heuristics disagree.
Fix: PlayerFudgeSpawnMixin injects at HEAD of adjustSpawnLocation and short-circuits to the cached placement when one is set, so the ClientboundLoginPacket carries our coords directly. See Feature-Login-Spawn for the full pipeline.
Symptom: Kinematic driver appears attached at assembly time but getKinematicDriver() returns null a moment later — train doesn't move.
Cause: Each Shipyards.of(level) call returns a fresh SableShipyard wrapping the level's container, and that wrapper holds a WeakHashMap<ServerSubLevel, SableManagedShip> cache of ManagedShip wrappers. Stateful wrapper data — most importantly the kinematic driver attachment — would be silently lost between calls because each fresh SableShipyard starts with an empty wrapper map.
Fix: A static per-ServerLevel cache in Shipyards backed by a WeakHashMap. Always go through Shipyards.of(level) — never construct SableShipyard directly.
Correction (v0.813.0): that
WeakHashMapdid not stop unloaded levels leaking, becauseSableShipyardholds itsServerLevelstrongly — a value that references its own key makes a weak map permanent. It is now cleared onServerStoppedEvent. See "A handle to a culled carriage kept all of its chunks" below.
Pre-v0.83.0, a whole train was a single VS ship with a rolling-window manager that painted carriages in/out of the ship's voxel grid. PR #119 replaced that with a per-carriage architecture: every carriage is its own Sable sub-level, identified by (trainId, anchorPIdx). Implications:
- No 128-chunk shipyard wall. Each sub-level has its own shipyard allocation; there's no upper bound on a train's length. The successor-train chain (Feature-Train-Chain) was retired as a result.
- Per-tick work is bounded by the active group count, not the all-time carriage count. Old long-session lag from accumulated pIdx drift is gone.
-
Player ↔ sub-level handoff happens at carriage seams. Sable tracks which sub-level the player is "in" via
getContaining(level, pos). The seam between adjacent carriages is mostly invisible to the player but is the validation boundaryDebugCommand.runPair(the/dt debug pairprobe) was added to exercise.
Sable runs one Rapier physics scene per ServerLevel, and SubLevelPhysicsSystem.tickPipelinePhysics steps every resident carriage sub-level together each substep — cost scales with the resident body count, not with how much content is near the player. On a long train (dedicated servers, multiplayer, or the transient resume/save whole-train holds) that means dozens of bodies get physics-ticked though the player rides only a few. Measured: MSPT ≈ 26.4 ms + 0.65 ms × resident-sub-levels.
The obvious fix — pipeline.remove() the distant carriages — crashes (bytecode-verified against 2.0.2): SubLevelPhysicsSystem.updateAllPoses() reads the native pose of every non-removed resident sub-level each tick, so a body-removed-but-still-loaded carriage segfaults on a freed handle. So PhysicsSubstepTuner instead adaptively lowers the level's PhysicsConfigData.substepsPerTick from 2 → 1 once the resident count crosses a threshold (hysteresis 12/8 since v0.922.0; 24/18 before), restoring the baseline when it drops. tickPipelinePhysics reads that field live as its loop bound over the whole per-substep block (four getAllSubLevels() pre-physics loops + the native Rapier3D.step), so 2 → 1 halves all of it. Pure public-API (SubLevelPhysicsSystem.get(level).getConfig()), no fork, no mixin — it never removes a body, unloads a sub-level, or touches residency, so the cull→reload hold fixes are unaffected.
A/B (oscillating 2↔1 on one world, matched body counts, identical terrain): −28% at 19 bodies (15.3 → 11.0 ms), −25% at 20–24 bodies; absolute saving grows with body count. Single-player caps ~22 resident bodies (integrated-server view distance), so with the original 24/18 band the tuner never engaged there. Introduced for issue #642; the durable body-count decoupling (a DT-side mixin gating Sable's per-tick native-body readers) is tracked as #646.
v0.922.0 (#1470) — the band moved to 12/8 because single-player was the regime that hurt. A survival rider reported digs and hits landing seconds late; the dev client showed MSPT 40–105 ms at 18–22 resident sub-levels with ~70 % of the Server thread inside native Rapier3D.step (jstack). Two things worth knowing before touching this again:
-
Soft-freeze (#646 as shipped) does not reduce the native step. It leaves every body in the Rapier scene by design (removal crashed) and only skips Sable's per-body Java loops, so freezing more carriages is not a lever for
Rapier3D.stepcost. Substeps are. - One substep is free for DT's scene. Every carriage is kinematic and teleported once per server tick; there are no constraints, ropes, boxes or dynamic bodies, and entities meet the deck through Sable's own OBB collision, which reads the pose rather than solver output. Seam gaps stayed at 0.40 blocks through a 23-minute ride at one substep.
Why it read as seconds rather than a slow tick: in survival ServerPlayerGameMode counts break time in server ticks (delayedDestroy), so a 2–3× slow tick turns a 1.5 s dig into a 1.5–3 s late break; combat has no such amplifier and simply runs at tick rate. Creative hides the symptom. The [mspt] line now carries physMs= (mean native step per tick, from RapierPipelineTimingMixin), substeps= and blockChanges= — read those before reaching for jstack, and /dungeontrain debug substep-tuner off|on gives a matched A/B in one ride (see Development). Result of the fix on the reporting world: 14–20 ms steady, physMs 4–5, zero Can't keep up in 23 min (nine in six minutes before).
Don't key per-train state on ManagedShip.id() — that's the per-sub-level id. The grouping key is TrainTransformProvider.getTrainId() (a UUID shared across all sub-levels of the same train). Trains.byTrainId(level) is the canonical aggregation helper.
ManagedShip.getKinematicDriver() returns a KinematicDriver interface. Our TrainTransformProvider implements it and adds train-specific state (trainId, pIdx, dims, canonicalPos). When you find a sub-level via Shipyards.of(level).findAll(), the canonical pattern is:
for (ManagedShip ship : Shipyards.of(level).findAll()) {
if (ship.getKinematicDriver() instanceof TrainTransformProvider provider) {
// ... your per-carriage work, using provider.getTrainId() etc ...
}
}Anything that's not a TrainTransformProvider is some other Sable user (or a probe carriage from /dt debug pair); skip it.
Symptom: Spawn placement code lands the player at coordinates ~20,481,000 (the shipyard offset).
Cause: Sable's currentWorldPosition() can return shipyard-translation coordinates in some code paths rather than the world-space pivot. Our TrainTransformProvider.getCanonicalPos() gives the kinematic driver's authoritative world-space center; use it instead.
Fix: PlayerJoinEvents.resolveTrainCenter calls getCanonicalPos() first and only falls back to the spawn anchor if the driver hasn't ticked yet.
Symptom: After a Dungeon Train ran for ~30 seconds, animals, villagers, and armor stands stopped showing up in newly visible carriages. Soak diagnostics (PR #180) revealed every armor stand was discarded at ageTicks=0 — same tick it spawned.
Cause: Two interacting things:
- Sable transforms
Mob(villagers, animals — entities with AI) into ship-local coords onaddFreshEntity, so theirgetX()returns small ship-relative numbers. But non-MobLivingEntity(e.g.ArmorStand) stays at shipyard-absolute world coords (e.g.(20481038, 126, 20489226)). -
TrainTickEvents.killEntitiesAheadsweeps the train's runway each tick. Its "spare interior" filter istrain_aabb.contains(e.getX(), …)wheretrain_aabbis computed in visible-world coords. Armor stands at shipyard coords fail that check and get killed. Mobs survive the first tick but eventually drift out of the contains window as the train moves and get killed too.
Fix: Every contents-spawned entity is now tagged dungeontrain_contents_pidx_<N> (see CarriageContentsPlacer.DT_CONTENTS_TAG_PREFIX). The kill-ahead filter exempts anything carrying that tag prefix. The tag is persistent NBT, so it survives save/load and chunk reload.
Defensive additions in the same PR: entity spawn is now deferred until the carriage's placement-collision tracker confirms placedSuccessfully (60 clean ticks), and Mob instances get PersistenceRequired=true so vanilla despawn can't silently remove them either.
Diagnostics: opt-in via /dungeontrain debug contents-entities on — emits per-entity JOIN/LEAVE log lines with stack trace at LEAVE, plus per-entity spawn lines with UUID. See ContentsEntityDiagnostics.
Context: Shared Carriages round-trips a carriage's blocks to the relay — capture the live blocks, place them back verbatim in another world.
Gotcha: A live carriage's voxels exist only in its Sable sub-level's plot chunks. A host-level read at the same world coordinates — level.getBlockState(pos), or Sable's EmbeddedPlotLevelAccessor which just offsets and delegates to the host — returns air, and the failure is silent (everything reads as empty, so it presents as "the feature does nothing" rather than as an error).
Capture therefore reads directly from ship.subLevel().getPlot().getLoadedChunks() (the idiom already proven in CarriageDeck and VillagerJobSiteAssigner), never from the host level — see CarriageBlockSnapshot.capture.
Placement is the inverse and runs at spawn time, before Sable assembly lifts the blocks into a sub-level — so CarriageBlockSnapshot.place writes at plain world coordinates, exactly like the normal template stamp, and hands the placed positions to Sable's assemble. Placing after assembly would hit the same host-reads-air trap.
Symptom: After clicking New World or Title Screen on the death screen (or running /new-world), the save sequence sometimes hangs on the Saving worlds log line and never reaches Saving chunks for level …. The integrated server has to be force-killed; in-memory progress is lost.
Cause: Sable's dev.ryanhcode.sable.plot.ChunkMapMixin tries to filter PlotChunkHolder entries out of vanilla's ChunkMap.hasWork() check via a @Redirect on Long2ObjectLinkedOpenHashMap.isEmpty(). Its own javadoc admits the fix is incomplete: "TODO: Remove when plot chunks are unloaded with their plots." When DT sub-levels are removed, Sable evicts them from getAllSubLevels() quickly but the PlotChunkHolder entries linger in ChunkMap.updatingChunkMap until async cleanup completes. If vanilla's MinecraftServer.stopServer() wait loop starts before that cleanup finishes, it spins on chunkMap.hasWork() returning true forever.
Upstream tracker: ryanhcode/sable#679. DT side tracker: bh679/dungeon-train-mc#226.
Partial mitigation (since v0.181.0):
-
DeathScreenLayoutHandler.preDrainTrainSubLevelsdeletes DT sub-levels and runscontainer.tick()in a wall-clock-bound loop (~1 s) withThread.yield()between ticks BEFORE the disconnect chain — giving Sable's async chunk-map cleanup threads CPU time before the stop sequence begins. -
ShipShutdownEvents.onServerStoppingruns a second wall-clock drain (~1 s per Sable-active level) unconditionally on every shutdown — catches the cull-race case where DT sub-levels were already evicted from Sable's list before this event fired.
Why wall-clock-bound, not tick-count-bound: container.tick() is a synchronous queue-pump that returns instantly when nothing's queued, but Sable's plot-chunk cleanup runs on OTHER threads. A tight tick-count loop completes in milliseconds — too fast for those threads to be scheduled. Wall-clock looping with Thread.yield() gives them CPU time.
Why this is "mitigation" not "fix": the wall-clock drain reduces hang frequency dramatically but doesn't eliminate it. The proper fix would be a DT mixin into vanilla ChunkMap.hasWork() that polls Sable's plot-chunk-holder state directly and returns false when all DT sub-levels are truly cleaned up — tracked in #226.
Cost: ~3 s extra on every clean shutdown for default 3-dimension worlds. Acceptable trade for the rate of hangs prevented.
Symptom: Train extends normally for ~15 carriages, then a far-edge spawn stops settling and no new carriages spawn in that direction for the rest of the session. Server log shows Appender about to spawn forward=true(N) backward=true(M) every tick with no actual Spawned group lines following.
Cause: Two-step interaction with Sable's player-relative plot:
- The lane-placement gate in
TrainCarriageAppender.isLanePlacementGateClearwaits for the previous spawn'splacedSuccessfullyflag to flip. That flag flips inside the per-tickrunPlacementCollisionTracker, whose loop iterates only ships inShipyards.findAll(). - When a fresh spawn at the edge of the train drifts outside Sable's plot before reaching 60 clean ticks, Sable transitions it to
SubLevelRemovalReason.UNLOADED(cached inHoldingSubLevelstorage, not currently visible). The tracker never sees it again, the flag stays false, and the gate stays closed.
Fix (PR #221): the gate now also clears the lane when the pending sub-level has dropped out of currentTrain for longer than CULL_DETECTION_GRACE_TICKS = 60 (matches the normal placement window so registration lag doesn't false-trigger). To prevent a cull-then-clear cascade from spawning unboundedly past the player, the cull-clear path is gated by a one-shot CULL_CLEARED_FORWARD / _BACKWARD latch — armed only after the next natural placedSuccessfully (i.e. the train has caught up). A separate proximity check in updateTrain re-arms the latch when the player's pIdx passes the registry edge (on foot or in creative flight), at which point cleanupGhostAnchors unregisters every anchor past the visible end and calls Shipyards.delete(ship) on each one, so Sable's holding-storage entry is dropped (SubLevelRemovalReason.REMOVED) and the freshly-respawned anchor can't collide with a resurrected ghost. needsForward was also made symmetric with needsBackward — the old unconditional needsForward = true had relied on the placement gate as a rate limiter, which the cull-clear path removes.
Sable API notes (collected while building this fix):
-
SubLevelRemovalReasonis a two-value enum:UNLOADED(plot moved away, retained inHoldingSubLevelcache, may reload if the plot returns) vsREMOVED(gone for good). -
SubLevelContainer.addObserver(SubLevelObserver)firesonSubLevelAdded(SubLevel)/onSubLevelRemoved(SubLevel, SubLevelRemovalReason)/tick(SubLevelContainer). Currently unused; a future refactor could replace the polling-with-grace approach with event-driven cull detection. -
SableShipyard.deletecallsSubLevel.markRemoved()which the container converts toREMOVEDon its next tick.
Cosmetic side-effect: the client occasionally logs [Sable/ERROR] Received a sub-level movement packet for a non-existent sub-level clustered ~170ms after each cleanup — a stale-packet race between the server's delete and the client's processing of a few last movement updates. Benign; gameplay is unaffected.
Since: v0.293.0 (PR #365).
On first entry the player is now teleported onto a flatbed deck of the live train (feet at trainY + 1; front-pad-centre X derived from the group's canonicalPos plus halfPadLen / subLevelStride geometry — see PlayerJoinEvents.computeFlatbedTarget) and rides it via Sable's normal entity carry — there is no DT carry code; it's the same path carriage contents and PlayerMobs already use. The player body needs none of the dungeontrain_contents_pidx_* tagging the kill-ahead exemption requires: TrainTickEvents.killEntitiesAhead already spares Player unconditionally.
The spawn cinematic camera is a pure render-viewpoint override (CameraCinematicMixin on Camera.setup) — it never touches the player's position or Sable physics, so detaching it has no effect on the ride, and it tracks the local player each frame so the framing stays correct even if the player slips off the deck. Verified in-game: the player is carried along the moving flatbed after the cinematic ends.
Resolved as of v0.517.0 (PR #853) by bundling the third-party Jade Sable Compat mod (
sablejade, MIT, by Maks_gaming) in the modpack. It adds a Sable-aware client retrace path that resolves the correct block under the crosshair on Sable sub-levels — the piece Sable's own bundledcompatibility.jade.*mixins didn't fully cover for the always-moving train (hypothesis (b) below). Verified in a dev client (Jade 15.10.5 + sablejade 1.2.1): both Sable's bundledRayTracingMixinand the standalone mod apply with zero mixin conflicts, and Jade tooltips now show the right block on the train deck. Because the fix landed, DT now ships Jade enabled by default in the modpack alongside Jade Sable Compat (bothrequired:true). The historical investigation below is kept for reference.
Since: observed with Jade 15.10.5+neoforge on Sable 1.2.1+mc1.21.1 (NeoForge 1.21.1). DT ships Jade as an opt-in add-on from v0.305.0 (PR #395).
Symptom: With Jade installed, aiming at a block that sits on the train (carriage chest, lantern, deck block — anything in a Sable sub-level) renders no tooltip. Jade works normally for every block off the train (terrain, structures, the station).
This is not a missing-compat problem. Sable already ships explicit Jade compatibility in sable.mixins.json (common section):
-
compatibility.jade.BlockAccessorImplMixin→snownee.jade.impl.BlockAccessorImpl -
compatibility.jade.RayTracingMixin→snownee.jade.overlay.RayTracing— a MixinExtras@WrapOperationonAABB.clip(Vec3, Vec3)that re-projects the look-ray into sub-level space viaActiveSableCompanion.getContaining+ClientSubLevel.renderPose().transformPositionInverse. -
compatibility.jadeaddons.CreatePluginMixin, plus the generalclip_overwrite.*raytrace overrides (BlockGetter/ClientLevel/GameRenderer/ClipContext/HitResult/Entity) so vanilla block picks resolve sub-level blocks.
Both Jade mixins log Mixing compatibility.jade.* into snownee.jade.* with zero apply failures against Jade 15.10.5 (confirmed in a dev-client debug.log). So the compat layer loads and applies — the blank tooltip is a deeper sub-level/runtime gap, not an unsupported Jade version. DT itself has no Jade plugin (grep -ri jade src/ is empty).
Leading hypotheses (unconfirmed): (a) DT trains are always moving, so a render-pose-timing problem in the @WrapOperation reprojection; or (b) RayTracingMixin re-projects the entity AABB.clip path while Jade's block pick takes a route the clip_overwrite.* mixins don't fully cover. Pinning it down needs an in-world repro (drop the Jade jar into run/mods/ + runClient, load a train world, aim at a carriage chest).
Status: Resolved in v0.517.0 — see the banner at the top of this section. The modpack now bundles Jade Sable Compat and ships Jade enabled by default; on-train tooltips render correctly. (Previously: known limitation, documented-not-fixed, Jade recommended for off-train use only.)
Since: Sable 2.0.2+mc1.21.1. Fixed (DT-side mitigation) in v0.383.0 (PR #630).
Symptom: at the exact world-load/join instant, Sable logs a one-time burst of ~13–18 Received a sub-level movement packet for a non-existent sub-level errors (client-side, logger dev.ryanhcode.sable.Sable). Benign and self-healing — it does not affect gameplay.
Root cause — a transport-ordering race. Sable's SubLevelTrackingSystem.tick() adds a joining player to a sub-level's tracking set and sends the full-sync over TCP (ClientboundStartTrackingSubLevelPacket, which synchronously allocateSubLevels the ClientSubLevel client-side). In the same tick, sendMovementUpdates() sends a movement snapshot over UDP (ClientboundSableSnapshotDualPacket). UDP is used even in singleplayer for the local owner (SableUDPServer.isConnectedTo → true over LocalAddress when ATTEMPT_UDP_NETWORKING, the default). The UDP snapshot can be processed before the TCP full-sync lands, so the ClientSubLevel doesn't exist yet → LOGGER.error at ClientboundSableSnapshotDualPacket.java:107 (unconditional; the STOP path uses a prod-disabled assert, so only the MOVE path spams). DT makes it fire because every carriage moves each tick from tick 0.
DT's mitigation — hold the fresh train still until the client has registered every sub-level. A stationary carriage emits no movement snapshot at all (Sable's sendMovementUpdates skips a sub-level whose pose is within tolerance of its last-networked pose), so the race can't happen while the train is parked. TrainTransformProvider keeps a per-dimension MOTION_HOLD_UNTIL deadline (opened once in TrainBootstrapEvents.ensureTrainSpawned, ~20 ticks / ~1 s); effectiveElapsedTicks = max(0, tick − max(spawnGameTick, holdUntil)) pins canonicalPos at the spawn position for the window, then releases smoothly (elapsed steps 0 → 0 → 1, no jump). In SP the integrated server keeps game-time paused until the player joins, so the bootstrap-keyed window is still active during the client's first tracking ticks. Carriages appended during normal play (spawnGameTick > holdUntil) are unaffected and stay in lockstep.
Scope: this hold eliminates the world-load burst (the ~13–18 eager-filled carriages present at join are held, so there are zero errors at the join instant). It cannot cover a carriage appended while the train is already moving, because holding it would break lockstep. The mid-ride fix below covers that case.
Mid-ride fix: ordered snapshots after every full sync (v0.1016.0, PR #1648).
-
The bigger source: appended carriage groups, not world load. In a 2026-09-29 ride, 44 of 53 errors landed within 160 ms of an appender
Spawned group, about one error per two spawns. -
Why every new group races: a new
ServerSubLevel'slastNetworkedPosestarts at the origin, so its first tracked tick always sends a MOVE, even if the carriage were stationary. The same race hits range-entry and reload-from-holding full syncs. -
The fix:
SubLevelTrackingOrderedSnapshotMixinrecords eachSubLevelTrackingSystem.sendFullSyncper player. For 10 ticks afterwards it makesSableUDPServer.isConnectedToreport false insidesendMovementUpdates, so Sable falls back to its own bundle on the ordered connection, which is handled after the full sync. Poses and timing are unchanged. - Result: same-save A/B went from 13 errors to 0 over 51 s. A fresh 5.5-minute ride had 90 spawns and 1 error.
- Residual (~1 per 5 min): most likely the reverse race. A stop-tracking packet (range exit or removal, sent over the ordered connection) is handled before a MOVE for the same sub-level that is still queued in the UDP event loop.
-
Maintenance: the mixin targets Sable internals (the private
sendFullSyncand the singleisConnectedTocall site). Re-verify it on anysable_versionbump.
Upstream: the general fix (every join scenario, every Sable mod) is a one-line client-side downgrade of that LOGGER.error → DEBUG (the full-sync is guaranteed in-flight over reliable TCP and registers the sub-level momentarily). Prior art for benign Sable log-spam: sable#1319.
Since: the portal carriages landed. Fixed in v0.562.0 (PR #970).
Symptom: a player who walked several rooms deep into a Train Dimension found that no corridor took them back — neither the one they came in through nor any of the extra ways out scattered through an endless room. The room repeats forever and is sealed, so there was nowhere else to walk and no way out but dying.
Root cause — the room is nowhere near its train. Every portal swap is driven from PortalCarriageEvents' per-tick walk over live carriage groups. A pocket room is stamped in its carriage's own chunk columns but in the basement below the world, so a player inside it is not near the train in any sense Sable cares about: nothing holds the group in the simulation bubble, Sable culls the sub-level to holding, the walk skips it (ManagedShip.isResident() false), and every corridor in the room goes inert in the same tick. Trains.knownGroups is keyed by train id and reaching a train id means finding a loaded carriage of that train — so at exactly that moment the group is in no train-side lookup either.
DT's fix, in three parts.
-
The train stops. Nobody can see it — everyone who could is inside its room — so it has no reason to move.
TrainMotionFreezecounts ticks that "do not count" per train, andTrainTransformProvidersubtracts them from the elapsed term. That is the only place a position built as a pure function of elapsed time can be stopped: there is no per-tick step to skip, and zeroingtargetVelocitywould re-evaluate the whole history of the formula and yank the train back to its spawn position. Because the frozen ticks were never counted, resuming needs no code and produces no jump. The count is captured per carriage at spawn and re-based whereverspawnGameTickis (including the post-gap re-anchor) — a carriage appended after an earlier freeze must not subtract time it was never present for. -
The group is force-loaded while anybody is in its room (
PortalPairResidency, viaShipyard.forceLoad), and released the moment they leave. Sable persists tickets to disk, so a ticket left live at save is swept by the bootstrapreleaseAllForceLoads()inTrainAssemblerbefore the train re-fills. The ticket must be the room's own — see the entry below. -
A group already culled is snatched back from holding (
PortalCarriageRevival→Shipyard.reloadFromHolding), once per held episode — re-issuing every tick only re-triggers Sable's benign "wasn't present in the holding chunk" error. Nothing is teleported off a culled group's stale pose: it still answersworldAABB()with its last position, and swapping against that would drop the player where the carriage used to be, on open track at speed.
The freeze rule: somebody inside one of the train's rooms and nobody on its footprint (CarriageDeck.isOnTrainFootprint) or within 96 blocks. Players inside a portal structure are excluded from that proximity half — a room sits directly under its carriage, so a player at the base tile is a couple of hundred blocks away in a straight line and would otherwise read as "near" in precisely the case this exists for. A rider keeps their train moving; so does a bystander beside the track.
Diagnosing it: /dungeontrain portal diagnose answers from inside a room — which pair's room you are in, whether its group is resident and force-loaded, whether its train is stopped, and whether its corridors are live this tick.
Untested: multiplayer (several players on one train, one of them in a room) and dedicated server.
Since: the portal rooms started force-loading carriage groups (v0.562.0). Fixed in v0.563.0 (PR #974).
Symptom: the fix above did not work. A room still lost its train, with the ticket taken and never released by the portal code:
18:36:48 Portal pair 12 force-loaded — somebody is in its room (no release, ever)
18:51:29 PAIR_NOT_WALKED for pair 12 — group gone, and not in holding
Root cause — a Sable ticket type is the holder's identity. container.addForceLoadTicket(sub, TYPE, value) is keyed on the type (and value), not on who called it, so two DT subsystems using the same SubLevelLoadingTicketType are indistinguishable: either one's release drops the other's hold. SableShipyard originally carried a single type, with the assumption stated in its own javadoc — "there is only ever one DT ticket per sub-level" — which held until the portal rooms became a second holder. TrainCarriageAppender's trailing window reconciles every tick and releases the ticket on every sub-level outside it; a portal room's group is normally outside it (nobody is near the train), so the window revoked the room's hold within a tick or two and Sable culled the group.
Fix: Shipyard.Hold names the holder — TRAILING_SEGMENT and PORTAL_ROOM — and each maps to its own registered ticket type (dungeontrain:trailing_segment, dungeontrain:portal_room). forceLoad/releaseForceLoad take a Hold; the no-arg forms default to TRAILING_SEGMENT so existing appender call sites are unchanged. releaseAllForceLoads() sweeps both types, since one sub-level can carry both at once and neither may survive a session boundary.
Confirmed in play: with the split, an occupied pair's group stayed resident for a whole ~4½-minute room visit (18:56:38 force-loaded → 19:00:56 released) while other anchors on the same train were culled repeatedly around it throughout.
If you add a third holder, give it its own Hold constant and ticket type. Do not reuse an existing one to "save" a registration — the failure is silent, delayed, and looks like a Sable bug rather than a DT one.
Since: always, but only reachable in play once remote catch-up landed (v0.783.0, PR #1276). Fixed in v0.806.0 (PR #1313).
Symptom: leave the train, walk back to the track, and find two identical trains stacked on top of each other — every overlapping carriage the same variant, the same contents, the same seed. It needed a save to happen while you were away, so it looked random.
Root cause — Sable's in-memory holding map is a cache, not a record. When a sub-level is culled it is filed into a holding chunk and registered in SubLevelHoldingChunkMap.allHoldingSubLevels. But saveAll() — which runs on every autosave, every Esc-menu pause in singleplayer, and every /save-all — writes the hidden holding chunks to disk and then evicts them, calling allHoldingSubLevels.remove(uuid) for each sub-level in them. The data is still on disk and Sable will resurrect it when the chunk loads again; it has simply stopped being listed.
SableShipyard.isHeld read that map, so after any save it answered "gone for good" about a carriage group that was merely asleep. TrainCarriageAppender.isReapableGhost (!held && !resident) then unregistered the anchor and called Shipyard.delete — which is a no-op on a non-live sub-level, since markRemoved() only flags the live wrapper — and the spawn lanes rebuilt the group. Trains.gateWorldXOrRecord deliberately pins a re-spawned anchor's worldgen gate so it rebuilds identically, which is why the twins matched. Walking back near the cull-time chunk let Sable resurrect the original onto the same canonical slot, and both stayed: Trains.byTrainId does not de-duplicate by pIdx.
Hook acceptHoldingSubLevel, not moveToUnloaded. This is the trap. moveToUnloaded looks like the cull funnel but across all 826 classes in the jar its only caller is PhysicsChunkTicketManager — it misses the chunk-status cull entirely, because the private SubLevelHoldingChunkMap.processUnload files to holding inline without going through it. SubLevelHoldingChunk.acceptHoldingSubLevel is the one method all three filing paths share (physics cull, chunk-status cull, and re-materialising a chunk from disk), it is referenced by no class outside SubLevelHoldingChunkMap, and each call site is immediately followed by allHoldingSubLevels.put. It also supplies its own getChunkPos(), so the position comes from the owner rather than a caller's argument.
The matching load funnel is loadHoldingSubLevel — both snatchAndLoad and processChanges → collectReadySubLevels end there — and it drops its own entry unconditionally, outside the fullyLoad != null branch. So a failed deserialize means the data is unreachable through Sable's holding store and nothing will ever retry it; DT must drop its record too, or it would tell the spawn lanes to keep waiting for something that can never come back.
Fix: SableHoldingIndex records uuid → ChunkPos from SubLevelHoldingChunkFileMixin and clears it from SubLevelHoldingChunkMapLoadMixin. isHeld now answers recoverable (Sable's map OR the index), which is what its interface javadoc always promised and what all eight call sites want. reloadFromHolding re-materialises the recorded chunk from disk via the existing getOrLoadHoldingChunk handle when Sable's map has lost it, and the recorded chunk is the first probe candidate, so the ±4 pose scan is now a fallback. A per-tick duplicate-anchor sweep is the safety net for anything this misses; it refuses to act on split lineage or an ambiguous registry, waits three ticks, and never deletes a carriage a player is standing in.
Making isHeld truthful exposes a latent deadlock — fix both together. claimReloadIssue fires once per (trainId, direction, subLevelId) and is re-armed only by clearEdgeUnresolved, which runs on the SPAWN and REAP_DEFER branches and never on RELOAD_DEFER. A held edge therefore got exactly one reload attempt, and isHeld going false by accident was the only thing that ever unparked the backward lane. Correcting isHeld without also adding a bounded retry trades a visible duplicate carriage for an invisible, permanent stall of backward generation — strictly worse. The retry is a 40-tick re-issue capped at three attempts, after which the index retracts its claim and the anchor becomes reapable again.
The index only claims what it can deliver. If the reflective getOrLoadHoldingChunk handle cannot be resolved, SableShipyard's static initialiser disables the index outright and logs an ERROR — claiming a group is recoverable without the means to recover it would park the lanes on a promise DT cannot keep. Both mixins are required, so a Sable rename fails loudly at class-transform time rather than silently restoring the bug. Re-verify both targets on any sable_version bump.
Since: always. Surfaced by template-saved wheat; fixed for crops in v0.584.0 (PR #1004).
Symptom: wheat saved into a carriage template was gone by the time the carriage appeared in the train — and gone from the editor preview when its author reopened the template.
Root cause — nothing is wrong with the capture or the write. The crop round-trips into the template NBT correctly, and SectionLocalStampProcessor writes it with a raw palette write that never consults canSurvive. It is deleted afterwards, by the first neighbour-shape cascade that reaches it: CropBlock.canSurvive demands farmland below and getRawBrightness(pos, 0) >= 8, and BushBlock.updateShape swaps the block for air the moment either fails. A carriage interior is a windowless box, so the light half fails in any unlit carriage. Three moments trigger it, and all three are the light half:
-
Source world, pre-lift — the shell is stamped section-local (
relight=false), so the light engine has not processed the carriage's own lanterns when the very next pass (applyVariantBlocks, flags2|32) cascades over it. This is why the bug looked intermittent: a carriage spawning under open daylight passed the check, one in a tunnel or at night did not. - The Sable lift — at shipyard coordinates.
- The carriage's whole later life — any neighbour update in an unlit carriage.
Fix: CropBlockCarriageSurviveMixin injects on CropBlock.hasSufficientLight (not canSurvive) and returns true when the position is in the shipyard region, or while CarriageStampGuard is held. Injecting at the light seam leaves super.canSurvive untouched, so the soil requirement stays enforced everywhere, including mid-placement — break the farmland under carriage wheat and it still pops. Growth is untouched: randomTick gates on its own inline >= 9, so an unlit carriage holds its crops at the saved age.
The guard exists for the coordinate gap. Windows 2 and 3 are at shipyard coordinates and are covered by the cheap TrackGenerator.isShipyardChunk test. Window 1 and the editor plots are at ordinary coordinates, which that test cannot see — hence CarriageStampGuard, a thread-local depth counter held across TrainAssembler.spawnGroup and both CarriagePlacer.placeAt methods (the 4-arg editor preview is a separate method, not an overload of the 10-arg spawn path — a fix applied to one silently misses the other).
The Sable lift is not a block-ordering hazard. SubLevelAssemblyHelper.moveBlocks is four sequential passes — write every destination cell (LevelChunk.setBlockState, no cascade), then markAndNotifyBlock(..., 3, 512) over all of them, then air the source cells, then notify clients. Every block, substrate included, is already in place before the first cascade runs, so a lift can never destroy a block through ordering. Verified against sable-2.0.2+mc1.21.1 bytecode (loop bounds 303/685/827/939) — re-verify on any Sable bump, as with SableBlockChangeGuardMixin.
Dimensional carriages — twin space (fixed PR #1573). A dimensional carriage's room is not a Sable sub-level. It is stamped once into twin space, meaning the sealed basement under the bedrock or the attic over the upside-down band's lid (PortalTwinSpace). That is ordinary world coordinates, which neither the shipyard test nor the stamp guard covered. It is pitch dark down there, and placeInWorld's own shape pass runs before the light engine. So every crop in the room popped on load, and survivors died on the next harvest next door. The editor plot sits in daylight at y≈250, so authors never saw it.
The mixin now also returns true in twin space (overworld only). Server and client answer from one pure rule, PortalTwinRegion.twinSpaceContains. On the server it is used through PortalTwinSpace.isInside, on the client through ClientUpsideDownBand.isInPortalTwinSpace, so the two cannot disagree and flicker a crop. The client counts the attic only inside the band's core, like the server does. The broader ClientUpsideDownBand.isInTwinSpace would wrongly count high mountains outside the band.
The sunk zone (PR #1584). Inside Amplified's slot and the ow:sunk approach, the terrain fills the basement. So twin space there is the attic above a barrier lid, with no basement fallback. The rule is PortalTwinSpace.amplifiedTwinSpaceContains, shared by server and client. The client render seal (ClientPortalSeal → PortalSealPlane.cutForAmplified) cuts at the sunk floor near the zone, since the stock cut at y32 culled every valley below it. It hides the world below the lid only from inside an attic twin. There is deliberately no cut above the lid from the world side: fade chunks mix in ordinary peaks that pass the lid height.
The upside-down attic's lid (PR #2065, v0.1319.0). From inside an attic twin, the seal used to keep the band's bedrock lid row, so it showed as a bedrock floor through every window. It also hid nothing at all for a twin the train had carried on into the Reassembly, because the client's attic test (ClientUpsideDownBand.isInBand) stops at the band's end. Now:
-
PortalSealPlane.cutForhides fromroofY + 1down. The twin's own underside sits on the row above the lid (PortalTwinLanes.FLOOR_MARGIN), so the room stays whole. - Cuts made from inside sealed space carry a block-level trim (
Cut.trimMinY/trimMaxY). It covers the rows of a straddling section that the section-sized cut has to keep. Block models (vanilla and Sodium) and fluids skip those rows while meshing, and the section layer is re-meshed once when the trim changes (only at a portal swap). The world view never trims. - The trim only applies within render reach of the camera. Sable sub-level plots keep world Y, so a row test alone would strip a passing train.
-
ClientPortalSealalso counts a camera inside a dimensional carriage (ClientPortalRoomDepth.isInsideStructure) as being in the attic, which covers the Reassembly.
PortalCarriageBuilder.stampRoomAt (with its contents and variant passes) and stampRoomFromLive also run under CarriageStampGuard. That covers the stamp moment in Compatible Terrain worlds, which have no basement. After the stamp, an unlit room's crops there still follow vanilla rules. Twin space is unreachable except through a portal, so no vanilla farm is affected. A headless A/B (ripe wheat in a dark pocket, neighbour written with UPDATE_ALL) showed: basement wheat pops on main and survives with the fix; ordinary dark-rock wheat pops on both.
If another support-needing block turns up broken in a carriage — sugar cane (needs adjacent water), cactus (needs sand, no neighbours), torches on a wall the parts overlay replaced — it is this same shape, but each has its own canSurvive rule and needs its own decision. Do not reach for a blanket BushBlock.updateShape bypass: it would silently rescue saplings, flowers, mushrooms and nether wart too, and hide genuine build errors behind floating blocks.
A carriage's rendered position is not where its blocks are — client-side region effects cannot use a box
Since: always. Surfaced while smoothing the dimensional-carriage lighting in v0.667.0 (PR #1106).
Symptom: none, directly. This is a design constraint that bites anything trying to answer "what is the light/biome/state where the player is standing while they ride the train".
Cause: Sable sub-levels are not a separate dimension. A carriage's blocks live in the same ServerLevel at far shipyard coordinates, while the player's position — and every piece of DT's own train arithmetic, PortalFrames included — is in rendered track space. The rendered geometry is the shipyard meshes drawn through a transform. So:
-
level.getRawBrightness(cameraBlockPos)while riding a carriage reads the open world beside the train, not the carriage interior. Same for any other positional world read at the camera. - The already-documented mirror of this is
CropBlockCarriageSurviveMixin— carriage crops die because the light engine sees the shipyard while the check runs there too.
What this rules out. DT's client-side region effects — ClientPortalRoomFog and ClientPortalRoomSky — cache a box and test the camera against it each frame. That shape is chosen deliberately: a box outlives a dropped packet, so a player who disconnects inside a region never needs a "you have left" message and can never come back permanently fogged. A carriage cannot be described that way. The only box a client could test is the rendered one, and it moves with the train every tick — so re-sending it every tick is exactly the cost the box form exists to avoid.
The pattern that works instead. Send the value, not the place, and expire it client-side on a countdown. PortalCrossingPacket is one byte; ClientPortalCrossing drops the effect if nothing restates it within ~20 lightmap rebuilds. That countdown is what buys back the stale-state safety the box was giving away for free — a disconnect, a lost packet and a server that stopped caring all land on the same harmless fade-out. The server side already computes the value each tick to run the swap, so it costs a dedupe rather than a measurement.
Quantise anything derived from a rider's position. A rider's coordinates disagree between client and server by ~0.17 blocks and the ship's pose drifts ~0.4 — the jitter PortalFrames.SWAP_HYSTERESIS exists to absorb. PortalFacing.depthFromTrainDoor and PortalCrossingLight both floor to the block for this reason: a continuous read would shimmer while the player stood still.
SubLevelTrackingSystem.tick decides whether a player is close enough to track a sub-level from subLevel.logicalPose().position() (distance < SUB_LEVEL_TRACKING_RANGE, default 320). That Java pose is written from the native Rapier body by exactly one method, SubLevelPhysicsSystem.updatePose, which DT's soft physics freeze (#646, SubLevelPhysicsSystemFreezeMixin) cancels for a parked carriage. Until v0.781.0 that left a parked group's pose at the spot it was parked while its true slot in the train moved on at train speed. Once the slot had moved more than (tracking range − render distance) away, the player could stand in the slot with the group's pose still out of range: never re-tracked, never unfrozen, a group-sized hole in the train, with the group itself stacked hundreds of blocks back on other parked groups (every group ahead of a player walking backward at train speed parks at the same world X), and serialized with the stale pose if an autosave landed meanwhile.
DT's fix (#1272): while a carriage is frozen, SableManagedShip.applyTickOutput writes the kinematic driver's output into the Java pose and calls updateBoundingBox() (PhysicsFreeze.followParked) — a plain field write, no native call, so the physics saving is untouched. The native body stays parked; the existing unfreeze teleport lands it on the already-correct pose. Verified against the 2.0.2 bytecode: Sable's whole sub-level pipeline runs at ServerLevel.tick HEAD, DT writes at LevelTickEvent.Post, and next tick updateLastPose (ungated), the cancelled updatePose, ServerSubLevel.tick → updateBoundingBox and then tracking all read the written pose. Sable's entity collision (SubLevelEntityCollision) also lerps the Java pose, so entities and clients meet the carriage at its true slot. Accepted side effect: PhysicsChunkTicketManager tickets follow the true position (the same chunks the group loads when unfrozen). Diagnose with /dungeontrain debug seamgap-trace on + scripts/traingen/analyse-seamgap.py; [freeze.unpark] (DEBUG) logs each resume's parked ticks and healed body lag, [reanchor] (INFO) logs the cull→reload catch-up. Rule for any Sable bump: anything that reads logicalPose() — tracking, culling, holding-chunk placement, serialization, entity collision — sees a parked carriage where DT's driver says it is; only Rapier-vs-Rapier contact sees the parked body.
A held (culled) sub-level cannot be snatched back by its pointer once it has moved since the last save
DT v0.783.0 (PR #1276), verified with javap -c on sable-2.0.2+mc1.21.1.
SubLevelHoldingChunkMap.moveToUnloaded(subLevel, chunkPos) (the cull) files the sub-level in the holding chunk of the chunk that failed the load check — i.e. where the sub-level is — but records subLevel.getLastSerializationPointer() on the HoldingSubLevel: the pointer from the last world save. snatchAndLoad(pointer, uuid) looks only in pointer.chunkPos(). A train group moves a chunk every few seconds, so for almost every group culled since the last save the snatch logs Attempted to snatch sub-level … but the requested sub-level wasn't present in the holding chunk, loads nothing, and returns normally. Sable's own reload (collectReadySubLevels when a holding chunk's world chunk loads) walks the chunk directly and is unaffected — which is why the bug only shows when DT asks for a group back explicitly (the lane's held-edge reload, and the remote-player wake).
SubLevelData.getOriginLoadedChunk() is not set by the holding code; SubLevelData.pose() (the cull-time pose) is. SableShipyard.reloadFromHolding therefore locates the filed chunk by probing the last-save pointer's chunk and then ±4 chunks in X / ±1 in Z around the pose through a reflective handle to the private getOrLoadHoldingChunk(ChunkPos, boolean) (which loads from disk when needed), and snatches with a pointer built from that chunk (only chunkPos() is read by the snatch). If the reflective handle is unavailable it logs a WARN and falls back to the pointer. Re-verify the private method on every Sable bump.
Sable wakes a held group as soon as its chunks load — a pinned neighbour did that every 16 blocks (v0.1244.0)
DT v0.1244.0 (PR #1946), verified with javap -c on sable-2.0.5+mc1.21.1.
SubLevelHoldingChunk.collectReadySubLevels (Sable's automatic wake, run from processChanges every container tick) loads a held sub-level the moment canLoadSubLevel — every chunk under its stored bounds isChunkLoadedEnough — says yes. A force-loaded carriage group's physics chunk tickets travel forward with the train, so a group held directly ahead of a pinned one had its holding chunk loaded every 16 blocks: Sable woke it, its driver re-anchored it 16 blocks on ([reanchor] gapTicks=160 deltaBlocks=16.000), past the loaded chunks, and Sable culled and re-filed it on the next tick — forever, with no DT reload log at all. Groups behind the lowest pin never wake (the pins move away from them).
DT now (a) never takes trailing force-load pins from below the players' needed window, (b) keeps one group of slack above the window's front edge, and (c) vetoes the automatic wake: SubLevelHoldingChunkAutoLoadVetoMixin makes the private static canLoadSubLevel (its only caller is collectReadySubLevels) answer false for a train group wholly outside the window the appender holds this tick (train/HeldGroupWakeVeto). DT's own reloads use snatchAndLoad, which never calls it. The veto applies only while a player is near (a window fresh within 20 ticks); at world load, with nobody near, or during a resume hold, Sable decides as before — its own wake is how a train reappears for a player arriving from the side. Re-verify the target and its single caller on every Sable bump.
A force-load ticket costs chunks, synchronously — pinning the whole train after a save froze the server (v0.1322.0)
DT v0.1322.0 (PR #2068), from a player log on v0.1286.0.
A sub-level holding a force-load ticket has Sable's PhysicsChunkTicketManager.inhabitChunk load the chunks around it, through Level.getChunk on the server thread — generating them if they are new. The #547/#622 resume/save hold (MinecraftServerSaveMixin → holdAllLoadedTrains) used to pin every visible group, and while its grace ran the trailing window targeted every visible group each tick. Because the wake veto above stands aside during a resume hold, Sable woke more held groups as those chunks loaded; they became visible and were pinned the next tick. After an autosave the player's train went from 16 to 151 pinned groups, 3.9k to 13.1k chunks, with 10 s of chunk waits. ResumeWatchdog then read that stall as a pause and re-armed the hold.
DT now pins on a save or resume only groups within 128 blocks of a player or not yet serialized (TrainCarriageAppender#shouldHoldForResume). While held, the window keeps what is already held rather than adding newly visible groups. ResumeWatchdog measures the idle gap between ticks (ServerTickEvent.Post → next Pre), so a stall inside a tick no longer counts as a resume. Repro: ride the tail to ~90 groups, pause once, then watch the next natural autosave. A /save-all alone shows nothing, because the groups are still held from settling.
DT v0.810.0 (PR #1319), verified with javap -c on sable-2.0.5+mc1.21.1.
DT drives every carriage kinematically: TrainTransformProvider returns identity rotation and SableManagedShip.applyTickOutput teleports the body onto that pose, zeroes its velocity and clears its queued forces. That is not enough to keep a carriage steady, because the correction lands once per server tick while SubLevelPhysicsSystem.tickPipelinePhysics steps physics substepsPerTick times inside it. Three separate paths move a carriage in that gap, and building on the train excites all three by moving its centre of mass:
-
Gravity torque. With the centre of mass off the pivot, the body tilts during the substeps.
updatePosewrites the tilted pose intologicalPose()every substep, and collision, tracking, networking and the renderer all read it before DT flattens it again. -
Centre-of-mass recompute.
MergedMassTracker.uploadData()is the single writer that pushes a changed centre of mass intoPose3d.rotationPoint, callsupdatePose, and issues a compensating body teleport. Since the world mapping isposition + R·(voxel − rotationPoint), moving the pivot translates every block in the carriage. Deleting one side of a carriage walked the pivot half a block across ~90 recomputes, one per block broken — each one corrected after the fact byCarriagePivotPin, which is a correction chasing a moving target. - Rapier's contact solver. A carriage whose collider brushes a world block (or a neighbouring carriage at a seam, or a player on the deck) is shoved every substep, with no pipeline call to intercept — DT teleported it back every tick, which reads as heavy jitter.
DT's fix — a rotation-lock flag carried on the ServerSubLevel instance (DtRotationLockable, set when a TrainTransformProvider is attached, alongside the soft-freeze flag), read by four mixins:
| Mixin | Target | Effect for a locked carriage |
|---|---|---|
SubLevelPhysicsSystemRotationLockMixin |
updatePose (RETURN) |
Orientation forced to identity, and latestAngularVelocity — which Sable derives from the pose delta and networks — zeroed |
RapierPipelineRotationLockMixin |
RapierPhysicsPipeline (string target) |
Angular argument of applyLinearAndAngularImpulse zeroed, point-impulse applyImpulse cancelled, teleport rotation forced to identity, and the MassData on onStatsChanged's way to Rapier3D.setMassPropertiesFrom swapped for ImmovableMassData
|
ServerSubLevelRotationLockMixin |
applyQueuedForces (HEAD) |
Native angular velocity cancelled once per substep; also triggers the one-per-body immovable-mass push |
MergedMassTrackerPivotFreezeMixin |
uploadData (HEAD, private) |
Cancelled outright — the pivot never moves, so there is no compensating teleport to undo |
The load-bearing piece is zero mass + zero inertia on the native body. Rapier reads that as zero inverse mass: contacts and forces cannot change the body's velocity or turn it, while DT's per-tick velocity still moves it — which is exactly kinematic-train semantics, and the only thing that stops the contact solver. Rapier accepts it without complaint (no Invalid position or orientation from updatePose's NaN guard). Only the native body sees it: ServerSubLevel.getMassTracker() still returns Sable's live merged tracker, so Java-side entity-vs-carriage collision (MassData.getInverseNormalMass) and DT's captureInertia are unchanged.
Gotchas for any Sable bump:
-
MergedMassTracker.uploadDatais private — a refactor that renames or inlines it silently drops the pivot freeze.CarriagePivotPinremains behind it as a backstop, and its[pinCorrected]DEBUG line with a drift materially above1e-6is the tripwire that the freeze stopped applying. - The substep hook targets
ServerSubLevel.applyQueuedForces, verified as the last per-body call beforepipeline.physicsTick(dt)inside thesubstepsPerTickloop. Re-verify that ordering. - Sable re-creates
ServerSubLevelinstances (world load, cull→reload) while DT's driver survives inSableManagedShip's UUID-keyed map withoutsetKinematicDriverbeing called again — so an instance flag alone reads as "not locked".TrainRotationLock.isLockedfalls back to that map and caches a positive on the new instance. - Bodies are re-created too, so the immovable-mass push is keyed on
getRuntimeId(), not on the sub-level.
Rapier never reports a contact on the kinematic train — player-block break-off probes for it (v0.1014.0)
Player-added carriage blocks break off when they touch another physics object; see Train § Collisions. DT can't hear that contact from Sable:
- The train body has zero mass, so Rapier resolves each contact by pushing the other body away.
- Sable's per-block
BlockSubLevelCollisionCallbackis attached perBlockState, so using it would mean hooking every block type.
Instead, PlayerBlockBreakOff.checkPhysicsContacts probes each player block's world-space centre, corners and face centres (pushed 0.1 outward) against the plots of nearby non-train sub-levels. Two notes:
-
Placement position. A player placement aimed at a carriage arrives in
EntityPlaceEventat the plot position, which was verified in game.CarriagePlotResolvermaps it straight to the sub-level. -
Re-verify on a Sable bump. Check that the plot-position placement still holds and that
LevelPlot.getChunkMin()(the key origin forPlayerPlacedTrainBlocks) is unchanged.
Sable carries a rider outside deltaMovement — shed it when you teleport them off the train (v0.822.0)
A rider on a moving carriage gets the train's motion two ways, neither of them the entity's
velocity: entity_sublevel_collision/LivingEntityMixin keeps a per-entity
sable$inheritedVelocity (the sub-level's velocity in blocks per tick, applied to position on its
own and decayed at ×0.7 a tick on the ground), and entities_stick_sublevels/ServerPlayerMixin
re-derives a tracked rider's position from the sub-level's pose every tick. Both outlive a
teleport. Put a standing player down in a static room and they slide 0.100 → 0.069 → 0.048…
blocks a tick — the train's speed at Sable's drag constant — with no sub-level carrying them.
Subtracting the train's velocity from deltaMovement cannot fix this and only removes the
player's own walking, because deltaMovement never held the carry. ship.sable.SableEntityCarry.shed
zeroes the inherited velocity (LivingEntityMovementExtension.sable$getInheritedVelocity()) and
drops the tracking sub-level (EntityMovementExtension.sable$setTrackingSubLevel(null)). For a
player the client is authoritative, so the client sheds too — PortalSwapPacket(leftCarrier).
Never shed on the way onto the train; Sable re-engages the carry by itself. Also note
TrainTransformProvider.getTargetVelocity() is blocks per second (2.0 = 0.1/tick).
Related: a multi-second server stall leaves the client extrapolating the train while server
game-time stands still, displacing the rider along it on resume — tracked in #1335.
An entity added at shipyard (plot) coordinates is moved to the carriage's world pose on its first
tick, not by addFreshEntity. The deferred entity pass used to spawn a carriage's shell/parts
decoration first and then run the contents pass, which opens with
CarriageContentsPlacer.discardEntitiesAt — a sweep of every non-player entity in the interior,
meant for a previous occupant's leftovers at that shipyard slot. The freshly spawned pictures, mobs,
stands and minecarts were still standing at plot coordinates and went with the sweep, the same tick,
logged as spawned and never seen. No bundled template carried an entity, so nothing shipped ever
showed it. CarriagePlacer.applyContentsEntitiesAt now spawns decor on every exit of the pass, after
that sweep, and logs Shell decor: spawned N entities for variant=… pIdx=….
What Sable then does with the entity depends on its type
(data/sable/tags/entity_type/*.json in the Sable jar): retain_in_sub_level — armor stands, every
minecart, displays, markers, leash knots and the wall_entities (paintings, item frames) — stay at
plot coordinates and are drawn at the carriage pose, exactly as the shipped armor-stand contents are;
everything else (mobs, boats) is kicked to world space beside the carriage and rides along through
Sable's ordinary carry. Boats do not behave once there, which is why templates that ride the train
refuse them (TemplateDecor.Rule.CARRIAGE) while dimensional carriages keep them (Rule.ROOM).
Sable resolves an entity against sub-level blocks with SAT minimum-translation pushes
(SubLevelEntityCollision.collide), not vanilla's swept per-axis clip. The difference shows when a
box already overlaps the entity: vanilla never clips an existing overlap, so on static ground you can
stand on a closed trapdoor laid over a fence even though the 1.5-tall post pokes 0.3125 into your
feet. On a carriage the same overlap yields a push — sideways whenever you are off-centre — which
counts as a horizontal collision, which tryStepUp answers by lifting you onto the post top, and
gravity drops you straight back. Eight substeps a tick of that is a visible bob that fights input.
Walls, and carpets or slabs over fences, do the same.
Since DT v0.891.0 (PR #1421) the clip lives in the sibling mod Sable Fence & Trapdoor Fix (sable_fence_trapdoor_fix, jar-in-jar inside DT and a required external mod on CurseForge — see Hybrid siblings); DT's own copy of the classes below was deleted so Sable's call is wrapped exactly once. The mechanism is unchanged:
ProtrudingShapeClip + SubLevelEntityCollisionShapeClipMixin (now in the sibling; originally DT's ship/sable/, mixin/) wrap both
getCollisionShape calls in Sable's sweep and cut any box rising above the unit cube down to
y=1.0 only when the block above rests flush on top and its bottom face covers that box's
footprint. A bare fence keeps its full post (you still stand on it at 1.5); a chain on a fence does
not cover the post so it is left alone. Results are cached per (lower, upper) shape pair; blocks with
dynamic shapes bypass the cache. Off-train collision is vanilla and untouched. The mixin targets
Sable-internal names (remap=false) and was bytecode-verified against sable-2.0.5+mc1.21.1 —
re-verify on any Sable bump. This is not a general cure for the push-vs-overlap difference; any
other geometry that leaves a box inside a standing entity would still push.
Since: v0.1143.0 (PR #1809)
Symptom: step onto a ladder while the train is moving and release every key — you rise to the top anyway. Standing on the deck is fine; only a rider on the ladder (feet off the floor) is affected, and only on a ladder whose wall faces along the direction of travel.
Root cause: vanilla climbs when horizontalCollision && onClimbable() (LivingEntity.handleRelativeFrictionAndCalculateMovement) — the collision flag is its proxy for "the player is walking into the ladder". Sable's entity_sublevel_collision/EntityMixin ORs its own subLevelHorizontalCollision into that flag, and that one is set whenever a sub-level block's SAT push resolves a horizontal overlap. A rider standing on the deck is tracking the sub-level, so Sable moves their bounds with the carriage before testing — no push. A rider on a ladder is off the ground, so they are not tracking; the wall catches up with them every tick, the flag is true every tick, and vanilla climbs every tick with no input. Sable's climbing_sub_levels mixin only lets onClimbable() see ship ladders; it does nothing about the flag. Mobs on a train ladder got the same free ride, server-side.
Fix: ship/sable/SableClimbPush + mixin/LivingEntityClimbPushMixin (@ModifyExpressionValue on the method's single horizontalCollision read, require = 1, common side). Sable's CollisionInfo keeps the pieces apart — horizontalCollision (own motion clipped by world blocks), subLevelHorizontalCollision (the push), preDeltaMovement (own motion before the collide) and firstCollisions (push direction per sub-level, the normalised MTV) — so a ship push counts as climb input only when the entity's own horizontal motion was into the pushing block. World clips and the no-Sable case pass through unchanged; jumping still climbs. ServerPlayer is short-circuited by Sable's collide, so the server-side player stays on the vanilla path (player movement is client-simulated anyway). Verified against Sable 2.0.5+mc1.21.1 and MC 1.21.1 — re-verify the target method on any MC/NeoForge/Sable bump.
Sable's LevelAccelerator sync-loads chunks on the server thread — three DT seams keep it non-loading (v0.940.0)
Symptom: the server thread freezes for 5 s to several minutes and the stall watchdog stack ends in
LevelAccelerator.grabChunkFast → Level.getChunk → ServerChunkCache.getChunk (managedBlock). Players
report it as "frozen again" when they break or place blocks on a carriage, or as items dropping through
the train, or as a permanent hang at first spawn.
Root cause: Sable's LevelAccelerator is a cached block-state reader. Its fast path uses the
visible chunk holder, but when the chunk isn't loaded it falls back to Level.getChunk(x, z), which on
the server synchronously loads and generates the chunk on the main thread. Train plot chunks use
Dungeon Train's expensive worldgen, so that fallback costs seconds to minutes, and when it fires inside
another managedBlock (forced worldgen) it deadlocks. Sable has three callers that reach it:
| Sable caller | DT seam | Since |
|---|---|---|
SubLevelEntityCollision.collide (items/mobs sweeping against sub-levels) |
SubLevelEntityCollisionNoLoadMixin flags the per-sweep accelerator noSyncLoad
|
v0.5xx, #831 |
Block changes during DT's forced spawn worldgen (postProcessGeneration fluid ticks) |
WorldgenForceGuard + SableBlockChangeGuardMixin cancel the physics hook inside a forced load |
v0.513.0, #846 |
RapierPhysicsPipeline.handleBlockChange and handleChunkSectionAddition → VoxelNeighborhoodState.getState (every block change in a sub-level, and every chunk section the physics ticket manager adds as the train moves) |
RapierPipelineNoSyncLoadMixin flags the pipeline's lifetime accelerator noSyncLoad in its constructor. Both methods read the same accelerator field (bytecode-checked, sable 2.0.5) |
v0.940.0, #1509 |
All three route through one wrap, LevelAcceleratorNoSyncLoadMixin, which turns the loading fallback
into a lazily built EmptyLevelChunk: an unloaded neighbour reads as air, and the real state arrives
with that chunk's own block-change events when it loads. setBlockFast writes are deliberately left
on the loading path so no block write is ever dropped into an unloaded chunk. The Rapier class ships
in Sable's jar-in-jar, so the mixin uses a string targets + remap = false and must be re-verified
against the bytecode on every sable_version bump (each new LevelAccelerator site is counted in the
javadoc).
What this does not fix: the autosave stall in the same player logs is a separate cause, covered
in the next section. NeoForge's own FluidInteractionRegistry neighbour lookup can still sync-load
(#1450 item 2).
Symptom: "Saving world" freezes: 5–15 s at nearly every autosave or pause, and one ~500 s autosave
on a Windows player with Distant Horizons. Stall watchdog stacks sit in native disk I/O
(FileChannel.force0 / write0 / SetEndOfFile) under
SubLevelHoldingChunkMap.saveAll → SubLevelStorageFile.write/flush/close.
Root cause: Sable saves trains on the server thread (ServerLevel.save → saveAll), unlike
vanilla chunks, which go through the IOWorker. SubLevelStorageFile opens its channel with
CREATE, READ, WRITE, DSYNC, so every header, sector and padding write blocks until the disk confirms
it, and flush() then force()s every open file. Distant Horizons writing its SQLite LOD database to
the same disk queues each of those synced writes behind its own I/O.
DT's fix (#1536):
SubLevelStorageFileNoDsyncMixin removes DSYNC from the constructor's open options and skips
flush()'s force() for files with no writes since the last flush. Changed files are still forced
at flush, so a completed save is on disk exactly as before. SubLevelRegionFile (holding chunks)
extends the class, so both file kinds are covered. What is saved, when, and what Sable moves to
holding are all unchanged; the on-disk format is byte-identical.
Tradeoff: writes made outside a save (e.g. a carriage filed to holding on chunk unload) wait in the OS cache until the next save's flush. A game or JVM crash loses nothing; a power cut or OS crash in that window could. Vanilla singleplayer chunk saves work the same way.
Diagnosing: SubLevelHoldingChunkMapSaveTimingMixin logs every holding save as
[DT-SableSave] saveAll <dim> took N ms (F files synced, S unchanged skipped): WARN above 1 s,
DEBUG otherwise, so a bug report's debug.log shows every save. If saves still run long, the next
step is moving saveAll off the server thread (#1508).
Both mixins target Sable's own classes and must be re-verified on every sable_version bump.
handleBlockChange also reads each neighbour straight through the ServerLevel — a second non-loading seam (v0.1004.0)
Symptom: the same freeze class as the section above, on a build that already has
RapierPipelineNoSyncLoadMixin: a player log at DT 0.994.0 shows a 35 s stall plus five more of 5 s,
every stack ending in RapierPhysicsPipeline.handleBlockChange:395 → ServerLevel.getBlockState → ServerChunkCache.getChunk (managedBlock), triggered by a fluid tick in LevelChunk.postProcessGeneration
setting a block on the edge of a sub-level plot chunk.
Root cause: inside the six-neighbour loop of handleBlockChange, the statement after the
accelerator read (VoxelNeighborhoodState.getState, covered by v0.940.0) calls
level.getBlockState(neighbourPos) directly on the ServerLevel to fetch the neighbour's physics
data. On a server that is getChunk(FULL, true): a synchronous generation when the neighbour chunk isn't
loaded. Bytecode of sable_rapier-1.21.1-2.0.5: exactly one ServerLevel.getBlockState(BlockPos) call
in the method (offset 103, line 395); the second getPhysicsDataForBlock takes the changed block's own
BlockState parameter and reads no chunk.
DT's fix (#1640):
RapierPipelineBlockChangeNoLoadMixin
wraps that one call: if getChunkNow says the neighbour chunk is not loaded, the neighbour reads as air,
otherwise the original read runs. Same tradeoff as v0.940.0 — the unloaded chunk's voxels are not in the
scene anyway, and Sable adds them when the chunk loads. String target + remap = false; re-verify
against the bytecode on every sable_version bump (the javadoc records the offset/line).
Third seam, outside Sable: the same fluid tick also reached an unloaded chunk through NeoForge's
FluidInteractionRegistry — see NeoForge 1.21.1 → Fluid interactions sync-load neighbour chunks (v0.1019.0).
Symptom: the "constant" lag players describe — item pickups and the train's own movement land
late — whenever the rider is away from the train ([mspt] near=0): physMs 45–205 ms in four players'
logs. Reproduced on a headless dev server with no player at all: 13 carriages, 12 parked by the
soft freeze, physMs 3 ms at 16 blocks of body lag → 45 ms at 128 → 109 ms at 368 → 262 ms at 414,
until the vanilla watchdog killed a 60 s tick. An RCON A/B with /dungeontrain debug physicsfreeze off
(every body teleported per tick) dropped it to 4.5 ms flat.
Root cause: the soft freeze (#646) parks the
native body where it was frozen while the Java pose keeps following the train (previous section). Sable's
native step turned out to cost roughly linearly in the distance between the two — the gap, not the
teleport, is what is expensive. Why Rapier charges for it is not established (a question for the Sable
author); the empirical curve is in the PhysicsFreeze javadoc.
DT's fix (#1640):
PhysicsFreeze.reparkIfLagging
— once a parked body lags REPARK_LAG_BLOCKS (8) behind its pose, one park-at-rest pass teleports it onto
the pose with the freeze gate lifted for that call, and the parked position is re-based there. The
per-tick Java work the freeze skips is still skipped; the body just never drifts far. Same headless
server, five minutes: 7.6 ms tick, 5.6 ms physics, lag capped at 7.7 blocks, ~800 re-parks. The [mspt]
line now carries activeTracked/activeEntity/activeSettling/frozen/maxBodyLag/reparks/reanchors so a
slow-physics report can be attributed from the log. Repro recipe: ./gradlew runServer in a worktree
with RCON enabled — the train bootstraps and runs with nobody aboard, so [mspt] samples appear every
2 s in debug.log.
PlayerMobs wedged on carriage doors — the reflex now assumes the train axis and probes when stuck (v0.886.0)
Since: DT v0.886.0 (PR #1414) requiring PlayerMob 0.98.0. The behaviour lives in PlayerMob's neoforge/compat/TrainDoorReflex; DT only raises the floor.
Symptom: an echo marching the train stood against a closed corridor door for good. Vanilla's DoorInteractGoal never fires on a Sable carriage (it inspects nav path nodes + world collision, and the carriage blocks live in the sub-level frame), so PlayerMob has its own per-tick door reflex. That reflex decided which door was "in the way" from the mob's own displacement — and shoving a closed door produces no progress along the door's axis but a small slide along the panel, which re-latched the heading to the panel's axis. A closed door does not obstruct travel along its own panel, so the reflex went idle with the mob wedged. A mob that stalled before ever moving never got a heading at all.
Fix (PlayerMob): while a march goal is driving, doors are judged against the train axis (carriages run along X; rotation is locked to identity, so sub-level and world axes agree) — X-facing corridor doors are opened ahead of the mob and Z-facing side doors are left alone. Off a march, a decayed displacement heading applies (a one-tick panel slide can't flip it). If the mob still makes no headway for ~1.5 s (progress measured in the carriage frame, like the dig reflex), it probes: toggles the nearest train-axis-blocking door, toggles it back on the next strike, then moves to another door, with growing pauses so nothing flaps; the probed door is pinned so the reflex can't undo it. The probe is withheld at the forward group edge, where the mob is waiting to leap the gap. Set debugSpawnLog=true in playermob.properties for [DoorReflex] / [DoorProbe] / [DoorStuck] / [Objective] server-log traces.
Still open: a mob can stall inside a doorway whose next cell is carpet (the narrative room, a brown-carpet room) with the door open and the path live — toggling the door does nothing, so this is Sable's sub-level collision/step-up or seam navigation, not door state. Door-only seams clear after one probe. Tracked on the board; the [DoorStuck] trace prints the 3×2×3 block neighbourhood for repro.
A PlayerMob is an Exposure camera holder — the shot is rendered by the gifting player's client (v0.1146.0)
Since: DT v0.1146.0 (PR #1813), requiring PlayerMob 0.106.0 (playermob_min_version raised: DT calls its new PlayerMobPickupHooks, the stack-carrying GiftObserver.onPlayerGift(giver, mob, stack) and PlayerMobEntity.isInCombat()).
How a mob can take a photo at all: Exposure captures on a client. CameraItem.release(holder, stack) sends CaptureStartS2CP to holder.getServerPlayerExecutingExposure(), whose client renders the frame from the holder entity's eyes (CameraCaptureTemplate → setCameraEntity(level.getEntity(cameraHolderId))) — the camera-stand model. PlayerMobCameraHolderMixin makes PlayerMobEntity a CameraHolder whose executing player is the photo's subject (session-only PlayerMobPhotoSubject, within 64 blocks); with no subject set, Exposure refuses to capture, so an idle mob can never be made to shoot. It is also a CameraOperator, so Exposure's HumanoidModel mixin poses its arms around the viewfinder on every client once the server sends CameraInHand#createSyncPacket() (and ActiveCameraRemoveS2CP when the camera comes down).
Traps hit on the way:
-
CameraHolderandCameraOperatorare injected interfaces whose non-trivial defaults throw — override all of them, includinggetExposureCameraOperator()(called fromonShutterOpen; crashed the server mid-shot) and the three operator methods. - Nothing ticks a mob's held item: DT calls
cameraItem.tick(holder, stack)itself until the shutter closes.release()puts the frame on the stack synchronously, and Polaroid'sInstantCameraItem.tickprints any frame it finds as a droppedItemEntity— which DT's burn-on-drop rule ignites — so the frame is taken offphotograph_frameright afterrelease()and before every tick, into the same pending dataDisposableCameraEventsuses for players. -
Photographer(holder)names a non-player by entity type (playermob:player_mob); the frame is re-credited to the mob's display name throughPhotographer.CODECbefore it is stored (also what the burn read-out and the Discord title show). -
getPlayerAwardedForExposure()is empty: the subject would otherwise receive Exposure's "frame exposed" stat and advancement for a photo it did not take. -
EntityJoinLevelEventfires insidespawnAtLocation, so a mark on the returned entity is too late;EchoDropCreditMixinopens aPlayerMobDropsscope around a PlayerMob'sspawnAtLocationinstead, and the photo goal's own toss carries an entity flag. Either (or a PlayerMob thrower) exempts the drop from ignition.
Since: DT (PR #1639), built against PlayerMob 0.100.0. playermob_min_version was deliberately not raised.
Symptom: when two PlayerMobs boarded together, the second one had no nameplate. PlayerMobs are named by Adventure Item Names' Mob.finalizeSpawn mixin (NameComposer.applyMobName). On a DT (EVENT) spawn, PlayerMob rolls a 10% random friend inside the leader's finalizeSpawn and builds it with EntityType.create + addFreshEntity. That skips finalizeSpawn on purpose, so pairs never chain. As a result, AIN never saw the companion.
Fix: PlayerMob fires PlayerMobSpawnHooks.SpawnObserver#onCompanionSpawned(companion, leader) just before the companion is added. PlayerMobSpawnBridge overrides it to run the same applyMobName call, so AIN's chance, category and colour config still apply. On an older PlayerMob the default method is never called, so the companion stays unnamed as before, with no crash. Friend-echoes are titled "Echo of X" by PlayerMob and are unaffected.
Also skipped: anything else DT does only in PlayerMobGroupSpawner#spawnPlayerMob (difficulty gear, the dungeontrain_contents_pidx_* tag, persistence) never reaches the companion either. This seam is the place to add it.
Since: v0.875.0 (PR #1402)
Symptom: a portal puppet (a twin-side mob drawn on the carriage, or vice versa) sat a constant fraction of a block off its floor, and the "snap to the corridor floor" that hid it drew anything on a stair or trapdoor a block too low. Measured live: the box-derived carriage origin, taken into plot space, was exactly −0.100 off an integer on every pair — one tick of train motion, constant, not jitter.
Root cause: SableManagedShip.worldAABB() reads subLevel.boundingBox(), which Sable refreshes from the previous pose; every transform (worldToShip, shipToWorld, the rider carry) reads logicalPose(). The corridor origin handed to the portal tick comes from bb.minX(), so anything that mixes that origin with a pose-based transform inherits the one-tick gap.
Fix (puppets only): PortalPuppets.poseAligned takes the box-derived origin into plot space, rounds it to the block grid the corridor is stamped on, and lets the pose put it back — the origin then moves with the pose and only the pose, and the floor snap is gone. The residual it removed is logged once per pair (snapped to plot grid — residual (…)); a value near ±0.5 would mean a corridor not on the grid. The swap and facing logic still use the box frame with SWAP_HYSTERESIS sized for it.
Since: v0.875.0 (PR #1402)
A still puppet was fine; a walking one stuttered. The server pose was applied when its packet arrived — at an arbitrary point inside a client tick — while render interpolation measures from the tick's start, so the puppet leapt forward by the elapsed fraction and then back when the tick rolled over. Vanilla never does this: a tracked entity's move packet only records a target and LivingEntity.tick advances xo → x over lerpSteps = 3. Puppets now do exactly that in ClientTickEvent.Post. Not a Sable issue, but the same lesson as Sable's renderPose(partialTick) (verified = lerp(lastPose, logicalPose)): anything drawn against the carriage must interpolate in phase with the carriage.
Since: v0.875.0 (PR #1402)
Symptom: summon a horde in the portal carriage, walk into the twin, and every mob is gone within one tick — no death, no drops, nothing in the save. Puppets vanished 60 ms after the swap.
Root cause: not culling. The pair's group stayed resident under its PORTAL_ROOM hold and ContentsDespawnController swept nothing. Mob.checkDespawn discards a non-persistent hostile the tick the nearest player is more than 128 blocks away, and the twin sits at y ≈ −61 under a train at y ≈ 79 ([despawn] … distSq=20454). protectStructureOccupants already defeated the rule for the twin and room (PortalOccupants + PortalDespawnEvents → MobDespawnEvent.DENY), but the structure box cannot see the carriage — it is on the train.
Fix: both corridors of every walked pair are registered with PortalOccupants each tick, so the carriage side gets the same 400-tick grace. Mobs in other carriages still despawn while a player is in the room — same as walking 130 blocks down the train; DT's own contents mobs are snapshotted regardless. Board item filed to decide whether a player in a room should count as "on the train".
Since: v0.920.0 (PR #1467, #1448)
Symptom: a player placed a bed inside a carriage well after the train had left the Nether band and it exploded with badRespawnPoint, ending a one-life run.
Root cause: Sable gives every sub-level plot one biome, chosen once at assembly from the world biome under the ship's spawn pose (ServerSubLevelContainer.createSubLevel → plot.setBiome). Every block of that plot reports it for the plot's whole life. A carriage appended while the train is in the Nether core is therefore an IS_NETHER plot forever — and BedBlockMixin, which explodes beds by the baked biome at the bed's block position, saw "Nether" 1,000 blocks into the overworld. The same holds for End-core-born carriages.
Fix: anything biome-gated aboard must not trust level.getBiome(plotPos). BedBlockMixin now resolves the carriage (Shipyards.of(level).findAt(pos)) and samples the parent world at ship.shipToWorld(pos) — the bed follows where the train actually is. Ground beds are untouched (findAt is null off-plot). Other readers of a carriage block's baked biome (mob-spawn biome checks inside carriages, grass/foliage tint on carriage blocks) inherit the same stale value; none are lethal and none are changed here.
Every band that rewrites terrain after generation (End erosion, chuncks slices, the upside-down mirror, the spheres carve) writes through LevelChunkSection.setBlockState(…, false), the Sable-safe path that bypasses LevelChunk.setBlockState and therefore the light engine. The chunk keeps the sky light computed for the terrain it used to have: anything that was underground before the rewrite stays dark when it ends up in open air. The spheres band (PR #1484) is the first band to fix this, and it does so without a light pass: SunlitChunks hands the engine a full-daylight DataLayer per sky section through queueSectionData — the call the chunk loader uses for saved light — and resends the chunk two ticks later so a first send to a player can't beat the light thread. Block light is untouched. The stamp is band-agnostic; the other three bands could take it in one line each. Riding the moving train through the band was verified headless (dedicated server + auto-joined client, four rides): carriages carry the rider through, the corridor is preserved as bed + rails only, and tick time stays where the resident carriage count puts it.
Sable never cancels carriage section rebuilds on logout — DT closes them at level teardown (v0.1094.0)
Since: v0.1094.0 (PR #1744)
Symptom: a rare client crash a moment after leaving a world, reported as Batching sections: NullPointerException … "container" is null in SectionCompiler.handler$…$sable$preCompile on a chunk-render worker. It looks like a mid-ride crash, but the log shows the disconnect first; the crash screen only appears once the title screen is up.
Root cause: LevelRenderer.setLevel cancels the rebuild tasks of the sections it owns (ViewArea.releaseAllBuffers → RenderSection.reset → cancelTasks). Sable's sub-level sections (VanillaChunkedSubLevelRenderData) schedule their rebuilds on the same dispatcher but live outside the ViewArea, and Sable only closes them through removeSubLevel — never on logout. A task still waiting on a worker runs after the world is gone. It fails first in Sable's dynamic_directional_shading.SectionCompilerMixin, which reads the global Minecraft.getInstance().level; with that guarded, the same task fails in vanilla's SectionOcclusionGraph.onSectionCompiled, whose graph was reset at teardown. A train keeps about 20 carriages rebuilding next to the player, so DT raises the odds without causing the bug.
Fix: mixin/client/LevelRendererSubLevelTeardownMixin closes every sub-level's render data at the HEAD of LevelRenderer.setLevel when the outgoing level is replaced or cleared — the same SubLevelRenderData.close() Sable's ClientSubLevel.onRemove() calls. RebuildTask re-checks isCancelled after compile and before setCompiled, so a cancelled task is dropped at whichever stage it has reached. mixin/client/SectionCompilerNoLevelGuardMixin skips a compile that starts with no client level, covering the instant between Minecraft.level = null and setLevel. Use this.level in that hook, not Minecraft.level, which is already null on a disconnect.
Reproducing: the natural race is rare (40 forced disconnect/reconnect cycles on unpatched code did not hit it). Holding every RebuildTask 400 ms across the disconnect crashes an unpatched client on the first cycle; every surviving task is at Sable plot coordinates. With the fix, 17 cycles ran clean and all 132 held tasks woke cancelled.
Not exercised: a real dimension change, single-player leave, and the Sodium render path. Both mixins can be removed once Sable tears its render data down upstream.
A block changed inside Rapier's step re-enters Rapier and freezes the server — DT defers it (v0.1242.0)
Since: v0.1242.0 (PR #1945). Affects Sable 2.0.5 and 2.0.6.
Symptom: the server thread stops for good, at 0% CPU. [DT-StallWatchdog] reports the same tick forever, the game freezes, and only kill -9 stops a dedicated server because shutdown waits on the server thread. A frozen game cannot upload logs, so players never report it. It hit 2 of 4 fresh boots of one test world.
Root cause: Rapier3D.step (native) holds Rapier's world RwLock while it calls back into Java. When a carriage hits a block with Sable's FRAGILE property (glass, ice, non-persistent leaves) at 4 m/s or more, Sable's FragileBlockCallback.onHit breaks it from inside that callback. The resulting setBlockState reaches RapierPhysicsPipeline.handleBlockChange. If that state, or a neighbour's new shape, needs a voxel collider Rapier has not built yet, Rapier3D.newVoxelCollider takes the same lock on the same thread and waits forever. jstack only shows the thread RUNNABLE in the native method; sample <pid> shows std::sys::sync::rwlock::queue::RwLock::lock_contended → semaphore_wait_trap.
Fix: mixin/SubLevelPhysicsStepColliderDeferMixin wraps the one PhysicsPipeline.physicsTick(D)V call per substep in SubLevelPhysicsSystem.tickPipelinePhysics in a forced ColliderBatch scope (ColliderBatch.callForced). Inside the scope, SubLevelPhysicsBlockChangeBatchMixin records the changed section instead of pushing it to Rapier. The scope's exit re-uploads the section after the step has returned and released the lock. This covers every callback that sets a block mid-step, not only the fragile one. The scope is forced, so the colliderbatch off A/B kill switch cannot re-open the deadlock. A deferred collider is stale for the rest of one substep.
Observing it: [mspt] … stepDeferred= (the last field) counts in-step block changes deferred per window. A non-zero value means the path fired and was handled. Every patched test run showed it, and none froze.
Re-verify on a Sable bump: tickPipelinePhysics must still contain exactly one physicsTick call. Report the re-entry upstream; the mixin can go once Sable stops changing blocks inside the step.
PR #1501 makes BetterNether: New Dawn (Reijin2312's MIT NeoForge continuation of BetterNether; the official build is Fabric-only on 1.21.1) a required dependency, with its libraries BCLib, WorldWeaver and WunderLib (same *-neoforge slugs on Modrinth and CurseForge). All four are pinned ON in both modpacks. Things learned wiring it in:
-
WorldWeaver injects BetterNether biomes into the vanilla Nether preset, not just the live Nether source —
MultiNoiseBiomeSource.createFromPreset(NETHER)returns BetterNether biomes too. DT's vanilla bands build their source fromMultiNoiseBiomeSourceParameterList.knownPresets()filtered tominecraft:. -
WorldWeaver also injects BetterNether ores into the vanilla Nether biomes.
biome_modifications/default_ores.json,ruby_ore.jsonandruby_ore_dense.jsonadd cincinnasite, ruby, lapis and redstone ores to nether_wastes, crimson/warped forest, soul sand valley and basalt deltas. The Nether band's core runs its biome's live feature list, so the vanilla-style bands were full of BN ores (14,396 BN ore blocks in one 432-chunk core). Since PR #1538,NetherCoreFeatureFilterlets aminecraft:core biome place onlyminecraft:anddungeontrain:features. Rejected features are skipped before they take a seed index. BetterNether core biomes and structures are unaffected. The real Nether dimension still gets the BN ores. -
The overworld biome source is NOT replaced, so
MultiNoiseBiomeSourceMixin's instance gate keeps working; the End band's live End source sampling is also unaffected (verified from region files). -
Dedicated servers (fixed in PR #1548): with
config/wover/main.json→server.force_default_world_presetat its defaulttrue, WorldWeaver'sDedicatedServerPropertiesMixinreplaces everylevel-type, explicit or not, with its ownwover:normal. Whenlevel-typeis missing, it also writeslevel-type=wover\:normalinto server.properties. Before #1548, every new DT server world therefore got the vanilla overworld (no y=32 floor or DT bedrock layer) and BetterX Nether/End, and thedungeontrain:dungeon_train_*floor presets couldn't be chosen. The bands still worked, because they only checkLevel.OVERWORLD.DedicatedServerPropertiesLevelTypeMixinis server-only and a@ModifyArgat priority 2000, so it runs after WorldWeaver's. It appliesServerLevelTypePolicy: adungeontrain:*level-type is kept, an unset /minecraft:normal/wover:normalone becomesdungeontrain:dungeon_train, and anything else is left to WorldWeaver. Look for[DT-LevelType]in the server log. Existing worlds keep the preset baked into theirlevel.dat. To check, decodelevel.dat→WorldGenSettings.dimensions."minecraft:overworld".generator.settings. -
Create World: WorldWeaver changes the default preset in
openFresh/WorldCreationUiState.<init>, both before DT'sCreateWorldScreenDefaultPresetMixin(<init>TAIL), so Dungeon Train stays default-selected. -
First launch: WorldWeaver would show a one-time "Welcome to BetterX" screen plus an updates screen, and check for new versions (
config/wover/client.json→did_present_welcome_screen,check_for_new_versions). Since v0.939.0 (PR #1507) DT cancelsVersionCheckerClient.presentUpdateScreenwith the client mixinmixin/wover/WoverStartupScreensMixin, so neither screen appears and the version check never runs.did_present_welcome_screentherefore staysfalse; that's expected. It no longer blocks--quickPlayMultiplayerauto-join in headless harnesses. WorldWeaver's experimental-settings warning is a separate hook and is unaffected. Since v0.1064.0 (PR #1711) the cancel is behind the client config keydungeontrain-client.toml→[betterX] suppressStartupScreens(defaulttrue); set itfalseto get WorldWeaver's screens and version check back. It runs at the title screen, before any world exists, so it cannot be a per-world choice. -
BCLib's "incompatible world" prompt (v0.1064.0, PR #1711): when a save's recorded BetterX patch level is behind (or missing),
DataFixerAPI.fixDataopensConfirmFixScreenand waits for Proceed.mixin/bclib/BclibFixPromptMixinanswers it automatically (proceed, no whole-world backup) only for Dungeon Train worlds; any other save gets BCLib's own prompt. Before #1711 it auto-answered every save, including plain BetterNether/BetterEnd worlds. The check runs before the world opens, so it reads the save'slevel.dat(world/DungeonTrainSave): adungeontrain:overworld dimension type or noise settings means DT.data/dungeontrain_world.datcannot be the signal, because DT writes it into every world it runs in. Known blind spot: thedungeon_train_compat(Compatible Terrain) preset stores the vanilla overworld, so those worlds get BCLib's one-click prompt. Dungeon Backup does not cover world saves (only<gameDir>/dungeontrainplayer data), so DT worlds still get no backup before BCLib patches them — the accepted trade-off for one-life runs. -
BCLib fog:
config/bclib/client.jsonhas its own Nether fog rendering (custom_fog_rendering,nether_thick_fog); no conflict seen with DT's band fog. - Deptest Case I (BetterNether missing) fails with
betternether [21.0.27,).
PR #1503 makes BetterEnd: New Dawn (Reijin2312's MIT NeoForge port; paulevs' BetterEnd is Fabric-only on 1.21.1) a required dependency. It shares BCLib, WorldWeaver and WunderLib with BetterNether (one declaration, floors bclib_min_version etc.). Things learned wiring it in:
-
Dev-only boot crash: in a dev environment (
!FMLEnvironment.production, i.e. runClient/runServer/JUnit) BetterEnd'sEndItems.ensureStaticallyLoadedbuildsDebugDataItems after item registration, and the registry freeze fails with "Some intrusive holders were not registered". Production is unaffected. DT cancels that method withmixin/betterend/BetterEndDebugItemsMixin. -
TheEndBiomeSourceis patched by both WorldWeaver and TerraBlender, so even a freshlycreate()d instance doesn't return vanilla's layout (see The End's biome source below for what it does return). DT's vanilla End bands (chorus, End-city gating, biome labels) useVanillaEndBiomes, a reimplementation of vanilla's rule (verified against the 1.21.1 bytecode). - The End terrain density is unchanged: vanilla band islands are block-for-block identical to pre-BetterEnd (A/B vs main). Chorus sites move, because added placed features shift DT's decoration-RNG feature index.
- BetterEnd also changes the real End dimension — the
dungeon_train_endstart preset and the spheres band's End spheres. - The JUnit launcher boots the full mod set; with these mods it needs
maxHeapSize = '2g'. - Deptest Case J (BetterEnd missing) fails with
betterend [21.0.35,). -
Chorus cosmetic forced off (v0.949.0, PR #1525): BetterEnd's
generator.json→cosmetic.change_chorus_plant(defaulttrue) swaps the vanillachorus_plant/chorus_flowerblock models for itscustom_chorus_*on the client (BlockStateModelLoaderMixin) and changes the flower hitbox. A model swap is global, so it restyled the chorus DT grows on the vanilla End bands.mixin/betterend/BetterEndChorusCosmeticMixinanswersfalsefromGeneratorOptions.changeChorusPlant(), the single getter every reader uses. Chorus is vanilla everywhere, including BetterEnd's own bands. Since v0.1064.0 (PR #1711) this is behind the common config keydungeontrain-common.toml→worldgen.keepVanillaChorus(defaulttrue);falsehands the decision back to BetterEnd's ownchange_chorus_plant. It cannot be per-world: the models are chosen when they load, before any world, and the flower's hitbox must match its model on both sides, so keep client and server on the same value and restart after changing it. -
Sampled End bands clip features at chunk borders —
endBandFeatureSpill(v0.1073.0, PR #1720): a BetterEnd (or BoP) End-band chunk is a real End chunk generated off-thread byEndBandSamplerand copied onto track level. It used to be decorated inside aWorldGenRegionwhose eight neighbours were blank throwaway chunks (OfflineChunkSampler.regionAround), so anything a feature wrote past the chunk edge was discarded and nothing from the neighbours' features spilled in: BetterEnd's trees, pillars, crystals and cave carves (all grown outward from an origin by BCLib'sSDF.fillRecursive) ended in flat faces on chunk lines. The common config keyworldgen.endBandFeatureSpill(defaultfalse, the existing look) decorates each chunk beside copies of its real neighbours' undecorated ground (EndBandGroundCache, shared between the sampler threads) and writes what spilled into those neighbours (EndBandSpill, applied byWorldEndBandEvents: adds only into air, carves only where the ground block they dug through still stands). Only chunks generated after enabling it change. Two things it does not touch: BetterEnd's structure-based mega lakes, mountains and bridges never appear in sampled bands (the offline sampler runs no structure starts), and a copied all-air chunk section must be rebuilt block by block —PalettedContainer.copy()leaves its palette resize hook on the original. -
Vanilla End patches in the BetterEnd band —
endBandBetterEndOnly(v0.1141.0, PR #1807, closes #1785): WorldWeaver's End map (wover:end_biome_source) keeps vanilla's End biomes beside BetterEnd's — about two fifths of it — and vanillaend_barrens/small_end_islandsplace no features at all, so the band copied them faithfully as flat bare end stone beside lush BetterEnd islands. A region-file census of a fresh 0.1126.2 world put 20 of 21 bare band chunks on those two labels (BetterEnd-labelled chunks were 398 vegetated to 1 bare); swallowed decoration exceptions,isSampling()re-entrancy, the ground cache and the world preset were all ruled out.EndBandBiomeRemapwraps the live End source for BetterEnd-style samples only: the four vanilla land biomes become awover:is_end/landbiome andsmall_end_islandsawover:is_end/small_islandone, picked per End quart by seed-stable jittered Voronoi cells (64 quarts) so replacements come in coherent clumps.OfflineChunkSampler.Workspacecarries the biome source so noise fill, surface rules, carvers, the spill ring and in-sample lookups all read it, andEndCoreBiomesresolves the display label through the same instance, so label, surface and decoration agree. The real End dimension, BoP passes and vanilla passes are untouched. Common config keyworldgen.endBandBetterEndOnly(defaulttrue); only chunks generated after the change are affected, so an existing world keeps its vanilla patches next to remapped new chunks. Headless A/B on the zone the bare chunks were found (seed 424242, X 36272–36719, Z −16..207, 392 chunks): vanilla-labelled chunks 144 → 0, bare 53 → 2 (BetterEnd ice starfield fragments), ore-only 2 → 0; a twin run differed in 0 chunks. Two traps: WorldWeaver tags vanillaminecraft:small_end_islandsintowover:is_end/small_islandat runtime (the jar's tag file lists three BetterEnd biomes, the registry tag four), so the pools dropminecraft:ids or they map vanilla to itself; and BetterEnd injects flavolite / thallasium / ender ore into vanilla End biomes, so a census counting anybetterend:*block as "decorated" hides vanilla chunks —scripts/endband/count_decorations.py(branchdev/endband-decoration-ab) classes thoseore-onlyand reportsvanilla_end_quartsfrom the section biome palettes. -
Spill is scoped to its own pass, re-lit once per tick, held one per neighbour (v0.1095.0, PR #1745): an
EndBandSpillcarries the pass it was sampled for and the chunk it came from.WorldEndBandEvents#writeSpillonly writes a spilled cell into a column whoseWorldGenCycle#endSourcePassAtis that same pass (EndBandSpill#ownedBy) — a spill holds blocks from its own pass's End offset, so across a joined seam of two sampled End looks it would otherwise land as the wrong half of another pass's feature. Chunks that spill is written into are collected and re-lit/resent once at the end of the tick instead of once per neighbour (headless A/B on a 320-chunk strip: 631 → 256 relights), and spill waiting for a chunk is held one per neighbour inEndBandSpillWaiting(a re-sampled neighbour replaces its earlier spill). Known and open: with spill on, two fresh same-seed worlds are not block-identical in part of a joined seam (#1746).
PR #1583. Until then the BetterEnd End band had no BetterEnd biomes at all, and its layout changed between runs of the same seed. Measured causes and fixes:
-
Wrong End biome source. DT's presets created the End as
minecraft:noise+minecraft:the_end. WorldWeaver and TerraBlender both HEAD-cancel that source. In the exported mixin class, TerraBlender runs first ingetNoiseBiomeand WorldWeaver first incollectPossibleBiomes. The End therefore returned vanilla + Biomes O' Plenty End biomes (0 BetterEnd on a 201×201 grid), while/locatecould only see vanilla + BetterEnd. WorldWeaver's ownTheEndBiomeSourcehook wouldn't help either: Endtemperature()is 0 everywhere, so it picks one biome per erosion zone. All 15 End-bearing DT presets now usewover:betterx+wover:end_biome_source(config copied from WorldWeaver's own preset;seed: 0is re-seeded by WorldWeaver at level init). Noise settings stayminecraft:end, so island density is unchanged.EndPresetBiomeSourceTestpins it. -
BoP kept out of the End:
data/wover/config/biome_config.json→{"exclude": {"*:is_end": ["biomesoplenty:*"]}}. Without it, WorldWeaver imports TerraBlender's End biomes (TerraBlenderEndBiomeCompat) in a boot-order-dependent way. -
Biome layout changed every boot.
WoverBiomePicker.rebuilditerates aHashSetandconsumeSubBiomesForSourcea registryentrySet(), both keyed on identity-hashedResourceKeys.mixin/wover/WoverBiomePickerOrderMixinfeeds both in biome-id order. -
Feature seeds changed every boot. WorldWeaver keeps
possibleBiomesas aSet.copyOfset (salted iteration order per JVM).FeatureSorterbuilds the per-step feature list from it, and each feature's decoration seed comes from its index in that list.WoverPossibleBiomesOrderMixin(collectPossibleBiomes) andWoverPossibleBiomesCompatOrderMixin(LithostitchedBiomeSourceCompat.replacePossibleBiomes, which writes the memo directly) keep it in biome-id order. -
BetterEnd shuffles static arrays in place. Tenanea, Lucernia, bushes, wall plants (
WallScatterFeature.DIR), geysers, sulphuric caves and BCLib'sStructureErodeshuffle astatic Direction[]and walk it, so each placement depended on every earlier one in the JVM.mixin/betterend/BetterEndStaticShuffleMixin/BetterEndWallScatterShuffleMixinreset the array toDirectionorder before each shuffle. The arrays are also shared across threads (DT's sampler threads + the End's worldgen thread), soBetterEnd{Dir,Directions,Horizontal}PerThreadMixinredirect every read to a per-thread copy (worldgen/PerThreadArrays). -
BetterEnd re-configures static SDF graphs per placement (v0.1112.0, PR #1771).
MossyGlowshroomFeature(foggy mushroomland) andOreLayerFeature(violecite / flavolite layers) build their BCLib SDF shape once in<clinit>and then set radii, blocks, offsets and angles on those shared static nodes inside everyplace(). With End-band chunks decorated on every worldgen worker plus the sampler threads, two placements at once filled with each other's settings: headless same-seed runs differed by ~1.6k glowshroom and ~330 rock cells with three sampler threads, and were identical with one.mixin/betterend/BetterEndGlowshroomPerThreadMixinandBetterEndOreLayerPerThreadMixinredirect every static read inplace()(and the glowshroom'slambda$static$2, which readsCENTER) toworldgen/PerThreadSdf.of(...), a per-thread reflective clone of the whole reachable graph (identity-mapped so the wiring survives;Vector3fscratch and the post-process list copied; functions shared). Two traps found while testing: BCLib's own lambdas live inorg.betterx.bclib.sdfand have no no-arg constructor, so clone byinstanceof SDF, not by package; andSDFFlatWave's constructor makes a lambda that reads its own node, so a copy must keep its constructor-made lambda rather than inherit the original's (isOwnLambda), or stems come out the wrong shape.BiomeIslandFeaturehas the same bug class and getsBetterEndBiomeIslandPerThreadMixin+BiomeIslandState, but nothing in the pinned build placesbetterend:overworld_island, so that mixin is unexercised. With the fix on, two band runs are identical to each other and to a single-thread reference; in the real End (11 workers) runs still differ by whole glowshrooms, and that is not a DT race (investigated 2026-10-03, below). -
Real-End glowshroom variance is vanilla decoration order, not a race (investigated 2026-10-03, no code). Six headless same-seed worlds (seed 7741953, the 8×8-chunk dust_wastelands / foggy mushroomland area X 2096..2223, Z -352..-225 of
minecraft:the_end, forceloaded one chunk at a time in a fixed order): three DT-preset worlds with the default 11 workers, one DT-preset world with-Dmax.bg.threads=1, and twominecraft:normalworlds. They fell into three outcomes, each shared across arms: one 11-worker DT world; two 11-worker DT worlds and one vanilla-preset world (60 identical stems, 1 cell apart); the single-worker DT world and the other vanilla-preset world (58 identical stems, 0 glowshroom cells apart). Between outcomes 4–7k cells differ, all of them whole mushrooms added or removed inside one chunk's 3×3 neighbourhood (centre (2176,-336)) plus ~120 flavolite cells where a cap now stands. A single worker reproducing an 11-worker outcome block for block rules out shared-state races; the vanilla preset showing the same outcomes rules out DT's End machinery. The mechanism is the placed feature itself:betterend:mossy_glowshroomusesminecraft:count_on_every_layer, whosegetPositionsreads the live heightmap and walks each column's block states at placement time, drawing randoms per solid layer it finds. A glowshroom cap spilled in from a neighbour chunk that decorated first adds a layer, so the number of random draws and every later origin in that chunk change. Which neighbour decorates first is scheduling order, and vanilla has exactly the same variance. The ~0.4%end_moss ↔ end_myceliumchurn in every pair is BetterEnd's unseededMHelper.RANDOMin its surface-noise conditions, also not ours. Scan of BCLibutil/noise/api.v2.levelgen.featuresand BetterEndutil/world.{biome,generator,surface}for other mutable statics found onlyIslandLayer.RANDOM(setSeed-then-draw, used by BetterEnd's own terrain generator, which DT's presets do not use) and the unseededMHelper.RANDOM; none is a determinism target. Nothing to fix: in-band chunks are decorated alone, so bands stay identical; the real End varies exactly as it does without DT. Dev toggle for A/B:-Ddungeontrain.betterendPerThreadSdf=false. Also seen there: in the defaultendBandTerrain = WORLDGENmode every headless world had a bare end-stone BetterEnd band (fix on or off), so the A/B ran inBACKGROUNDmode — fixed in v0.1117.0, see The inline End sample and the void erosion below. -
Result: twin fresh-world runs, 240 End-band samples, 2 sampler threads: 0 samples and 0 blocks differ (was 29–33 samples, 13–20k blocks). The vanilla End band is block-identical to before. Only new worlds get the new End (the End stem is saved in
level.dat). The real End, thedungeon_train_endstart preset, End spheres and BetterEnd dimensional carriages all get BetterEnd's layout, and WorldWeaver's Nether pickers (BetterNether) are boot-stable too. -
Mixin gotcha: a multi-target mixin's
@Shadowneedsremap = false. Otherwise Mixin logsFound a remappable @Shadow annotationat ERROR and silently skips the whole mixin, without crashing. Grep[mixin/]ERROR after adding one. - Cost: BetterEnd decoration is heavier than the vanilla-biome End it replaced. Rough wall-clock sampling time is about +45% per End-band chunk; a profiled review is pending.
PR #1525. OfflineChunkSampler runs a dimension's real generator into a throwaway ProtoChunk for foreign spheres, BetterEnd End-band copies and dimensional-carriage rooms. Three things it got wrong, all fixed there:
-
Heightmaps went stale during decoration.
ProtoChunk.setBlockStateonly maintains the heightmaps ingetPersistedStatus().heightmapsAfter(); atSURFACEthat is just the*_WGpair, so every heightmap-placed feature (trees useOCEAN_FLOOR) read the noise-fill ground for the whole pass. Trees stacked at ground level; a jungle tree whose trunk column was already logs passedTrunkPlacer.isFree(allows logs) butplaceLogrejected every log, andCocoaDecoratorthrew on the empty log list.fillGroundnow raises the sample toFEATURESafter priming (still belowINITIALIZE_LIGHT). -
DUMMYblock-entity placeholders.WorldGenRegion.setBlockrecords a data-less{id:"DUMMY"}tag for a block-entity block in a proto chunk; vanilla promotes it inLevelChunk.promotePendingBlockEntity. DT's copy-outs forwarded it andBlockEntity.loadStaticloggedBlock entity has invalid type: DUMMYand dropped the block entity.OfflineChunkSampler.isPlaceholderBlockEntityfilters them so the apply side creates the block entity fresh. -
DT's own features ran inside samples. The track bed is added to every DT dimension's biomes, so a sample laid its own corridor at
trainYand the copy carried it 20 blocks above the real track in BetterEnd bands (and into lifted spheres).OfflineChunkSampler.decoratesets a thread flag andChunkGeneratorDecorationMixinvetoes everydungeontrain:feature while it is set. Local overworld spheres had the same symptom by another route —WorldSpheresEventslifted the corridor's own bed/rail rows with the column — and now leave those rows behind. -
BCLib's feature-seed counter raced across sampler threads (PR #1570, v0.969.0). BCLib's
ChunkGeneratorMixinrewrites everysetFeatureSeedseed insideapplyBiomeDecorationtorotateRight(seed, counter++). The counter is oneintfield perChunkGeneratorinstance (bclib_featureIteratorSeed), not per thread. Live worldgen never overlaps: in 1.21.1, FEATURES tasks run one at a time in each level's"worldgen"mailbox. The samplers, though, decorate with the dimension's live generator from their own pools (spheresSamplerThreads, default 2). Two at once reset and bump the same counter, so End-band and foreign-sphere decoration depended on thread timing (measured: 64 and 11 corrupted calls per run).mixin/BclibFeatureSeedCounterMixin(priority 1100, so it applies after BCLib's handler) replays the same formula fromworldgen/FeatureSeedCounter's per-thread state. Output is bit-identical to BCLib wherever no race happened (verified on 204k live seeds). Re-check BCLib's mixin on every BCLib bump (note besidebclib_version).
Verification harness: fresh dedicated dev server, forceload add a chunk-aligned 64×4 slab (the 256-chunk cap; an unaligned X gives 65 columns and the whole command is refused), wait for the samplers, save-all flush, then scan region files. /locate is not a usable check here — a set absent from possibleStructureSets answers in 0 s, and DT's re-sited band sets answer "Could not find" in 0 s too.
PR #1506 makes William Wythers' Overhauled Overworld (+ Cristel Lib) and Biomes O' Plenty (+ TerraBlender, GlitchCore) required dependencies, pinned ON in both modpacks (CurseForge slugs william-wythers-overhauled-overworld, cristel-lib, biomes-o-plenty, terrablender-neoforge, glitchcore; Modrinth wwoo, cristel-lib, biomes-o-plenty, terrablender, glitchcore). BoP's floor 21.1.0.14 is not strict semver, so its modpack entry carries no gradle_property (same as Fast Paintings). Things learned wiring them in:
-
TerraBlender HEAD-cancels
MultiNoiseBiomeSource.getNoiseBiome(x,y,z,sampler)from its own mixin, so a@ModifyReturnValueon that method never runs once TerraBlender is installed. DT's biome override is now a cancellable HEAD inject at mixinpriority = 500(applied first ⇒ runs first). -
TerraBlender fills each chunk's biomes through an
Object.clone()of the overworld source (its region-noise cache isn't thread-safe). An identity gate on the published source therefore skips real chunk generation while/locateand debug samples on the original still look right — DT's Nether-core / End-core / highland biomes were being silently lost on disk. The source is now marked with a@Uniquefield the shallow clone inherits (OverworldBiomeSourceMark). Verify from region files (sections[].biomes.palette), never from the source object. -
A lost biome context must never fall through to TerraBlender (PR #1543, v0.955.0). DT used to clear
NetherBandContext+OverworldStretchBiomesonServerStoppingEvent, but NeoForge fires that beforestopServer()drains in-flight chunk generation and saves it. Those last chunks got TerraBlender's pick, BoP anywhere, and were saved for good (reproduced: 87 chunks withbiomesoplenty:*in a vanilla stretch after stopping with a player mid-teleport). The contexts are now cleared onServerStoppedEvent. As a backstop, the mixin answers a marked overworld source with a vanilla pick (OverworldStretchBiomes.vanillaFallback: a cached vanilla climate table plus holders from the source's ownpossibleBiomes()) whenever it has no context, no stretch tables, or throws. It logs at DEBUG only. (Vanilla's stronghold-ring search used to hit it on every boot. Since PR #1562 it waits forpublish(), see below.) To reproduce, use a real player: RCONforceloadgenerates synchronously, so nothing is in flight atstop. -
Stronghold rings wait for DT's biome context (PR #1562, v0.967.0). Vanilla starts the overworld's concentric-ring search (128 async
findBiomeHorizontaljobs at y=0) inside theServerLevelconstructor, beforeLevelEvent.Load. That meant the jobs racedNetherBandContextEvents.publish(), and the same world could compute different rings on different boots. Reproduced on seed 424242: ring #23 moved 3 chunks between two reloads, so eyes of ender could point where no stronghold was generated.ServerLevelDeferRingGenMixinskips that eager start for the overworld only, andStrongholdRingGate.startruns it from afinallyat the end ofpublish(), on the server thread, beforesetInitialSpawn/prepareLevels. The ring jobs stay async. Later republishes can't change their answers: y=0 is belowBandBiomeDecision's sea-level gate, and the legacy-band and stretch biomes depend only on the overworld, seed and config. The band columns were never involved. - TerraBlender only initialises stems whose dimension type is in
#terrablender:overworld_regions; DT'sdungeontrain:overworld*types join it viadata/terrablender/tags/dimension_type/overworld_regions.json. Without it BoP never appears on the classic preset (no regions, no surface rules, not inpossibleBiomes). -
TerraBlender replaces the surface rule for
minecraft:biomes (PR #1541, v0.954.0). ItsMixinNoiseGeneratorSettingsHEAD-returnsSurfaceRuleManager.getNamespacedRules, which maps"minecraft"to TerraBlender's own hard-coded vanilla rule. DT'ssurface_ruleonly ran as the fallback, so its 1-thickbedrock_floor(above_bottom: 1) became vanilla's 5-layer random gradient (bedrock specks at floor+1..+4).mixin/terrablender/SurfaceRuleManagerMixindrops theminecraftentry for DT's owndungeontrain:noise settings (identified by registry identity inworldgen/DtSurfaceRules). Vanilla biomes then use DT's rule, andbiomesoplentybiomes keep BoP's rules. The surface pass got slightly cheaper (one fewer rule tree per chunk). Log line:Surface rules: dungeontrain:overworld keeps its own rule for vanilla biomes. -
/locate biomepre-filters bypossibleBiomes(), so it can never find DT's forced Nether/End biomes in the overworld — samplegetNoiseBiomedirectly (the debug command does). -
VanillaRegistries.createLookup()fails once WorldWeaver is present ("Unreferenced key … betternether:…"): it patches the biome-source parameter lists to reference its biomes, which aren't in vanilla's biome bootstrap.VanillaWorldgenLookupbuilds only carvers, processor lists, configured/placed features and biomes — self-contained. -
"Differs from vanilla" is the wrong scope for WWOO confinement: BetterNether (WorldWeaver) patches the vanilla Nether biomes and BetterEnd the End ones, and those edits must stay everywhere.
WwooDatapackreads thedata/minecraft/worldgen/{biome,placed_feature,configured_feature}files out of WWOO's jar and scopes both confinement layers to exactly those. -
WWOO's
minecraft:tags are global and can't be confined to its stretch (PR #1539, v0.955.0). Cristel Lib registers WWOO's packs (wwoo:resources/wwoo_main) atPack.Position.TOP, above DT's mod resources, so a DT override file loses to WWOO'sreplace: true.TagLoaderWwooFilterMixin+WwooTagFiltertherefore drop WWOO's copy of the tags none of its features use:snow_layer_cannot_survive_on(snow settled on ice),spawns_{warm,cold}_variant_frogs(swapped frog colours),deepslate_ore_replaceables(+smooth basalt) andmangrove_logs(+bamboo/roots →#logs, so leaves never decayed). Kept because WWOO features need them:sand(+packed mud),mushroom_grow_block(+mushroom stem),mangrove_{logs,roots}_can_grow_through. Verify with theIgnoring minecraft:tags/…INFO lines at boot or/reload.fillbiomecan't be used to probe biome tags in the overworld (DT answers the biome read), so probe at/locate biomeresults instead. - WWOO's biome effects leak visibly if unconfined: cyan beach water (
#43D5EE), near-black old-growth-taiga water, tan flower-forest fog, yellow taiga leaves — a "colour plane" seen from the train on lap 0 was the first symptom. -
Foliage and water tint are confined per block, not per camera (PR #1549, v0.958.0).
Biome.getFoliageColor()/getWaterColor()take no position, so gating them on the camera's X tinted every visible chunk by which side of the stretch the player stood on, andClientLevel's per-chunk tint cache kept those colours after crossing.client.BiomeColorsTwinMixininstead wraps those calls insideBiomeColors.FOLIAGE_COLOR_RESOLVER/WATER_COLOR_RESOLVER, which receive the block's(biome, x, z), and swaps in the vanilla twin there. Those resolvers are vanilla's only callers of the two getters; Sodium and Distant Horizons tint through the sameColorResolver. Grass was already per block (getGrassColor(x, z)). Water fog, sky, fog, sounds, music and particles still follow the camera, because they describe where the player is. - Deptest Case K (WWOO + BoP missing, libraries present) fails with
wwoo [2.6.7,)+biomesoplenty [21.1.0.14,).
PR #1599 makes Big Lost City (modId big_lost_city; Modrinth big-lost-city, CurseForge 1472594) a required dependency. Its ruined cities fill the legacy run's Lost City era.
-
Structure-only. An MCreator datapack-in-a-jar: 42 jigsaw structures, 42
random_spreadsets, 4 chest loot tables. No blocks, items, mixins or registries, and every block and entity in its NBTs isminecraft:, so Sable, the editor and stamping see nothing foreign. It does place 154 zombie spawners and its own chest loot, which bypass DT's loot roller. -
All Rights Reserved, so it can't be jarJar'd. Declared
big-lost-city(required)in bothrelease.ymlrelation lists,big_lost_city [${biglostcity_min_version},)inneoforge.mods.toml, pinned inmodpack.config.jsonwithmod_ids: ["big_lost_city"](whitelisted for fair play). -
Confined by id, not by biome. The mod's structures accept only vanilla overworld biomes, so on their own they'd scatter across every vanilla overworld stretch.
StructureBasementMixinreturnsINVALID_STARTfor anybig_lost_city:*(and DT'sdungeontrain:lost_city/*copies) outside the era, keeping/locateand chunk references clean. -
DT's data depends on it. The
lost_citystructure set and the 13 trackside structure copies carryneoforge:conditionsmod_loaded big_lost_city, so a forced load without the mod doesn't break datapack loading. - Pins live in
gradle.properties(biglostcity_version= Modrinth version id,biglostcity_min_version). Runscripts/deptest/run-all.shbefore the first release that ships it.
The mod ships 1.20.1 NBT, so each template vanilla loads is datafixed first (~0.5 s). A city can place 42 of its 75 templates (worldgen/LostCityTemplateIds); together they hold ~135 MB of heap. event/LostCityTemplatePreloadEvents loads them on a background thread (DungeonTrain-lostcity-preload) before worldgen asks, and removes them from StructureTemplateManager's cache once nothing needs them.
-
What holds the cache is where chunks are generated, not where a player stands (
worldgen/LostCityTemplatePreload, since #1773). Each player — and each absent player's last overworld X — is RUN (the run within 3000 blocks: load all 42), FORETASTE (the WWOO stretch within view distance + 10 chunks + 1024 blocks: load this world's foretaste pick only), HOLD (within those plus 1000 blocks: keep, load nothing) or QUIET. Eviction needs every position QUIET for 30 one-second scans. -
Demand signal (
worldgen/LostCityTemplateDemand). Every approved Lost City start reports in from whichever thread is generating. With the cache cold it starts an urgent pre-load at once, that structure's templates first; and no eviction happens until starts have stopped for 120 s. This is what covers Distant Horizons, whose LOD workers (DH-World Gen Thread[N]) generate far beyond any lookahead: the first city a DH worker meets may load a template or two on that DH thread, then the cache stays put while DH keeps generating there. DT reads no DH config for this. -
Teleports: a jump of 128+ blocks in a tick runs the scan immediately. Measured A/B (same seed): a
tpinto a cold run blocked the tick 40.8 s and dropped the client under the old rule, 9.7 s with the client staying under the new one. -
Diagnosing:
/dungeontrain debug lost-city-templatesprints the scope, cached count, pre-loads, evictions, approved starts and cold lookups by thread kind (StructureTemplateManagerLoadMixintimes everybig_lost_citylookup that misses the cache). Anything outsidePRELOADwas a stall.at <x>shows what a player at X would need;legacy/reachswitch to the pre-#1773 rule and back for an A/B.
PR #1701 makes VanillaBackport (BlackGear, MIT) a required dependency, with its library Platform (LGPL-3.0). Both are needed on client and server — a client without them is rejected at login ("Incompatible client"), and vice-versa. Pinned ON in both modpacks; deptest Case M (VanillaBackport missing) fails with vanillabackport [1.1.7.10,).
-
Nether-exit caves: the mountain interior after every Nether core rolls deep dark 50% · sulfur caves 30% · lush/dripstone 20% over 512-block regions (
worldgen/NetherBandBiomes#pickCavePost). Without the sulfur biome the original 3-in-4 deep-dark roll is kept pick for pick. -
Overworld biomes confined: VanillaBackport registers Pale Garden / Sulfur Caves / Dappled Forest in the
minecraftnamespace and Platform appends their climate points to vanilla'sOverworldBiomeBuilder. DT keeps them to the vanilla overworld stretches (and spheres / chuncks / stacks); WWOO, Lost City, BoP and legacy-band chunks pick fromOverworldStretchBiomes#vanillaTableWithoutBackport.mixin/terrablender/RegionsMixindrops VanillaBackport's TerraBlender region, which DT never reads but which would reshuffle the BoP stretch's region layout. A/B vs main: WWOO and BoP stretch censuses byte-identical. -
Surface rule: VanillaBackport injects its sulfur/cinnabar rule into every
NoiseGeneratorSettingsconstructor, sodungeontrain:overworld*carry it with no DT code;event/VanillaBackportSurfaceRuleCheckwarns at server start if that stops. -
No overworld decoration in the Nether core: found while adding this — the core is stamped at CARVERS, so later overworld features (cave features, and the core's own low overworld-labelled rows) decorated it.
worldgen/feature/OverworldDecorationGuardrefusesWorldGenRegionwrites into core columns while a non-DT feature places (decoration loop +WwooDecorationPassextras). -
Tunnels: sulfur, potent sulfur, cinnabar, dripstone block and pointed dripstone count as underground material (
TunnelPalette). - Performance: smoke-tested before adopting — no measurable tick or chunk-gen cost; no Sable crash on a moving train (VanillaBackport issue #403 did not reproduce).
PR #1762 makes Exposure (mortuusars, MIT, 1.9.19) and its add-on Exposure: Polaroid (1.1.6) required dependencies, on client and server. Polaroid needs Exposure itself, so the two travel together (exposure(required) / exposure-polaroid(required), same slugs on both platforms).
-
What players get: only DT's Disposable Camera. Exposure's original camera, films, developed films, lightroom and chromatic sheet, and Polaroid's reloadable instant camera and its four instant slides (PR #1867), are disabled — recipes, loot and creative (
compat/DisabledModContent,HIDDEN_ITEMSplus the stack-level instant-camera rule). -
Creative menu: Exposure's tab is hidden with the other bundled mods' tabs and comes back with the Mod Block Tabs / Mod Blocks in Search settings.
-
Vanilla chests: Exposure appends its own
exposure:chests/*tables to five vanilla chest tables through a global loot modifier.compat/ExposureLootSuppressorcancels those tables at load, so nothing is appended. -
DT compiles against both mods (
compat/DisposableCamera*): camera item, frame, photograph and photograph-screen classes. An Exposure update that changes those needs a DT rebuild. -
Disposable camera prints on its own timer (v0.1121.0, PR #1782): two Polaroid behaviours don't fit several cameras in one inventory.
-
Item cooldowns are per item, not per stack. Polaroid's
printingmodel predicate and vanilla's hotbar sweep both read the instant camera's cooldown, so one shot animated and locked every disposable camera. -
Polaroid prints from the camera's
exposure:photograph_framecomponent on the client as well. When the server fell behind the client during the print, the client produced a ghost photo, a duplicate.
How DT now handles a shot (
compat/DisposableCameraEvents):- The frame moves into the camera's custom data (
dt_pending_frame, encoded withFrame.CODEC), so Polaroid never sees it on either side. - Each camera runs its own timer (
dt_shot_tick→dt_print_start). The item cooldown is lifted when the viewfinder closes; until then it is what blocks a second shutter release. - A spent camera can't be raised again (
RightClickItemcancelled). -
client/DisposableCameraClientreplaces theprintingpredicate for disposable cameras, and draws vanilla's cooldown sweep as anIItemDecoratoron the shooting stack only.
-
Item cooldowns are per item, not per stack. Polaroid's
-
Capture path: Exposure forces its "direct" capture whenever Veil is installed, and Veil ships with Sable — so every DT photo is taken from the real framebuffer for a frame, not its background render.
-
Photo storage: each photo is
<world>/data/exposures/<id>.dat(one byte per pixel, gzip); the item only carries the id, so a photo does not survive leaving its world. Relay upload is tracked in #1753. -
Runtime compatibility run (2026-10-02, PR #1763): both mods' mixins apply with no injection errors; the only method both touch is
Camera.setup, where DT's handler runs last. Direct capture gave correct photos in every band, mid shader crossfade and under a shader pack, at the cost of a two-frame flicker (HUD off, then one frame at the photo's zoom/brightness). -
Do not switch to background capture: removing
veilfrom Exposure'sforce_direct_capture_default_modsis seamless with no shader pack, but aborted the client on every attempt (3 of 3, macOS GL driver) with Complementary Reimagined active. With Distant Horizons and Iris installed Exposure hard-codes direct capture whatever the config says. -
Ride snapshots vs the viewfinder (v0.1107.0): the snapshot's extra render pass goes through
GameRenderer.getFov, so it used to inherit the viewfinder's (or a spyglass's) zoom.GameRendererSnapshotMixinnow hands that pass the player's own FOV setting, andRideSnapshotCaptureholds a pending shot whilecompat/ExposureCaptureState.captureInFlight()is true (HUD hidden and Exposure overriding FOV) — under a shader pack the two passes otherwise spoiled each other's image. -
Flash does not light a carriage: the flash light block is placed at the player's world position, and a carriage is a sub-level lit from its own plot.
-
Camera stand: using the item on a carriage block creates no entity (a vanilla armor stand placed the same way works).
CameraStandItem.useOnspawns at the clicked block position, which on a carriage is a shipyard coordinate. Tracked in #1750. -
Photo frames ride as blocks (v0.1182.0, PR #1864): Exposure's
PhotographFrameEntityis aHangingEntity, so on a carriage it has the same problem as paintings: Sable keeps it at the shipyard position and never draws it at the carriage's pose.- A wall-hung frame, plain or glass, now places DT's
dungeontrain:photograph_frameblock instead.mixin/PhotographFrameItemPlaceMixinhooks the start ofPhotographFrameItem.useOn→compat/photo/PhotoFrameBlockPlacer; the clicked block still gets its own use first. - A 1×1, 2×2 or 3×3 frame is a block group sharing Fast Paintings'
facing/x_offset/y_offsetmaster convention. Because of that,PaintingTransformProcessorre-lays frames under mirrored, rotated and flipped stamps unchanged. - The master cell's block entity (
PhotographFrameBlockEntity, same NBT keys as the entity) holds frame item, photo, rotation and glow. -
client/render/PhotographFrameBlockRendererdraws Exposure's own frame models andphotographRenderer(). Exposure's pixel-perfect frame option is not mirrored. - Floor and ceiling frames still use Exposure's entity, and existing frame entities are not converted.
- A wall-hung frame, plain or glass, now places DT's
-
Photo metadata records
minecraft:overworldand the overworld biome in every band — the bands are not separate dimensions. -
Not yet verified / deferred: the camera stand on a moving train (#1750), the Interplanar Projector's URL loading policy (#1751), an upstream dedicated-server camera report (#1752).
-
Pins live in
gradle.properties(exposure_version/exposurepolaroid_version= Modrinth version ids, plus_min_versionfloors). Deptest case Q covers both missing.
Effortless Building writes blocks with raw setBlock / destroyBlock from its own packet handlers
and UndoManager, so it fires no NeoForge block events and the editor's live mirror
(EditorMirrorLiveHandler) never saw its builds. DT now wraps those writes
(EffortlessBuildingPacketHandlerMixin, EffortlessBuildingUndoManagerMixin), records each cell
(compat/EffortlessBuildingMirror) and mirrors them through the hand-edit path when the action ends —
before EffortlessBuildingHistory closes its undo capture, so one Ctrl+Z reverts both halves.
The preview is mirrored client-side: EditorMirrorPlotPacket syncs the mirror plot (the one the
player stands in, else the one they aim at, else the last one) and a mixin on EB's client
ModifierSystem.processBlocks appends the images, built like EB's own MirrorModifier copies. It adds
nothing while EB's build mode is DISABLED. The mixins are required: false and gated on EB being
loaded; a renamed EB seam degrades to "no mirroring", never a crash.
The Live Eye text filter's server pass (event/LiveChatCensorEvents) rewrites chat with ServerChatEvent.setMessage at LOWEST priority: the bundled Discord Presence listener (NORMAL) rebuilds @dev lines from the raw text, so the censor must run last. Discord Presence's own copy of a line to Discord still carries the raw text (Discord never reaches Twitch). A server-rewritten line shows vanilla's "Modified" marker on secure-chat clients — the streamer's own lines never do, because their client censors before signing. See Features → Live Eye text filter.
The 1.20.1 client-overlay event was renamed and slightly reshaped. registerAboveAll / registerBelowAll replace the per-id register call. Affects VersionHudOverlay. (commit 288500f)
TickEvent is gone. NeoForge 1.21.1 uses separate per-phase events (LevelTickEvent.Pre / LevelTickEvent.Post, ClientTickEvent.Pre / Post). Subscribers must declare which phase they want; there's no event.phase field anymore. (commit c2ee329)
NeoForge dropped Forge's SimpleChannel net registration. All packets must implement CustomPacketPayload, register via RegisterPayloadHandlersEvent, and serialize via StreamCodec. (commit 8fb0550)
NeoForge cleaned up the event-bus subscriber annotation. Drop the Mod. prefix. Only matters when reading older code — the new annotation is in net.neoforged.fml.common.EventBusSubscriber.
NbtIo.read(File) / write(File, ...) are gone in 1.21.1 — replaced by Path overloads. Callers that built File objects from world data folders must switch to Path. (commit 504ef03)
vertex(...) / color(...) / endVertex() chain on BufferBuilder was replaced by addVertex(...) / setColor(...). Affects every custom render. (commit 504ef03)
Symptom: the third and last member of the fluid-tick freeze family (#1450): with both Sable seams
above already closed, player logs at DT 0.999.0–0.1001.0 still showed 5–60 s server-thread stalls,
every stack ending in FluidInteractionRegistry.canInteract:62 → InteractionInformation.lambda$new$2:121 → Level.getFluidState → ServerChunkCache.getChunk (managedBlock), from a fluid tick in
LevelChunk.postProcessGeneration at a chunk border.
Root cause: canInteract(level, pos) — called from LiquidBlock.onPlace and
LiquidBlock.neighborChanged — asks every registered interaction predicate about the six face
neighbours of the liquid block, and NeoForge's built-in predicates read them with
level.getFluidState(relativePos) / level.getBlockState(relativePos). On a ServerLevel that is
getChunk(FULL, true): a synchronous generation when the neighbour's chunk is not loaded, and each
generated chunk's own post-processing can repeat it into the next. Bytecode of forge-universal 21.1.228:
canInteract itself reads only pos (offset 2); the neighbour reads live inside the predicates, which is
why the guard sits at the method head rather than around one call.
DT's fix (#1651) — defer, don't skip.
FluidInteractionNoLoadMixin
injects at the head of canInteract. Interior blocks (both local coordinates in 1..14) take a zero-lookup
fast path. For a border block, if a chunk across that border is not loaded (getChunkNow == null) the call
returns false ("no interaction") and the position goes into a per-level
FluidInteractionDeferral
store. FluidInteractionDeferralEvents
drains the store on LevelTickEvent.Post and re-runs canInteract once every missing chunk is loaded — so
obsidian / cobblestone / basalt still form, a moment later, and the server thread never waits for terrain.
Unlike the two Sable seams (which read air), the outcome here is preserved rather than dropped, so no
opt-in setting was needed.
Bounds and the one visible cost: at most 4096 pending positions per level; an entry older than 5 minutes
is dropped unreplayed (the train has left; that chunk is not coming). A dropped entry is a lava/water pair
at a chunk border that never turned to stone. debug.log carries a [fluid.defer] dim=… deferred= replayed= expired= dropped= pending= line every 10 s per level; a large expired share in a lag report is a finding
(#1653). Dev baselines: 66/336 expired headless with no rider, 24/1012 with a rider, 0/332 in a short
singleplayer session; dropped always 0.
Not covered: third-party interaction predicates that read positions other than the six neighbours.
Maintenance: canInteract is public NeoForge API and identical on 21.1.228 and the 21.1.230 builds
players run; still re-verify the target on any NeoForge bump.
DT requires the sibling mod Adventure Item Names (AIN) as an external download — not bundled via jarJar (adventureitemnames_min_version floor in gradle.properties, currently 0.48.0 — note DT itself compiles and dev-runs against a newer AIN than the floor requires: as of DT v0.593.0 adventureitemnames_version is 0.49.0 while the floor stays 0.48.0, so a player on 0.48.0 still loads and simply never rolls names added since. The modpack pins the newer build; see AIS below for the same contract). AIN names loot items and, standalone, also names mobs — every villager (100%) and passive animal (5%) at spawn, everywhere in the world, via a blanket Mob.finalizeSpawn mixin.
Since: DT 0.272.0 (PR #337) — requires AIN ≥ 0.39.0.
With DT installed, AIN's ambient mob naming is restricted to entities on the train: off-train / vanilla-world villagers and passive animals are no longer auto-named. On-train mobs (carriage villagers, on-train mob-group passives) are named as before. PlayerMob naming is unaffected — it bypasses the gate inside AIN.
How it works: AIN 0.39.0 exposes a generic veto hook — NamingConfig.registerMobNameGate(Predicate<Mob>), consulted by NameComposer.applyMobName for villagers + passive animals only. DT registers TrainMembership::isOnTrain in DungeonTrain.commonSetup. The predicate is a cheap tag-prefix check (dungeontrain_contents_pidx_* — the same tag carriage contents and on-train mob-group spawns already carry), so it runs allocation-free on every mob spawn. AIN stays train-agnostic: with no DT present it registers no gate and names everything as before.
It is a pure veto — it can only suppress off-train naming, never break on-train naming. Loot/item naming is unaffected; only ambient mob naming is gated. TrainMembership is also the single source of truth that VillagerTrainSpawnEvents and MobDifficultyEvents use for their own on-train checks.
Since: DT 0.581.0 (PR #1002) — floor raised 0.45.0 → 0.48.0.
Rendered into neoforge.mods.toml as versionRange="[0.48.0,)". Unlike the AIS 0.7.0 raise, this one is not a seam requirement — DT calls nothing in AIN that 0.45.0 lacks. It moved for content currency: 0.47.2 and 0.48.0 add twelve Discord supporters to the name pool plus the Archivist Society place and Archivist/Detective title prefixes, and a player on 0.45.0 never rolls any of them.
Because the platform apps only resolve newest-satisfying-range on a fresh install, existing direct-install players on AIN 0.45.0–0.47.2 are hard-blocked until they update AIN manually. Modpack players are unaffected — both packs pin 0.48.0.
DT requires the sibling mod Adventure Item Stats (AIS) as an external dependency (adventureitemstats_min_version floor in gradle.properties, currently 0.7.0). DT's loot and difficulty pipelines call AIS's StatsModifier.applyStats for deterministic Gaussian stat rolls, and ItemStatLevelScaling layers DT's per-tier progression bonus on top.
Since: DT 0.501.0 (PR #829)
DT expects AIS's runtime config (config/adventureitemstats.properties) to match AIS's defaults — DT's balance is tuned against them. At server start DT compares the file against the installed AIS build's own AisConfig.defaults() (never a stale hardcoded table, so an official AIS defaults change in a newer build is not mistaken for tampering; a built-in AIS 0.7.0 table is kept only as a guarded fallback should a future AIS relocate AisConfig). Parse semantics mirror AIS exactly — per-key fallback on malformed values, unknown keys ignored — so a config AIS would treat as defaults is never flagged.
On deviation the session runs in Free Play (session-only), and the join notice offers one-click /fixaisconfig: timestamped backup, then defaults restored. AIS reads its config once at game launch, so the fix takes effect on the next game/server restart.
PlayerMob's combat/scavenging settings and Edible Backpacks' resetOnDeath/maxSlots are held to their defaults by the DT config check. Edible Backpacks' settings are a NeoForge config that in-game editors (Configured) can change live; PlayerMob's apply from the next launch. See DT config integrity.
Since: DT 0.1275.0 (PR #1988, Edible Backpacks 0.20.0 / EB #14)
Items picked up off the ground use the backpack panels. Backpack stacks of the same item are topped up first, even while the inventory has room. Next comes the vanilla inventory, and whatever is left fills empty unlocked backpack slots. When it collects is each player's choice: Options → Dungeon Train → General → Backpack Auto-Collect — Off, While Open (default: nothing is collected while the panels are closed) or Always (collects into the backpack even while the panels are closed). Since DT 0.1282.0 (PR #1992, Edible Backpacks 0.21.0 / EB #15); the row writes EB's CLIENT config autoCollect (ediblebackpacks-client.toml), which ClientAutoCollect sends to the server on login and on change (AutoCollectPayload, guarded by hasChannel, so an older-EB server just keeps While Open) and EB stores on the player's backpack data (saved, kept through death). EB wraps only the Inventory.add call inside ItemEntity.playerTouch (mixin/ItemEntityMixin), so pickup events, the take animation, stats and the pickup delay are all vanilla. Another pickup mod wrapping the same call chains with it. EB's SERVER config autoCollect (default on) switches it off for everyone, overriding the per-player choice. It is a convenience, not balance, so the DT config check does not hold it to its default.
DT requires the sibling mod Trade Everything as an external download (tradeeverything_min_version floor in gradle.properties; un-bundled from jarJar in PR #1029 once its Modrinth/CurseForge listings went live) — the villager Trade Anything slot (feature page). Fully mixin-driven on vanilla merchant classes (MerchantContainer.updateSellItem, AbstractVillager.setTradingPlayer/save path, MerchantMenu.tryMoveItems); all injects are exception-guarded so third-party trade hooks degrade to vanilla trading instead of wedging villagers. Third-party mods listening to TradeWithVillagerEvent will observe synthetic trades.
DT requires the sibling mod Keep Trim (keeptrim_min_version floor in gradle.properties, currently 0.2.0) on the hybrid contract shared with Dungeon Backup and Sable Fence & Trapdoor Fix: jarJar'd inside the DT jar for Modrinth and manual installs, and declared keep-trim(required) on CurseForge (listing 1691315) so the CurseForge app installs it as its own mod — NeoForge's JarSelector drops the nested copy when a top-level one is present. History: bundled since v0.839.0 / PR #1359 (no listing existed) → fully external in PR #1388 / DT 0.863.0 once CurseForge approved → hybrid in PR #1452 / DT 0.909.0 (see below). crafting-grid repair keeps the left-most (then top-most) input's armor trim (feature note). A single @Inject at RETURN of RepairItemRecipe.assemble that sets DataComponents.TRIM on the result — the same injection point AIS uses to carry rolled ATTRIBUTE_MODIFIERS through a repair; the two write disjoint components and co-exist (verified in the dev client with AIS 0.7.0). No config, registries or networking; no Sable surface.
Why hybrid: Modrinth is behind CurseForge for this one. Keep Trim's Modrinth listing (keep-trim, HE6l2LC9) has been in review since 2026-09-11. While DT 0.863.0–0.908.x treated it as fully external, release.yml could only declare the relation on CurseForge (mc-publish cannot resolve a non-public slug), the Modrinth modpack pinned the GitHub release asset through a modrinth_pending_url stopgap in modpack.config.json, and anyone installing the DT mod standalone from Modrinth or by hand hit Mod dungeontrain requires keeptrim 0.2.0 or above — not installed with nothing findable to install (a Legacy Launcher player reported exactly this). PR #1452 / 0.909.0 therefore re-bundled it: the modpack entry is curseforge_only: true (kept out of the .mrpack, shipped as a CurseForge Include), and the pending-URL stopgap is unused. Deptest Case G (nested only — the Modrinth/manual layout) and Case A (top-level copy present — the CurseForge-app layout) cover both paths. The board item "Keep Trim on Modrinth approved → finish the sibling wiring" now records the decision to make once the listing is public: stay hybrid (the DB/SFF default) or go fully external on both platforms.
DT requires the sibling mod Pigman Villagers (pigmanvillagers, floor pigmanvillagers_min_version 0.1.2) and jarJars it inside the DT jar on every platform (since PR #1579, DT 0.973.0) — it has no CurseForge or Modrinth listing yet (v0.1.2 is a GitHub release). Once the listings exist, the choice is hybrid (like Keep Trim) or fully external. Declared required, side BOTH: the pigman flag is synced entity data added to Villager, so client and server must both have it (automatic while it is jarJar'd). Mixins: Villager (synced flag + NBT PigmanVillager/PigmanRolled + first-tick roll), Entity.playSound (pig voice), EntityRenderDispatcher.getRenderer + VillagerRenderer (player-model renderer), and StructureTemplate's entity pass (placeEntities in vanilla/Fabric, addEntitiesToWorld on NeoForge/Forge) clearing a template's captured roll. DT's own template placers call compat/PigmanVillagersBridge.freshRoll (→ PigmanVillagersApi.clearRoll); ContentsEntitySnapshot deliberately does not, since it restores the same villagers. A mod that registers its own villager renderer keeps working, just without pigmen. No physics or collision surface — verified riding a moving Sable train.
DT requires the sibling mod Ender Chest Persistence as an external download (enderchestpersistence_min_version floor in gradle.properties). ECP keeps one Ender Chest per player per game mode, outside any world save. DT registers a SlotKeyProvider (EnderChestLockBridge) that pins a Free Play run onto the creative slot and scopes legit chests by difficulty profile.
Up to ECP 0.4.0 the store file was written only at logout and server stop. A crash skipped both, and on the next login restore() put the previous logout's chest over vanilla's fresher autosaved EnderItems — players saw their Ender Chest rolled back to before the session they crashed in. Since 0.5.0 EnderChestStore.checkpoint runs from PlayerEvent.SaveToFile (autosave, /save-all, logout) and after every slot swap (swapGameMode, refreshSlot — i.e. /gamemode and DT's Free Play lock), writing only when the slot actually changed so an idle autosave neither touches the file nor rotates .bak. The residual loss window is the last autosave interval, the same one vanilla already has for the rest of the player's inventory. DT's floor (enderchestpersistence_min_version) moved to 0.5.0 with it — the floor is the only lever that upgrades existing installs.
The 114-slot Free Play chest needs no ECP change: ECP serialises whatever container is live through vanilla's createTag/fromTag (Slot byte per entry, bounded by getContainerSize()), so a bigger live container round-trips as-is, and slots 0–26 stay the vanilla 27 so a legit slot never receives an index ≥ 27.
Ordering matters at login. ECP restores the stored chest into the live container at PlayerLoggedInEvent NORMAL priority. If the live container is still 27 slots at that moment, fromTag silently drops every stack past slot 27 — and the next logout saves the truncated chest back over the full one. EnderChestExpansionEvents therefore restores capacity at HIGHEST, before ECP, and decides purely from the player's own record (the flag file, or — self-healing fallback if the flag went with a reinstall, since ECP's store lives in OS app-data and DT's flag in the instance — EnderChestLockBridge.freePlaySlotReachesBeyond reading ECP's <uuid>.dat directly for any creative entry with Slot ≥ 27). It deliberately does not consult the Free Play verdict, which isn't settled that early: a legit run with the flag on file gets a silently oversized container behind the vanilla 27-slot menu, which nothing legit can address.
Since DT v0.891.0 (PR #1421); Keep Trim joined the contract in v0.909.0 (PR #1452) while its Modrinth listing is in review — everything below applies to it too (keep-trim(required) on CurseForge, curseforge_only: true in the modpack config). Dungeon Backup (dungeonbackup, floor dungeonbackup_min_version 0.2.0 — DT's player-data backup/recovery system extracted in v0.862.0) and Sable Fence & Trapdoor Fix (sable_fence_trapdoor_fix, floor sablefencetrapdoorfix_min_version 0.1.2) follow a third contract, neither fully bundled like Discord Presence nor fully external like AIN/AIS/PMOB/ECP/TE/KT:
-
Jar-in-jar inside the DT jar (
jarJarinbuild.gradle) — so the Modrinth listing, the Modrinth modpack, and anyone installing by hand have both built in with nothing extra to download. The Modrinth side has no relation, no modpack entry and noSiblingModentry for them by design. -
Required external mods on CurseForge only —
release.ymldeclaresdungeon-train-backup(required)+sable-fence-trapdoor-fix(required)in the CurseForge list, and the CurseForge modpack ships them as Includes (curseforge_only: trueon theirmodpack.config.jsonentries keeps them out of the.mrpack). The CurseForge app therefore installs both from their own pages, which get the download credit. -
Both copies present is fine: NeoForge's
JarSelectorstrips a nested jar whose mod id is already provided by a top-level file inmods/(Attempted to select a dependency jar for JarJar which was passed in as source: <id>— themods/copy wins).neoforge.mods.tomldeclares bothrequiredwith[<floor>,), so a top-level copy older than the floor is rejected at load rather than silently used. The dependency-contract harness covers both layouts: Case A (top-level jars present → dedupe warnings, clean boot) and Case G (nested only → clean boot, sibling mixin applied).
Since PR #1554, bundled since PR #1556. DT used to read the running-process list itself (OSHI, names only) for two title-screen prompts. That code now lives in two small client-side sibling mods (Fabric/Forge/NeoForge):
-
Stream Detect (
streamdetect, CurseForge 1711267): is OBS or Streamlabs open? This drives the Videos icon's Share stream! tab and the Videos page's Submit pulse. -
DPI Bypass Detect (
dpibypassdetect, CurseForge 1711272): is zapret or GoodbyeDPI running? This drives the Connection Blocker Detected card.
Why split out. Reading the process table is the kind of thing that can send a mod into CurseForge's manual review, and DT releases many times a week. Moving it into its own small mods means that review eventually lands on them, not on DT.
Contract: hybrid, like Keep Trim.
- Both are jarJar'd inside the DT jar on every platform, pinned to 0.1.1 (the build uploaded to CurseForge), while their CurseForge listings are in review.
- Once CurseForge approves them, CurseForge switches to installing them as sibling mods: CurseForge-only
(optional)relations,curseforge_only: truemodpack entries. NeoForge's JarSelector then drops the nested copy. - Modrinth stays jar-in-jar; there's no Modrinth listing yet.
- Until the switch-over, DT's CurseForge upload still contains the detection code inside
META-INF/jarjar/. -
neoforge.mods.tomldeclares bothoptional,[0.1.0,),side=CLIENT. - All UI and wording stays in DT.
compat/StreamDetectBridgeandcompat/DpiBypassDetectBridgecall the add-on APIs behind aModListcheck, from a nested holder class. A missing add-on, or one whose API fails to link, reads as "nothing found". - Dev seams:
-PstreamingTest=obs64.exeand-PdpiBypassTest=winws.exeset the add-ons'streamdetect.test/dpibypassdetect.testproperties. - Deptest: Case A (top-level copies → nested skipped) and Case G (DT jar only → loaded from the nested copies).
DT requires the sibling mod Lost City Terrain Fit (lostcityterrainfit, floor lostcityterrainfit_min_version 0.2.0, pinned build lostcityterrainfit_version 0.3.0). It is how Big Lost City's buildings sit in the ground — footprint-floor seating, the no-carve + fill-only beard, terrain and water climbing over the ruins — extracted from DT in 0.1031.0; DT still decides where the cities start (worldgen/LostCityStructures) and vetoes the sibling's own lostcityterrainfit:all_biome/* copies. Requires Big Lost City; WWOO optional. Declared required, side BOTH.
Contract: hybrid, like Keep Trim, since PR #1704. jarJar'd inside the DT jar for Modrinth and manual installs, and declared lost-city-terrain-fit-wwoo-and-vanilla(required) on CurseForge (listing 1717775, approved 2026-09-29) with a curseforge_only: true modpack entry (file 9009664) so the CurseForge app installs it as its own mod — NeoForge's JarSelector drops the nested copy when a top-level one is present. No Modrinth listing yet. _version is pinned to the exact CurseForge upload so the nested and top-level jars are byte-identical; _min_version is the API floor DT calls and is what neoforge.mods.toml renders. Deptest key lctf rides Case A (CurseForge layout — nested copy skipped) and is absent from Case G (Modrinth layout — nested copy loads); both boot cleanly. No physics or collision surface.
Contract: hybrid, like Keep Trim, since PR #1994. Both stay jarJar'd inside the DT jar for Modrinth and manual installs, and are declared <slug>(required) on CurseForge with curseforge_only: true modpack entries, so the CurseForge app installs each as its own mod and NeoForge's JarSelector drops the nested copy:
-
Dungeon Train Mod Verification (
dtmodverify, the Free Play file-verification engine): CurseForge 1738856, file 9128034 (0.1.1, approved 2026-10-13).dungeon-train-mod-verification(required)since PR #1940; stilloptionalinneoforge.mods.toml(DT falls back to the id-only whitelist without it). -
Discord Presence (
discordpresence): CurseForge 1573749, file 9146819 (0.68.0). Its Modrinth listing (KRNiOrqL) isn't public and thediscordpresenceModrinth slug belongs to an unrelated mod, so it can't go fully external yet.neoforge.mods.tomlnow renders the floordiscordpresence_min_version(0.68.0), not the pin, so routine DP bumps don't raise the bar for players or stale the modpack pin.
Deptest keys dp/dmv ride Case A (CurseForge layout: the top-level copy wins) and are absent from Case G (Modrinth layout: the nested copy loads); both boot cleanly. joml-primitives is the only other nested jar and stays nested (third-party library, no CurseForge listing).
Third-party hard dependencies since PR #1425:
Fast Paintings 1.21-1.3.0 (MehVahdJukaar) and its
library Moonlight 1.21.1-3.6.4. Declared required in
neoforge.mods.toml as floors ([1.21-1.3.0,) / [1.21.1-3.6.4,)), pinned in both modpacks as required downloads
(fast-paintings(required), selene(required) — Moonlight's CurseForge slug is selene), and put on the dev
classpath from the Modrinth maven by version id (PQ155hbf / BnjVBHVl — version numbers are shared across
loaders and re-uploads there, so they are ambiguous coordinates).
Mod-page relations differ by platform slug. In release.yml, CurseForge curseforge-dependencies has
fast-paintings(required) + selene(required); the Modrinth dependencies list has fast-paintings(required) +
moonlight(required) (Moonlight's Modrinth slug is moonlight). PR #1425 only added the CurseForge pair, so Modrinth
versions up to v0.934.0 didn't declare either and the Modrinth app didn't auto-install them — fixed in
PR #1500. scripts/modpack/check-relations.py now fails CI if
any dependency_type: required modpack entry with a modrinth_project (and not curseforge_only) is missing from
the Modrinth list.
Fast Paintings' HangingEntity.tick mixin converts every painting entity into fastpaintings:painting blocks on its
first tick: one master cell holding a PaintingBlockEntity (variant), plus slave cells for anything larger
than 1×1, related by master = cell.above(y_offset).relative(facing.getClockWise(), x_offset). Blocks are what Sable
moves natively and what structure templates capture, so paintings stop being the one decoration that drifted, vanished
or fell off a carriage: editor saves carry them as palette + BE NBT (0 entities), shared carriages round-trip them through
the relay delta upload, and setblock on a live moving carriage survives. DT's decor-entity path
(TemplateDecor.DECOR_TYPES, TrainStaticContentsCarrier) keeps minecraft:painting for legacy templates and still
carries item frames, which FP does not touch.
A template holding those blocks is air for anyone without the mod — that is why FP is a hard dependency rather than a companion.
PaintingBlock overrides neither mirror() nor rotate(). The default contents-flip roll (FlipOptions.DEFAULT = Z,
50 % per carriage, Mirror.LEFT_RIGHT) therefore left every painting beside the far wall still facing the near one; no
support ⇒ canSurvive fails ⇒ every cell pops as a loose painting item. Even with the facing fixed, a mirror reverses the
run, so the master would sit at the wrong end and every slave would point at air.
PaintingTransformProcessor (a StructureProcessor on every flipped contents stamp and on TrackGenerator's mirrored
pillar stamps) runs at finalizeProcessing over the moved block list and hands the painting cells to
PaintingBlockLayout.relayout: facing ← rotation.rotate(mirror.mirror(facing)), the master ← the cell at the start of
the new run (least along facing.getCounterClockWise(), top row), offsets re-derived, block-entity tag moved to the new
master. Handles X/Z mirror, 90°/180° rotation and DT's vertical flip. The image is not mirrored — the painting re-hangs
on the wall it now stands beside. Finds the block by registry id and its properties by name, so DT compiles without FP.
If FP ever adds its own mirror(), this double-applies — re-check on FP bumps.
Verified headless (18 carriages, 8 Z-flipped → 8/8 kept every cell, facing the new wall, master on the correct end, variant intact; 0/N before).
FP's group-destroy cascade: overwriting one cell of a painting destroys the whole painting, including cells another painting just claimed. A contents template painting that lands on a shell template painting kills both — a template-authoring rule, not a code bug (it masqueraded as a flip failure during testing).
Since: DT 0.908.0 (PR #1451) for the fix, DT 0.916.0 (PR #1461) for the single owner + guard.
StructureTemplate.placeInWorld(…, 3) — Block.UPDATE_ALL, the flag vanilla structure blocks and /place use —
fires neighborChanged on every cell already down each time the next one lands. FP answers that with canSurvive,
which wants the picture's master block entity and every one of its cells present already; half-way through a 3×2
that is false, so FP removes the whole group and drops the item. Every picture in a relay build popped on load.
Block.UPDATE_CLIENTS (2) still runs the neighbour cascade, once, in placeInWorld's own final pass after every cell
and its NBT are in — the play-side stamps had always used it, which is why they kept their pictures.
That choice now has one owner: CarriageStampGuard.STAMP_FLAGS (= Block.UPDATE_CLIENTS, rationale in its
javadoc). template/TemplateStamp wraps the common guard + placeInWorld + TemplateDecor.replace shape for the
editor plot loaders, builder and persistence restores; the stamps that erase inside the guard or use a non-default
decor rule reference the constant directly. StampFlagsSourceScanTest scans src/main/java and fails the
build on any placeInWorld( whose last argument is not STAMP_FLAGS, so a future loader cannot quietly bring
flag 3 back. Out of scope, on purpose: cage/outline setBlock(…, 3) writes, PortalBuilder's procedural
UPDATE_ALL fills, and BuilderStructureStamp.
DT bundles the sibling mod Discord Presence via jarJar (discordpresence_version in gradle.properties). It posts player presence (join / online / death) to a Discord channel and is off by default — a server owner opts in by setting a webhook URL + bot token in config/discordpresence-server.toml. For join / online / advancement presence the bundled mod hooks vanilla events itself; for player death DT compiles against its public DiscordService API to post a custom run recap (see Death screen → Discord mirror) and suppresses the mod's generic auto-death message.
Since: DT 0.284.0 (PR #351) — bundles discordpresence 0.2.0.
v0.2.0 adds persistent per-player Discord threads (one thread per player, anchored to their first-join message; later joins, deaths and advancements route into it) and colour-coded advancement embeds (green = task/goal, purple = challenge, matching the in-game frame colour).
New bot permissions required, on top of v0.1.0's Add Reactions / Read Message History / View Channel: Create Public Threads, Send Messages in Threads, Embed Links. Without Embed Links the thread + messages still post but advancement embeds won't render; without the thread permissions the per-player thread is never created.
Advancement embeds need the bot token, not just the webhook — they post into the per-player thread, and webhooks cannot create or post in threads. With a webhook but no bot token you get join/death messages only, no advancements.
Config keys (config/discordpresence-server.toml, server-deploy only — DT ships no bundled config):
-
advancementNamespaces— which advancement namespaces to announce. Default[]= all namespaces; set["dungeontrain"]to announce only DT's advancements. -
createThreadOnJoin(defaulttrue),threadNameTemplate,threadAutoArchiveMinutes,firstJoinMessageTemplate,onlyDisplayAdvancements,advancementMessageTemplate.
Gotcha — silent when unconfigured: with blank webhookUrl/botToken the mod is disabled entirely. A player earning an advancement in-game then posts nothing to Discord — that's expected, not a bug. The per-player thread mapping (UUID → thread id) persists to config/discordpresence-threads.json; its appearance after a join is the sign the bot authenticated and created the thread.
Since: DT 0.318.0 (PR #445).
The Dungeon Train modpack ships FerriteCore (CurseForge project 429235) as an on-by-default Include — a memory-usage reducer that deduplicates in-memory data structures (block-state neighbour/shape caches, property→value maps, data-component maps, and on the client baked models / quads). It is referenced as the pack's own CurseForge file, never bundled into DT's jar. Players can untick it at install; the standalone mod is unaffected.
FerriteCore touches no rendering, physics, collision, or chunk logic — it only deduplicates data structures at registry/model load. It therefore never crosses the surfaces Sable rewrites (sub-level collision, the kinematic driver, chunk-map plot handling), so there is no interaction to break. Verified in a dev runClient: FerriteCore co-loads with Sable + DT, all ten of its mixin configs (blockstatecache, fastmap, dedupmultipart, datacomponents, dedupbakedquad, …) apply cleanly, and the client reaches the title screen with zero crashes.
DT is content-heavy — it jarJars AIN/AIS/PlayerMob/Discord Presence/Ender Chest Persistence into its own jar — so a running instance holds a lot of block-state/registry/model data. That is exactly the profile FerriteCore helps. The table below is an A/B of retained heap (used memory after a forced full GC — the live set, with garbage filtered out), measured with FerriteCore as the only variable.
| Where | Without FerriteCore | With FerriteCore | Saved |
|---|---|---|---|
| Client — title screen (post model-bake) | 339.7 MB | 279.4 MB | −60.3 MB (17.8%) |
| Dedicated server — vanilla world (spawn) | 299.8 MB | 274.4 MB | −25.4 MB (8.5%) |
| Dedicated server — DT world preset (spawn) | 284.7 MB | 230.5 MB | −54.2 MB (19.0%) |
The DT world preset shows the strongest saving — DT's repeated dungeon block-states are prime dedup targets. The client title screen additionally benefits from FerriteCore's model / baked-quad dedup on top of the block-state dedup, which the server (no models) does not get. The cost is ~+0.6 MB of metaspace (FerriteCore's own classes); there is no FPS or CPU effect — it is a memory-only optimization.
Method: for each arm the FerriteCore jar is toggled in run/mods/, the JVM is launched headless, and retained heap is read with jcmd <pid> GC.run then jcmd <pid> GC.heap_info, taking the median of three steady-state readings (confirmed by flat metaspace). Numbers come from a dev (deobfuscated) build, which runs heavier than a production install, so the percentage is the portable figure, not the absolute megabytes.
Added: PR #451 — modpack only; lands in the next DT release.
The Dungeon Train modpack ships ModernFix (CurseForge project 790626, pinned to modernfix-neoforge-5.27.14+mc1.21.1) as an on-by-default Include — a launch-time, world-load, and memory optimiser (lazy registry/resource loading, deferred model baking, optional dynamic resources). It is referenced as the pack's own CurseForge file, never bundled into DT's jar. Players can untick it at install; the standalone mod is unaffected.
Unlike FerriteCore (pure data-structure dedup), ModernFix applies a large mixin set and has an optional dynamic resources feature, so it touches more surfaces — a higher (though still low) chance of interacting with Sable's sub-level / render rewrites or DT's own mixins than a pure memory mod. In practice it co-loads cleanly: across many dev-client runs and a dedicated-server run, ModernFix's mixins applied with zero apply failures and zero crashes alongside the full DT + Sable stack (Sable, Sable Companion, Veil, AIN/AIS/PlayerMob/Discord Presence). A DT world loaded and the train assembled with the player riding the moving flatbed, ModernFix active, no crash. Its dynamic_resources feature is off by default, and every feature is individually togglable in ModernFix's config if anything ever conflicts with Sable or DT's mixins.
ModernFix's headline wins (launch-time JAR scanning, client render / model / resource memory) show most on a production install with many mods. Measured here in two constrained environments, with ModernFix as the only variable:
| Environment | Metric | Without ModernFix | With ModernFix | Δ |
|---|---|---|---|---|
| Dev client (exploded classpath) | launch → main menu | ~9.7 s | ~9.7 s | ~0 (see note) |
| Dedicated server (real JARs) | startup Done()
|
3.95 s | 3.81 s | −3.5% |
| Dedicated server (real JARs) | steady-state RSS | 1360 MB | 1313 MB | −47 MB (−3.5%) |
The dev client shows no launch difference — expected: a dev runClient loads mods from an exploded classpath (no JARs to scan/dedup, ModernFix's main launch lever) and runs only ~12 mods. The dedicated server (real JARs) shows a small, consistent win on both startup and memory (both ModernFix runs beat both baseline runs). The larger client-side gains (rendering / model / resource memory, large-pack launch) are not exercised here — they need a production client install (the real modpack), where ModernFix is designed to shine.
Method: ModernFix toggled in run/mods/. Dev-client launch from the Game took Xs to start / Veil Finished uploading vanilla shaders log markers (paired runs). Server startup from NeoForge's Done (Xs)! line; RSS via ps -o rss at steady state, 2 runs per arm, full DT + Sable stack on a vanilla world (no crashes, no mixin-apply failures in any run). Dev (deobfuscated) numbers run heavier than production, so treat the percentages as the portable figures, not the absolute megabytes.
Every server tick, ServerSubLevel.tick() runs a per-sub-level connectivity splitter, SubLevelHeatMapManager.tick() (gated only by the global SableConfig.SUB_LEVEL_SPLITTING boolean). It flood-fills the sub-level's solid blocks and, when a player mines blocks so an island becomes disconnected and free-floating, split() carves that island into a brand-new ServerSubLevel (inheriting the origin via setSplitFrom). On a carriage that meant mining part of the structure spawned the loose blocks as their own drifting mini-ship.
DT trains are kinematic — SableManagedShip.applyTickOutput teleports the whole body every tick, they are not force-simulated — so internal block connectivity is irrelevant to how a train moves. Splitting therefore only ever produces the visible bug.
DT's fix (#786): SubLevelHeatMapSplitMixin (remap = false) cancels SubLevelHeatMapManager.tick() at HEAD for DT-managed sub-levels, gated by SableManagedShip.isDungeonTrainManaged — an O(1) probe over the existing DRIVERS_BY_UUID map (+ a getSplitFromSubLevel() origin walk). Disconnected blocks now stay welded in the group's block grid and keep travelling with the train; non-DT Sable ships still split normally. Cancelling tick() is safe: onSolidAdded/onSolidRemoved still run to keep the heat map current (they're just never consumed), and mass/collision are handled separately by MassTracker/SubLevelPhysicsSystem. It is also net-cheaper on the tick loop — a per-tick flood-fill is replaced by one map lookup. Follows the same gating pattern as ServerSubLevelFreezeMixin/SubLevelPhysicsSystemFreezeMixin; re-verify the tick method + subLevel field names on any sable_version bump.
Sable redirects Entity.move into SubLevelEntityCollision.collide, which sweeps the entity's bounds against every intersecting sub-level using a throwaway LevelAccelerator. That accelerator's grabChunkFast first tries the non-blocking visible-chunk-holder path, but falls back to Level.getChunk(x, z) — a synchronous main-thread chunk load + generate. When an item or mob collided against a train plot chunk whose (expensive) DT worldgen hadn't run yet, the whole server thread froze — multi-second to multi-minute stalls (frozen trading, dead menus, hanging quit), captured by the server stall watchdog as ItemEntity.tick → … → ServerChunkCache.getChunkFutureMainThread.
DT's fix (#831): two remap = false mixins. SubLevelEntityCollisionNoLoadMixin flags the per-sweep accelerator through the NoSyncLoadChunkAccess duck interface; LevelAcceleratorNoSyncLoadMixin then swaps the flagged instance's fallback to non-blocking ServerChunkCache.getChunkNow, treating a missing chunk as an EmptyLevelChunk (pure air). The flag is instance-scoped: every other LevelAccelerator user in Sable — including setBlockFast block writes — keeps the loading behaviour, so nothing can silently write into an empty chunk. Real players never enter this sweep (collide() short-circuits ServerPlayer at the top). Zero hot-path cost: the wrap only executes on the already-exceptional fallback branch. Accepted tradeoff: an item/mob colliding against a not-yet-generated plot chunk sees air and may fall through geometry that doesn't exist server-side yet. Complementary to the trailing-carriage force-load work (#519) — that prevents one specific unload case; this makes any unloaded-chunk collision non-blocking. Re-verify grabChunkFast + the single new LevelAccelerator site in collide on any sable_version bump.
ModernFix's perf.cache_strongholds wraps the ServerLevel constructor's ensureStructuresGenerated()
call to record where its ring cache lives, and redirects the ring search's background executor to a pool it
creates only once that path is known. DT's ServerLevelDeferRingGenMixin skips that same call for the
overworld (the ring search runs later, once DT's biome context is published — v0.967.0). In v0.982.0 DT's
skip nested outside ModernFix's wrap, so the path was never recorded and every new world died with
NullPointerException: Exception generating new chunk (or sat at 33% on the loading screen).
Fixed in v0.983.0 (PR #1603): the DT mixin runs at priority 900 so it nests inside ModernFix's wrap, and
compat/ModernFixStrongholdCache records the path through ModernFix's IChunkGenerator duck as a
backstop that does not depend on mixin order. ModernFix's cache is written on the first boot and read on
the next (Using cached stronghold positions), and stronghold positions match with or without ModernFix.
Any change to that constructor hook must be re-tested with the ModernFix jar in run/mods — the dev
client does not load ModernFix by default, so the crash is invisible there.
Vivecraft is an optional, player-installed VR mod — it is not bundled and not in the DT modpack. DT ships a small, gated compat layer so VR works on the train (added in v0.382.0, #629).
When a VR player stands on a carriage (a Sable sub-level, world pivot ≈ 2.048e7), Vivecraft's SwingTracker.activeProcess broad-phases melee candidates with mc.level.getEntities(mc.player, swingAABB). The swing box is built from a mix of the player's render position and the player entity's stored sub-level (tick) position, so it spans from real coords to the shipyard pivot — ~20 million blocks across. Sable's SubLevelInclusiveLevelEntityGetter.get(AABB, …) rejects any query with AABB.getSize() > 100000 (MAX_GET_SIDE_LENGTH): it returns nothing and logs at ERROR on logger dev.ryanhcode.sable.Sable with a freshly-captured new Throwable("Stack Trace") every call. On the train that fires ~15×/sec on the render thread → frame hitching (the reported "lag"; FPS + heap are fine — it's I/O stutter), and the swing finds no targets so VR melee silently fails on the train.
DT's fix (two parts):
-
Log-spam:
SableAabbLogFilter— a message-specific Log4j2 filter installed at mod construction that DENYs only the"abnormally large AABB"line on thedev.ryanhcode.sable.Sablelogger (NEUTRAL for everything else — Sable's log level is not raised). Always-on; independent of Vivecraft. -
VR melee:
SwingTrackerSubLevelAabbMixin— an optional client mixin (separate configdungeontrain.vivecraft.mixins.json, gated byVivecraftMixinPlugin→LoadingModList.get().getModFileById("vivecraft") != null; string-targeted, so no Vivecraft compile dependency). A MixinExtras@WrapOperationon bothClientLevel.getEntities(Entity, AABB)calls inactiveProcessrebuilds an oversized swing box intoplayer.getBoundingBox().inflate(4.0)(pure, unit-tested helperclient.vivecraft.SwingAabbClamp), so Sable resolves it against the sub-level and returns the on-carriage mobs instead of aborting.
Scope: VR melee-on-train + the log-spam only — not locomotion, controller ray-casts, VR GUI, or seated play (flagged as follow-up).
Gate: because Vivecraft is absent from most installs, the mixin config is gated by an IMixinConfigPlugin rather than listed in the always-on config — this avoids a "target class not found" error when Vivecraft isn't present. Use this pattern for any future optional-mod mixin. The plugin's presence check must use LoadingModList (early class-transform phase), not ModList.get() (not yet populated then).
Testing caveat: the live VR swing can't be exercised on macOS — there's no OpenVR/SteamVR runtime (Vivecraft's own MCOpenVR.initializeOpenVR throws UnsatisfiedLinkError: libopenvr_api.dylib when toggling VR, then falls back to flat mode; that error is Vivecraft's, not DT's). Verified in dev instead: the mixin applies to the real SwingTracker with a real Vivecraft 1.21.1-1.3.13 jar (Mixing …SwingTrackerSubLevelAabbMixin into …SwingTracker, no InvalidInjectionException), and the clamp is unit-tested against the exact log AABB. Live swing→hit needs a Windows/Linux + SteamVR + headset.
Upstream (already reported — no duplicates filed): Sable log-spam — #823, #893, #527, #1317 (and #180, maintainer marked won't fix). Vivecraft sub-level/moving-contraption compat — #438, #304.
The DT modpack (not the standalone mod jar) bundles Sodium and Iris, both enabled by default. Iris ships with no shaderpack selected, so it's perf-neutral until a player drops a pack into shaderpacks/ — the modpack simply makes shaders a one-flip option. DT-the-mod never bundles or requires either; they're declared as compatible/optional dependencies on the CurseForge/Modrinth pages only.
The pins are not free choices — Sable dictates them. Sable 2.0.2's neoforge.mods.toml declares Sodium incompatible below 0.8.12-alpha.2+mc1.21.1 (Sable renders its sub-levels through Sodium's modern pipeline — mixin/sublevel_render/impl/sodium/* + compatibility/sodiumextras/*). So:
-
Sodium must be the modern 0.8.x line — mainline 0.6.x is rejected by Sable. Pinned to
0.8.12(stable, 2026-07-06). -
Iris must be a build that supports Sodium 0.8.x on 1.21.1. As of 2026-07 the only such build is a beta,
1.8.14-beta.1— every stable Iris (≤1.8.12) still targets Sodium 0.6.x. Shipped by explicit decision despite the beta status.
Sable carries first-class Iris compat too (compatibility/iris/SableIrisCompat + ExtendedShaderMixin), so shaders-on-sub-levels is supported by design, not a hack.
Verified (DT v0.509.0, dev client, full stack Sable 2.0.2 + Sodium 0.8.12 + Iris 1.8.14-beta.1 + DT + siblings): all mods load ({sodium} 0.8.12, {iris} 1.8.14-beta.1); zero mixin-apply failures; Sable's SodiumWorldRendererMixin binds into net.caffeinemc.mods.sodium.client.render.SodiumWorldRenderer; Iris's mixins.iris.compat.sodium.json (19 mixins) engages and Sodium force-disables its own sky/fog mixins in favour of Iris (normal handoff); the client loads into a world with Sable sub-levels active and shuts down cleanly (no crash report). One Iris mixin, MixinGameRenderer_NightVisionCompat, is skipped in dev (class-version 65 > the config's declared JAVA_8) — a night-vision-under-shaders detail, non-fatal.
Not yet eyeballed: the visual moving-train paths — train render under Sodium while riding a moving ship, a shaderpack enabled on the train (Sable's compatibility.iris.*), and ride-photo capture staying non-black now that Veil and Sodium both cache the main framebuffer (see Ride photos / the in-context double-render). Do this before a public release.
Version coupling: the Sodium floor + Iris pin are documented in gradle.properties (Sable-bump checklist item #6) and modpack/modpack.config.json. A future Sable bump can raise the Sodium floor and strand Iris — re-check both when bumping sable_version. Iris↔Sodium are also version-locked to each other.
Any DT mixin that changes how a block is meshed must exist twice, once for vanilla and once for Sodium. Sodium does not delegate to vanilla chunk meshing; it reimplements it. Its ChunkBuilderMeshingTask goes straight from BlockModelShaper.getBlockModel(state) to its own BlockRenderer.renderModel(BakedModel, BlockState, BlockPos, BlockPos), which emits quads through FRAPI (emitBlockQuads → bufferDefaultModel → BakedModel.getQuads). ModelBlockRenderer.tesselateBlock — the NeoForge 12-arg overload SectionCompiler calls — is never invoked.
This bit the upside-down band: its per-block visual flip wrapped tesselateBlock, so from the band's debut until v0.719.0 (PR #1181) in-band terrain rendered upright for anyone running Sodium — which is every Iris user and every modpack player, shaders on or off. It looked correct only in a plain dev client. This is also the most likely explanation for the older "terrain near the tracks renders upright" report.
The shape of the fix, worth copying for any future meshing mixin:
- Hoist the decision into a plain client helper (
client/UpsideDownRenderFlip) so the two pipelines can never drift apart. - Add a second mixin on Sodium's entry point (
mixin/client/sodium/BlockRendererUpsideDownMixin). Target it by class name with@Mixin(targets = "…")— every parameter ofrenderModelis a vanilla type, so Sodium stays off the compile classpath. Its third argument is the world position; the fourth is the section-local origin. - Gate the config on the mod being loaded (
SodiumMixinPlugin, a copy ofVivecraftMixinPlugin), and set"injectors": {"defaultRequire": 0}indungeontrain.sodium.mixins.jsonso a future Sodium refactor degrades to the old behaviour instead of failing to launch.
Model-level changes carry over for free. Sodium ultimately calls getQuads on whatever BakedModel it is handed, so the existing UpsideDownBakedModel wrapper worked verbatim — it only had to be handed over. A change made by rewriting vertices during meshing would not port so cheaply.
What does not need duplicating: Level.getShade-based work. Sodium's light pipelines call BlockAndTintGetter.getShade, and its LevelSlice.getShade delegates straight to ClientLevel.getShade, so LevelGetShadeMixin's in-band side-lighting already applies under Sodium.
To verify a meshing mixin actually applied under Sodium, launch the dev client with JAVA_TOOL_OPTIONS=-Dmixin.debug.export=true, join a world, then disassemble the dumped class: javap -c run/.mixin.out/class/net/caffeinemc/.../BlockRenderer.class | grep dungeontrain. A defaultRequire: 0 config fails silently, so "no errors in the log" proves nothing on its own.
Renderer mixins are gated both ways; the dimensional-carriage prewarm rides Sodium's own build queue (v0.823.0)
A DT mixin on a vanilla renderer method that Sodium merges is a fatal crash on every Sodium/Iris client, and require = 0 does not save it. v0.818.0's arrival fix injected into LevelRenderer.setupRender; Sodium's LevelRendererMixin merges that method, and Mixin refuses to inject into a merged method with an InvalidInjectionException at INJECT_PREPARE — fatal, before the title screen. require = 0 only suppresses "no injection point matched". Every modpack install crashed at launch until v0.822.0 (#1334). The rule now: vanilla-only renderer hooks live in dungeontrain.vanillarenderer.mixins.json, gated by VanillaRendererMixinPlugin to apply only when Sodium is absent (the inverse of SodiumMixinPlugin), and any new mixin under mixin/client/ is booted against the Sodium + Iris stack before it merges.
What the arrival needs exists twice, like block meshing. The seal cut that hides the world beyond a twin's bedrock is recomputed from the current camera at the head of vanilla's setupRender; under Sodium the same recompute sits at the head of SodiumWorldRenderer.setupTerrain (SodiumWorldRendererPortalSealMixin), because Sodium culls through its own Viewport (hooked by ViewportPortalSealMixin) and would otherwise read a cut one frame stale on the swap frame — the flash.
Building the destination ahead of the swap has no public Sodium API. RenderSectionManager.scheduleRebuild returns early unless the section is already built, and Sodium builds only what its occlusion walk reaches — for a sealed room far below bedrock, the frame you arrive. DT's RenderSectionManagerPrewarmMixin pushes the room's pending sections, eight a frame, into Sodium's ZERO_FRAME_DEFER queue at the head of updateChunks; that queue is drained first, on an unbudgeted collector, and awaited before the frame draws, so each section is built and its pending update cleared the same frame. Three things that do not work, each measured: the INITIAL_BUILD queue (drained last, budgeted, never cleared — it grew to 6,000 duplicates); gating on !isBuilt() (the room lands in chunks the client already holds, so its sections are built-empty with a rebuild pending — test getPendingUpdate() != 0); and caching the deque (Sodium refills taskLists from its collector after every walk — cache the enum key, look the deque up per frame). The vanilla PortalPrewarmTicker stands down when Sodium is loaded: Sodium 0.8 leaves vanilla's viewArea in place, so it was meshing sections Sodium never draws, on the main thread.
Version coupling: the two mixins that @Shadow Sodium internals (RenderSection.getPendingUpdate, RenderSectionManager.sectionByPosition / taskLists) apply only on Sodium 0.8.* — a missing shadow is fatal under required: true, unlike a missing injection point under defaultRequire: 0. On any other Sodium line the prewarm stands down (first-visit pop-in returns) and the game launches. Re-check when the Sable bump moves the Sodium pin.
Since: DT 0.642.0 (PR #1079) — bundled in the modpack (CurseForge project 1051344, Modrinth scribble), pinned to 1.5.1+mc1.21-neoforge. Never bundled into DT's jar, and DT does not compile against it.
Scribble makes writing a book in-game bearable: page insert/delete, undo/redo, and save/load a book to a file. Books written with it look identical to everyone else, with or without the mod.
A player on an Intel UHD 610 (Sodium started one chunk-build worker) hard-froze on < Back from
Test the Carriage. Their log shows the render thread stopped ticking the instant the test-session packet
called LevelRenderer.allChanged(), while the integrated server kept going; the same call on test entry
had already cost 3 s + 21 s of client stall. On the way out it landed in the same tick as the teleport home
and a 1 M-block sweep of the test window, on a renderer that had just thrown away every mesh.
allChanged() was there because Skybox Blocks flip between "there" (collide + occlude) and "not there"
when a test starts or ends, and standing meshes were built against the other answer. But only sections
that contain a Skybox Block — plus their six neighbours, whose faces the occlusion shape culls — can
mesh differently. SkyboxSectionRebuild now palette-tests the sections in render distance
(LevelChunkSection.maybeHas) and marks just those through LevelRenderer.setSectionDirty(int,int,int),
the vanilla entry point Sodium overrides with its own scheduler, so one call serves both renderers.
Rules of thumb from this one:
- A full renderer reload is a deliberate user action (the Skybox Blocks toggle in Settings keeps its
allChanged()); it is never an acceptable side effect of a packet, a teleport, or a state flip. - Under Sodium the build-worker count follows the CPU; on a 2-core machine it is one, and the render thread waits on it for the sections it must have this frame. Budget rebuilds for that machine.
-
ResumeRenderDiagnosticsis the tell in a player log: it dumps 30 ticks after any 2 s gap — a single dump line followed by silence while the server thread keeps logging is a hung render thread, not a crash.
Not covered: Skybox Blocks aboard a Sable carriage are meshed by Sable's plot-space renderer, which the
dirty pass does not reach — a carriage-resident wall keeps its previous mesh until its next rebuild.
The still-synchronous PortalClear.clearBox on Back is tracked in #1469.
DT wants the written page close to vanilla, so ClientDisplayConfig.isScribbleColorPickerVisible() (default false) strips Scribble's 16 swatches from the book-editing screen. Setting visible = false does not work — Scribble's invalidateControlButtons re-asserts visibility on page insert/delete, page change, undo/redo and entering signing mode, and it re-asserts only visible, not active, so the swatches came back looking live but doing nothing. DT instead unregisters the widgets on ScreenEvent.Init.Post; Scribble's own colorSwatches list keeps live references, so nothing NPEs. See ScribbleColorPickerToggle for the full reasoning (including why this is an event, not a mixin — DT's mixin config is required: true, so targeting a soft-dependency mod would need an IMixinConfigPlugin purely to stay inert when Scribble is absent).
Scribble ships centerBookGui = true BY DEFAULT. Its BookScreenMixin slides the entire book-view GUI down by (screenHeight - 192) / 3:
| Injection | What moves |
|---|---|
shiftBackgroundY (@ModifyArg on renderBackground) |
the book.png texture |
shiftCloseButtonY / shiftPageButtonY (@Redirect) |
the close + page-turn widgets |
translateRender / popRender
|
a matrix translate around everything BookViewScreen.render draws — page text and the "Page N of N" indicator |
shiftTextStyleY / shiftHoverTooltipY
|
the matching hit-tests |
The trap: popRender is at RETURN of render(), and NeoForge fires ScreenEvent.Render.Post after that. Anything DT draws from Render.Post therefore lands outside Scribble's matrix and does not move with the book. Vanilla's own geometry (book at y=2, page indicator y=18, page text y=32, page buttons y=159) stops being where the book actually is.
This bit the community-book vote page — fixed in DT 0.649.0 (PR #1085). Its rows came off a hardcoded BOOK_TOP = 2, so at a ~22px offset the train's question was drawn straight through the "Page 2 of 2" line, and at larger offsets (1080p @ GUI scale 3 → ~56px) the thumbs and report icon rendered over empty screen while their hitboxes stayed behind — the controls did not click where they appeared.
The pattern to copy for any future book-screen overlay: do not read the offset from Scribble (DT never links against it). Read it off vanilla — the back page-turn button, which createPageControlButtons() fixes at y = 159 and which any re-centring mod must move by the same amount:
private static int bookTop() {
PageButton back = ((BookViewScreenAccessor) (Object) screen).dungeontrain$getBackButton();
return back == null ? BOOK_TOP : BOOK_TOP + back.getY() - VANILLA_PAGE_BUTTON_Y;
}That is correct for re-centring mods generally and an exact no-op when none is installed. Hitboxes must use it too, not just the draw calls.
DT's toggle targets 1.5.1, which has no config option for the colour picker (the swatches are built unconditionally in its BookEditScreenMixin.initButtons()). Scribble 2.x added showFormattingButtons, but it needs MC 1.21.4+ and also hides the bold/italic column, which DT keeps. Scribble 2.x additionally replaces the screens outright (ScribbleBookViewScreen) rather than mixing into them — re-verify the bookTop() probe if the pin is ever moved to 2.x.
Enabled companion (v0.889.0) — Particle Effects
(LopyMine / KlashRaick) ships on by default in both the CurseForge and Modrinth modpacks. It replaces
the flat vanilla colour swirl of every status effect with a unique textured particle per effect, so a
glance tells you what a player or mob is under. DT-the-mod neither bundles nor depends on it; it is a
particle-effects(optional) platform relation only.
Client-only, no server side. side="CLIENT" in its neoforge.mods.toml — auto-skipped on dedicated
servers, nothing networked, nothing in saves. Declares NeoForge [1,) and MC 1.21.1 exactly, so it
does not constrain neo_version. It carries two client mixin configs (particle_effects.mixins.json and
an inventory_particles compat set) and marks inventory_particles < 2.0.1 incompatible — not in the pack.
Verified with the shipped render stack: loaded alongside the pack's pinned Sodium 0.8.12 + Iris
1.8.14-beta.1 with Sable choosing its Sodium renderer mixins — clean boot, world join and shutdown, zero
mixin apply failures. Licence CC-BY-ND 4.0: bundled by reference (platform file id), never modified.
Pinned 1.5.0 (CF file 8325471, Modrinth AH6BZ7jO).
Enabled companion (v0.973.0): Advanced Shulkerboxes
by henkelmax ships on by default in both the CurseForge and Modrinth modpacks. Right-clicking a Shulker
Box in the main hand opens its inventory without placing it. DT-the-mod neither bundles nor depends on it;
it is an advanced-shulkerboxes(optional) platform relation only.
Server-side logic. modId shulkerbox, side="BOTH" dependencies on NeoForge [21.0.0-beta,) and MC
[1.21,1.21.1], with no other dependencies. Modrinth lists the client as unsupported, so a multiplayer
server needs the mod installed as well. It carries a small shulkerbox.mixins.json item-use set. In the dev
client its refmap warning is harmless, because NeoForge 1.21.1 runs Mojang mappings.
Verified: loads in the DT dev client alongside Sable and the sibling mods with no mixin failures (PR #1578).
It is licensed All Rights Reserved, so the modpacks bundle it by reference (platform file id) and never modify it.
Pinned 1.21.1-2.0.5 (CF project 253861, file 5610975; Modrinth TFonUZnY). This is the only NeoForge
1.21.1 build, and it is on the beta channel.
Both modpacks ship What Are They Up To (modId watut) with its required library CoroUtil
(coroutil), on by default. It shows what other players are doing — typing, in a menu, idle — on
their model. Dungeon Train itself does not depend on it and never touches its classes; it is a
modpack roster entry only, whitelisted for fair play. It only animates real players.
It has to match on both sides. WATUT registers its two payloads (watut:nbt_server,
watut:nbt_client) as non-optional, so NeoForge refuses the connection when only one side has it.
Tested on a real NeoForge 21.1.230 server (2026-10-02, WATUT 1.21.0-1.2.7):
| Server | Client | Result |
|---|---|---|
| with WATUT | with WATUT | Joins |
| without WATUT | with WATUT (a modpack player) | Refused — both channels "missing on the server side, but required on the client" |
| with WATUT | without WATUT | Refused — both channels "missing on the client side, but required on the server" |
| without WATUT | without WATUT | Joins |
So:
- Running a server for modpack players? Install WATUT and CoroUtil on it. A server set up from the Modrinth pack gets both (they are marked server-required there).
- Players who installed Dungeon Train without the modpack need both jars too before they can join a server that has them.
- The disconnect message is "Incompatible client! Please use NeoForge …", which does not name the
mod. The mod-mismatch screen behind it lists the
watutchannels.
WATUT without CoroUtil fails at mod loading with a clean "coroutil … [1.21.0-1.3.7,) …
MISSING" error (deptest Case P). Both are All Rights Reserved, so the packs bundle them by
reference and never modify them. Pinned WATUT 1.21.0-1.2.7 (CF project 945479, file
6418577; Modrinth uWr2aTW9) and CoroUtil 1.21.0-1.3.8 (CF project 237749, file 5622966;
Modrinth H2YXCYUY) — 1.3.8 because it is the newest build on both platforms.
Not yet verified: how the indicators look on a moving carriage (needs two players).
scripts/deptest/README.md §"Client-join test" has the reproduction.
Optional companion — shipped off (opt-in) in the modpack, optional in neoforge.mods.toml, never a
compile dependency. DT ships two compat mixins for it (dungeontrain.effortlessbuilding.mixins.json,
required:false, gated on the mod's presence by EffortlessBuildingMixinPlugin; both reach its classes by
targets string + reflection, so a renamed seam degrades to "no effect", never a crash — the seams are those of
effortlessbuilding-4.2+1.21.1, whose classes sit under a relocated neoforge. package prefix):
-
Free Play gate (server).
EffortlessBuildingPacketHandlerMixinhooks its server packet handlers — a multi-block place/break, undo/redo, or switching a mirror / array / radial modifier on — and puts the run into Free Play behind the same confirmation as/gamemode. Merely installing it, or ordinary single-block placement, changes nothing. The same hooks wrap each build in an editor undo capture, since EB writes with rawsetBlockand fires no block events the editor's recorder could see. -
Variant clipboard shape paste (v0.893.0).
EffortlessBuildingTriggerItemMixinmakesBuildPipeline.isBuildTriggerItemaccept the editor's variant clipboard, so Floor / Wall / Cube (and the modifiers) open their shape UI with it in hand — the clipboard is a plainItem, and EB otherwise only arms a sequence for block items, its randomizer, buckets and digging tools. The resultingPlaceBuildModePacketis consumed server-side bycompat.EffortlessBuildingVariants, which asks EB's ownBuildPipeline.SERVERfor the cell list and callsVariantClipboardItem.pasteAton every cell inside the author's plot (pool, lock group, copy settings, mirror and container pool all come along), saving the plot and contents store once under one Ctrl+Z step. Cells outside the plot are skipped and counted; no item is consumed. With EB set to Disabled the clipboard's plain right-click paste still runs as before. -
Shape breaks clear variant pools (v0.966.0). EB's shape break fires no
BlockEvent.BreakEvent, so the hand-break cleanup (VariantBlockBreakHandler) never ran and broken cells kept their variant pools.compat.EffortlessBuildingVariantBreaksnow bracketshandleBreakBuildMode: it notes the plot's occupied flagged cells before the break and drops the pool of each one left as air afterwards (diff-based, so EB's shape / mirror / array maths never need reproducing). The undo capture includes the plot's sidecar, so one Ctrl+Z restores blocks and pools. EB's own plain-Zundo restores blocks only; DT's "V" variant-mirror toggle is not applied to EB breaks.
Optional companion — DH is an opt-in (off by default) entry in the Dungeon Train modpack and an
optional dependency in neoforge.mods.toml; it is never bundled and never required. Only the API is a
compile-time dependency (maven.modrinth:distanthorizonsapi), and DH ships those classes inside its own
jar, so nothing extra is downloaded or added to any run classpath.
The modpack pins DH 3.3.3 (since DT 0.1255.0, PR #1961).
Vista (the TVs) declares distanthorizons [3.3.1,), so any older DH stops the game at NeoForge's
"Mod vista only supports distanthorizons 3.3.1 or above" screen. The pack pinned 2.4.3-b until then, so
every pack player who switched DH on hit that screen once the Live Feed TVs shipped (#1900). Never pin DH
below Vista's floor: check Vista's neoforge.mods.toml whenever either is bumped.
History: DH 3.0.3-b crashed the JVM on DT world entry under both G1 and generational ZGC, which is why the pack stayed on 2.4.3-b. 3.3.x enters a DT world cleanly.
Since: DT 0.970.0 (PR #1571).
DH's GLState.saveState() (2.x core.render.glObject.GLState, 3.x common.render.openGl.glObject.GLState)
reads GL_POLYGON_MODE with a one-int glGetInteger (LWJGL's GL32/GL33 before 3.3, DH's own
com.seibel.distanthorizons.lwjgl.ILWJGLService from 3.3 on). Apple's GL-over-Metal driver writes two
ints, so the second one landed 4 bytes past the end of the render thread's 64 KB MemoryStack buffer,
every frame, overwriting the low half of the next malloc block's first word. On macOS with DH that
surfaced as random JVM crashes with no DT frames: C2 Chunk::chop() SIGBUS or Metal objc_msgSend
SIGSEGV, with the fault address ending in 00001b02 (GL_FILL). JVM flags, GC choice and newer JDKs
don't help.
mixin/client/distanthorizons/DhGlStateSaveMixin (@Pseudo, config dungeontrain.distanthorizons.mixins.json,
gated by DistantHorizonsMixinPlugin) redirects those reads to client/gl/SafeGlGet, which gives the
driver a 4-slot buffer. Log line when it applies: Distant Horizons GL_POLYGON_MODE overflow guard applied to …GLState. If a DH update moves GLState or renames saveState, that line disappears and the guard
silently stops applying, so check it after re-pinning DH.
DH 3.3 changed the call, not the class. In 3.3.x saveState reads through ILWJGLService.glGetInteger,
so the GL32/GL33 redirects matched nothing (require = 0) and the crash came back: a dev client on DH 3.3.3
died with the Chunk::chop() SIGBUS two seconds after joining. A third redirect targets the interface call;
its receiver is @Coerce Object because DH's internal types aren't on DT's compile classpath. The plugin's
log line still appears when no redirect matches, so verify a re-pin in the bytecode: run the client with
JAVA_TOOL_OPTIONS="-Dmixin.debug.export=true" and javap -c the exported
run/.mixin.out/class/.../GLState.class. Every glGetInteger in saveState should call
dungeontrain$safeGetInteger…. Note that sipush 2880 (not ldc) loads the constant, so grep for that.
Since: DT 0.858.0 (PR #1381).
DH's MixinMinecraft opens UpdateModScreen at startup whenever its self-updater finds a newer build on
Modrinth — and since the pack pins one build while Modrinth lists newer ones, every pack player saw it at every
launch. client/DistantHorizonsUpdatePromptSuppression
watches NeoForge's ScreenEvent.Opening, matches that screen by class name (no DH import, so the
class loads with or without DH), and — only when the loaded DH version starts with the pack's pin
(distant_horizons_version from gradle.properties, now baked into dungeontrain_version.properties as
VersionInfo.MODPACK_DISTANT_HORIZONS_VERSION) — cancels it and runs what vanilla was about to run
instead (below). A player on any other DH build gets DH's prompt as before. Nothing of DH's
config or updater state is written. Log line: Distant Horizons update prompt suppressed: loaded 2.4.3-b matches the modpack pin (RUN_INITIAL_SCREENS; current screen …GenericMessageScreen).
What the prompt replaces, and why the fix is a capture (v0.1021.0,
PR #1656). DH's hook is a @Redirect of the
Runnable.run() in Minecraft#onGameLoadFinished: when it has an update to offer it runs its prompt in
place of vanilla's initial-screen task — the one that opens the title screen, or with --quickPlayMultiplayer
/ --quickPlaySingleplayer joins straight in, behind NeoForge's loading-warnings screen. At that moment
the current screen is the Minecraft constructor's "Loading Minecraft" GenericMessageScreen, not a
ConnectScreen. Until 0.1021.0 DT opened a fresh TitleScreen there, which silently cancelled quick-play
joins (./gradlew runClient -PquickJoin=…, used by scripts/perf/run-arm.sh); cancelling the event
alone would have parked the player on the loading placeholder. mixin/client/MinecraftInitialScreensMixin
now stashes vanilla's task in client/InitialScreensCapture at the end of buildInitialScreens and
forgets it at the end of onGameLoadFinished; the suppression cancels the prompt and runs the captured
task, so QuickPlay, the warnings screen and the title screen all come back exactly as vanilla intended.
The decision is the pure DistantHorizonsUpdatePromptSuppression.decide(...) (unit-tested): task
captured → run it; otherwise keep whatever screen is showing; otherwise a fresh title screen.
When you re-pin DH in the modpack, distant_horizons_version must move with it or the suppression
silently stops applying. If a DH update changes where its prompt opens from, the log line's
current screen tells you what it interrupted.
Since: DT 0.1027.0 (PR #1662).
DH's distant generator (distantGeneratorMode = FEATURES, its default) runs the vanilla chunk pipeline
up to FEATURES on its own DH-World Gen Thread[N] workers — so it also runs DT's density functions, the
Nether-core stamp and the Nether-core feature pass, at up to ~24 chunks/s. Lag reports showed that
starving the server and Sable physics threads on 4-core machines (91 ms ticks with nothing nearby).
Two independent halves:
-
Modpack override
overrides/config/DistantHorizons.toml(partial file; DH fills the rest): 1 generator thread at 50 % duty — the absolute values behind DH's MINIMAL_IMPACT preset, which is GUI-only and never written to the file —threadPriority = 3, and a 128-chunk LOD radius (DH default 256). DH is still off by default in the pack, so the file is inert until a player enables it. Trade-off: launchers replaceconfig/on every pack update and DH re-saves its whole file, so a player's own DH settings reset on each update.rendererModeanddistantGeneratorModeare deliberately untouched. -
LOD-lite decoration (
worldgen/LodGeneration, common configdistantLodLiteDecoration, default true): on aDH-World Genthread the Nether-core feature pass places only the silhouette steps (LOCAL_MODIFICATIONS,SURFACE_STRUCTURES,VEGETAL_DECORATION,TOP_LAYER_MODIFICATION) and skips ores, glowstone, fire, mushrooms and springs, which are below LOD resolution. Skipped features still consume their seed index, so the kept fungi and pillars stand where the real chunk's will. DH never saves LOD chunks, so real terrain is unaffected. Headless A/B: 1.39 → 0.63 ms per decorated chunk. Detection is by thread name (DH API 5.0.0 has no hook): a DH release that renames itsWorld Genpool silently falls back to full decoration — checklodLite=trueon theDecorated … Nether core chunkdebug line after re-pinning DH./dungeontrain debug lod-lite auto|force|off|statusis the A/B seam.
Measuring it: DH FEATURES generation never runs SPAWN and never fires ChunkEvent.Load, so [gen.timing]
chunksFulled does not count LOD chunks; read the deco= bucket totals and count the decorated-chunk
lines instead. Under DH the largest DT bucket is biome forcing (biome=), not decoration.
Since: DT 0.1154.0 (PR #1822).
Pausing does not stop DH. Its DH-World Gen workers keep requesting chunks, and the paused integrated
server keeps servicing them from waitUntilNextTick (ServerStallWatchdog: "main thread is servicing
off-thread chunk loads requested by … DH-World Gen Thread[0]"). One player log showed 2 h 16 min on the ESC
menu spent generating ~20 LOD chunks/s. On resume, tick time climbed 25 → 200 ms (Sable 5 → 125 ms) and
there was a 15.9 s stall.
client/DistantHorizonsPauseHold sets DH's API override enableDistantWorldGeneration = false once the
game has been paused (hasSingleplayerServer() && isPaused(), so pauseOnLostFocus counts too) for 10 s.
It clears the override on resume or LoggingOut. API values live in memory only, so DH's config file is
never written and a crash mid-hold leaves DH's setting as the player had it. DT only clears an override it
set itself. It does nothing when another mod already holds the override, or when the player has distant
generation switched off. DH drains and restarts its own queue on the change, so it caches no
half-decorated LOD chunks.
LAN-opened worlds never pause and dedicated servers have no client, so the hold is inert there. State lives
in the DH-free common class worldgen/LodGenerationHold. /dungeontrain debug lod-lite status appends
DH pause-hold: HOLDING for Ns | idle | unavailable.
Log lines: Game paused for 10s — holding Distant Horizons LOD generation until it resumes and
Game resumed — released Distant Horizons LOD generation after Ns held. If DH refuses the override, DT
logs Could not hold Distant Horizons LOD generation while paused and DH generates as before.
Gate 2 (DH 2.4.3-b, dev client) measured DH world-gen thread CPU per 10 s: 13–33 s unpaused, then 0 ms while held, then back to 6–24 s after resume.
Since: DT 0.792.0 (PR #1292).
The upside-down band does not flip world data — the blocks generate normally and DT inverts them at
mesh time, in both chunk-meshing pipelines (ModelBlockRendererUpsideDownMixin for vanilla,
mixin.client.sodium.BlockRendererUpsideDownMixin for Sodium), through client/UpsideDownRenderFlip.
DH renders neither: it draws its own LODs from its own database. In-band that produced two contradictory
views of the same world — an upright DH horizon under an inverted sky and inverted near terrain.
client/DistantHorizonsUpsideDown now cancels DH's own DhApiBeforeRenderEvent while the camera is in
the flipped zone (band core, entry lead-in, exit crossfade) or within an approach margin of it — the band
enters DH's draw distance long before the camera does, so without the margin you would watch an upright DH
copy of the band on the way in.
Only DH's rendering is cancelled, per frame. DH's config file is never written, its LOD generation and
stored data are untouched, and nothing persists past the frame — unlike flipping DH's own "enable
rendering" setting, a crash mid-band cannot leave a player's DH switched off. Two client config values:
distantHorizons.hideInUpsideDown (default true) and distantHorizons.hideMarginBlocks (default 1024).
An earlier attempt went the other way — mirroring DH's LOD chunks and asking DH to re-read them — and ran
into LOD-rebuild storms that flashed the sky at band altitude. That work is parked on the unmerged
dev/distant-horizons-upside-down branch and is not what shipped.
DH stops drawing inside dimensional carriages — and under a shader pack its frame textures are cleared too (v0.1194.0)
Since: DT 0.792.0 for the cancel (PR #1310); the texture clear since 0.1194.0 (PR #1884).
A portal room is stamped into twin space at the coordinates of the carriage it stands in for, and DH draws
the overworld's LODs at exactly those coordinates, so the room's sky arrived with the surface horizon behind
it. client/DistantHorizonsSuppression cancels DhApiBeforeRenderEvent while the camera is inside the
structure box the server names (ClientPortalRoomDepth), same per-frame contract as the band above;
client config distantHorizons.hideInPortalRooms (default true).
Cancelling alone is not enough under Iris. DH clears its colour and depth textures only inside the
render the cancel skips, and Iris's own DH hooks (LodRendererEvents — re-attach the depth texture, clear
it, copy it into its no-translucent twin) sit on that same setup step. Iris still samples those textures
every frame as dhDepthTex0 / dhDepthTex1, so the pack's sky-and-fog composite read the last-drawn
horizon and painted it as dark, frozen terrain silhouettes across the room's fog. Without a pack nothing
reads them, which is why the cancel looked complete for a year. client/DistantHorizonsFrameClear now
clears DH's two textures through the public render proxy (getDhDepthTextureId / getDhColorTextureId)
and Iris's private copy by cached reflection (best effort, logged at DEBUG if a future Iris renames it),
via client/gl/DhFrameTextureClear, which saves and restores every piece of GL state it touches. The log
carries one Cleared Distant Horizons' frame textures on a suppressed frame line per session when the
path first runs. The deferred transparent pass (DhApiBeforeDeferredRenderEvent, a subclass DH dispatches
by its own class) is bound separately, so water and glass LODs obey the room rule as well.
Dev A/B seam: ./gradlew runClient -PdhFrameClear=false reproduces the pre-fix behaviour on the same
build. Known dev-client traps: Complementary Unbound r5.6.1 + DH exceeds this Mac's texture-unit count
(shadowcolor1) and silently disables shaders — BSL loads; and the shader sweep's backrooms room is
fully enclosed, so the ghost cannot appear in sites 05–07 — it needs an open Void-mode room.
Symptom: Server memory climbs for the whole session on a long ride — roughly 400 MB an hour — and after five or six hours the server thread stalls for seconds at a time and logs Can't keep up! … N ticks behind. Restarting clears it; nothing in the world looks wrong.
Cause, and the general rule worth remembering: LevelPlot.onRemove() clears its loadedChunks list and nulls localBounds, but it never nulls the PlotChunkHolder[] chunks array, and each holder still holds its LevelChunk. A removed ServerSubLevel therefore still pins every block of its carriage — about half a megabyte. Anything in DT that keeps a reference to a sub-level past its cull keeps the carriage.
DT kept several, and the most surprising one was a cache that looked safe:
-
SableShipyard.wrappersis aWeakHashMap<ServerSubLevel, SableManagedShip>— but the value held a strongfinal ServerSubLevelback to its own key. A weak map whose value references its key never expires an entry. SincefindAll()wraps every sub-level in the level, this alone retained every carriage of the session. -
Shipyards.CACHEhad the same shape, keeping oldServerLevels alive across single-player world switches (and one death starts a new world). - The registries that keep handles on purpose —
Trains.SPAWNED_GROUPS,PortalPairResidency.GROUPS— inherited the pin. They have to outlive the cull: a group culled to holding is found again through its stale handle'ssubLevelId(). -
PortalPairIndex.ENTRIESadditionally held each carriage'sLevelPlot, and itsforget/clearmethods had no callers at all.
Fix: SableManagedShip now holds its sub-level through a WeakReference and caches the two things a stale handle is actually asked for: the sub-level id, and a last-known pose (LastKnownPose, refreshed for free inside applyTickOutput). A dead handle answers subLevelId(), reports isResident() false, returns its frozen cull-time AABB, and no-ops every mutating call. Because the value no longer references the key, the wrapper cache is finally a real weak map and every registry that keeps a handle stops pinning chunks without changing its own design.
subLevel() is now @Nullable — guard it. PortalPairIndex also drops pairings the per-tick portal walk stops republishing, and the shipyard cache plus the kinematic-driver map are cleared on server stop.
Measured (headless dedicated server, rider walked one carriage group forward every 15 s for 28 minutes, jcmd GC.run then GC.class_histogram): live ServerSubLevel objects went from 21 → 637 against 19–21 resident carriages, and now stay at 19–22; plot chunk holders went 63 → 1911 and now stay ~60; post-GC heap growth fell from ~12.7 MB/min to ~4.4 MB/min. That recipe is written up in Development ▸ Measuring memory.
If memory climbs again, the first check is that histogram: if ServerSubLevel tracks the resident carriage count, the leak is elsewhere (Sable's own physics-body maps are the next suspects, and belong upstream). If it tracks the number of carriages ever spawned, something in DT is holding a handle strongly again.
Not officially supported, but both are on the approved-mod whitelist (farmersdelight, cuttingdelight),
so installing them doesn't put a run in Free Play. No compile dependency: DT only knows Farmers' Delight's
modId, config file and one key. Code: cheat/FarmersDelightSoupStacking.
Stackable soups are held off. Farmers' Delight's farmersdelight-common.toml →
[overrides.stack_size] enableStackableSoupItems (default true) lets vanilla soups and stews stack to 16.
DT is balanced around unstackable soups, so:
| How it gets turned on | What DT does |
|---|---|
| First launch with Farmers' Delight | DT switches it off once (marker config/dungeontrain/farmersdelight_soup_stacking_off.marker). |
| From a config menu (Configured, NeoForge's config screen) | The change is undone and a confirm screen asks first. Accepting turns it on and the run is Free Play. |
| Editing the file by hand | No prompt. DtConfigIntegrity governs the key: session Free Play at launch, permanent Free Play if changed mid-run. |
/fixconfig and the launch "Settings Changed" reset back the file up and set only this key back to off.
Gotchas worth knowing for any other mod's config:
-
Menu save vs hand edit is told apart by thread.
ILoadedConfig.save()(used by Configured and NeoForge's screen) writes the file and postsModConfigEvent.Reloadingon the client thread. A hand edit only reaches the game through NeoForge's file watcher, which posts on its own thread. -
Another mod's
Reloadingfires on that mod's bus: register viaModList.get().getModContainerById(id).get().getEventBus(). -
Don't pre-create a partial toml. NeoForge "corrects" it and leaves
<name>-1.toml.bakbehind. For a missing file, set the value on its firstModConfigEvent.Loadinginstead. COMMON configs load before registry events, so that is still before the mod reads the key. - night-config 3.8 file builders write asynchronously by default; add
.sync().
Dungeon Train uses SpongePowered Mixins (provided by NeoForge). Active mixins are listed in src/main/resources/dungeontrain.mixins.json:
-
Server-side:
-
PlayerFudgeSpawnMixin— overridesServerPlayer.adjustSpawnLocationfor first-time login cached-placement (see Sable section above). -
PlayerEnderChestAccessor,PlayerEnderChestContainerAccessor,PlayerEnderChestContainerSizeMixin— swap a player's live Ender Chest container for a bigger one (Free Play expansion, v0.883.0); the size mixin is a@ModifyConstantonPlayerEnderChestContainer'ssuper(27)reading a thread-local that is 27 everywhere except insideEnderChestExpansion's own construction. -
DecoratedPotBlockEntityPotLootMixin— round-trips thedt_pot_lootNBT field that the rolled-content writer adds to vases.
-
-
Client-side: several
CreateWorldScreen*Mixins that wire the "Dungeon Train Options…" button into the World tab and inject the dimension picker, plusCreativeModeInventoryScreenPrefabMixinfor the prefab side-tabs andAbstractContainerScreenPrefabTintMixinfor the uncommitted-prefab yellow tint.client.CameraCinematicMixinoverridesCamera.setupto drive the spawn-intro cinematic camera (see Spawn cinematic).
Vanilla worldgen structures (v0.577.0). DT re-sites the vanilla Nether structures inside its Nether band rather than shipping copies, via head-cancellable mixins on NetherFortressStructure, JigsawStructure, NetherFossilStructure and RuinedPortalStructure (plus Structure.findValidGenerationPoint). Each returns immediately, leaving vanilla untouched, unless the position is inside the band core — so the real Nether is unaffected. Two of those classes are shared with unrelated structures (villages / trial chambers; the six overworld ruined-portal variants), so they are identified by their start pool and their setups' vertical placement rather than by class. ChunkGeneratorStructureStateMixin keeps DT's structure sets alive on the overworld generator only — a load-bearing gate, since the Nether structures' biomes are listed by the real Nether's biome source and DT's sets would otherwise add a second helping of fortresses there. Another worldgen mod that also mixes into these classes, or that replaces the overworld biome source, is the most likely source of interaction.
Mixins into BetterEnd / BCLib / WorldWeaver / TerraBlender internals degrade instead of crashing (v0.1118.0, PR #1775). About eighteen DT mixins (dungeontrain.betterend.mixins.json, dungeontrain.terrablender.mixins.json) hook private members of those libraries by name, and the libraries are declared [floor,), so a newer build on a player's machine can rename one. Those two configs run ThirdPartyMixinPlugin with defaultRequire: 0:
- Before each mixin applies, the plugin reads the target classes' bytecode and checks every member and call site listed in
compat/mixinguard/ThirdPartyMixinTargets. If anything is missing, the mixin is skipped with one WARN in the log, in the form[DungeonTrain] Skipping <Mixin>: <library> <version> no longer has <what>. Instead: <fallback>.The library named is the one that owns the class, so BCLib'sStructureErode/MHelperread as BCLib even though BetterEnd mixins hook them. - After a mixin applies,
postApplychecks that each@Inject/@Redirecthandler is actually called from the target. One that is not (the injection point moved, but the names still match) gets the same treatment, with the WARN<Mixin> applied, but its hook … did not take hold. - Fallbacks are recorded in
MixinGuardReport. Any skipped End-layout mixin makes the BetterEnd End bands stamp vanilla End. A skippedSurfaceRuleManagerMixinleaves TerraBlender's default rule: bedrock specks up to 4 blocks above DT's floor, so a seam at the floor in existing worlds. Cosmetic ones (chorus models, WorldWeaver screens, the BCLib backup prompt) just stop applying. -
/dungeontrain debug mixin-guardsprints what is off this boot. When a support log shows one of these WARNs, the player is on a library build newer than DT knows. - After a library bump, run
ThirdPartyMixinTargetsTest; it checks the table against the pinned jars. A new mixin in either config must get a table entry, or the test fails. TerraBlender has its own floor (terrablender_min_version, deptest case R).
Lightmap mixins share one method (v0.625.0). Four DT mixins target
LightTexture.updateLightTexture(F)V — LightTextureUpsideDownBandMixin,
LightTextureEndBandMixin, LightTextureNetherBandMixin and
LightTexturePortalRoomMixin — each hooking ClientLevel.getSkyDarken and the
tail of DimensionSpecialEffects.adjustLightmapColors. They compose by design:
every one scales its lift by an intensity that is 0 outside its own band or
room, so at most one is normally live, and where two would overlap their lifts
simply add. All four are client-side only — they retint the lightmap and
change nothing about the light the server stores, so mob spawning and the clock
are unaffected.
Another mod that mixes into LightTexture is the first thing to suspect if
world lighting looks wrong — most likely a shader pipeline or a lighting-engine
replacement that rewrites or bypasses updateLightTexture. The portal-room lift
alone can be switched off server-side with portalRoomDaylight (see
Editor ▸ Sky), which is the quickest way to tell whether
it is implicated.
If a mixin fails to apply at startup (InvalidInjectionException in the FATAL log), the mod won't load — most often the cause is a method rename in a Minecraft / NeoForge minor version, since SpongePowered Mixins target methods by name. Fix by retargeting to the new name; see fix(login): retarget fudge mixin to ServerPlayer for the canonical example.
Every LevelChunk.setBlockState inside a sub-level (and in any world section the physics scene
tracks) runs SableCommonEvents.handleBlockChange → SubLevelPhysicsSystem.handleBlockChange → RapierPhysicsPipeline.handleBlockChange, which re-derives the neighbourhood state of the changed
cell and its six neighbours and pushes seven native Rapier3D.changeBlock calls, then
wakeUpObjectsAt queries every body around the cell. One TrainAssembler.spawnGroup hit that
three to four times per block — the source-world stamp, Sable's lift into the plot, the airing of
the source cells and the contents pass — about 2,100 per group, which was the 2,000–7,000
blockChanges= per [mspt] window in player lag logs and a 1.3–2.7× tick after every append.
Sable itself never goes per block when it loads a sub-level: ServerLevelPlot.load writes the
chunks and then calls PhysicsChunkTicketManager.addTicketForSection +
PhysicsPipeline.handleChunkSectionAddition(section, x, y, z, true) once per non-air section — one
native addChunk per 16³. ColliderBatch (ship/sable) uses that seam: a scope opened around
spawnGroup makes SubLevelPhysicsBlockChangeBatchMixin (two @WrapOperations on
SubLevelPhysicsSystem.handleBlockChange) skip the pipeline call and the wake-up while the scope is
open, and on the outermost exit — in a finally — every touched section is re-uploaded once
(handleChunkSectionRemoval + handleChunkSectionAddition when the ticket manager already tracks
it, addSectionIfNotTracked when not; PhysicsChunkTicketManagerAccessor reads the private
physicsChunks map), then woken once per section. Sable's ticket, mass-tracker and DT's pivot-pin
paths still run per block, so centre-of-mass behaviour is unchanged.
Measured on the headless bootstrap (11 groups, 23,268 block changes): Sable's per-block handling
fell from ~700 ms per boot to ~110–190 ms plus 40–80 ms of section rebuilds, i.e. 63–68 ms per
group → 14–24 ms. [mspt] now carries colliderRebuilds=, batchedBlockChanges= and
blockChangeMs= (wall time of the per-block handler); blockChanges= still counts un-batched
edits. Toggle with /dungeontrain debug colliderbatch <on|off|status> or boot with
./gradlew runServer -PcolliderBatch=false.
Re-verify on any sable_version bump: SubLevelPhysicsSystem.handleBlockChange must contain
exactly one invokeinterface PhysicsPipeline.handleBlockChange and one invokevirtual wakeUpObjectsAt(III)V; PhysicsChunkTicketManager.physicsChunks must still be a
Map<SectionPos, PhysicsChunkTicket>; and handleChunkSectionAddition's native addChunk is
assumed safe to call after handleChunkSectionRemoval on the same section (the load path only
ever adds fresh sections). Verified against sable-2.0.5+mc1.21.1 with javap -c -p.
findAll() is empty while a player is in another dimension — never spawn a seed on "nothing loaded" (v0.1026.0)
Symptom: a player beat the dragon, dropped through the End's exit portal, and came back to two trains overlapping at the origin (PR #1660).
Cause: with nobody in the Overworld for the length of a dragon fight, Sable culls every carriage to holding, so Shipyards.findAll() lists nothing. TrainBootstrapEvents.ensureTrainSpawned used that as "no train" and laid a fresh seed — and spawnTrain → deleteExistingTrains wiped the registry, SableHoldingIndex and the carriage/contents stores for the sleeping train on the way, so its groups came back as untracked orphans under the new one.
Rule: a train is known in a dimension when a carriage is loaded or the grow-only Trains.knownGroups registry names a group that Shipyard.isHeld still answers for (TrainBootstrapEvents.trainKnownIn). ensureTrainSpawned refuses to spawn over a held train. Callers that need a deck — the End return and the rolled-dimension respawn in RespawnDimensionEvents.placeOrWake — wake one held group via TrainCarriageAppender.wakeHeldGroup (reload + adopt, same as the remote-player wake pass and PortalRoomRescue), park the player invulnerable at the ground pose, and let DtpPlacementService land them once the reloaded deck settles.
Dedicated-server caveat: the appender's tick returns early with no players in the level, so its near-player force-loads are never released and the train stays resident while the player is away. The bug is only reachable there after a walk-away; singleplayer culls readily.
Not covered: a relaunch while in the End starts with an empty registry, so that path still spawns a seed as before.
The sampled End-band passes (BetterEnd, and BoP from cycle 2) used to generate each display chunk empty and copy the real End terrain in later from a background sample, which showed as chunk-shaped void squares on /dtp arrival or fast flight until the sample landed. Since v0.1077.0 the sample runs inside the display chunk's own generation (event/EndBandInlineTerrain, called at the tail of applyBiomeDecoration), so a chunk never exists without its islands. The COMMON key endBandTerrain (WORLDGEN default, BACKGROUND = the old path) switches it; the background path still repairs old saves' end_band_pending chunks via the sweep.
The inline End sample and the void erosion (v0.1117.0, PR #1777). From v0.1077.0 to v0.1116.0 the default mode's BetterEnd (and BoP End) band generated as bare end stone + chorus, with and without a player: EndBandInlineTerrain wrote the sampled blocks into the proto chunk at decoration time, and WorldDisintegrationEvents.onChunkLoad (the band's void erosion, which runs once when a new chunk goes live) then dissolved every block that is not vanilla End — isPreservedEndBlock knows only end stone, chorus and the core's End-city palette, and the fade keeps a sampled block on exactly the Disintegration.coherentNoise values the erosion removes one (keepSampledBlock: noise < ramp; erosion: noise < p, p ≥ ramp, p = 1 in the core). BACKGROUND mode never had the problem because it writes its sample on a tick after the chunk loaded, i.e. after the erosion. The fix restores that order inside worldgen: the inline path runs WorldDisintegrationEvents.erode on the proto chunk first (so the sample lands in cleared air rather than only in the gaps between overworld blocks about to be removed), writes the sample through EndBandTerrainWriter.recordingSink, and stores the written cells in the transient chunk attachment dungeontrain:end_band_sampled_cells (worldgen/SampledCells, a bitset; NeoForge copies an attachment from the proto chunk to the live chunk only if it has a copy handler, and the builder insists on serialize() being set first). The load-time erosion skips those cells — it still cleans what neighbouring chunks' decoration spilled in, the reason it runs at load — and then drops the record. A chunk saved mid-generation and promoted on a later boot reads the record back as ALL and skips its erosion rather than erasing its islands. Proof (seed 7741953, 64-chunk slab at X 38400 Z 32..95, region-file counts): unfixed WORLDGEN 0 betterend:* cells, BACKGROUND 72,809, fixed WORLDGEN 72,812; end stone identical; overworld, upside-down and fade-in control slabs byte-identical. Only chunks generated after the fix change — bare band chunks in older saves stay bare.
Two things bite when an End sample runs on a vanilla worldgen worker:
-
The sample must never wait on the worldgen pool.
NoiseBasedChunkGenerator.fillFromNoiseschedules onUtil.backgroundExecutor()and returns a future; joining it from a worker deadlocks (the portal-room lesson).OfflineChunkSampler.fillGroundInlinecalls the privatedoFilldirectly (NoiseBasedChunkGeneratorInvoker). -
WorldWeaver's
WoverBiomePicker.getBiomeAtloads live chunks. BetterEnd'sSpireFeature→EndBiome.findUnderMaterialasks it for the biome under a spire, and it answers withlevel.getChunkSource().getChunk(x, z, status, true)— on aWorldGenRegionthat is the live End's chunk cache. On the old sampler thread every spire loaded and generated a real End chunk 16 000+ blocks out; from a worker it waited on the pool it occupied (a 10-minute server stall in testing).WoverBiomePickerSampleMixinanswers from the sample whileOfflineChunkSampler.isSampling(), andOfflineChunkSampler.SampleRegionhands every sample a chunk source that never loads (logs once, the asking feature fails alone).
Also: vanilla's end_islands erosion (EndIslandDensityFunction) is pure (x, z) but is asked once per quart by every End biome source and per cell corner by the terrain interpolator (625 simplex evaluations each; RandomState strips cache_2d). EndIslandDensityFunctionMixin memoises it by column for every caller, live End included — about 70 % of an End-band sample.
Reported on 0.1093.0 in the Far Lands on a 512 MB AMD iGPU: Cannot read field "asGLMode" because "this.mode" is null at VertexBuffer.draw, reached through ShaderWorldCrossfade.render only because that wraps every renderLevel (PR #1784).
Vanilla 1.21.1 behaviour. When a section's VertexBuffer.upload throws, SectionRenderDispatcher$RenderSection$RebuildTask.lambda$doTask$1 calls Minecraft.delayCrash("Rendering section") and then still setCompileds the section with that layer marked non-empty. The same frame's renderSectionLayer draws the never-uploaded buffer (mode is set only by a successful upload) and NPEs, so the report hides the real error. Even without the NPE, Minecraft.run() raises the delayed crash on its next loop.
What DT does.
-
VertexBufferUnuploadedDrawGuardMixinskipsdraw/drawWithShaderon a buffer whosemodeis null, and records (and logs, rate-limited) any erroruploadthrows before rethrowing it. -
SectionRebuildUploadFailureMixinwraps thatdelayCrashcall: a recorded upload failure is forgiven (budgetclient/gl/SectionUploadFailures: 5 per 60 s), and the section is marked dirty so it rebuilds. Past the budget, vanilla's crash goes through with the real error.
Costs, measured with a forced failure. Vanilla builds the full CrashReport (system report + oshi probe, ≈0.5 s) before delayCrash, so even a forgiven failure is a brief hitch. A failure that keeps recurring freezes the game for one long frame while uploadAllPendingUploads drains every queued failure, then crashes. Both are vanilla behaviour.
Symptom: on a PC set to Turkish (or Azerbaijani), "New Game" shows Loading the World and drops straight back to the title screen. There's no crash. The log shows Unknown element name:spikes_down / Unknown element name:nether_ceil while parsing BetterNether's patch_bone_stalagmite_on_ground and ghast_hive, then "Failed to load datapacks". A dedicated server refuses to start the same way.
Cause: WorldWeaver (21.0.25) builds codec names with a bare name().toLowerCase(). Java lowercases I to a dotless ı in those locales, so SPIKES_DOWN becomes spıkes_down. Other mods have the same habit (Exposure fetched …/ıron.json).
Fix (#1842): CoreMixinPlugin (the plugin on dungeontrain.mixins.json) runs locale/CaseLocaleGuard in onLoad, before any mod class initialises. For tr/az it sets the JVM's base default locale to Locale.ROOT and keeps the player's FORMAT/DISPLAY locales. In-game language, translations, notes and Death Notes are unaffected. The one visible side effect is that log4j timestamps in log files read Oct instead of Eki. The startup log line System locale tr-TR lowercases 'I' to a dotless 'ı'… confirms the guard ran.
Reproduce: JAVA_TOOL_OPTIONS="-Duser.language=tr -Duser.country=TR" ./gradlew --no-daemon runServer -PserverDir=<fresh dir>. Removing the "plugin" line from dungeontrain.mixins.json brings the failure back.
Older builds: the workaround is the launcher JVM arguments -Duser.language=en -Duser.country=US.
ReForgedPlay (Replay Mod for NeoForge, modIds reforgedplaymod + replaymod) is an optional, player-installed, client-only mod for filming — not bundled, not in the modpack. DT carries a small compat layer for it (added in v0.1197.0, #1887).
Symptom (videographer report): play a recording back and the train stands still at the pose it had when the recording started, while the recorded player walks away from it.
Root cause: Replay Mod records only what passes through the vanilla Connection. Sable sends its per-tick sub-level pose snapshots outside it — SubLevelTrackingSystem.sendMovementUpdates asks SableUDPServer.isConnectedTo(player) and, when true, hands the snapshot to its UDP pipeline (singleplayer: a direct in-memory drop onto the client's NETWORK_EVENT_LOOP; multiplayer: a real UDP socket). The .mcpr therefore held the StartTracking/full-sync bundles (carriage geometry at its first pose) and no movement. The player's recorded positions are world-space, hence the drift.
Fix:
-
compat/ReplayModRecordingProbe— reads ReForgedPlay's recording / playback state by reflection only (ReplayModRecording.instance.getConnectionEventHandler().getPacketListener() != null,ReplayModReplay.instance.getReplayHandler() != null). No compile dependency; idle without the mod; any reflective failure logs once and latches tofalse, so nothing can throw inside Sable's tracking tick. -
mixin/SubLevelTrackingOrderedSnapshotMixin(the sameisConnectedTowrap that already keeps snapshots ordered for 10 ticks after a full sync) also answers "not connected" while the player is recording → Sable takes its ordered-connection fallback, which Replay Mod records and replays through Sable's normal client handler. - Dedicated servers can't probe a client-only mod, so
client/ReplayRecordingStateClientsendsnet/ReplayRecordingStatePacket(C2S, one boolean) on change and on join;ship/sable/ReplayRecordingPlayerskeeps the set (cleared on logout / server stop).PROTOCOL_VERSION118 → 119. Server log:<name> is recording a replay — Sable snapshots now take the ordered connection. -
Quick Mode rebuilds the world without NeoForge's modded handshake, and a NeoForge client then hung forever on "Reconfiguring".
mixin/client/NetworkRegistryReplayPlaybackMixinwraps the three vanilla-server checks inNetworkRegistry.initializeOtherConnection(ClientConfigurationPacketListener)(NegotiationResult.success,CheckExtensibleEnums/CheckFeatureFlags.handleVanillaServerConnection) to pass only while a replay is playing. Bytecode-verified against NeoForge 21.1.230 — re-verify on a NeoForge bump. -
reforgedplaymodandreplaymodare on the curated fair-play whitelist — the jar declares both[[mods]]entries and the Free Play check reports the second one.
Prior art: the GPL-3 Sable Replay Compat addon does the singleplayer half the same way (an isConnectedTo HEAD inject). Nothing was copied; the two coexist because both only ever force the same answers.
Caveats: only replays recorded on this version move — older .mcpr files hold no pose data and cannot be repaired. A recording player's snapshots take TCP instead of UDP (Sable's own documented Replay-Mod trade-off); other players are unaffected.
Testing without a GUI: unzip the .mcpr and count grep -a -o "sable:snapshot[a-z_]*" recording.tmcpr | sort | uniq -c — a moving train gave 2,434 sable:snapshot_packet in 62 s, the broken path (or a parked train) ~3. Sable only sends a snapshot when the pose leaves tolerance, so a dead-player dev save (train never moves) legitimately records none.
Open follow-up: the replay camera does not follow the recorded player into a dimensional carriage (board item: Replay Mod: camera follows the recorded player into dimensional carriages).
Since 0.1222.0 the Free Play whitelist judges each installed mod file, not only its mod id: a jar is
verified when its SHA-1 is a published build of an approved mod (Modrinth / CurseForge, baked into
approved_mods.json by scripts/modpack/pull-approved-hashes.py, refreshed live by the relay's
POST /approved-mods/verify), plausible when the hash is unknown but the code packages inside are the
claimed mod's, unknown when DT has no package knowledge for one of its ids (reported, never Free Play),
and suspect when the code is not the mod it claims to be — only suspect puts the session in Free Play,
and only while the relay's enforceHashes switch is on (ships off).
The engine — jar scanning, hashing, the CurseForge fingerprint, the package fingerprint and the verify
client — lives in the sibling mod Dungeon Train Mod Verification
(dtmodverify), jarJar'd inside the DT jar and declared optional: DT reaches it only through
compat/DtModVerifyBridge (Stream Detect pattern), and without it DT is the id-only whitelist it was before.
Bumped by hand via dtmodverify_version / dtmodverify_min_version in gradle.properties. Once its
CurseForge listing exists it also gets a curseforge_only modpack entry and a release.yml dependency, like
the other hybrid siblings.
OpenAL. Stream sound needs OpenAL Soft's ALC_SOFT_loopback and ALC_EXT_thread_local_context. Minecraft's bundled LWJGL 3.3.3 OpenAL Soft has both on macOS, Windows and Linux. If either is missing, the stream goes out silent and the log explains why.
What never reaches the stream:
- Voice-chat mods. They use their own OpenAL device, so they are never mirrored.
- Resource packs or server packs that replace a sound file. That sound stays off the stream; the player still hears it.
- Mods that play their own audio streams (e.g. Vista TVs).
- Everything, when an unapproved mod is loaded on the client.
Vista's ffmpeg needs its native aac encoder. This is probed once per launch with ffmpeg -encoders.
Sound source pool. While streaming with sound, sounds whose slider is at 0 still occupy (silent) OpenAL sources.
PR #2000. How a TV's ffmpeg reads the stream:
-
Pacing. It uses
-readrate 1 -readrate_catchup 2, which plays at real speed and catches up after a stall. That needs ffmpeg 7.1 or later, probed once per launch withffmpeg -h long. Vista's downloaded builds are recent, but Vista prefers a system ffmpeg on PATH, which can be older. An older one gets-reinstead (no catch-up). The HLS demuxer cannot pace on its own: without either flag it bursts a whole segment, then freezes. -
What it will fetch. It may use only
-protocol_whitelist https,tls,tcp, with plainhttponly for a loopback test relay. Every read times out after 15 s (-rw_timeout). Before each run the mod fetches the playlist itself and refuses any playlist listing anything but relativesegNNNNN.tsentries (or with aURI=tag): the TV shows static and the log says why once. -
Relay URLs. The status
playlistUrlandreplayUrl, and presigned upload targets, must be https (or loopback http). Anything else reads as off air or is dropped. - Watchdog. The stall (20 s without a frame) and idle (30 s unwatched) kills run from the client tick, so they work with no TV in view.
PR #1996. Three shader-pack-only faults in the Skybox Block depth punch, all invisible on the 2026-09 sweep because its site uses END blocks (whose stencil path clears every frame) with nothing in front of or behind the wall:
-
Stale marks. The reopen's
0x80stencil bit was only cleared when a variant with its own sky was on screen; a view of plainskybox_blockaccumulated marks across frames, so nearer blocks were pushed to the far plane wherever a cube had once been visible.SkyboxStencil.clearBitnow zeroes it at the top of every mark pass — nothing in vanilla or Iris ever clears stencil. -
Mobs drew through the block. Iris swaps in a fully buffered entity source and flushes every entity from its own
renderLevelcallback at the"translucent"profiler constant — afterAFTER_BLOCK_ENTITIES, and the same instruction its deferred pass starts from. No NeoForge stage and no vanilla instruction sits between the two (the next vanillaendBatch()differs between Fancy and Fabulous). The reopen now runs from the Iris-gatedIrisPipelineSkyboxReopenMixinat the head ofIrisRenderingPipeline#beginTranslucents. -
The depth restore had never landed. It drew the cube meshes under vanilla's frustum stack without the identity model-view they need, so translucent terrain and translucent entity layers behind a block showed through. It now runs at the tail of the same Iris call, under the identity, before Iris' translucent entity flush — closing the "translucents show through" limit in
docs/shaders/compat-matrix.md.
The F3+5 panel gained a Restore: line (centre-pixel depth before/after, bound framebuffer at reopen and restore); read it with the crosshair on a hole pixel. Verified on Complementary Unbound r5.8.1 + Iris 1.8.14-beta.1 + Sodium 0.8.12 with a live-sky wall, pillars in front, a cow, a floating villager and stained glass behind. Not yet ridden with a Skybox Block inside a moving carriage under a pack.