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

Auto-generating static tracks beneath the train

Since: v0.20.0 โ€” PR #26 โ€” initial 5-wide stone-brick deck + uniform-spaced pillars v0.22.0 โ€” PR #28 โ€” height-scaled pillar spacing and thickness; each column in a thick pillar descends to its own terrain v0.26.0 โ€” PR #35 โ€” bed / rail / pillar placement is silent: no container spill when the generator replaces block-entities from natural structures v0.30.0 โ€” PR #40 โ€” bridge pillars split into three editable StructureTemplate sections (top cap / middle slab / bottom base) authored via /dungeontrain editor pillar enter|save; each section is 1 ร— H ร— dims.width() so the full track-width front face is designable v0.32.0 โ€” PR #42 โ€” pillar NBTs moved into their own config/dungeontrain/pillars/ directory (and /data/dungeontrain/pillars/ inside the jar) so the carriage-variant registry's *.nbt scan can't register them as custom carriages and leave holes in the train. Legacy pillar_*.nbt under config/dungeontrain/templates/ are migrated automatically on first boot v0.33.0 โ€” PR #43 โ€” bed + rail rows become an editable 4ร—2ร—W StructureTemplate tile that tiles every 4 blocks along the train axis, authored via /dungeontrain editor track enter|save. Same feature also clamps TrainTickEvents.clearBlocksAhead to the carriage floor so VS-AABB float precision can no longer floor minY down to railY and destroy the authored rail layer ahead of the train v0.38.0 โ€” PR #49 โ€” new stair adjunct template (3ร—8ร—3) spawns alongside roughly every 40 blocks of track, alternating +Z / โˆ’Z sides via Mirror.LEFT_RIGHT; authored via /dungeontrain editor pillar enter stairs. Distinct from PillarSection โ€” adjuncts decorate the pillar rather than being part of its vertical slab v0.38.1 โ€” PR #50 โ€” bundled default adjunct_stairs.nbt ships with the mod, so stair adjuncts render on fresh installs without any /editor pillar save bootstrap. Generated offline by AdjunctStairsBundledGenTool (a gated JUnit test) to match PillarEditor.stampProceduralStairsFallback v0.46.0 โ€” PR #60 โ€” track-side template variants: track tile, all 3 pillar sections, stairs adjunct, and both tunnel kinds now support multiple named NBTs per kind (weighted random pick per instance) and per-block randomization sidecars (*.variants.json schema-v1, shared with the carriage path). Editor plots laid out on a unified 5-block-gap grid โ€” track-side row at Z=74, variants along +Z per kind, pillar parts and tunnel kinds stacked on +Y. New /dungeontrain editor tracks new <kind> <name> clones the variant the player is standing on; /dungeontrain editor tracks reset <kind> deletes the current variant. Legacy paths (pillars/pillar_*.nbt, pillars/adjunct_stairs.nbt, tunnels/section.nbt, tunnels/portal.nbt, tracks/track.nbt) auto-migrate to <kind>/default.nbt on first server start post-v0.74.0 โ€” PR #105 โ€” pillars and stair adjuncts now skip placement when their entire footprint is over pure void (every column passes through air all the way to the world floor). Most visible in the Dungeon Train (End) start, where the End is mostly void โ€” pillars used to anchor at the world floor and hang up through empty space. Both call sites now ignore probeGroundY's "no ground found" return (minBuildHeight + 1); columns that hit existing terrain anywhere on the way down still anchor normally, so the rule is "skip when no block exists below" not "skip when shallow ground" v0.75.0 โ€” PR #110 โ€” bed + rails are now placed during chunk generation by a Forge worldgen Feature (TrackBedFeature), wired through three biome modifiers (overworld / nether / end). Tracks become a property of the chunk save with zero ongoing tick cost โ€” no more drain backlog when a player flies past spawn into freshly-generated chunks. Pillars + stair adjuncts stay on the legacy post-load drain because they need cross-chunk heightmap reads (ยฑ40 X) that aren't safe inside per-chunk worldgen. Also: TrackGenerator.bootstrapPendingChunks now probes the bed-block at corridor centre and skips chunks the Feature already painted, sparing the heavy pillar precompute drain at spawn (was wedging "Joining World" behind ~13 s of post-load work) v0.305.0 โ€” PR #399 โ€” arch taper: tall pillars (height โ‰ฅ 5) fade toward their neighbours with a stepped corbel hanging from the pillar top โ€” 3ยท2ยท1ยท1 blocks per side, 5ยท3ยท2ยท1ยท1ยท1 at height โ‰ฅ 10 โ€” built from the pillar's own top/middle blocks extruded along the train axis. Worldgen-only (placePillarsAtWorldgen); see Arch taper between pillars

A 5-wide stone-brick bridge deck with two parallel east-west rails auto-generates in world space directly below every Dungeon Train, with stone-brick pillars dropping to solid terrain wherever the bed floats over air, water, or forest canopy. Short pillars sit on the legacy 8-block grid; tall pillars spread out and thicken along the train axis โ€” see Height-scaled pillars. The bed extends to the server's view distance in both X directions and fills in as new chunks load. Once placed the blocks are plain world state, so they stay behind the train as it rolls past โ€” fly back and the full bridge is still there.

Layout

Anchored off the train carriage footprint (9ร—5ร—4), captured once at spawn:

Layer World Y World Z Block
Bed / bridge deck origin.y โˆ’ 2 full 5-wide carriage Z range minecraft:stone_bricks
Rails (ร—2) origin.y โˆ’ 1 trackZMin + 1, trackZMax โˆ’ 1 (2-block gauge) minecraft:rail with SHAPE = east_west
Pillars descend from origin.y โˆ’ 2 to first motion-blocking terrain (per column) every X row of the bed; spacing and thickness adapt to pillar height (see below) minecraft:stone_bricks

Both bed and rails sit below the ship's world AABB. From v0.33.0 the per-tick forward-clear pass in TrainTickEvents.clearBlocksAhead also clamps its lower Y bound to the carriage floor (bedY + 2 = origin.y) via TrainTransformProvider.getTrackGeometry() โ€” so VS's world-AABB reporting minY a sub-pixel below origin.y can no longer floor down to railY and destroy the authored rail layer 8 blocks ahead.

Pillar descent โ€” why not a heightmap

Pillars scan downward manually from the bed, passing through isAir, any fluid state (so pillars reach the seafloor through ocean water), leaves, and vines; stopping at the first truly motion-blocking block. Every MC heightmap fails at least one biome:

  • MOTION_BLOCKING_NO_LEAVES counts water surface as "ground" โ†’ pillars terminate 13 blocks above the seafloor in oceans.
  • OCEAN_FLOOR counts leaves as ground โ†’ pillars sit on tree canopies in forests.

A single downward scan with the correct passable predicate handles both. "Bed sits on solid ground" is covered implicitly: first iteration finds non-passable terrain, returns without placing โ€” no separate guard needed.

Height-scaled pillars (v0.22.0)

Before v0.22.0 every pillar was 1 block thick on every base-grid X โ€” fine over flat ground, spindly over ravines. v0.22.0 keeps the legacy shape for short pillars and scales spacing + thickness with pillar height for tall ones, so deep terrain reads as a proper viaduct instead of a row of toothpicks.

Spacing (X between adjacent pillars):

Pillar height Spacing
< 5 blocks 8 (base grid โ€” unchanged from v0.20.0)
โ‰ฅ 5 blocks min(40, height + 8)

Examples: height 4 โ†’ 8, height 5 โ†’ 13, height 10 โ†’ 18, height 20 โ†’ 28. Clamped at 40 to match the per-chunk precompute scan margin.

Thickness (X blocks occupied by a single pillar):

Spacing Thickness
8 1
13 2
18 3
24 4
30 5

Formula: thickness = max(1, spacing / 6). Thickness grows along the X axis (train direction); Z stays the full 5-block corridor. Even-thickness pillars bias one block to the right of the centerline; odd-thickness pillars are symmetric around it.

Per-column terrain descent. Each column in a thick pillar's footprint descends independently to its own ground. On sloped or uneven terrain the whole footprint follows the slope โ€” no more corner columns floating above the real ground. Height input to the spacing/thickness formulas is still the center-column probe, so the "tall pillar โ†’ wider spacing" rule stays deterministic per anchor X.

How placement decisions are made. TrackGenerator.ensureTracksForChunk precomputes a NavigableMap<centerX, PillarSpec> over [chunkMinX โˆ’ 40, chunkMaxX + 40] โ€” probing ground depth at every X in the expanded range and emitting a pillar entry wherever worldX % spacing(height) == 0. Column rendering then does O(log n) containment lookups against the map to decide whether a given (X, Z) is inside a pillar's footprint. The ยฑ40-block margin is essential โ€” it ensures thick pillars whose center sits just outside the current chunk still paint into this chunk.

Arches between tall pillars are now implemented as a top-anchored taper โ€” see Arch taper between pillars below.

Arch taper between pillars (v0.305.0)

Tall pillars no longer stand as bare posts over a chasm โ€” each one fades toward its neighbours with a stepped corbel that hangs from the pillar top (bedY โˆ’ 1) and steps up into the gap, so a span between two pillars reads as the springing of an arch.

The per-side step profile is chosen by the pillar's gating height (the centre-column probe):

Pillar height Taper columns (block counts, stepping outward from each side)
< 5 none
5โ€“9 3, 2, 1, 1
โ‰ฅ 10 5, 3, 2, 1, 1, 1 (an extra column by the pillar + one reaching further into the gap)

Each taper column copies the pillar's own face extruded along the train (X) axis โ€” it reads the same top / middle section paint (and per-block variant) the slab uses, keyed by the same pillar centre, so a themed or variant pillar arches in its own material rather than plain stone brick. Columns hang from the cap downward and are truncated to the step height; an authored air cell in the pillar face stays as a gap in the arch too.

Placement is worldgen-only โ€” TrackGenerator.placeArchTaperWorldgen, called from placePillarsAtWorldgen immediately after each pillar slab, so the slab wins on overlap. The taper writes only into passable cells, so where two pillars' tapers overlap โ€” or where gap terrain rises into them โ€” the result is the per-column maximum (the arch embeds into a hill instead of carving it). A taper never reaches the adjacent pillar (spacing is always โ‰ฅ 8, which exceeds the taper's reach), so each is stamped exactly once by its own pillar's chunk and affects only newly generated chunks.

Editable pillar section templates (v0.30.0)

Each pillar column is stamped from three in-world-editable StructureTemplate sections โ€” the decorative cap below the bed, the repeating middle slab, and the base that sits on the ground โ€” authored via the same editor plumbing that backs the carriage variants. Customise the full track-width front face once; the generator replays it under every pillar in every chunk.

Sections

Section Height (Y blocks) Width (Z blocks) Role
top 4 dims.width() (default 7) Decorative cap โ€” top row sits at bedY โˆ’ 1, just under the bridge deck
middle 1 dims.width() Repeats N times to fill the middle of a pillar; N scales with column height
bottom 3 dims.width() Base that sits on the ground; bottom row lands on groundY

All three share the same 1 ร— H ร— dims.width() footprint โ€” thin on the train axis (X = 1), full track-corridor span on Z. A thick pillar (multi-X) stamps the same template at every X column in its footprint; the Z axis carries the editable design.

For a column of total height H:

  • H โ‰ฅ 7 โ€” bottom(3) + middle ร— (H โˆ’ 7) + top(4), stacked from the ground up.
  • H < 7 โ€” short-pillar rule: the bottom template is placed first up to 3 rows, and whatever space remains (0โ€“3 rows) is filled with the top template's highest rows โ€” its lowest rows are truncated so the decorative cap stays at the column's top.

Slope rule

Each Z column in the track span is probed independently for its own ground; the slice anchors at the minimum (deepest) of the probes. Shallower Z columns have their lower rows simply skip the template (terrain is already solid there โ€” the non-passable check wins), so the pillar tucks into one-sided slopes naturally and the cap stays horizontally aligned across the full W-block face.

Usage

/dungeontrain editor pillar enter <top|middle|bottom>   # teleport to the section's plot
/dungeontrain editor pillar save                         # capture plot footprint as NBT
/dungeontrain editor exit                                # return to pre-enter position (shared with carriage editor)
/dungeontrain editor pillar list                         # show per-section tier status (config / bundled / fallback)
/dungeontrain editor pillar reset <top|middle|bottom>    # delete the config override
/dungeontrain editor pillar promote <top|middle|bottom>  # copy config โ†’ source tree (dev checkout only)
/dungeontrain editor pillar promote all                  # same for every section with a saved config copy
  • Requires permission level 2 (same as spawn / speed).
  • The shared /dungeontrain editor exit restores the player's pre-enter position whether they entered via a carriage or pillar plot.
  • /dungeontrain editor devmode on|off is global โ€” toggling it on makes pillar save also write the NBT into src/main/resources/... alongside carriage templates.

Editor plots

Pillar plots sit alongside the carriage row at y = 250 but offset to z = 40 so even a max-width (32-block) carriage row at z = 0 stays clear:

Section Plot origin Footprint
top (0, 250, 40) 1 ร— 4 ร— dims.width()
middle (20, 250, 40) 1 ร— 1 ร— dims.width()
bottom (40, 250, 40) 1 ร— 3 ร— dims.width()

On enter the mod clears the footprint, pre-populates it with the section's current content (saved template if present, else stone brick across the full width), wraps it in a bedrock cage (always visible; unbreakable in survival โ€” v0.40.0), and teleports the player to the plot center. Anything you place or break inside the footprint becomes part of the saved template on save.

Storage โ€” three-tier loader

Same mechanism as the carriage editor โ€” see Feature-Carriage-Editor ยง Storage for the full pattern.

Tier Location Written by
1 โ€” per-install override config/dungeontrain/pillars/pillar_<section>.nbt (v0.32.0+) /dungeontrain editor pillar save
2 โ€” bundled mod default /data/dungeontrain/pillars/pillar_<section>.nbt inside the jar (v0.32.0+) /editor pillar save while devmode on, or /editor pillar promote
3 โ€” hardcoded fallback TrackPalette.PILLAR (stone brick) per row Implicit โ€” every row without a resolved template gets plain stone brick

v0.32.0 directory move. Pillar templates previously lived under config/dungeontrain/templates/ alongside the carriage NBTs. The carriage-variant registry's on-disk scan misread pillar basenames as custom carriage variants, and their 1 ร— H ร— dims.width() footprints failed the carriage dims check โ€” producing occasional empty carriages in the train. PillarTemplateStore.migrateFromLegacyDirectory() runs at every CarriageVariantRegistry.reload() (server start) and moves any legacy pillar_*.nbt into the new location before the scan. Safe to run repeatedly โ€” no-op on subsequent boots.

Tier filter: if the loaded template's recorded Z size doesn't match the current world's dims.width(), it's rejected with a log warning and the next tier is tried. Saves a 7-wide template from a default world, then loads the same install on a 5-wide custom-dims world โ†’ the 7-wide file is ignored and the fallback stone brick paints instead.

Cache invalidation: the in-memory cache drops on ServerStoppedEvent, same as the carriage cache (WorldLifecycleEvents.onServerStopped). The BlockState[][] unpacking uses reflection to reach the private StructureTemplate.palettes field โ€” no access transformer is shipped, and the reflection lookup is fail-soft: on any reflection error the logger emits an ERROR and every row falls back to stone brick.

Designing cut-outs

Breaking a block inside the plot leaves AIR at that cell; on save the template captures the air explicitly (the editor uses fillFromWorld(..., ignoredBlock = null) so nothing is skipped). When the pillar is painted, those air cells are left as passable air โ€” you can design deliberate holes or hollow-centre pillars.

Authoring pillars that ship with the mod

Same flow as the carriage editor โ€” see Feature-Carriage-Editor ยง Authoring. Quick version:

/dungeontrain editor devmode on
/dungeontrain editor pillar enter top
... build ...
/dungeontrain editor pillar save   # writes config-dir + src/main/resources/
git add src/main/resources/data/dungeontrain/pillars/pillar_*.nbt   # v0.32.0+ path

v0.30.0 shipped bundled defaults for all three sections authored this way; v0.32.0 relocated the bundled copy to its own pillars/ subdirectory.

Editable track tile template (v0.33.0)

The bed + rail rows are stamped from an in-world-editable StructureTemplate tile that repeats every 4 blocks along the train axis. Authoring uses the same editor plumbing as the pillar sections โ€” same /editor devmode, same three-tier storage, same bedrock-cage plot. Design the rails, sleepers, decorative bed strips once; the generator replays the tile at every worldX mod 4.

Tile shape

Dim Value Role
X TrackTemplate.TILE_LENGTH = 4 Blocks along the train axis before the tile repeats. Per-column lookup is cells[worldX mod 4][y][zOff], so the tile tiles without seams.
Y HEIGHT = 2 y = 0 at bedY, y = 1 at railY (= bedY + 1).
Z dims.width() (default 7) Full track corridor, same convention as the pillar template.

Air cells in the saved template are skipped (left passable) at paint time โ€” a missing cell means "leave the terrain alone", useful for viaduct gaps, mid-tile holes, or partial-width decking. Non-air cells are placed with SilentBlockOps.setBlockSilent (flag UPDATE_CLIENTS only), idempotent against re-scans and ship-owned voxels.

Usage

/dungeontrain editor track enter       # teleport to the 4x2xW plot
/dungeontrain editor track save         # capture plot footprint as NBT
/dungeontrain editor exit               # return to pre-enter position
/dungeontrain editor track list         # show tier status (config / bundled / fallback)
/dungeontrain editor track reset        # delete the config override
/dungeontrain editor track promote      # copy config โ†’ source tree (dev checkout only)
  • Requires permission level 2 (same as spawn / speed).
  • /dungeontrain editor devmode on|off is global โ€” toggling it on makes track save also write the NBT into src/main/resources/data/dungeontrain/tracks/track.nbt alongside the other editor templates.
  • /dungeontrain editor exit restores your pre-enter position regardless of which editor plot you entered.

Editor plot

Track plot sits at (0, 250, 60) โ€” z = 60 is past the pillar row at z = 40 (which extends to z โ‰ˆ 47 at default carriage width), so all three editor rows are visible from a single vantage point.

Plot origin Footprint
(0, 250, 60) 4 ร— 2 ร— dims.width()

On enter the mod clears the 4ร—2ร—W footprint, stamps the current saved template (or the hardcoded fallback โ€” TrackPalette.BED at every Z on y = 0, TrackPalette.RAIL at z = 1 and z = W โˆ’ 2 on y = 1), wraps it in a bedrock cage, and teleports the player to the plot center. Anything placed or removed inside the footprint becomes part of the saved template on save.

Storage โ€” three-tier loader

Same mechanism as the carriage editor โ€” see Feature-Carriage-Editor ยง Storage.

Tier Location Written by
1 โ€” per-install override config/dungeontrain/tracks/track.nbt /dungeontrain editor track save
2 โ€” bundled mod default /data/dungeontrain/tracks/track.nbt inside the jar /editor track save while devmode on, or /editor track promote
3 โ€” hardcoded fallback TrackPalette.BED at every Z on y = 0, TrackPalette.RAIL at trackZMin + 1 and trackZMax โˆ’ 1 on y = 1 Implicit โ€” worlds with no template render identical to pre-v0.33.0 behavior

Separate config directory from carriages (config/dungeontrain/templates/) and pillars (config/dungeontrain/pillars/) so the carriage-variant registry's *.nbt scan can't misregister the 4ร—2ร—W track tile as a custom carriage. Mirrors the v0.32.0 pillar directory move.

Tier filter: if the loaded template's recorded Z size doesn't match the current world's dims.width(), it's rejected with a log warning and the next tier is tried. A 7-wide template saved in a default world is silently ignored when loaded on a 5-wide custom-dims world; the fallback bed + rails paint instead.

Cache invalidation: both the parsed StructureTemplate and the unpacked BlockState[4][2][W] cells array drop on ServerStoppedEvent via WorldLifecycleEvents.onServerStopped. The cells array uses reflection to reach StructureTemplate.palettes (no access transformer shipped) โ€” fail-soft: on any reflection error the logger emits an ERROR and every column falls back to the hardcoded bed + rails.

clearBlocksAhead floor clamp

The same release adds a safety clamp to TrainTickEvents.clearBlocksAhead: the lower Y bound of the 8-block forward-clear sweep is now max(minY, bedY + 2) via TrainTransformProvider.getTrackGeometry(). Without the clamp, VS's worldAABB.minY() can report one sub-pixel below origin.y (the carriage floor), which Math.floor would round down to railY and destroy the authored rail layer within the look-ahead. Vanilla rails hid this bug because they're sparse (two Z rows) and visually similar to clean terrain; a dense authored tile makes the missing layer obvious. The clamp protects both the rail row (y = railY) and the bed row (y = bedY) regardless of AABB precision.

Files

Stair adjuncts alongside pillars (v0.38.0)

A fourth editable template โ€” PillarAdjunct.STAIRS โ€” spawns a 3ร—8ร—3 prism alongside the pillar, outside the track corridor, with its top row anchored to bedY + 2 so the staircase cap sits one block above the rail. Stairs alternate sides every ~40 blocks of track, so a player walking forward sees a staircase on the right, then a plain pillar stretch, then a staircase on the left, and so on.

Adjuncts are deliberately separate from PillarSection: sections describe the stacking of the pillar column itself (top + repeating middle + bottom, 1ร—Hร—W), while adjuncts decorate a pillar with an arbitrary 3-axis footprint outside the corridor. The same three-tier NBT store backs both โ€” same /editor pillar ... subtree, same config/dungeontrain/pillars/ directory, distinguished on disk by an adjunct_ filename prefix vs. the pillar_ prefix used for sections.

Footprint

Axis Size World placement
X (along train axis) 3 Centered on pillar.centerX() โ†’ [centerX โˆ’ 1 .. centerX + 1]
Y (vertical) 8 Top row at bedY + 2, descends to bedY โˆ’ 5 on flat ground; clipped against terrain otherwise
Z (outward from track) 3 +Z side: [trackZMax + 1 .. trackZMax + 3] / โˆ’Z side: [trackZMin โˆ’ 3 .. trackZMin โˆ’ 1]

Side selection and mirroring

TrackGenerator.placeStairsBesidePillar runs once per pillar (gated on worldX == centerX inside the per-chunk fill loop). It picks:

  • Which pillars get stairs โ€” "first pillar in each 40-block window," with a distance check against the previous window's first pillar so two stairs can never land closer than MIN_STAIRS_SPACING = 40. On flat terrain with 8-block pillar spacing this produces exactly centerX = 0, 40, 80, 120, โ€ฆ; on variable terrain the window rule adapts so stairs don't disappear entirely when pillar spacing drifts off 40-multiples.
  • Which side โ€” alternates based on floorMod(floorDiv(centerX, MIN_STAIRS_SPACING), 2). Derived purely from the window index, so placement is stable across chunk-load order.

The saved template is authored in its โˆ’Z-side orientation. When stamped on the +Z side (non-flipped), the code applies Mirror.LEFT_RIGHT and shifts the stamp origin by +(STAIRS_Z โˆ’ 1) = +2 blocks. Without the shift, the mirrored template would extend in โˆ’Z from the origin, landing 2 columns inside the track. The shift is the same trick TunnelTemplate uses for its FRONT_BACK-mirrored exit portals.

Top-anchored vertical duplication

For a column whose ground sits far below bedY, the 8-tall template is duplicated downward as many full copies as fit. The loop walks from currentTop = bedY + 2 and places one copy at [currentTop โˆ’ 7 .. currentTop] per iteration, decrementing by 8 each pass. The last (bottom-most) copy is clipped via a BoundingBox on the StructurePlaceSettings so only its top rows render โ€” the template's bottom rows that would fall below ground are discarded. The invariant: the top of the template always lines up with the top of that copy, so the decorative landing at the top of every duplicate stays coherent even when a partial bottom copy is truncated.

Usage

/dungeontrain editor pillar enter stairs    # teleport to the 3ร—8ร—3 plot at (60, 250, 40)
/dungeontrain editor pillar save             # capture plot footprint as adjunct_stairs.nbt
/dungeontrain editor pillar list             # now reports pillar sections AND adjuncts
/dungeontrain editor pillar reset stairs     # delete the config override
/dungeontrain editor pillar promote stairs   # copy config โ†’ source tree (dev checkout only)

The same pillar subtree now accepts top | middle | bottom | stairs as a unified target list โ€” PILLAR_TARGET_SUGGESTIONS feeds autocomplete for all four.

Editor plot

Adjunct Plot origin Footprint
stairs (60, 250, 40) 3 ร— 8 ร— 3

Adjunct plots sit on the pillar row (z = 40) but step past the existing section plots (X=0, 20, 40) to X = 60. On enter the mod clears the footprint, stamps the saved template if present, and falls back to a procedurally-generated stone-brick staircase (one step per Y level, ascending in +Z) so the author has a sensible starting point to edit. The bedrock cage wraps the 3ร—8ร—3 footprint the same way the section plots do.

Storage โ€” three-tier loader

Same three-tier pattern as pillar sections and everywhere else. The adjunct cache lives in a separate EnumMap<PillarAdjunct, Optional<StructureTemplate>> inside PillarTemplateStore โ€” dropped on ServerStoppedEvent alongside the section cache.

Tier Location Written by
1 โ€” per-install override config/dungeontrain/pillars/adjunct_<id>.nbt /dungeontrain editor pillar save while standing in an adjunct plot
2 โ€” bundled mod default /data/dungeontrain/pillars/adjunct_<id>.nbt inside the jar /editor pillar save while devmode on, or /editor pillar promote <id>. stairs ships with a bundled default in v0.38.1+ via AdjunctStairsBundledGenTool
3 โ€” no-op fallback none If no template is resolved, the adjunct simply doesn't paint โ€” unlike pillar sections, there's no hardcoded stone-brick fallback because adjuncts are decorative (missing them leaves the pillar looking pre-v0.38 rather than broken). Tier 2 now fills for stairs out-of-the-box, so this tier only fires if the player explicitly resets the bundled copy without promoting a replacement

The loader rejects any template whose recorded size doesn't match the adjunct's declared xSize ร— ySize ร— zSize (3ร—8ร—3 for STAIRS) โ€” same defensive check the section loader uses against dims.width() changes. An out-of-size template logs a warning and the next tier is tried.

VS compatibility

placeStairsBesidePillar routes its StructureTemplate.placeInWorld call through VSShipFilterProcessor.INSTANCE โ€” the same processor the tunnel stamps use โ€” so any position currently owned by a VS ship is dropped from the stamp. The placeInWorld path doesn't go through SilentBlockOps.setBlockSilent; instead the processor pre-filters ship positions, and vanilla's placeInWorld writes with UPDATE_CLIENTS flags that don't cascade into neighbour-notification storms.

Files

  • PillarAdjunct.java โ€” enum with STAIRS(3, 8, 3); one extension point for future adjuncts (railings, lanterns)
  • PillarTemplateStore.java โ€” parallel adjunct cache + getAdjunct / saveAdjunct / promoteAdjunct / deleteAdjunct methods alongside the section ones
  • PillarEditor.java โ€” plotOriginAdjunct, plotContainingAdjunct, enter(player, adjunct), save(player, adjunct), procedural stair fallback stamp
  • TrackGenerator.java โ€” placeStairsBesidePillar, MIN_STAIRS_SPACING = 40, first-pillar-in-window + distance-check selection, Mirror.LEFT_RIGHT shift, top-anchored duplication
  • EditorCommand.java โ€” PILLAR_TARGET_SUGGESTIONS (sections + adjuncts), unified tryParseSection / tryParseAdjunct dispatch in enter / save / reset / promote
  • src/main/resources/data/dungeontrain/pillars/adjunct_stairs.nbt โ€” bundled default stair template (v0.38.1+; matches the procedural fallback visually)
  • AdjunctStairsBundledGenTool.java โ€” offline generator for the bundled NBT + always-on round-trip integrity check. Regenerate with ./gradlew test --tests 'games.brennan.dungeontrain.editor.AdjunctStairsBundledGenTool.generate' -Dgenerate-adjunct-stairs=true

Known limitations

  • Occasional long gaps. When the previous window's first pillar is itself too close to its prior window's first, the distance check rejects the current pillar; the next window retries. In the worst case this produces a ~80-block gap instead of 40. Acceptable โ€” the constraint is a minimum, not a fixed spacing.
  • Sparse on deep-ravine runs. If pillar spacing climbs past 40 (ravines taller than 32 blocks), whole 40-block windows can end up empty, stretching visible stair intervals to 80+ blocks. The track is still navigable; the first pillar that lands in a populated window gets stairs again.
  • Adjunct plot does not integrate with the v0.36.0 editor categories yet. /dt editor tracks scrolls through carriage โ†’ track โ†’ pillar sections โ†’ tunnels; the stairs plot is reachable only via the explicit /dungeontrain editor pillar enter stairs path. Follow-up item.

Worldgen Feature (v0.75.0)

Bed + rails are placed during chunk generation, not on ChunkEvent.Load. Painted blocks become part of the chunk save โ€” no per-tick drain, no view-distance race. Pillars + stair adjuncts continue to use the post-load drain described in the next section because they read terrain heightmap ยฑ40 blocks across chunk borders, which is unreliable inside per-chunk worldgen.

Pipeline

Stage File What runs
Mod load DungeonTrain.java โ†’ ModFeatures.register(modBus) DeferredRegister<Feature<?>> exposes dungeontrain:track_bed to the Forge feature registry
World load data/dungeontrain/forge/biome_modifier/track_bed_{overworld,nether,end}.json Three forge:add_features modifiers attach the placed feature to all biomes tagged is_overworld / is_nether / is_end at the top_layer_modification step. Only the modifier matching the world's chosen StartingDimension actually paints โ€” chunks generate against their dimension's biome registry
Chunk gen TrackBedFeature.place(ctx) Two-int-comparison prefilter (cz โˆˆ [0, MAX_WIDTH/16] AND cx >= 0) โ†’ resolve DungeonTrainWorldData from the overworld โ†’ derive TrackGeometry.from(dims, trainY) โ†’ call TrackGenerator.placeTracksForChunk
Block writes TrackGenerator.placeTracksForChunk(WorldGenLevel, ServerLevel, CarriageDims, โ€ฆ) Same TrackVariantRegistry.pickName + TrackTemplateStore.getCellsFor + TrackVariantBlocks sidecar pipeline as the live painter โ€” authored tile variants render identically whether placed at gen or post-load. Falls back to the default TrackPalette when no template matches the world's dims

Prefilter

The fast-reject is purely arithmetic. Train spawns at world (0, trainY, 0) and only moves +X (TrainBootstrapEvents.TRAIN_VELOCITY has Y = Z = 0; TrainChainManager only spawns successors AHEAD), so:

  • cz >= 0 && cz <= MAX_WIDTH/16 โ€” the corridor occupies at most โŒˆMAX_WIDTH / 16โŒ‰ chunks on Z. With CarriageDims.MAX_WIDTH = 32 this is cz โˆˆ {0, 1}. Constant auto-updates if MAX_WIDTH ever changes.
  • cx >= 0 โ€” train never reaches negative X. Pre-feature, the legacy painter only ran around the live train (which always moves +X) so negative-X chunks never had tracks anyway. Halves the corridor work for any world where the player flies backward.

Of a typical 21ร—21 spawn area = 441 chunks, ~21 actually do work (the cz โˆˆ {0,1} ร— cx >= 0 quadrant). The other 95% reject in three int comparisons.

Bootstrap shortcut

TrackGenerator.bootstrapPendingChunks (called from TrainAssembler.spawnTrain) now probes level.getBlockState((cx<<4)+8, bedY, trackCenterZ) for each loaded chunk in the train's view-distance corridor. If the block matches TrackPalette.BED, the Feature already painted the chunk โ€” it's added to filledChunks and the legacy drain skips it. Without the probe, every fresh-world spawn would re-run the heavy pillar precompute on ~100+ feature-painted chunks, wedging "Joining World" for ~13 s before the player teleported in.

Log line at spawn looks like:

[DungeonTrain] Track bootstrap for ship 3: enqueued 84 already-loaded chunks (21 skipped as feature-painted, centerCx=0, viewDistance=10)

Caveats

  • Spawn-area pillars don't paint. Chunks loaded before the train ship existed never fired ChunkEvent.Load while a train was loaded, so they have bed + rails from worldgen but no pillars. Visible only as floating bed segments where ground dips below bedY near the original spawn point โ€” players are typically teleported behind the train looking forward, so the back of the corridor is out of frame on join.
  • BlockEntity NBT not seeded during worldgen. Custom tile variants that use a block entity (chests, signs, banners) get the BE created at default state. Default palette has no BEs, so this is invisible for stock content. The post-load drain seeds BE NBT correctly.
  • Negative-X chunks intentionally skipped โ€” see prefilter above. Documented for parity with pre-feature behaviour.
  • Pre-feature world saves. Existing chunks painted by the legacy mechanism continue to render. Newly-generated chunks past the legacy frontier appear instantly via the Feature.

Files

Two async paths feed one FIFO queue

Synchronous setBlock on the ChunkEvent.Load tick was observed to wedge the server thread for 17+ seconds while VS was still settling a freshly-spawned ship (see Compatibility ยง Deferring chunk-modification off the ChunkEvent.Load tick). Both triggers now only enqueue โ€” the actual block writes happen on a separate tick:

  • TrackChunkEvents.onChunkLoad โ€” for chunks loading after spawn. Prefilters out shipyard chunks (VS voxel coords) and chunks outside the Z corridor, then offer()s the chunk key onto the train's pending deque.
  • Spawn-time bootstrap โ€” TrackGenerator.bootstrapPendingChunks, called from TrainAssembler.spawnTrain right after TrackGeometry attaches. Walks all currently-loaded chunks in the corridor and enqueues them. Necessary because chunks loaded before the train existed already fired ChunkEvent.Load while no train was registered; without bootstrap they stay permanently unpainted as gaps in the bed.
  • TrainTickEvents drain โ€” every 10 ticks (phase-offset +5 from clearBlocksAhead so the two heavy ops never collide on the same tick), drains up to 4 chunks from the pending deque via TrackGenerator.fillRenderDistance. ConcurrentLinkedDeque so drain order matches load order โ†’ bed appears spatially contiguous outward from the player, not the hash-scattered patchwork a HashSet iteration produced.

Per-chunk work is ~80 bed + ~32 rail + up to ~240 pillar setBlocks (flag UPDATE_CLIENTS only โ€” no neighbor cascades, which otherwise trigger RailBlock.neighborChanged โ†’ O(Nยฒ) rail-state rewrites). At budget 4 chunks ร— 2 calls/sec = 8 chunks/sec fill rate, averaging ~100 setBlocks/tick โ€” well under the 50 ms tick budget.

Idempotency and VS skip

  • filledChunks set (ConcurrentHashMap-backed) on each TrainTransformProvider caches processed chunk keys; re-entering is O(1).
  • Per-column: first check level.getBlockState(bedPos).is(TrackPalette.BED) โ€” if already stone brick (neighbouring train, previous cache-cleared scan), skip the whole column.
  • Per-setBlock: VSGameUtilsKt.getShipObjectManagingPos(level, pos) โ†’ if non-null, skip. Never overwrite voxels owned by our train or any other VS ship in the dimension.
  • Shipyard chunks (|worldX| or |worldZ| > 10M blocks, which is where VS stores ship voxel data in the same dimension) are filtered by a coordinate cutoff before the drain even considers them.

Usage

Per-world server config: <save>/serverconfig/dungeontrain-server.toml โ†’ generateTracks = true|false (default true). Turning the toggle off stops new generation; already-placed tracks stay in the world until the player breaks them.

Files

Pillar-editor files (v0.30.0):

  • PillarSection.java โ€” TOP(4) / MIDDLE(1) / BOTTOM(3) enum + id()
  • PillarTemplateStore.java โ€” three-tier loader, getColumn(level, section, dims) โ†’ BlockState[height][width], save/promote/reset, dim-filter, reflection-based palette unpack
  • PillarEditor.java โ€” plot allocation at z = 40, enter/save/plotContaining, bedrock cage (v0.40.0)
  • src/main/resources/data/dungeontrain/pillars/pillar_*.nbt โ€” bundled default NBTs for the three sections (moved here in v0.32.0 to avoid a scan collision with custom carriage variants; migration shim in PillarTemplateStore.migrateFromLegacyDirectory())

Worldgen-stairs SavedData (v0.83.0)

Since: v0.83.0 โ€” PR #119

The pillar-side stair adjuncts (Stair Adjuncts section above) used to be placed at runtime by the per-tick FIFO drain; spacing was decided by re-querying the world for nearby stairs. That worked but became fragile under chunk-load races and high travel speeds. The Sable migration moved stair placement to chunk-gen time and made spacing strict via a new SavedData registry:

  • StairsRegistryData โ€” per-world SavedData (dungeontrain_stairs.dat) holding the set of X-anchored chunks that own a stair adjunct. Keyed by chunkX along the corridor, since the corridor is X-monotonic.
  • Strict 40-multiple slot rule. Stairs may only spawn at chunkX % 40 == 0. Hard-codes deterministic spacing across chunk boundaries โ€” the cross-chunk neighbour queries that used to fail under async chunk-gen are gone.
  • Cross-chunk awareness. Worldgen consults the registry before placing; if an adjacent slot already owns a stair, the candidate slot defers. The 40-multiple rule combined with MIN_STAIRS_SPACING = 100 (still enforced as a sanity check) gives one stair every ~100 blocks along the corridor on flat terrain, more often skipped on extreme slopes.
  • Depth probe relaxed. Anchor accepted at bedY (no longer required to descend below sea level), so coastal or hill-line stairs that previously got rejected now place. Capped at 2 blocks below sea level so we don't rappel into a deep ocean basin.

PR #119 also moved pillar-side stair placement itself to chunk-gen time (was runtime). See StairsRegistryData.java and TrackBedFeature.java for the implementation.

Corridor +1 clearance + deferred foliage cleanup (v0.83.0)

Since: v0.83.0

Two related fixes for tree / canopy intrusion into the corridor:

  • +1 clearance envelope at chunk-gen. TrackBedFeature now clears one extra block above the corridor's airspace at chunk-gen time. Previously trees that grew right up to the corridor edge had branches dangling into the airspace; the +1 envelope catches them.
  • Deferred foliage cleanup on chunk load. When a previously-generated chunk is later loaded (e.g. mid-session re-entry into far chunks), CorridorCleanupEvents re-clears any leaves / vines / saplings that grew into the corridor envelope while the chunk was unloaded. Defensive โ€” most chunks don't accumulate intrusion, but mossy / jungle biomes can.

Known limitations

  • Fill rate vs. flight speed. A player flying faster than ~8 chunks/sec (creative flight) can outpace generation; tracks catch up as the FIFO drain processes the backlog. Walking or boat speed never sees a gap.
  • Natural terrain variation is honest. Pillars over underwater hills are genuinely shorter than pillars over deep basins โ€” that's the real terrain, not a bug. No aesthetic "minimum pillar depth" is enforced.
  • Z corridor is captured at spawn. The axis-locked train (velocity hardcoded +X in TrainCommand) doesn't need re-capture. A future turning train would need TrackGeometry to become a function rather than a snapshot.
  • Bed is a terrain-replacer. When the train flies through a mountain, the bed overwrites the mountain slice at bedY. That's intentional (the train is supposed to carve through the world), consistent with clearBlocksAhead, and the pillar's "stop at solid" guard prevents unnecessary stone-brick columns inside mountains.
  • Pillar templates are per-dim. The template store validates size.getZ() == dims.width(). A template saved in a default 7-wide world is silently ignored (log warn, fallback paints) when the install is later loaded on a 5-wide custom-dims world. Mirrors CarriageTemplateStore.filterForDims.
  • Pillar slopes prioritise cap alignment. The slice anchors at the deepest of the N Z probes, so on extreme one-sided slopes the pillar face can appear to hang on one side. Acceptable for typical terrain; revisit if real play reveals ugly cases.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ