Feature Track Generation - bh679/dungeon-train-mc GitHub Wiki
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.
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.
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_LEAVEScounts water surface as "ground" โ pillars terminate 13 blocks above the seafloor in oceans. -
OCEAN_FLOORcounts 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.
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.
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.
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.
| 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.
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.
/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 exitrestores the player's pre-enterposition whether they entered via a carriage or pillar plot. -
/dungeontrain editor devmode on|offis global โ toggling it on makes pillarsavealso write the NBT intosrc/main/resources/...alongside carriage templates.
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.
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 their1 ร H ร dims.width()footprints failed the carriage dims check โ producing occasional empty carriages in the train.PillarTemplateStore.migrateFromLegacyDirectory()runs at everyCarriageVariantRegistry.reload()(server start) and moves any legacypillar_*.nbtinto 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.
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.
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.
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.
| 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.
/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|offis global โ toggling it on makes tracksavealso write the NBT intosrc/main/resources/data/dungeontrain/tracks/track.nbtalongside the other editor templates. -
/dungeontrain editor exitrestores your pre-enter position regardless of which editor plot you entered.
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.
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.
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.
-
TrackTemplate.javaโTILE_LENGTH,HEIGHTconstants -
TrackTemplateStore.javaโ three-tier loader,getCells(level, dims) โ BlockState[4][2][W], save/promote/reset, dim-filter, reflection-based palette unpack -
TrackEditor.javaโ plot at(0, 250, 60),enter/save/plotContaining, bedrock cage (v0.40.0), fallback stamp -
TrackGenerator.javaโplaceTrackColumn(tile)per-column lookup; fallback branch preserves pre-v0.33.0 behavior exactly -
TrainTickEvents.javaโclearBlocksAheadfloor clamp viaTrackGeometry -
src/main/resources/data/dungeontrain/tracks/track.nbtโ bundled default tile
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.
| 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]
|
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 exactlycenterX = 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.
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.
/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.
| 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.
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.
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.
-
PillarAdjunct.javaโ enum withSTAIRS(3, 8, 3); one extension point for future adjuncts (railings, lanterns) -
PillarTemplateStore.javaโ parallel adjunct cache +getAdjunct/saveAdjunct/promoteAdjunct/deleteAdjunctmethods 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_RIGHTshift, top-anchored duplication -
EditorCommand.javaโPILLAR_TARGET_SUGGESTIONS(sections + adjuncts), unifiedtryParseSection/tryParseAdjunctdispatch inenter/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
- 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 tracksscrolls through carriage โ track โ pillar sections โ tunnels; the stairs plot is reachable only via the explicit/dungeontrain editor pillar enter stairspath. Follow-up item.
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.
| 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 |
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. WithCarriageDims.MAX_WIDTH = 32this iscz โ {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.
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)
-
Spawn-area pillars don't paint. Chunks loaded before the train ship existed never fired
ChunkEvent.Loadwhile a train was loaded, so they have bed + rails from worldgen but no pillars. Visible only as floating bed segments where ground dips belowbedYnear 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.
-
TrackBedFeature.javaโ the worldgen Feature -
ModFeatures.javaโDeferredRegister<Feature<?>>mirroringModItems -
TrackGeometry.from(CarriageDims, int trainY)โ single source of truth for corridor coords; shared withTrainAssembler.spawnTrain -
TrackGenerator.placeTracksForChunkโ templated placement usingWorldGenLevel.setBlock -
src/main/resources/data/dungeontrain/forge/biome_modifier/โ three biome modifier JSONs src/main/resources/data/dungeontrain/worldgen/configured_feature/track_bed.jsonsrc/main/resources/data/dungeontrain/worldgen/placed_feature/track_bed.json
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, thenoffer()s the chunk key onto the train's pending deque. -
Spawn-time bootstrap โ
TrackGenerator.bootstrapPendingChunks, called fromTrainAssembler.spawnTrainright afterTrackGeometryattaches. Walks all currently-loaded chunks in the corridor and enqueues them. Necessary because chunks loaded before the train existed already firedChunkEvent.Loadwhile no train was registered; without bootstrap they stay permanently unpainted as gaps in the bed. -
TrainTickEventsdrain โ every 10 ticks (phase-offset +5 fromclearBlocksAheadso the two heavy ops never collide on the same tick), drains up to 4 chunks from the pending deque viaTrackGenerator.fillRenderDistance.ConcurrentLinkedDequeso 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.
-
filledChunksset (ConcurrentHashMap-backed) on eachTrainTransformProvidercaches 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.
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.
-
TrackGenerator.javaโ bed/rail/pillar placement, pending drain, spawn bootstrap, shipyard filter, manual pillar descent -
TrackGeometry.javaโ per-train layout record (bedY, railY, Z corridor) -
TrackPalette.javaโ block-state constants -
TrackChunkEvents.javaโChunkEvent.Loadenqueue -
TrainTickEvents.javaโ tick drain (off-phase fromclearBlocksAhead) -
TrainTransformProvider.javaโTrackGeometry,filledChunkscache,pendingChunksdeque -
TrainAssembler.javaโ attach geometry + bootstrap call on spawn -
DungeonTrainConfig.javaโgenerateTrackstoggle
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 atz = 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 inPillarTemplateStore.migrateFromLegacyDirectory())
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 bychunkXalong 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.
Since: v0.83.0
Two related fixes for tree / canopy intrusion into the corridor:
-
+1 clearance envelope at chunk-gen.
TrackBedFeaturenow 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),
CorridorCleanupEventsre-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.
- 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 needTrackGeometryto 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 withclearBlocksAhead, 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. MirrorsCarriageTemplateStore.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.