Feature Tunnel Generation - bh679/dungeon-train-mc GitHub Wiki

Auto-generating stone-brick tunnels through thick terrain

Since: v0.23.0 β€” PR #29 β€” stone-brick tunnel with stepped arched roof, stepped pyramid portals at each entrance/exit, 20-block open-trench approach cutting so portals frame against sky, and sea-lantern lamp stations every 10 blocks on the walls v0.26.0 β€” PR #35 β€” tunnel overwrites are silent: no container spill when the generator replaces mineshaft / village / stronghold block-entities v0.29.0 β€” PR #38 β€” tunnel geometry is now NBT-backed and editable in-world. Two StructureTemplate models (tunnel_section, tunnel_portal) replace the per-column hardcoded paint; players edit them via /dungeontrain editor enter tunnel_section|tunnel_portal and save them to config/dungeontrain/tunnels/. Procedural paint is preserved as the fallback when no template is saved, and used for partial (< 10 col) tunnel runs. Qualification samples raised from ceilingY + 1/+2 to ceilingY + 5/+6 so the train's forward-clearing AABB can't false-disqualify columns for taller carriages v0.46.0 β€” PR #60 β€” tunnel section + portal both support multiple named variants (weighted random pick per tunnel) and per-block randomization sidecars. Authoring uses the unified track-side editor grid β€” see Feature-Track-Variants. Legacy tunnels/section.nbt / tunnels/portal.nbt auto-migrate to tunnels/<variant>/default.nbt on first server start. v0.85.0 β€” PR #123 β€” tunnels are now generated entirely at chunk-gen time. Removes the v0.83.0 probe-and-confirm hybrid plus the runtime drain (TunnelChunkEvents deleted, TrainTickEvents tunnel-fill block removed, TrainTransformProvider tunnel-chunk sets removed). Single-phase placement via TunnelGenerator.placeTunnelSpaceAtWorldgen: per-column qualification probe at ceilingY+1 (lowered from +5/+6); inside an ongoing tunnel run an extended qualifier scans 4 Y levels going down with alternating Z patterns A {-3,-1,0,+1,+3} and B {-2,0,+2} so a soft spot at centerZ (cave entrance, river bed) doesn't terminate an otherwise continuous corridor. Stamp origin shifted +1 on Z (origin Z = wallMinZ + 1) to align template content with the corridor walls. New debug toggle: MIN_CARRIAGES = 0 lets numCarriages = 0 skip bootstrap auto-spawn for inspecting worldgen without carriages in the way.

Wherever the train's +X corridor runs under enough natural underground material, a proper stone-brick tunnel auto-generates: walls, arched ceiling, floor extension. Each entrance and exit gets a stepped pyramid portal on top and a 20-block open cutting on the non-tunnel side so the portal always reads against open sky instead of being buried in the hillside. Mirrors the track-gen chunk-driven async pattern β€” per-train cache, deferred paint, 1 chunk/tick budget, phase-offset from clearBlocksAhead and the track drain.

Since v0.29.0 the actual block placement is a StructureTemplate stamp, so the tunnel's shape is no longer hardcoded β€” see the editor section below.

Detection β€” "is this column underground?"

Since v0.85.0: qualification has two modes, selected by an ext flag the generator threads through the per-chunk loop.

Default (ext=false) β€” single block read at (worldX, ceilingY+1, centerZ). If that block is natural underground material, the column qualifies. Otherwise it doesn't, and we move on. Used for the first qualifying column in a chunk and for chunks with no tunneled X-neighbour.

Extended (ext=true) β€” entered once a tunnel is already "active" upstream: either we're a column past a successful stamp earlier in the same chunk, or we entered the chunk with a tunneled neighbour at chunkXΒ±1 (tracked in an in-memory TUNNELED_CHUNKS set). On a center-probe miss we scan a coarse 3D grid before giving up: 4 Y levels going down from ceilingY+1, with Z patterns alternating between

  • Pattern A (even dy): dz ∈ {-3,-1,0,+1,+3} (5 probes)
  • Pattern B (odd dy): dz ∈ {-2,0,+2} (3 probes)

If any of those probes hits underground material, the column qualifies. This tolerates a soft spot directly at centerZ β€” a cave entrance, river bed, or thin ceiling pocket β€” so it doesn't terminate an otherwise continuous corridor. Down-scanning 4 Y levels also keeps a tunnel from breaking when the prospective ceiling clips an overhead air pocket while the lower body of the column is still buried.

TUNNELED_CHUNKS is in-memory only (a ConcurrentHashMap.newKeySet<Long> keyed by ChunkPos.asLong); on server restart, chunks generated post-restart at the corridor boundary may not inherit ext=true from already-tunneled neighbours. Trade-off vs. SavedData synchronization across worldgen worker threads. Visible artifact: a small seam where a tunnel doesn't extend through a soft spot it would have crossed pre-restart.

Why ceilingY + 1?

The probe sits 1 block above the tunnel's interior ceiling (the arch rises 4 more rows above that, but the probe doesn't sample those). Lowered from the v0.29.0 +5/+6 two-row sample because:

  1. The old worldgen path was joined by the new chunk-gen path in v0.85.0, so the train-clearing concern that motivated +5/+6 (forward-slab clear sweeping up to aabb.maxY) doesn't apply: at chunk-gen time no carriages exist.
  2. With single-probe at centerZ, lowering the probe gets more shallow-buried columns to qualify so tunnels extend closer to the surface where they used to terminate prematurely. The arch roof containment concern is now handled by the extended mode's down-scan inside an active tunnel run.

Layout (per 10-block tunnel run)

Anchored off the existing TrackGeometry; derived in TunnelGeometry.from(g).

Templates are 10Γ—14Γ—13 (XΓ—YΓ—Z) β€” aligned with LAMP_SPACING = 10 so a single section contains exactly one lamp station on its left edge. Stamped end-to-end along a qualified run, with portals replacing the first + last sections:

Layer World Y World Z Block
Floor + track bed bedY (= origin.y βˆ’ 2) [wallMinZ + 1, wallMaxZ βˆ’ 1] β€” 11 wide minecraft:stone_bricks
Rails railY (= bedY + 1) railZMin, railZMax (2 blocks apart) minecraft:rail facing east-west
Air interior [railY, ceilingY] [airMinZ, airMaxZ] β€” 11 wide minecraft:air (rails preserved)
Walls (Γ—2) [floorY, ceilingY] wallMinZ = trackZMin βˆ’ 4, wallMaxZ = trackZMax + 4 minecraft:stone_bricks
Arched roof [ceilingY + 1, ceilingY + 3] stepped inward 1 per tier + stair-smoothed outer edges minecraft:stone_bricks + minecraft:stone_brick_stairs
Apex cap ceilingY + 4 5-wide stone-brick minecraft:stone_bricks
Corner wedges above arch [ceilingY + 2, ceilingY + 4] outside the arch profile minecraft:structure_void in the editor β†’ stripped from NBT on save

ceilingY = bedY + 9 β€” total tunnel depth is 10 blocks floor-to-wall-top, then the arch rises 4 more rows above that. Interior air column in the center is 11 wide Γ— up to 11 tall (8 rectangular + 3 stepped arched). The corner wedges above the walls but outside the arch silhouette are marked STRUCTURE_VOID in the editor plot so they're stripped from the saved NBT and the stamp leaves whatever's at those positions (mountain rock) untouched β€” without that guard the 10Γ—14Γ—13 bounding-box stamp would carve rectangular gashes through the rock above the arch.

Per-chunk per-column scan (v0.85.0)

Run detection and portal stamping (v0.29.0–v0.84.x) have been removed. Each chunk does its own per-column scan:

for each X column (chunkMinX..chunkMaxX):
  if !qualified(col, ext):           continue   # skip natural-terrain columns
  if col <= lastStampEndX:            continue   # covered by previous stamp's footprint
  if canStampSection(chunk):
    placeSectionAtWorldgen(col)                  # stamp 10-col NBT
    lastStampEndX = col + 9
    ext = true                                   # subsequent cols use extended qualifier
  else:
    LegacyTunnelPaint.paintTunnelColumnWorldgen(col)   # 1-col fallback
    ext = true

canStampSection is a single Z-window fit check: the stamp footprint spans [wallMinZ+1, wallMaxZ+1] (13 wide, including the v0.85.0 +1 Z shift) and must fall within the worldgen 3Γ—3 decoration window (stampMinChunkZ β‰₯ chunkZ-1 && stampMaxChunkZ ≀ chunkZ+1). Default carriage widths fit; very wide corridors (width > 16) exceed the envelope and fall back to legacy paint per-column for the whole chunk.

Cross-chunk alignment is per-chunk relative. Each chunk starts its tile chain at its own leftmost qualified column rather than at a world-absolute multiple of LAMP_SPACING. This can leave a visible seam at chunk boundaries inside long tunnels β€” accepted trade-off vs. legacy gaps with absolute alignment. Inside a single chunk, adjacent stamps tile flush.

Portals removed from worldgen-time. The v0.29.0 portal stamping (mirrored tunnel_portal at run start/end) ran off run detection, which is gone. As of v0.85.0 the worldgen path produces continuous tunnel-section content; portal placement is a separate concern and currently no entrance/exit pyramid is stamped at the corridor's start/end. Portal templates remain in the project (still authorable via /dungeontrain editor enter tunnel_portal) and TunnelTemplate.placePortalAtWorldgen is wired, just not yet called from the new path.

Stamp origin (Z+1 shift)

Stamp origin is (worldX, floorY, wallMinZ + 1) so the template's local Z=0 lands one block toward +Z relative to the corridor's βˆ’Z wall. The default tunnel section + portal NBTs ship in the resource pack with content authored to land flush against the corridor walls under this anchoring; if you author your own template, expect template-local z=1 (not z=0) to align with the βˆ’Z wall.

NBT templates + in-world editor

Each tunnel section + portal is a StructureTemplate saved to config/dungeontrain/tunnels/{section,portal}.nbt β€” a separate directory from config/dungeontrain/templates/ (carriages) so CarriageVariantRegistry's *.nbt scan doesn't register tunnels as custom carriages.

Two editor plots at (x=80, y=250) and (x=100, y=250) β€” past the carriage plots at x=0/20/40/60 so all variants are visible from one vantage point:

/dungeontrain editor enter tunnel_section    # teleport to (85, 251, 6), 10Γ—14Γ—13 plot in bedrock cage
<edit blocks freely>
/dungeontrain editor save                    # writes tunnel_section.nbt (with STRUCTURE_VOID ignore filter)
/dungeontrain editor exit                    # return to pre-enter position

/dungeontrain editor enter tunnel_portal     # separate plot at (105, 251, 6)
<edit β€” same model used for entrance AND exit; pyramid lives at x=0>
/dungeontrain editor save

/dungeontrain editor list                    # shows all carriage + tunnel variants with config/bundled status
/dungeontrain editor reset tunnel_section    # deletes the saved NBT, reverts to procedural fallback

The editor plot is painted procedurally on enter including the track bed and rails, so what you see in-world is exactly what stamps at a real tunnel.

Why STRUCTURE_VOID for corners

fillFromWorld(..., Blocks.STRUCTURE_VOID) strips void positions from the captured template. The editor's fillCornersWithVoid pass marks the triangular wedges above the walls (outside the arch silhouette) with void only at positions currently air β€” user edits in a corner override the void and are captured normally. Result: the saved NBT only covers the arch profile itself, and stamping leaves mountain rock in the corners alone. Interior air inside the arch is captured, so the stamp still carves the walkable volume when placed underground.

Why a ship filter (editor path only since v0.85.0)

StructureTemplate.placeInWorld calls level.setBlock directly, which physics mixins route to the ship's voxel storage when the position lands inside an active ship. The editor path's placeSectionNamed / placePortalNamed (ServerLevel-bound, runs from /dungeontrain editor save) registers ShipFilterProcessor on its StructurePlaceSettings to drop any template block whose target world position is currently ship-managed. Without it, a stamp at the train's current chunk would wipe carriage wall/floor voxels.

The worldgen path's placeSectionAtWorldgen / placePortalAtWorldgen (WorldGenLevel-bound) skip this filter β€” no ships exist at chunk-gen time, so the guard would always be a no-op and would force Shipyards lookup which is awkward through WorldGenLevel.

Fallback chain

Both TunnelTemplate.placeSectionAt and TunnelTemplate.placePortalAt try TunnelTemplateStore.get first; on miss they call into LegacyTunnelPaint.paintSection / paintPortal, which run the v0.23.0 procedural code path column-by-column. Missing or malformed NBT never leaves a hole β€” you get the default stone-brick tunnel unchanged.

Approach cutting β€” 20-block open trench (v0.29.0–v0.84.x)

Historical β€” the 20-block open trench at tunnel entrance/exit was wired into the runtime drain that v0.85.0 removed. LegacyTunnelPaint.paintApproachColumn is still in the code but is currently unreachable from the worldgen path. With portals also pulled out at v0.85.0 (see the Per-chunk per-column scan section), the approach trench has no runtime equivalent yet. Future work to re-introduce portal placement at worldgen-time should bring back the approach trench at the same time.

Sea-lantern wall lamps

Baked into the default tunnel_section model β€” every 10-block section has a lantern pair at y = railY + 2 (walking eye height) on both wallMinZ and wallMaxZ at its leftmost column. Stamping sections end-to-end gives the original "lantern every 10 world blocks" cadence automatically. Players who save a custom section template without lanterns lose them; players who add extra lights at other Y/Z positions get those too. Portal and approach cutting get no lamps β€” tunnel section only.

Underground-material qualification

TunnelPalette.isUndergroundMaterial(BlockState) accepts:

  • Stone family β€” BlockTags.BASE_STONE_OVERWORLD (stone, granite, diorite, andesite, deepslate variants), DEEPSLATE_ORE_REPLACEABLES, STONE_ORE_REPLACEABLES
  • Dirt family β€” BlockTags.DIRT (dirt, coarse dirt, rooted dirt, podzol, grass-block subsoil)
  • Common overworld fill β€” minecraft:tuff, minecraft:gravel, minecraft:clay, minecraft:sandstone, minecraft:red_sandstone
  • Clay-based biomes (via isClayFamilyMaterial helper) β€” minecraft:terracotta + all 16 colored variants (badlands layers), minecraft:red_sand, minecraft:mud, minecraft:packed_mud (mangrove swamps)
  • Ores β€” coal / iron / copper / gold / redstone / lapis / diamond / emerald tags

Any air, fluid state, leaves, wood, plants, or un-recognised block disqualifies. Modded biomes with custom surface blocks aren't covered β€” a future dungeontrain:underground_material tag will let datapacks inject their own.

Chunk scheduling (v0.85.0 β€” worldgen-only)

Removed: TunnelChunkEvents, the per-tick FIFO drain on TrainTickEvents, and the pendingTunnelChunks / tunnelFilledChunks sets on TrainTransformProvider. Tunnel placement now runs once during chunk generation, inside TrackBedFeature.place after pillars + tracks:

TrackBedFeature.place(ctx)
β”œβ”€β”€ TrackGenerator.placePillarsAtWorldgen(...)
β”œβ”€β”€ TrackGenerator.placeTracksForChunk(...)
└── TunnelGenerator.placeTunnelSpaceAtWorldgen(...)   ← per-chunk per-column scan

The feature is wired by datapack: data/dungeontrain/worldgen/configured_feature/track_bed.json β†’ placed_feature/track_bed.json β†’ biome modifier JSONs (overworld / nether / end). Only the modifier matching the world's chosen starting dimension fires.

Cost is paid once per chunk at chunk-gen time. Already-generated chunks pre-v0.85.0 keep whatever their old runtime drain placed; new chunks use the new path. Eliminates the runtime "Can't keep up!" stalls and ConcurrentModificationException save-busy race that the v0.83.0 mitigations chased.

Config

Per-world server config: <save>/serverconfig/dungeontrain-server.toml β†’ generateTunnels = true|false (default true). Sibling to the existing generateTracks. Turning off stops new tunnel generation; already-placed tunnels stay in the world.

Files

  • TunnelGenerator.java β€” placeTunnelSpaceAtWorldgen (per-chunk per-column scan), isColumnUndergroundWorldgen (default + extended qualifier), TUNNELED_CHUNKS neighbour-inheritance set
  • TunnelTemplate.java β€” placeSectionAtWorldgen / placePortalAtWorldgen (WorldGenLevel-bound stamping); editor-side placeSectionNamed / placePortalNamed (ServerLevel-bound, runs from /dungeontrain editor save)
  • TrackBedFeature.java β€” chunk-gen entry point that calls placePillarsAtWorldgen β†’ placeTracksForChunk β†’ placeTunnelSpaceAtWorldgen in order
  • TunnelTemplateStore.java β€” NBT read/write cache under config/dungeontrain/tunnels/
  • TunnelEditor.java β€” editor plots, enter / save / exit / plotContaining
  • LegacyTunnelPaint.java β€” the v0.23.0 per-column paint, now the fallback path (NBT miss / wide-corridor) + editor preview painter
  • TunnelGeometry.java β€” derived layout record (floorY, ceilingY, airMinZ/MaxZ, wallMinZ/MaxZ, centerZ, railY/ZMin/ZMax)
  • TunnelPalette.java β€” block-state constants + isUndergroundMaterial + isClayFamilyMaterial
  • EditorCommand.java β€” /dungeontrain editor subtree; routes tunnel_ prefix to TunnelEditor
  • DungeonTrainConfig.java β€” generateTunnels toggle, MIN_CARRIAGES = 0 debug switch

Removed in v0.85.0: TunnelChunkEvents (deleted entirely), TrainTickEvents tunnel-fill block, TrainTransformProvider.tunnelFilledChunks / pendingTunnelChunks sets and getters, TunnelTemplate.placeSectionAt / placePortalAt (the ServerLevel-bound runtime variants β€” editor still has placeSectionNamed / placePortalNamed).

Probe-and-confirm worldgen path (v0.83.0 β†’ superseded in v0.85.0)

Historical, since v0.83.0 β€” PR #119; fully replaced by v0.85.0 β€” PR #123.

PR #119 introduced a hybrid where chunk-gen probed cheaply and the runtime drain confirmed by painting. PR #123 collapsed both halves into a single chunk-gen pass β€” there is no longer a runtime confirm step or a pendingTunnelChunks queue. See Per-chunk per-column scan above for the current path.

Known concerns / follow-ups

  • ConcurrentModificationException on chunk save under heavy paint load β€” resolved in v0.85.0. The runtime drain that raced with chunk save NBT IO is gone. Tunnel placement happens once during chunk generation, before the chunk is in the save pipeline.
  • "Can't keep up!" server-thread stalls β€” resolved in v0.85.0. The runtime FIFO drain that produced multi-second TPS spikes on fresh train spawn over heavy-rock terrain is gone. Cost is paid at chunk-gen time, amortized into vanilla's normal worldgen budget.
  • Cross-chunk section misalignment for runs > 56 cols β€” partially resolved in v0.85.0. The 56-col qualified-window scan is gone. Cross-chunk alignment is now per-chunk relative β€” each chunk starts its tile chain at its own leftmost qualified column. Long-tunnel seam at chunk boundaries is still visible (accepted trade-off vs. legacy gaps with absolute alignment).
  • TUNNELED_CHUNKS is in-memory only. Server restart drops the flag, so chunks generated post-restart at a corridor boundary may not inherit ext=true from their already-tunneled neighbour. Visible artifact: a small seam where a tunnel doesn't extend through a soft spot it would have crossed pre-restart.
  • Portals not yet stamped at worldgen-time. v0.29.0–v0.84.x's run-detection-driven portal placement was removed with the run system. TunnelTemplate.placePortalAtWorldgen exists but is not yet called from the chunk-gen path; portal templates remain authorable in the editor.
  • Modded biomes unsupported. Only vanilla surface blocks qualify. A modded "crystal caverns" biome with custom stone would need its blocks added to TunnelPalette or (future) a dungeontrain:underground_material block tag.
⚠️ **GitHub.com Fallback** ⚠️