Development - bh679/dungeon-train-mc GitHub Wiki
Building, running, and hacking on the mod.
-
JDK 21 (NeoForge 1.21.1 requires it; configured in
build.gradleasJavaLanguageVersion.of(21). The wrapper usesorg.gradle.jvmargs=-Xmx3G.) - Git (optional β only needed so the build can stamp the current branch into the jar; see Version HUD overlay below)
On macOS with Homebrew:
export JAVA_HOME=/opt/homebrew/opt/openjdk@21
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"Pre-v0.83.0 builds: Forge 1.20.1 builds shipped on JDK 17. Anything you check out from a tag earlier than
v0.83.0will need the JDK 17 toolchain.
./gradlew build # compile and package the mod jar
./gradlew runClient # launch dev Minecraft client with the mod loaded
./gradlew runServer # launch dev dedicated server
./gradlew test # run JUnit 5 unit tests
./gradlew --stop # stop the gradle daemon if the dev client hangs./gradlew build produces build/libs/dungeontrain-<mod_version>.jar.
DT bundles five sibling mods by the same author (AdventureItemNames, AdventureItemStats,
PlayerMob, DiscordPresence, EnderChestPersistence) via implementation "bh679:<mod>:${x_version}"
-
jarJar(...)inbuild.gradle, fetched from each repo's GitHub Releases through a custom IvypatternLayoutrepo (no Maven/Modrinth/CurseForge required at build time). Versions are pinned per-mod ingradle.properties(<mod>_version=x.y.z), with a changelog-style comment above each pin summarizing notable upstream releases.
PlayerMob is a special case since v0.48.0: upstream started shipping builds for multiple
Minecraft versions per release, so its NeoForge asset name gained a +<mcversion> suffix
(playermob-neoforge-<v>+1.21.1.jar) instead of the flat playermob-neoforge-<v>.jar the
shared GitHubReleases Ivy repo's pattern expects. build.gradle declares a second Ivy repo
(GitHubReleasesPlayerMob), scoped to just the playermob module via content { includeModule(...) },
with a pattern that appends ${minecraft_version}; the shared repo excludes that module so
resolution stays unambiguous. If another sibling mod adopts multi-version releases, the same
pattern applies.
Sable Fence & Trapdoor Fix (DT v0.891.0, PR #1421) publishes a single jar named after its mod id from a repo whose name doesn't match the module (sable-fence-trapdoor-fix-mc β sable_fence_trapdoor_fix-<v>.jar), so it has its own Ivy repo GitHubReleasesSableFenceTrapdoorFix with the pattern [organization]/sable-fence-trapdoor-fix-mc/releases/download/v[revision]/[module]-[revision].[ext]. It and Dungeon Backup are the hybrid siblings β implementation + jarJar in build.gradle (nested for Modrinth/manual installs) and required external mods on CurseForge; _min_version floors + modpack.config.json entries with curseforge_only: true. See Compatibility β Hybrid siblings. The deptest harness (scripts/deptest/run-all.sh) gained keys db/sff and Case G (nested-only layout) for them. Since DT v0.1280.0 (PR #1994) Discord Presence and Dungeon Train Mod Verification are hybrid too (CurseForge Includes, still nested for Modrinth); DP's mods.toml range now uses discordpresence_min_version. See Compatibility.
Fast Paintings + Moonlight (DT v0.892.0, PR #1425) are the first
third-party required deps. They come from the Modrinth maven (maven.modrinth:fast-paintings / maven.modrinth:moonlight)
keyed by Modrinth version id (fastpaintings_version / moonlight_version in gradle.properties), with separate
_min_version floors rendered into neoforge.mods.toml. ./gradlew runClient / runServer load them from the Gradle
cache β no run/mods/ drop needed. The deptest harness gained keys fp/moon on every case and Case H (missing FP +
Moonlight). See Compatibility β Fast Paintings.
AIN followed suit at its 0.46.0 (DT 0.581.0, PR #1002):
adding Minecraft 1.20.1 builds gave it the same +<mcversion> asset suffix, so build.gradle now
declares a third Ivy repo, GitHubReleasesAdventureItemNames, scoped to the adventureitemnames
module exactly as PlayerMob's is.
adventureitemnames_version ran its verification build, failed to resolve
the new coordinate, reverted itself with SKIP_AIN=1, and fell through to the next candidate. No
diff, no failed run, no notification. AIN sat frozen at 0.45.0 from 2026-06-23 to 2026-08-20 while
the cascade appeared to be working normally. If a sibling mod stops advancing across cascade
ticks, check its release asset naming first β compare a recent GitHub release's asset name against
the Ivy pattern in build.gradle before looking anywhere else.
The client always draws a small watermark in the top-left of the screen:
Dungeon Train v<mod_version> (<git_branch>)
Purpose: when the dev client is launched repeatedly across different feature branches, the overlay removes any doubt about which build is actually loaded in-game.
- Rendered by
VersionHudOverlayvia NeoForge'sRegisterGuiLayersEventβregisterBelowAll(...). (Renamed fromRegisterGuiOverlaysEventin the 1.21.1 migration.) - Client-only β class-gated via
@EventBusSubscriber(value = Dist.CLIENT), so it is not loaded on dedicated servers. - Respects F1 (hide HUD). F3 debug draws over the top, as intended.
- Can be switched off on its own: Options β Dungeon Train β General β Dev HUD (config
dungeontrain-client.tomlβ[dev] devHud, default on). The row is listed on dev builds only (v0.1245.0, PR #1949). - Values come from a resource file
dungeontrain_version.propertiesinside the jar, populated at build time β see next section.
build.gradle's processResources task expands two properties into
src/main/resources/dungeontrain_version.properties:
| Key | Source | Example |
|---|---|---|
version |
mod_version from gradle.properties
|
0.7.0 |
branch |
git rev-parse --abbrev-ref HEAD at build time |
dev/my-feature |
Fallbacks on the branch resolver:
-
Detached HEAD (tag builds, CI checkouts by SHA) β short SHA from
git rev-parse --short HEAD -
Git unavailable β literal
unknown
The resolver runs at gradle configuration time, so ./gradlew build on a
fresh branch stamps the correct branch into the jar without any extra
steps.
Since: v0.98.0 β PR #137
Updated: v0.111.0 β PR #151 β flipped the default: New World is now the resting button on dev branches, and holding Shift restores the vanilla Singleplayer/world-select button. Pre-v0.111.0 the modifier ran the other way (default = Singleplayer, Shift = New World).
Updated: v0.144.0 β PR #185 β added a dedicated Train Editor button that combines New-World + auto-/dungeontrain editor for one-click "drop me into the editor" iteration. See Main menu buttons β Discord + Train Editor for the full UX and how the auto-open dispatch works.
Updated: v0.788.0 β PR #1281 β the Train Editor world is no longer a Dungeon Train world. It is created on its own dungeontrain:dungeon_train_editor preset (see below) with startsWithTrain=false, so no train, corridor, bands, terrain, nether or end are generated; tile click β standing on a plot dropped from ~40 s to ~4 s.
Since v0.799.0 (PR #1304) a builder world holds the two authoring documents for its build inside its own save folder:
<world>/dungeontrain/builder/build.variants.json the Z menu's block-variant pools
<world>/dungeontrain/builder/build.contents.json the C menu's container contents
Per-world rather than in the config dir, because a builder draft has no template to be named after:
BuilderCarriagePlot.key() is the constant builder:carriage in every builder world, so a
config-dir file named after the key was one document that each builder world overwrote in turn.
Same layout idiom as CarriagePersistenceStore β world root, dungeontrain/β¦, a
namespace__path dimension segment. BuilderStorePaths is the only place that spells it out.
The documents are working copies, not the template's own: BuilderWorldSetup seeds them from the
template an Open resolved, and BuilderSave writes them onto whichever template the build is saved
as (BuilderSidecarCarry, which also holds the per-kind coordinate offset β a carriage room's
template starts one block inside the shell, a part's at its first placement). Deleting the world
deletes them, which is the intent: they describe a build, not a template.
The Train Editor button's world uses the Train Builder's recipe β a minecraft:flat generator with zero layers (pure void), minecraft:overworld as the only dimension, its own dimension type β at Dungeon Train's full vertical shape. The constants live in EditorWorldLayout:
| Y | What |
|---|---|
| 320 | build ceiling β EditorLayout.PLOT_Y (230) + an 80-tall portal room (PortalRoomLayout.MAX_HEIGHT) fits under it |
| 230 | the plots |
| 0 | the terrain floor. A flat generator's getMinY() is hard-coded to 0 in vanilla (FlatLevelSource), whatever the dimension's min_y is |
| β80 | world bottom β 80 blocks of basement under the floor, the same depth every DT noise preset keeps (dimension min_y β48, noise min_y 32) and what /dt portal test stamps its twin rooms into |
Hence dimension_type/editor.json is min_y: -80, height: 400. The builder's own type (min_y 0, height 96) cannot host the plots and has no basement. Everything that made the builder's void survivable is gated on the editor type too: TrainBootstrapEvents anchors spawn at (0, 231, 0), BuilderSpawn.startFlying keeps the player hovering until the editor command lifts them onto a plot, and BedrockFloorEvents skips its floor stamp. EditorQuietRuleEvents now keys on the dimension type as well as the train editor name prefix (old saves keep the old preset).
DimensionTypeBasementTest holds editor.json to the same three basement invariants as the noise presets (a "flat-floor" dimension whose floor is 0), and pins the preset to a zero-layer flat generator in the overworld slot.
First-open plot wipe. EditorCategory.clearAllPlots erases every plot of every category β several hundred chunk columns at y=230 β and used to do so on a brand-new world where nothing had ever been stamped (a 21 s server stall). DungeonTrainWorldData.editorPlotsStamped records whether any plot has ever been stamped; the sweep is skipped until it is set, and clearAllPlots itself sets it (every stamp path calls it first). An absent tag loads as true, so saves from before the flag keep the full clear.
Category entry stamps across ticks (v0.856.0, PR #1380). /dungeontrain editor <category> used to stamp every plot in the category inside the command β one synchronous chunk load + template placement per plot, and the Portals rooms twice (category loop + enter β stampAllPlots) β which held the server thread for as long as the whole category took: 9.5 min for Portals on an Intel UHD 610 laptop, with every extra X-menu "Editor" press queuing another full pass. Now EditorCommand.runEnterCategory stamps only the landing plot, teleports, sets EditorStampedCategoryState, and queues the rest on EditorStampQueue: a static job list drained on the overworld LevelTickEvent.Post under a 20 ms/tick budget, always at least one job per tick (a plot can't be split). Action bar shows Setting up plotsβ¦ n/N then Plots ready.; the log gets Editor plots ready: N stamped in T s. Rules that keep it coherent:
-
EditorCategory.clearAllPlotJobs(overworld, dims, keep)does the state resets now (labels, strays, undo, cancels the queue) and returns the erases as jobs; the entering category's own erases are skipped because every stamp erases its own footprint first.clearAllPlotsis "run those jobs now" for the sync callers (runExit, deletes). - Each editor's
enter(β¦, stamp)overload skips the restamp when the category entry has already stamped that plot. - Every whole-kind restamp (
stampAllPlots,stampAllSectionPlots, tunnel/track kind stamps, the stage restamps) callsEditorStampQueue.flush()first so it never races a fill;flushis a no-op from inside a job. - Labels/roster appear before the fill completes on purpose (they are registry-driven) β a click on an unstamped tile issues a single-plot enter, which stamps it.
"Already standing in it" is one test (v0.919.0, PR #1464). The X menu's Go here (walkTo) and the panel's Enter (enterInside) restamp only when the player is not already inside the target plot β a walk to the menu must not throw away unsaved edits. That decision is EditorPlotScope.standingIn(ServerPlayer, Template) for every editor: it runs the same EditorCategory.locate cascade the HUD, save and reset use and compares on the same four-segment EditorPlotScope.keyFor key (<category>:<kind>:<id>:<name>; EditorCategory.of(Template) supplies the category for a not-yet-located model). The per-editor private copies it replaced had drifted (equalsIgnoreCase in portals, equals/== elsewhere, and TrackEditor.resolveName lacked the + 2 roof headroom every other plotContaining and the client TrackSidePlots.containsWithMargin have β so Go here from the track tile roof wiped edits while the HUD said "inside"). Names compare exactly; /dt editor portals enter <name> and the group-landing path pass the registry's canonical spelling. The command-path enter(β¦, onTop) always stamps (that is the reload a relay Load needs β PR #1457). EditorPlotScopeKeyTest pins the key contract.
One resident type, one origin (v0.888.0, PR #1420). Every category now lays its plots out from the same (0, PLOT_Y, 0) origin β the disjoint per-category Z lanes (CONTENTS_FIRST_Z = 150, TRACKS_FIRST_Z = 475, sized by an 8-sub-variant guess) are gone, because a contents group grows along +Z without bound and the enemies group had already run into the Dimensions lane (the HUD called its far members "Dimensions / moria"). Only one category is ever standing β the resident one, EditorStampedCategoryState, now mirrored into DungeonTrainWorldData.editorStampedCategory and restored on ServerStartedEvent β and every "which plot is here" question is answered only for it:
-
EditorStampedCategoryState.isActive(category)gates every editor'splotContaining(CarriageEditor,CarriagePartEditor,CarriageContentsEditor,TrackEditor,PillarEditor,TunnelEditor,PortalRoomEditor,TrackSidePlots.locateper kind). Strict: no resident category β no plot anywhere.EditorCategory.locateAtwalks one category's arms instead of eight. - A category switch erases only the previous category (
clearAllPlotJobs; all four when the world predates the record, or aftereditor exit). Each erase job carries aBoundingBox footprint;runEnterCategoryruns the ones under the landing plot synchronously before stamping it (EditorStampQueue.partitionOverlapping), queues the rest, then queuesEditorLayerSweepahead of the fill: every non-air section of the plot layer (loaded chunks, up to a 600-block legacy Z floor) minus the landing box goes to air. That sweep β generalised from the old portal column sweep β is the safety net for anything the layout no longer predicts (old-lane plots, a renamed variant, the named track tile whose cageTrackEditor.clearPlotmisses). It also means blocks an author left outside every plot (the red-ghost strays) are erased on a type switch. - Single-model enters (
editor enter <carriage>,contents enter,portals enter,tracks/pillar/tunnel/part enters) callEditorCommand.ensureCategoryfirst, so entering one template of another type does the full category switch rather than stamping into the resident type's plots. -
Template.Contents.plotSizeis the plot's own box (the corridor for the portal contents) andTemplate.Track.editorPlotOriginthe named tile's slot β the strays sweep, the erase footprints and the layer counts all read these. - Dev checks:
/dungeontrain debug editor-locateprints the(category, model)the status HUD resolves for the running player plusresident=;editor-layercounts blocks inside vs outside the resident category's plots over loaded chunks (expectblocksOutsidePlots=0once a switch has settled). Both work over RCON withexecute as <player> run β¦, which is how the rule is proven headlessly (server +runClient -PquickJoin+ RCON, ~70 s a pass).
Where a plot entry lands you (v0.907.0 PR #1449; one seam since v0.917.0 PR #1462). Every editor's enter(β¦) ends in a single call to EditorPlotArrival.land(player, level, origin, footprint, onTop, inside, doorBase): onTop β the roof, three blocks βX of the plot's menu anchor (EditorPlotLabels.anchorAbove), facing +X; else Inside.FRONT_DOOR with a door β the βX doorway's lower cell facing in; else the footprint centre with the player's own heading. doorBase is null for kinds with no doorway (parts, pillars, track tiles, tunnels) and for a portal room whose doorBases came back empty β both land at the centre, so there is no per-editor special case. Every inside landing then runs freeCell (preferred β its +X row β nearest interior cell by expanding shells) so a walled-up doorway or filled centre still lands in air. Change landings in EditorPlotArrival, not in the editors; EditorPlotArrivalTest pins the roof formula, the free-cell search and the landing dispatch without a server.
On dev branches (anything not main), the title screen replaces the vanilla Singleplayer button with New World β clicking it skips CreateWorldScreen entirely and drops straight into a fresh creative-mode world on the Dungeon Train default preset, cheats enabled. Each invocation generates a new Dev World <ts> save so rapid iteration never collides on existing folder names. Hold Shift at any time to swap back to the vanilla Singleplayer button (existing world list).
- Implemented by
DevQuickWorldHandlerusing NeoForge'sScreenEvent.Init.Post(button install β New World is wired invisible=trueand the vanilla Singleplayer button is set tovisible=falseat install time) +ScreenEvent.Render.Pre(per-frame visibility flip fromScreen.hasShiftDown()). - Branch-gated identically to the Version HUD overlay β release jars (built with
branch=mainstamped intodungeontrain_version.properties) skip the handler entirely, so the title screen looks vanilla. - Calls
Minecraft.createWorldOpenFlows().createFreshLevel(...)withGameType.CREATIVE,Difficulty.NORMAL,allowCommands=true, a random seed, and aFunction<RegistryAccess, WorldDimensions>that resolves thedungeontrain:dungeon_trainpreset (falls back toWorldPresets.NORMALif it ever fails to resolve). - Compatible with the existing CreateWorldScreen flow β the regular Singleplayer (Shift held) β Create New World path is unchanged. The shortcut is purely additive.
Use case: when a code change needs in-game verification across many quick test cycles (collision tweaks, render tuning, content edits), the shortcut cuts the test loop from ~10 clicks + typing a world name down to a single click β and since v0.111.0, no Shift modifier needed.
A fresh checkout or worktree has no run/options.txt, so vanilla treats the launch as a brand-new
player's: Options.onboardAccessibility defaults to true and Minecraft.addInitialScreens puts the
narration accessibility onboarding screen in front of the title screen, at 100% master volume.
On a dev build both are pure friction β it is by definition not somebody's first launch.
On dev branches (anything not main), the client now clears both itself:
- Master volume is set to 0%.
- The onboarding flag is cleared via vanilla's own
Options.onboardingAccessibilityFinished(), so the screen is never constructed and the client goes straight to the title screen. - One INFO line is logged (
DevFirstLaunchDefaults) so the silence never reads as a bug.
Details:
- Implemented by
DevFirstLaunchDefaultsonFMLClientSetupEvent. Mod loading finishes inside the initialLoadingOverlayreload, andonGameLoadFinishedβbuildInitialScreensβaddInitialScreensall run after it β so clearing the flag there means the screen is never built, rather than cancelled after the fact. - Branch-gated on
DungeonTrain.isDevBuild()β the same!"main".equals(VersionInfo.BRANCH)signal the Dev TitleScreen shortcut and Version HUD overlay use. Release jars skip it entirely, so players keep the vanilla onboarding. -
Applies once, on a fresh profile. The second gate is
onboardAccessibilitystill being set β exactly the condition under which the screen would have appeared. Turn the volume back up and it sticks across relaunches;rm run/options.txtto get the fresh-profile behaviour back.
Since: v0.896.0 β PR #1433
The Custom train content prompt (New World / reboard, or at join as the fallback) normally offers two answers: Default Train (suppress the content, run counts) or Custom Train (keep it, run is Free Play). A dev checkout's user tier is nearly always populated β the editor auto-writes it β so on a dev build every new world cost either the in-progress content or the stats.
On dev builds (DungeonTrain.isDevBuild()) a third, plainer button sits under the two cards:
I'm a dev β ignore. It keeps the custom content loading and the run Live.
- Recorded per-world as
CustomContentChoice.DEV_IGNORE(dev_ignoreonDungeonTrainWorldData), and rememberable like the other two (CustomContentPreference.DEV_IGNORE; shows as Dev: ignore under Options β Dungeon Train β Custom Train Content, dev builds only). - The waiver lives in one place β
EditorContentIntegrity.isSessionFreePlay()β and applies only whileisDevBuild()is true. A release build reads a carried-overDEV_IGNOREasALLOW(Free Play); the join-time handler recordsALLOWoutright, and the options screen shows it as Always continue. - Logging in to a dev-ignored world skips the Free Play badge and the prompt
(
β¦ dev-ignored for this world; <name> joined Live)./customcontentreports ON β¦ and the run still counts (dev build). -
CustomContentChoicePacketnow carries the choice id (string) rather than a boolean.
The /new-world client command short-circuits the death-screen β New World flow so you can re-roll a world without dying or returning to the title screen first. Useful when iterating on world-gen, spawn placement, or any feature that depends on a fresh seed.
/new-world fresh seed, current preset (default)
/new-world fresh same as the no-arg form (explicit)
/new-world same re-roll the SAME seed β useful for reproducing a specific bug
Implementation: NewWorldCommand (client-only, registered via RegisterClientCommandsEvent) calls into the same DeathScreenLayoutHandler.launchWorld(Screen, boolean) entry point the death-screen buttons use. That means the Sable pre-drain runs identically to the death-screen path β see Compatibility Β§ Save hangs on Saving worlds after rapid world-switch for the chunk-map race this avoids.
Refuses to run on multiplayer servers (mc.getSingleplayerServer() == null β "/new-world only works in singleplayer.").
This command is always available, not branch-gated β it's a thin shortcut over an existing UI path, not a separate code path that would diverge from release behavior. The branch-gated dev-iteration shortcut on the title screen is Dev TitleScreen shortcut above.
Two runtime toggles for profiling DT's chunk-generation cost (both op-only, live, no restart):
/dungeontrain debug gen-timing on|off|status # [gen.timing] per-chunk cost attribution in latest.log
/dungeontrain debug band-earlyout on|off|status # A/B kill-switch for the off-band worldgen skips
/dungeontrain debug biome-memo on|off|status # A/B kill-switch for the biome hook's per-column memo
/dungeontrain debug substep-tuner on|off|status # A/B kill-switch for the Sable substepsPerTick tuner (2β1 on a real train)
substep-tuner (default on, since v0.922.0 engaging at β₯ 12 resident sub-levels β every real train, single-player included) is the physics-side twin of band-earlyout: off restores Sable's baseline substeps at the next reconcile, so flipping it mid-ride gives a matched A/B on the [mspt] line, which since the same release ends with physMs=<mean native Rapier step per tick over the window> substeps=<live value> blockChanges=<carriage block edits in the window>. physMs is the number to read when a ride "feels laggy": it was invisible before (70 % of the server thread, found only by jstack) and is what the tuner halves. See Compatibility for why one substep costs nothing on DT's kinematic-only scene.
gen-timing (default on in dev builds, off in prod) drains per-window worker-thread cost into a DEBUG log line: [gen.timing] β¦ perChunk df=β¦ biome=β¦ track=β¦ β the per-chunk figures are the load-bearing numbers while riding forward. Off-thread samplers report window totals: sphSample/sphApply (foreign spheres) and endSample/endApply (End-band samples). Divide by the sample count for per-sample cost; windows with sampler work but no chunks fulled are still logged. OfflineChunkSampler answers surface-rule and carver biome lookups from the sample chunk's own filled biomes (vanilla's WorldGenRegion pattern). Going to the biome source per lookup was ~70% of an End-band sample (#1585).
netherStrip (v0.1014.0, #1646) is the Nether-band foliage strip's scan, now on the worker at the SPAWN step (NetherFoliageStrip.compute, hooked from the same ChunkStatusSpawnMixin inject as the upside-down mirror precompute). Only the few block writes still run on the server thread at ChunkEvent.Load. Before this it scanned every block of every band chunk on the server thread (the 5 s DT-StallWatchdog stall in the 0.983.0 lag reports); a section is now skipped unless its palette holds a strippable block (LevelChunkSection.maybeHas) and the six tag checks are cached per Block (StrippableFoliage, reset on TagsUpdatedEvent). Expect ~0.3 ms/chunk in the band. Common-config netherFoliageStripPrecompute (default true) turns the precompute off and scans inline at Load β same output, only where the work runs changes.
band-earlyout (default on) gates the off-band early-outs added in #819: O(1) window predicates (WorldGenCycle.netherInfluence) let the density raise (NetherBandTerrainDensityFunction), biome forcing (BandBiomeDecision) and track-bed feature skip chunks provably outside their bands. off restores the exact pre-#819 code paths β output is byte-identical either way (pinned by the seam-safety test suites), only timing differs, so flipping it mid-ride gives a clean matched A/B (~2.5β3Γ DT gen cost difference in overworld gaps). Same pattern as debug physicsfreeze.
biome-memo (default on) gates the per-thread column memo in the biome-source hook (ColumnBiomePlan, #1663): the legacy override, band verdict, sampled Nether/End core biome and stretch look are computed once per column instead of once per quart (96Γ per column). off restores the exact per-quart path β output is byte-identical either way. The [gen.timing] line carries a pick= sub-bucket of biome=: the climate sample + table lookup vanilla itself would do, so biome β pick is DT's own cost. Headless slab A/B (7 band positions): DT's own share fell from β6.9 to β0.86 ms per FULL chunk; the BoP End core alone from 47 to 2.9 ms. Memo validity keys: the published contexts, the config cycle, the legacy-biome token and the live reverse slide.
Used for the End-band void-square fix (#1725). Boot ./gradlew runServer -PserverDir=<dir> with RCON on, join a ./gradlew runClient -PquickJoin=127.0.0.1:<port> client, then over RCON step a spectator along X (tp once a second by the tested speed) and probe the chunk column N chunks ahead on first sight and again ~8 s later while it is still in view:
execute if loaded <x> 100 <z> # never probe an unloaded chunk β execute if blocks would generate it synchronously on the server thread
execute if blocks <x> 20 <z> <x+15> 131 <z+15> <x> 200 <z> all # "Test passed" = all air = void
A chunk that is void on first sight and holds terrain on the revisit is a visible void square; one that stays void is a real gap between islands. /dtp arrivals use the same probe over the 21Γ21 view at +3 s, +11 s and +31 s. Count chunks still owed terrain afterwards by scanning the region files for the dungeontrain:end_band_pending attachment id (it is serialized only while set).
Profile the sampler with JFR: jcmd <pid> JFR.start name=x settings=profile on the net.neoforged.devlaunch.Main @β¦/serverRunProgramArgs.txt JVM (not the Gradle wrapper), forceload a strip, JFR.dump, then jfr print --events jdk.ExecutionSample --stack-depth 80 and bucket samples by the innermost OfflineChunkSampler phase frame. [gen.timing] in debug.log gives endSample/endApply per window; divide by the chunks that reached FEATURES (each gets a sample), which for a forceload is the FULL area plus a one-chunk ring.
NetherBandContextEvents.publish runs on every server-side dimension load and again at ServerStarted (about 4Γ per boot). Its log lines carry the cost: Nether-band terrain context (re)published on level load: β¦ (N ms, overworld stretches N ms) at DEBUG per load, and β¦ published: β¦ (N ms, overworld stretches N ms) at INFO for the final one. Since #1706, OverworldStretchBiomes.resolve reuses its build while the biome registry and TerraBlender's Region objects are the same, so only the first publish shows a non-zero overworld stretches figure (β3 s headless; it was 16β23 s per boot rebuilt four times). A non-zero figure on a later publish means the memo missed. VanillaWorldgenLookup.get() builds the code-made vanilla registries once per JVM; each build logs Vanilla worldgen lookup built on <thread> (N ms) at DEBUG, so there should be one per game session.
VanillaBiomeTwins.twinFor is called for every tinted block from every chunk-mesh thread (BiomeMixin, BiomeColorsTwinMixin) and for every snow/ice check on worldgen workers. Since #1772 it takes no lock: the twin/spawn maps live in a TwinTable (copy-on-write, unmodifiable snapshots published under a write lock by build/clear), and the "is this X outside the WWOO stretch" answer comes from WwooStretchMemo, a 64-slot per-thread memo keyed on block X, the cycle instance (identity) and the live reverse slide β every input SecondLapOverworld.lookAt reads, so a hit is identical by construction. If you add a new input to lookAt's answer, add it to the memo key; WwooStretchMemoTest compares memoised vs direct across real stretch edges and will catch a stale key only if the test's cycle exercises it. Measured: ~30 ns β 8 ns per call single-threaded, ~250 ns β 9 ns on 4 mesh threads (the old cost grew with thread count because of the lock).
The [mspt] line (every 40 ticks per level with a train) now ends with the non-physics suspects,
so avgTickMs β physMs can be attributed instead of guessed:
gcMs= gcN= heapUsedMb= heapMaxMb= chunkWaitMs= chunkWaits= chunksLoaded= pendingChunkTasks= entities= onCarriages= tickMaxMs=.
-
gcMs/gcN: GC pause time and collections in the window (G1 Young/Old, ZGC/Shenandoah "Pauses"; concurrent cycles excluded). A fullheapUsedMbnearheapMaxMbwith largegcMsis heap starvation. -
chunkWaitMs/chunkWaits: server-thread time blocked on a synchronous chunk load (ServerChunkCacheWaitTimingMixin, observe-only). Only waits still pending on entry count; already-loaded cache misses are ignored. -
entities/onCarriages: loaded entities, and non-player entities inside a resident carriage's world box (live contents mobs ride in world space, not shipyard coords). -
tickMaxMs: the worst single tick in the last 40. Far aboveavgTickMsmeans a spike, not steady load.
Fields are only ever appended; scripts/perf/analyze.py's parse_mspt() reads every vintage by name.
Full field table: scripts/perf/README.md. Source: perf/ServerLoadSampler.
Sable Pathfinder is optional in neoforge.mods.toml (required on Modrinth, not listed on
CurseForge), so players split into two mob-pathing behaviours. Since
PR #1714 every server start logs one INFO
line from compat/SablePathfinderPresence (ServerAboutToStartEvent, integrated and dedicated):
[DungeonTrain] Sable Pathfinder present (v1.4.0) β mobs path across carriages; villager POI/sleep/golem logic runs in sub-level coordinates
[DungeonTrain] Sable Pathfinder absent β vanilla mob pathing; carriages are opaque to pathfinding
Grep a Lag log for Sable Pathfinder before reasoning about mob behaviour on carriages.
./gradlew runServer -PnoSablePathfinder (or runClient) boots the dev classpath without the
jar, matching an absent player. The 2026-09-30 headless A/B (4 interleaved arms, 15 carriages, 40
wandering villagers, parked and moving) found no measurable avgTickMs or physMs cost from
it; the shared >50 ms tickMaxMs spikes in that scenario were DT's own contents-despawn sweep.
What it does change: villagers on a carriage resolve POIs placed in the carriage's shipyard plot
(a bell became meeting_point, beds home), but none slept on a moving carriage in the test and
untagged mobs aboard vanished once the train moved (suspect TrainTickEvents.killEntitiesAhead,
tracked separately). Sable Pathfinder itself has no config; the ask to its author, should a cost
ever appear, is a per-region gate in PathNavigationRegionMixin rather than a per-node lookup.
Headless harness traps: on the dedicated server the dungeon_train_flat ground is y=0 (mobs
summoned at track height fall into the void), and lines appended to run/logs/debug.log from
outside are overwritten by log4j β mark windows with wall-clock times instead.
FML's log4j2.xml runs the root logger at all and writes DEBUG to debug.log synchronously on the calling
thread, so any DEBUG line is a server-thread cost for every player (a Lag report once stalled 6 s inside that
appender). Since #1658 (util/DtLogging):
-
games.brennan.dungeontrain.jitter(per-event physics/train probes:[freeze.unpark],[reanchor],[pinCorrected],[subTrack],[colliderBatch], β¦) is DEBUG in dev (runClient/runServer) and INFO in shipped jars. WARN/INFO lines on it ([tripwire],[baseline],[panic.canonicalPos]) still reach players. -
games.brennan.dungeontrain.perfcarries the 2 s window summaries Lag reports are read from β[mspt],[freeze],[gen.timing],[sweep.perf],[ud-drain.win],[despawn] dim=, throttled[stuck.timing]β and is left uncapped, so they stay in players'debug.log. - Repeating lines go through
util/ThrottledLog(one summary per window with a count):[tripwire]per train (trainTrips=), portal "copy skipped", Nether-core decoration, the force-load "kept held" line.
Switches:
./gradlew runClient -PjitterDebug=false # log like a player: jitter capped + dev traingen trace left offTo get the per-event lines from a player's install, have them add -Ddungeontrain.jitterDebug=true to their
launcher's JVM arguments. Adding a new probe: per-event β the jitter logger (guard any String.format with
isDebugEnabled()); once-per-window summary a Lag report needs β DtLogging.PERF.
While a client streams with the Live Eye or decodes the Live Feed (a TV, a worn TV, the pinned viewer), it writes
one [live] line a minute to debug.log on the perf logger, and nothing while Live Eye is idle
(#1943, client/live/LivePerfStats + LivePerfWindow):
[live] role=stream+view size=1280x720@20 grabs=1198 skipped=0 grabMs avg=0.67 max=4.11 encFrames=1184 dup=14 encCpu=24% | view sessions=1 width=480 decoded=1195 stalls=0 runs=0 uploadMs avg=0.10 max=1.69 decCpu=6% | fps=110 worstFrameMs=34.82
-
role:stream,view,stream+view, orendedfor the one trailing line in the minute activity stopped. - Streamer:
grabsshould be about 1,200 a minute at 20 fps;skippedcounts grabs dropped because every read-back buffer was still waiting on the GPU (a GPU-bound machine);grabMsis render-thread time per grab;dupcounts frames the encoder repeated because no new one arrived. - Viewer:
decodedshould be about 1,200 a minute;decoded=0with risingrunsmeans the decoder cannot reach the stream;stallscounts the 20 s no-frame watchdog firing;widthis the decode width. -
encCpu/decCpuare percent of one core fromProcessHandle.Info.totalCpuDuration()(no process spawned). Java reports them on Windows and Linux; on macOS they always readn/a. -
fpsandworstFrameMsare the client's frame rate and slowest frame in the window.
Reference numbers from a 12-core Apple Silicon Mac (2026-10-12): streaming adds about 0.7 ms to the average frame, grabs take 0.6β0.7 ms, the encoder uses 17β30 % of a core and a 480-wide decode about 7 %.
The Train Editor's per-tick server work lives in VariantOverlayRenderer.onLevelTick β labels, type menus, hover, outlines, stray sweep β and the [stuck.timing] overlay= bucket never reaches the log there because the editor world has no train (the tick handler returns early). Since #1344 dev builds log a 1 Hz DEBUG line while anyone is at plot height:
[editor.timing] overlay avg=3.21ms max=4.20ms ticks=20 players=1 stamped=CONTENTS
Read it from the runClient stdout (e.g. ./gradlew runClient > client.out), not run/logs/latest.log β the file appender filters DEBUG. Healthy steady state is low single-digit ms at any plot; the pre-#1344 numbers were 33β36 ms at a plain Contents plot and 85β180 ms at a group parent.
What #1344 fixed, and the shape to keep:
-
CarriageContentsRegistry.allContents()/CarriageVariantRegistry.allVariants()hand out one immutable snapshot until aCUSTOMSmutation (customsChanged()at every mutation site). Previously every call rebuilt the list and a freshCustomrecord per id, and the editor called it once per template per pass β O(nΒ²) per tick. -
CarriageContentsEditor/CarriageEditorresolve plot origins through an idβslot map memoised on the snapshot's reference identity (plus the group child-set identity for contents). -
ProvenanceCachememoisesUserContentPaths.provenanceOf(the user/imported/bundled tint), flushed by every template-store write and byTemplateStores.reloadAll, with a 5 s TTL safety net.
Poor-man's profiler that found it: jstack <client pid> in a loop, keep the "Server thread" block, drop samples parked in waitUntilNextTick (that's the pause menu β ServerStallWatchdog logs those as 45 s "stalls", ignore them), histogram the top frames.
Headless A/B recipe (dedicated server + runClient -PquickJoin=127.0.0.1:25571 + RCON execute as Dev run dungeontrain editor carriages) works for the Carriages category; editor contents used to stamp ~470 plots inside the command and block the server thread ~125 s on first entry, timing the remote client out; since #1380 the plots fill in across ticks (EditorStampQueue), so the headless recipe works for Contents too.
The question "is memory growing because of us?" is answered by an instance count, not by the heap
graph β a heap that climbs is normal on a train that has generated 4000 blocks of terrain. The
number that decides it is how many ServerSubLevel objects survive a forced GC compared with how
many carriages are actually resident. If it tracks the resident count (~20), nothing is retained;
if it tracks the number of carriages ever spawned, something is holding a handle strongly (see
Compatibility βΈ A handle to a culled carriage kept all of its chunks).
The whole thing runs headless β no screen, no hands on the keyboard.
1. A dedicated server whose console you can type into. runServer does not forward stdin, so
feed it from a file that stays open:
: > /tmp/dtcmds.txt
tail -f /tmp/dtcmds.txt | ./gradlew --no-daemon -I scripts/perf/stdin.gradle runServerrun/server.properties needs level-type=dungeontrain\:dungeon_train, a pinned level-seed,
online-mode=false, enforce-secure-profile=false, max-tick-time=-1 and a port that no sibling
worktree is using (25571 works). Delete run/logs/latest.log first β the previous run's Done (
line satisfies a readiness wait instantly.
2. A rider, because the train needs one. TrainCarriageAppender early-returns with no players,
so a fully headless server measures an empty world:
./gradlew --no-daemon runClient -PquickJoin=127.0.0.1:255713. Make the train work. Carriages only spawn and cull as the player moves along the line. One carriage group is 37.4 blocks, so append a teleport to the command file every 15 s:
echo "execute as Dev at @s run tp @s ~37.4 ~ ~" >> /tmp/dtcmds.txt4. Sample the live set. jcmd from a Homebrew JDK attaches fine to the toolchain JVM. Find the
server with pgrep -f -i bootstraplauncher (both JVMs match β check ps -o args= for Server):
jcmd <pid> GC.run
jcmd <pid> GC.heap_info | grep used
jcmd <pid> GC.class_histogram | grep -E 'ServerSubLevel |ServerLevelPlot |PlotChunkHolder |SableManagedShip |TrainTransformProvider 'Read those against the server's own [mspt] line (carriages= is the resident count) and the
[anchor-div] line (registryCount= is how many groups have ever been spawned). A healthy build
holds ServerSubLevel β carriages while registryCount climbs freely.
Sample every four minutes for half an hour. The v0.813.0 fix moved the numbers from 21 β 637 live sub-levels over 133 spawned groups to a flat 19β22.
When the train seems to end behind a player walking or standing backward, arm the backward-lane decision trace (op-only, live, default off everywhere):
/dungeontrain debug traingen on|off|status # [bwdgen] once/sec per train + stall detector + [seamgap]/[anchor-div]
Each [bwdgen] line names the gate the backward lane reached that tick (SPAWNED, FILL_RUN, NO_NEED, EDGE_RELOAD, EDGE_DEFER, FRONTIER_REAP, GATE_PENDING, GATE_CULL_LATCH, CHUNKGEN_DEFER, NOT_NEAR) with the full state behind it: the player's carriage index read in their own group's frame (playerPIdx) next to an independent recomputation (occupiedPIdx, skew should be 0), the needed window, registry vs visible counts, the frontier anchor being resolved, and whether the player's own group is force-loaded (heldOccupied). A lane blocked for 5 s escalates once to WARN + chat (STOPPED), and walking to within 20 blocks of the visible tail logs AT-TAIL. status prints the last sample per train in chat.
Analyse a ride with python3 scripts/traingen/analyse-bwdgen.py run/logs/debug.log (read debug.log, not latest.log β the dev client and its integrated server share the run directory and clobber latest.log). It reports groups added, the tick the lane stopped, which gate stopped it with a verdict, the player-vs-lane race in carriages/min, the closest approach to the tail, and hold-window exposure. Since #1268 the backward lane fills the needed range outward from the player's own group (TrainCarriageAppender.backwardFrontier), so a healthy ride shows skew=0 throughout and registryCount == visibleCount.
The in-world editor can write directly into src/main/resources/data/dungeontrain/templates/ so author-built carriages get committed and shipped as the mod's bundled defaults. See the Features wiki for the full feature description; the workflow you'll typically follow on a checkout:
-
Launch the dev client β
./gradlew runClient. The source-tree write paths only resolve when the game directory is<project>/run, which is what the dev gradle task uses. -
Verify dev mode is on β
/dungeontrain editor statusshowsDev mode: ON. Since v0.142.0 (PR #183) the flag auto-defaults toonwhenever the source tree is writable (which is exactly the./gradlew runClientcase), so you no longer need to type/editor devmode onbefore each session. The manual command still works as a mid-session override (/editor devmode offpauses auto-promotion); the override resets back to the writable-source default on the next world load. In a packaged jar the flag staysoffregardless, since source writes are no-ops there anyway. -
Build a carriage β
/dungeontrain editor enter <variant>, edit blocks inside the bedrock-cage footprint,/dungeontrain editor save. With dev mode on, each save writes BOTHconfig/dungeontrain/templates/<variant>.nbt(your local override) ANDsrc/main/resources/data/dungeontrain/templates/<variant>.nbt(the bundled default for the next build). -
Verify β
/dungeontrain editor listshowsbundled: yesfor any variant whose source-tree NBT exists. The mod logsWrote bundled template <TYPE> to .../src/main/resources/...on every successful source write. -
Commit the NBTs β
git add src/main/resources/data/dungeontrain/templates/*.nbt && git commit. Use the editor only for blueprint changes; the four.nbtfiles belong in the same PR as any code changes that depend on them.
If you didn't have dev mode on while saving, run /dungeontrain editor promote <variant> (or promote all) afterwards to copy from config-dir into the source tree. Same checkout-only requirement.
The delete commands (editor reset <variant>, editor contents reset <id>, editor part reset <kind> <name>, and the track-side reset <kind> / portal-room remove) are the inverse of the save write-through. Every delete removes the template's user-tier sidecars β <id>.variants.json, the containers/<key>.contents.json loot links, its weights.json entry and its slot in any *.group.json β so a name you reuse later starts clean. With dev mode on and a writable source tree, a custom template's delete also removes the promoted src/main/resources/... copy of each of those files (TemplateDeletes). Previously only the user-tier .nbt went, which left orphaned weights.json keys and group members in src/ that BundledCarriageAndContentsWeightsTest then rejected.
A dev-mode source delete also removes the exploded classpath copy under build/resources/main/ (SourceTreeFiles.deleteWithClasspathTwin). The dev classpath serves that directory, and every store's bundled tier reads from it, so deleting only src/main/resources/... let the next cache miss resurrect the template until the next processResources β a promoted dimensional-carriage parent kept its old group in the roster that way (PR #1360). Deleting a group parent requires a mode (all / unparent / promote, see Editor Commands); the pure logic is editor/ParentDeletes.
Two deliberate limits: a built-in reset (default, standard, portal, β¦) still means "drop my override, revert to the shipped fallback" and leaves the bundled copy, weight and group slot alone; and the cleanup is best-effort β every step is isolated and named in the chat reply (Also removed: β¦ / Could not remove: β¦), so a source-tree hiccup never blocks the delete itself. Parts have no built-in/custom split, so a part delete in dev mode does remove the bundled copy (the reply says so; the registry entry lasts until the next build re-reads the classpath).
The editor's New (tracks new / portals new, portals group new, editor new, contents new, part new β¦ current) duplicates the template you're standing in, and it now copies everything beside the .nbt, not just <id>.variants.json: a carriage's .parts.json and .contents-allow.json, a dimensional carriage's .copies.json and .contents-allow.json, the containers/<key>.contents.json loot links, and the template's weights.json entry (weight, gate, Stage link, mode, flip, builder credit β everything but the display label). The mode tag is the one that matters most: for a dimensional carriage it holds the sky, walls, copies, exits and door settings, so before this a copy came up as a bare box under no sky.
One enumeration drives it β editor/TemplateCopy walks TemplateSidecars.filesFor, the same list the rename path moves and the relay install writes β so a sidecar added there is carried by New without a change. Each role is read config-first, then from the bundled classpath (/data/dungeontrain/<subdir>/β¦), so copying a bundled-only room like house still brings its variants. With dev mode on, every copied sidecar and the weights entry also land in src/main/resources/... (the same write-through as save), so revert them after test copies. Not carried on purpose: the source's own .group.json, its membership in other groups, and its photo.
Save-as carries the variants sidecar from any tier (v0.903.0, PR #1444). CarriageContentsVariantBlocks.rename / CarriageVariantBlocks.rename / the new CarriagePartVariantBlocks.rename move a user-tier <id>.variants.json when there is one, and otherwise write the cached / package / bundled sidecar out under the new id (Carried N β¦ entries in the log). Before this, editor contents enter armor10 β contents save phtest found no user-tier file to move, dropped the cache entry, and the dev write-through shipped phtest.variants.json with "variants": {}; the part editor never renamed its sidecar at all. As with every dev-mode save-as, the old id's src/ files are deleted and the new ones written β git checkout the templates after a test rename.
The loader walks config-dir β bundled jar resource β hardcoded legacyPlaceAt at runtime β see Storage β three-tier loader on the Features page for the full chain.
Live chat relay env (v0.1257.0): the Discord live chat bridge needs LIVE_CHAT_CHANNEL_ID and LIVE_CHAT_WEBHOOK_URL on the relay (plus LIVE_CHAT_DEV_CHANNEL_ID / LIVE_CHAT_DEV_WEBHOOK_URL for the dev cap), and the relay bot must be able to read that channel. Unset, the routes answer 503 and the mod stays quiet. For a local test, a throwaway bot needs the Message Content and Presence intents or the gateway closes with 4014.
Twitch chat relay env (v0.1259.0): LIVE_CHAT_TWITCH_CHANNEL (defaults to TWITCH_CHANNEL_LOGIN), LIVE_CHAT_TWITCH_BOT_LOGIN and LIVE_CHAT_TWITCH_REFRESH_TOKEN (the bot account's refresh token, scopes chat:read chat:edit user:write:chat user:bot), refreshed with the existing TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET; a rotated token is kept in <ANALYTICS_DIR>/twitch-chat-token.json. Mint the token once with node scripts/twitch-chat-auth.js in the relay repo (needs http://localhost:3177/callback registered on the Twitch app), logged into the browser as the bot. The bot must be a moderator of the channel. Which cap's ring Twitch joins follows TWITCH_SOURCE_CAP_LABEL, so a Gate 2 run on the dev cap works with dev. Local test without Twitch: wrap server.js and replace live-twitch-chat's start with a createBridge({fetchImpl, wsFactory}) built on an in-process socket and a fake fetch for id.twitch.tv / api.twitch.tv.
The item values page runs on the relay; the mod only consumes it. Three tools keep the two in step.
| Task | Command | Output |
|---|---|---|
| Refresh the page's item list |
/dungeontrain debug trade-values dump on a dev server or client |
run/trade-values-catalog.json β copy to dp-relay public/dungeontrain/items/catalog.json
|
| Refresh the page's icons |
./gradlew runClient -PtradeValueIcons (renders at the first menu, then quits) |
run/trade-values-icons/ β copy to dp-relay public/dungeontrain/items/icons/
|
| Ship approved values |
./gradlew pullTradeValues (-PtradeValuesUrl= for a local relay) |
src/main/resources/data/dungeontrain/trade_values.json β review the diff, commit in its own PR |
-
pullTradeValuesis never part ofbuild. CI and the auto-release cascade build offline, so each pull is a deliberate, reviewable change. -
Re-dump the catalog after a Trade Everything bump. The catalog records each item's real villager quote; dp-relay's
item-values-trade-quote.test.jsfails if the page's pricing port no longer matches it. - Re-render icons after adding mods with new items. Items with no icon simply show none on the page.
Every in-game weight edit (/dt editor weight, tracks weight, contents weight, the [-] Weight (N) [+] HUD row) now writes to both the per-install config/dungeontrain/.../weights.json (your local override, as before) and src/main/resources/data/dungeontrain/.../weights.json (the bundled default that ships in the next jar) β no extra command, no devmode on toggle.
The auto-promote is gated on sourceTreeAvailable(), which only returns true when FMLPaths.GAMEDIR.getParent() resolves to a checkout with a writable src/main/resources/. End-user installs see no behaviour change β the source tree is unreachable, so the second write short-circuits to a no-op.
Workflow on a checkout:
-
Launch the dev client β
./gradlew runClient. Same source-tree resolution as the template editor. -
Tune weights in-game β use the editor menu's
[-] Weight (N) [+]row, or/dt editor weight <variant> <n>. The success chat line lists only the config path, but the server log showsWrote bundled <kind> weights to .../src/main/resources/...on every successful source write. -
Commit the JSON β
git add src/main/resources/data/dungeontrain/**/weights.json && git commit. The three weight files (templates/weights.json,contents/weights.json,<track-kind>/weights.json) belong in the same PR as the code that depends on them.
If the source-tree write fails for any reason (read-only mount, file lock), the in-game state stays correct β the config write already succeeded. A warn log surfaces the failure without breaking the command.
Before v0.58.0, dev-mode weight tweaks lived only in run/config/ (git-ignored), so a session of in-game tuning was silently lost on commit unless you remembered to copy the JSON by hand.
The per-install file (<gameDir>/dungeontrain/user/.../weights.json, all three kinds) holds only the entries that differ from the bundled record β TemplateWeightOverlay.diff(merged, bundled), structural TemplateMeta equality on weight, gate, Stage link, mode, flip, label and builder. Each store keeps the bundled tier beside its merged view for exactly this comparison. Two consequences:
-
Bundled retunes reach every player. Before, any setter wrote the whole merged map, so one edit to one room froze every other room's weight as shipped that day β and the overlay wins on reload, so a room later retired to weight 0 (
outsidethebox) kept spawning for anyone who had ever touched a room setting. An old snapshot self-heals on its next write: redundant entries drop, entries that genuinely differ are kept as overrides (they are indistinguishable from deliberate ones). -
A world with custom content disabled reads no overlay at all.
openConfig()returns nothing whileEditorContentIntegrity.isSuppressed(), matching whatUserContentPaths.searchDirsalready does for every other store; the reload barrier re-runs the threereload()s when the choice flips. Previously the weights were the one store that bypassed suppression, which is how stale weights leaked into real (non-Free-Play) runs.
Dev mode is unchanged: saveToSource still writes the full merged map to src/ β that is the bundled file. Note the overlay is diffed against the classpath copy (build/resources), which lags src/ until a rebuild; the merged view stays correct either way, and the leftover delta entries prune themselves after the next build + write.
A template's id is its file basename (book.nbt is book in the registry, in weights.json, in every <parent>.group.json and in the plot layout), so an id change is a multi-file move and is refused for a bundled template β there is nothing in the user tier to move and the classpath copy would keep answering to the old name. The editor therefore renames the way a Stage does: a stable id plus a free-text display label.
- The label is the optional
"name"field on the id's entry in the kind'sweights.json("book": {"weight": 3, "name": "Tome"}), so it overlays a bundled entry from the user tier, follows the id through a realrename, and writes through tosrc/in a dev checkout like every other weights edit (see the section above). Nothing at spawn time reads it. -
X menu β Rename now sets the label (works on built-ins, bundled rooms and sub-variants; blank clears it). Rows, tiles, plot signs and the sub-variant chips draw the label; the tooltip shows
Label Β· idand the data sheet adds anId:line, and every command still sends the id. - Commands:
/dt editor label <id> <nameβ¦>,/dt editor contents label <id> <nameβ¦>,/dt editor <tracks|portals> label <kind> <name> <labelβ¦>. The id-changingrenameverbs are unchanged and stay on the command line.
Sub-variant membership already lives in the parent's .group.json rather than in the child, so re-parenting never moves a file either. group move <child> <new_parent> (contents and dimensional carriages) carries the member's weight, inline gate and Stage links across in one step β remove then add would reset them β and the X menu β Move toβ¦ picker offers Top level plus every eligible parent (move / promote / demote). The pure half is editor/VariantGroupMoves; refusals mirror group add (self, single-hop nesting, cycles).
Both are dev write-through paths: after an in-game session, git diff --name-only origin/main HEAD -- src/main/resources/data/ lists the weights.json and .group.json files your test edits touched β keep the ones you meant to ship and git checkout the rest.
Every template can carry who originally built it: the optional "builder": {"uuid": "<undashed>", "name": "<display name>"} on the id's entry in the kind's weights.json, beside the weight, gate and label (TemplateMeta.builder, BuilderCredit). Same tiering as the label β overlays a bundled entry from the user tier, writes through to src/ in a dev checkout β and nothing at spawn time reads it. The uuid is the identity (what the relay counts); the name is a cache for display. A name with no uuid is allowed (thanked on the Credits page, never counted on a board).
-
X menu β data sheet β Built by. Read-only in play. In dev mode the cell is a picker: it opens Find a Builder in credit mode with Me / No builder above the relay search (dark light = dev pool, green = live pool through the admin URL). A downloaded build with no authored credit shows its
BuildCreditscreator as a fallback (display only). -
Commands:
/dt editor builder <id> <uuid|none> [nameβ¦],/dt editor contents builder <id> β¦,/dt editor <tracks|portals> builder <kind> <name> <uuid|none> [nameβ¦].-as the uuid means name-only. -
Relay mirror. After the local write the command posts the credit to the relay's operator route (
POST /<ADMIN_CAP>/templates/credit?cap=live,TemplateCreditClient) and the second chat line says whether that happened (recorded/saved locally only β no relay admin URL/unreachable). A release jar has no admin URL and never posts. The relay feeds the Most Templates Built board and the Credits page's relay half from it. -
Catch-up:
python3 scripts/relay/sync-template-credits.py [--dry-run] [--cap live|dev]walks every bundledweights.jsonand upserts each credit (idempotent) β run it after committing credits set without an admin URL, or to make the relay match a fresh jar. Reads the admin URL from$DUNGEONTRAIN_RELAY_ADMIN_URLor~/.config/dungeontrain/relay-admin-url.txt. -
Advancement requirement values are the same shape: relay
GET /<CAP>/advancement-requirements(anonymous, fetched once per launch, applied at datapack load β see Feature-Advancements), edited at/<ADMIN_CAP>/advancement-requirements[/<id>|/log]?cap=from the explorer, imported back byscripts/advancements/import-requirement-edits.py. Lang descriptions carry%sfor the number;scripts/localization/requirement-placeholders.pyis the one-off that converted the locales. -
Advancement flags (
disabled/notRequired, v0.885.0) ride the same payload asflags, edited at/<ADMIN_CAP>/advancement-flags/<id>/<flag>?cap=from the explorer's Enabled / Burrito checkbox columns; nothing to import back (the jar carries no per-advancement flag). See Feature-Advancements. -
Local relay for testing:
./gradlew runClient -PrelayBaseUrl=<dev cap url> -PrelayLiveBaseUrl=<live cap url> -PrelayAdminUrl=<admin url>points the dev pool, the live pool (Credits page reads, My Builds' live toggle) and the operator route at a localdp-relayrun withCAP,DEV_CAPandADMIN_CAPset. -
Dev write-through gotcha: the whole merged store (bundled + your user tier) is promoted into
src/on every save, so stale test credits inrun/config/dungeontrain/user/**/weights.jsonreappear in the diff until you clear them there. Keep the credits you meant to ship andgit checkoutthe rest before Gate 3.
Since: v0.833.0 β PR #1353
The inventory-style editor screen (client/menu/editorscreen/) is four pages (EditorScreenPage: TEMPLATES, LAYOUT, NAV, SETTINGS β Nav is the picker's four tiles indoors, EditorNavPane, and owns both columns while active) over one shared filter state in EditorScreenState. Things to keep straight when extending it:
-
Category is a filter.
EditorCategoryFilter(ALL, CARRIAGES, CONTENTS, TRACKS, DIMENSIONS) owns the parts-under-Carriages fold and the "Dimensions" naming;EditorTabBar.layouttakes a label function, not a positional list. -
EditorFilterBaris the single row above both columns (InventoryEditorLayout.filterspans the full inner width; both columns start under it). Collapsed it shows the active chips from the pureactiveChips(...); expanded it adds the chip row and the two strips in the left column.InventoryEditorLayout.of(w, h, expanded)returns zero-height strips when collapsed; the preview gives way so the sheet and test row fit at 427Γ240. -
Layout rows are pure.
EditorLayoutPage.rows(index, Folds, Query, select, toggleSection, toggleGroup)buildsCommandMenuEntry.Cellsrows (the new N-column record;MenuRowPainter/MenuEntryDispatcherdecompose it).Foldsholds what is opened β sections and groups default to folded;Queryis the filter state.EditorLayoutPanecaches the rows on roster/folds identity +Queryequality and handles the weight cell itself (inc / shift dec / cmd βInlineEdit). Never route a typed value throughEditorModalHost.beginTypingfrom a pane: its Enter path isrunAndCloseand shuts the screen. -
Where X opens is
EditorScreenGate.opensInventoryScreen(inPlot, inEditorWorld, screenspace);EditorRosterClient.prefetch()asks for the roster on the editor-world tick and onEditorStatusPacket, so the screen has rows on open.EditorRosterRequestPacketlogs[editor.timing] roster β¦ built in N msat DEBUG (stdout ofrunClient). -
HUD category is an id.
EditorStatusPacket.categoryisEditorCategory.id()("portals"), neverdisplayName()("Dimensions") βPlotCategory.fromId,EditorTemplateJumpBridge,EnterCategoryMenuScreenandBuilderProfileScreenall compare it to command tokens.EditorStatusHudOverlay.categoryLabelmaps it to the label only at draw time (PR #1395). -
Trap fixed:
EditorTemplateJumpBridgeused the HUD category, which is empty between plots, andrunEnterCategoryclears + restamps every plot unconditionally β it now falls back toEditorRosterIndex.stampedCategory(). Anything else that "switches category" must be sure it is actually switching.
Since: v0.832.0 β PR #1351
The editor screen's creator browser (Find a Builder β a builder's profile or All builders) loads relay builds into this install's library. For the two kinds that have sub-variants β contents and dimensional carriages (portal rooms) β the Load slot is two buttons: Load as sub-variant and, to its right, the variant parent the build lands under (βΈ User builds by default). The parent button opens Load under, listing User builds (new) while no such parent exists plus every top-level template of that category (never a member, never the default room). The choice is remembered per category for the session. Carriages, parts and tracks have no group system and keep the plain Load.
- Server side:
BuilderRelaySubVariant.joinruns afterBuilderRelayInstallreportsINSTALLED, with the same refusals asgroup add(self, single-hop nesting, cycles, built-in parents). A refused join logsinstalled at top level, not under '<parent>' β <why>and the build stays a top-level template. - A missing parent is created with
selfWeight: 0β the bundledcontents/traps.group.jsonprecedent, a parent that only exists to hold its members. A contents parent needs no.nbt(the registry lists group parents from.group.jsonbasenames); a room parent must be a real room (rooms are discovered by.nbtscan and the editor stamps the parent plot), souser_builds.nbtis written from the first build loaded under it and never spawns as itself. Room-group writes go throughPortalRoomEditor.relayout, like the editor's own group verbs. - The Find a Builder panel header carries a small relay light: green while it asks the live relay, dark for dev ("Live"/"Dev" on hover). Clicking it flips the same switch as Settings β Relay, re-requests the starred builders and re-searches the typed name against the other pool.
- Dev write-through applies: a test session mirrors
user_builds.group.json, the weights label and (for rooms)user_builds.nbtintosrc/main/resources/data/dungeontrain/β¦βgit checkout/ delete those before a PR, since the group would reference an.nbtthat is not in the repo.
The operator's verdict on a submitted build (review on the relay: none|submitted|accepted|declined| feedback|resubmit) can be set from inside the game. The gate is BuilderReviewEdits.canReview: a dev
build (DungeonTrain.isDevBuild()) that resolves a relay admin URL (RelayTarget.adminSearchBase() β
DUNGEONTRAIN_RELAY_ADMIN_URL, <gamedir>/relay-admin-url.txt, or ~/.config/dungeontrain/) and is
not the build's owner. It is checked on the server; the client only shows the buttons when the hints
answer says canReview.
Flow: X menu β Find a Builder β a player β a build β sheet page 2 (Submitted answers). Four icon
cells (the My Builds tile badges): accept, feedback, resubmit in version, decline. Each opens
BuilderReviewCommentScreen; resubmit adds a version source (Latest release from the title-screen
check, This build = VersionInfo.VERSION, or a typed dotted version) and a qualifier (or above /
exactly / or below). Save flips the tile at once (BuilderProfileState.noteReview) and sends
BuilderReviewPacket β SharedCarriageClient.adminSetReview β POST /<ADMIN_CAP>/carriages/<id>/review {review, comment, by, version, versionOp}. Accept, feedback and resubmit post the build, its picture and the comment
through BuildReviewReporter (announces(review)) on the same route as a submit announcement
(build-submissions on main, the dev channel on a dev build); the embed's one field reads #155 Β· carriage. The reviewer never sees Edit answers β that button is the owner's.
Relay side (dp-relay β₯ 0.215.1): every /carriages/publish carries dtVersion; a build in resubmit
is only re-queued when that version satisfies the rule, otherwise the publish answers
{ok:false, reason:'needs_version', version, op} and the player sees the rule in chat. The web admin's
#/builds page has the same four verdicts plus the comment and version inputs; a verdict set there
does not post to Discord.
Testing locally: run the relay branch with throwaway DEV_CAP/ADMIN_CAP, seed a build under another
uuid via /carriages/submit + /publish, and launch with
./gradlew runClient -PrelayBaseUrl=http://127.0.0.1:<port>/<DEV_CAP> -PrelayAdminUrl=http://127.0.0.1:<port>/<ADMIN_CAP>.
The server drops builder packets until run/config/discordpresence-client.toml has networkConsent = "GRANTED".
Since: v0.927.0 β PR #1477
EditorCategory.WHOLE (first in the enum, so first in every bar) with PlotCategory.WHOLE (rooms)
and PlotCategory.WHOLE_GROUP (groups, the PARTS-style extra whose owner() is WHOLE;
browsesUnder replaces the old CARRIAGES && PARTS folds). Template.WholeCarriage gained a plot
and Template.CarriageGroup / TemplateKind.CARRIAGE_GROUP are new. Plots: WholeCarriageEditor
β rooms at Z=0, groups at EditorLayout.WHOLE_GROUP_FIRST_Z (the parts band; safe because only
the resident category answers plotContaining). A group plot is groupSize Γ length long, from
DungeonTrainConfig.getGroupSize().
-
Stores
WholeCarriageTemplateStore/CarriageGroupTemplateStoreare three-tier now (bundled/data/dungeontrain/whole/{room,group}/, userwholecarriages//carriagegroups/β the old subdirs kept on purpose β dev promote). Registries are bundled + user viaBundledNbtScanner.scanBasenames.TemplateStores.reload()now reloads the group store too (it never did). Both stores judge dims per call, not in the cache (CarriageTemplateStore's rule). -
Weights
WholeWeightskeyed byWholeKind {ROOM, GROUP}(modelled onTrackVariantWeights: oneweights.jsonper kind, overlay-diffed, gates/stages/labels/credits). Since v0.1103.0 the whole-group rate is the weight of thewholegroupGroup template (WholeGroupSelection.VARIANT_ID), andWholeGroupSettingskeeps only the forced test cadence. Before that,every Nlived inWholeGroupSettingsβ bundledwhole/group/settings.json, whole-file user overridecarriagegroups/settings.jsonβ editor content, not world SavedData, so it ships and travels in packages.WholeVariantBlocksis the Z/C sidecar (<id>.variants.jsonbeside the template) andBlockVariantPlot.WholePlot(keyswhole:<id>/whole_group:<id>) is what makes the Z and C menus resolve inside a whole plot. -
Spawn
WholeCarriageSelection.pickRoomruns besidetryLeaseSharedinTrainAssembler.spawnGroupwhen the shell roll is thewholevariant (portal slot β null; pool empty / gated out / wrong dims β null βwhole.nbtplaces as a plain carriage).WholeGroupSelectiondecides the group before the slot loop (SeededDraw.hitwithGROUP_SALT; portal group wins;WholeGroupSettings.force(n)is a session-only periodic override) and stamps the run at the first enclosed slot beforeShipyards.assemble; every slot then skips shell, lease, room and contents passes.WholeOverlayrolls the sidecar pools and container prefabs after either stamp. Log lines:whole room pIdx=N β ROOM_HIT id=β¦|POOL_EMPTY|NO_FIT|PORTAL_SLOT,whole group anchorPIdx=N β GROUP_HIT id=β¦, andwhole=group=<id>/rooms=[β¦]on theSpawned groupline. -
Wire
EditorTypeMenusPacket.wholeGroupEveryrides every push (the Settings page reads it); protocol 74 β 75. The Group companion's header carries the value beside the title (EditorTypeMenuSettingsRow.headerEvery); the nav menu draws it as a settings row. -
Relay
BuilderRelayWholeRoom: the sentinel parent idwhole:room(never a valid template id) routes a CARRIAGE or CONTENTS download into the room pool; contents are baked onto the bundledstandardshell by stamp-into-scratch-then-capture (no stage scope, so placeholders survive). -
Commands
/dungeontrain editor whole β¦(WholeEditorCommand, incl.stage apply whole|whole_group) and/dungeontrain whole β¦(WholeCarriageCommand). Dev write-through applies to whole weights, settings and sidecars βgit checkoutthose before a PR. - Headless Gate 2 recipe: seed
run-server/dungeontrain/user/wholecarriages/<id>.nbt(a shell is a valid room) and a tiled 27Γ7Γ7carriagegroups/<id>.nbt,portal carriage off,whole group force 1β every group logsGROUP_HIT; thenwhole group off+editor weight whole 100βROOM_HITonwholeslots.
Since: v0.857.0 β PR #1378 Β· relay dp-relay#573 (v0.185.1)
TemplateSidecars carries a template's chest β prefab links (the containers store) but not the prefabs, which are a library shared by every template. TemplateLootPrefabs now carries them: on every profile upload (BuilderRelayUpload, both reconcile paths) it collects the config-tier prefabs the template's containers store links (ContainerContentsStore.linkedPrefabIds(); bundled prefabs are on every install and are skipped) and sends them as the relay's lootPrefabs {id: json} field. The relay files them under the row's owner in a per-author loot_prefabs table (never on the carriage row β one prefab serves many builds) and records the linked ids on carriages.lootPrefabIds; /carriages/fetch joins them back.
On download, BuilderRelayDownload asks TemplateLootPrefabs.conflicts before writing anything β once the name collision is settled, so the two questions are each asked once. An incoming id that exists locally with different parsed contents comes back as Outcome.PREFAB_CONFLICT carrying both texts; the client's BuilderProfilePrefabConflictScreen (comparison model in LootPrefabDiff, MC-free and unit-tested) shows Yours/Theirs and the answer replays the exact same download press with a PrefabAnswer (overwrite set + old β new renames). After the template and sidecars land, install writes missing ids, overwrites the chosen ones and files renamed ones under their new id; relink re-points the installed template's containers store at renamed ids; then PrefabRegistrySyncPacket is pushed so the creative tab shows them without a rejoin. Protocol 66 β 67.
- Relay env:
LOOT_PREFABS_MAX_PER_OWNER(default 500) β past it, new ids are skipped and reported aslootPrefabsSkipped; never evicted, since an evicted prefab would break a live build's links. Reads:POST /<cap>/carriages/loot-prefabs {uuid}(metadata) andGET /<ADMIN_CAP>/carriages/loot-prefabs?cap=&uuid=(with json). Schema is created lazily bycarriages.ready()on the first carriages call after boot. - The store seam is
TemplateLootPrefabs.Library(defaults toLootPrefabStore), which is what makes collect/conflicts/install testable without a game directory (TemplateLootPrefabsTest). - Dev write-through applies to prefabs too: a Gate 2 session mirrors installed prefabs into
src/main/resources/data/dungeontrain/prefabs/loot/βgit checkout/ delete those before a PR, or the bundled-data tests fail on the leaked files. - Test-setup trap: two local relays on the same port answer the same cap (
aaaaβ¦) β a dev client pointed at127.0.0.1:<port>silently talks to whichever session's relay owns it. Pick a port per session.
The wooden stretch of the run is one Stage per timber, each the same seven parts as
wood_oak (floor mudbricks_, walls mudbricks_ / mudopen_ / mudwindows_, roofs
mud_ / mudopen_, door mud_) with the oak block ids swapped. Rather than copying them
one at a time in the editor, scripts/parts/clone-wood-stage.py builds a wood from the oak
originals:
python3 scripts/parts/clone-wood-stage.py # write whatever is missing
python3 scripts/parts/clone-wood-stage.py --check # report only; exit 1 if anything is missingPer wood in its WOODS table it (1) clones each oak .nbt with the palette ids mapped and
each .variants.json with the same map applied to every minecraft: id, as
<family>_wood_<type>; (2) adds / re-bands the Stage in stages.json; (3) appends a
manifest entry beside every wood_oak entry in the seven templates that carry them
(standard, shared, black, cracked, fancywood, pen, windowed); (4) syncs the legacy
parts/<kind>/manifest.json files. Idempotent β existing parts, stages and entries are left
alone, so adding a wood is one line in WOODS and a re-run. The id map handles the
non-standard woods: Nether woods use hyphae / stem for wood / log, bamboo uses
bamboo_block / stripped_bamboo_block, and bamboo_mosaic swaps the pillar blocks so the
mosaic reads against them. The NBT codec is scripts/portal/nbt.py (byte-exact round-trip).
Woods authored in the editor before the script existed (darkwood, spruce, acacia)
keep their editor names (mudbricks_wood_oak_darkwo_spruce β the copy dialog truncates)
and have block map None in WOODS, so the script only manages their bands.
Since: v0.60.0 β PR #75
Updated: v0.144.0 β PR #185 β auto-promote now also covers shipping custom carriages (any variant whose <id>.nbt exists in src/main/resources/data/dungeontrain/templates/, e.g. pen, black). Previously gated on CarriageType enum membership only, which silently dropped pen/black part toggles into the config dir without source-tree write-through.
The same source-tree write-through applies to per-carriage part assignments edited via the in-world block-variant menu (Walls / Floor / Roof / Doors panels β Add / Remove / Clear / BumpWeight / CycleSide). On a dev checkout, every menu mutation now writes to BOTH config/dungeontrain/templates/<id>.parts.json AND src/main/resources/data/dungeontrain/templates/<id>.parts.json, mirroring the weights flow shipped in PR #73. The same dual-tier behaviour also applies to the per-variant contents-allow sidecars edited via the X-menu's carriage-contents <variant> <contents> on|off rows (<id>.contents-allow.json).
Restrictions:
-
Shipping variants only β auto-promote fires when a corresponding
<id>.nbtis committed tosrc/main/resources/data/dungeontrain/templates/. Covers built-in (standard/windowed/flatbed) AND custom shipping (pen/black/...) variants. Per-install user-only variants (no shipped NBT) stay config-dir only β correct, since they don't have a shipping home. -
Dev mode is not required for parts/contents-allow (unlike the template auto-promote above) β the gate is purely whether
src/main/resources/is writable, which is true on any./gradlew runClientfrom a checkout.
Look for Wrote bundled parts assignment <TYPE> to .../src/main/resources/... in the server log to confirm a write. A menu Clear also propagates to the bundled file via tryDeleteFromSource, so deleting a variant in-game doesn't leave a stale promoted file shadowing the cleared state on the next reload.
Before v0.60.0, dev-mode part-variant edits lived only in run/config/ (git-ignored), so a session of in-game wall/floor/roof/door authoring was silently lost on worktree switch β exactly the same defect PR #73 fixed for weights.
Block-variant prefabs (V-key menu's Save button) and loot prefabs (C-key menu's Save button) saved in dev mode now write to BOTH config/dungeontrain/prefabs/<kind>/<id>.json (your local override) AND src/main/resources/data/dungeontrain/prefabs/<kind>/<id>.json (the bundled default that ships in the next jar). Mirrors the dual-write pattern used by the carriage-template editor.
Workflow on a checkout:
-
Launch the dev client β
./gradlew runClient. Same source-tree resolution as the rest of the editors. -
Verify dev mode is on β
/dungeontrain editor statusshowsDev mode: ON. Auto-on by default in./gradlew runClientsessions since v0.142.0 (PR #183) β manual/editor devmode on|offonly needed to override the default mid-session. -
Save a prefab β open the V-key (block-variant) or C-key (loot) menu, hit Save, type a name. The action-bar feedback tells you which tiers were written:
-
Saved prefab 'foo' (β src)β both config and source tree (dev mode on, source writable) -
Saved prefab 'foo'β config only (dev mode off) -
Saved prefab 'foo' (no src tree)β dev mode on but running from a packaged jar (e.g. dedicated server) -
Saved prefab 'foo' (src write failed)β config write succeeded but the source-tree file write hit an IO error (rare; check log)
-
-
Visual confirmation in the creative tabs β uncommitted prefabs (config-only) render with a translucent yellow tint behind the icon in the prefab side-tabs. Committed prefabs (in source tree or bundled jar) render normally. The tint is driven by an
NBT_PREFAB_UNCOMMITTEDmarker set on the creative-tabItemStackwhen the server-sideisCommitted()check returns false. -
Commit the JSON β
git add src/main/resources/data/dungeontrain/prefabs/**/*.json && git commit. The next mod build packages them as bundled resources for all players.
If you didn't have dev mode on while saving, run /dungeontrain editor prefab promote <kind> <id> to copy the config-dir file into the source tree without re-saving in-game. (Or just re-save with dev mode on β it overwrites both tiers cleanly.)
The reload chain is bundled jar resource βͺ config-dir β server-side IDS TreeSet. delete() only removes the config-dir copy β bundled prefabs are read-only from the running jar's perspective and stay registered.
Before v0.78.0, prefab saves landed only in run/config/ (git-ignored), so a session of in-game prefab authoring was silently lost on commit unless you copied the JSONs by hand.
All four template registries β CarriageVariantRegistry, CarriageContentsRegistry, CarriagePartRegistry, and TrackVariantRegistry β now use a shared BundledNbtScanner that walks the classpath at server start. Drop a new .nbt into src/main/resources/data/dungeontrain/... and it auto-registers β no manifest update required.
src/main/resources/data/dungeontrain/
βββ templates/<id>.nbt # carriage shells β CarriageVariantRegistry
βββ contents/<id>.nbt # carriage interiors β CarriageContentsRegistry
βββ parts/<kind>/<id>.nbt # FLOOR/WALLS/DOORS β CarriagePartRegistry
βββ tracks/<id>.nbt # open-air track tiles β TrackVariantRegistry
βββ pillars/<section>/<id>.nbt # pillar / stairs β TrackVariantRegistry
βββ tunnels/<kind>/<id>.nbt # tunnel section/portal β TrackVariantRegistry
The scanner handles three URL schemes: file: (dev mode, exploded resources under build/resources/main/), jar: (production builds), and Forge's union: (the transformer-pipeline filesystem used during runClient). Built-in NBTs (standard.nbt, flatbed.nbt, default.nbt, etc.) are filtered via existing isReservedBuiltinName checks before drift comparison so they don't shadow the enum-driven types.
Before v0.59.0, each registry maintained its own JSON manifest:
| Registry | Legacy manifest |
|---|---|
CarriageVariantRegistry |
templates/customs.json |
CarriageContentsRegistry |
contents/customs.json |
CarriagePartRegistry |
parts/<kind>/manifest.json (one per kind) |
TrackVariantRegistry |
(never had one β bug; see Feature-Track-Variants Β§ v0.59.0) |
These manifests are still in the repo and still read by the scanner β but only for a drift cross-check. The scan is authoritative; a WARN fires if any manifest disagrees with what's actually on the classpath:
[DungeonTrain] Bundled drift in parts/floor: 'standard' scanned but not in manifest
[DungeonTrain] Bundled drift in parts/walls: 'cracked' scanned but not in manifest
If you see drift WARNs after editing the resource tree, either delete the stale manifest entry or commit the new .nbt. The manifests will be removed entirely in a follow-up commit once a few releases ship with clean drift logs.
The four parts/<kind>/manifest.json files are regenerated from the .nbt files on disk by scripts/parts/clone-wood-stage.py (v0.860.0 β they had drifted to 388 WARN lines per boot). contents/customs.json is not yet synced the same way and still warns.
Forge's UnionFileSystem doesn't implement getPathMatcher, so the scanner uses Files.newDirectoryStream(dir) (no glob) and filters by .nbt suffix in code. Don't pass "*.nbt" to newDirectoryStream from anywhere that might run against a union: path β it throws UnsupportedOperationException at server start.
DT's own Lost City templates are generated, never edited by hand β the NBT under src/main/resources/data/dungeontrain/structure/lost_city/ and the pools/structures/processor lists under worldgen/*/lost_city/ are build outputs.
python3 scripts/lost-city/test_lostcity.py # generator unit tests
python3 scripts/lost-city/build-templates.py # recipes β <name>.nbt + manifest.json (byte-identical re-runs)
python3 scripts/lost-city/generate-variants.py # designs β template_pool / structure / processor_list JSON + structure_set entries
python3 scripts/lost-city/build-templates.py --check # CI: outputs up to date?
python3 scripts/lost-city/generate-variants.py --check-
Add a building: a recipe module in
lostcity/archetypes/exposingARCHETYPE = Archetype(SPEC, draw), listed inarchetypes/__init__.py; its designs inlostcity/variants.py.ArchetypeSpecdeclares size, margin, weight and the exactfloor_period/bay_period_x/bay_period_zthe stretch may use (Nonewhen the building has no clean repeat β the wrecks).drawlays the pad (footprint_pad), the shell, interiors (furnish.kit,spawners_and_loot), thenfinish()fromarchetypes/common.py; anything that must survive the damage pass (a summit chest, dangling chunks) goes afterfinish(). -
Conventions the checks enforce (
lostcity/check.py,LostCityTemplatesTest): pad aty = 0over the whole footprint (natural ground the world may take, paving it may not); nothing inside the margin; the top of the box within four layers of the roof; no fallable block unsupported; no iron/gold/diamond/netherite blocks; block-entity NBT only on spawners and loot chests; declared periods must be real repeats (Jaccard β₯ 0.45 of full-cube mass, asLostCityStretchProcessor#findBandmeasures). -
Licensing rule: the generator never reads Big Lost City's jar;
datagen.pyrefuses to write any file naming abig_lost_city:resource except the shared structure set. Seescripts/lost-city/README.md.
Every entry in .github/release-notes/changelog.json carries tags[] β the in-game Versions page
filters release notes by them, and the rendered notes (GitHub / Modrinth / CurseForge / Discord) open
with a tag-count line. At Gate 3:
python3 scripts/release-notes/append-entry.py --id <slug> --type feat --title β¦ --summary β¦ --tag editor --tag trainThe type-derived tag (featβfeature, fixβfix, contentβcontent, perfβperformance) is
automatic; --tag adds topical ones. Tag what the change is about, not what its prose mentions β
the ledger's summaries say "rides the train" / "the books other players wrote" constantly, and a tag
that fires on narration is noise the filter can't be trusted with. The 17 tags and what each means are
in .github/release-notes/README.md Β§ Tags; the list must agree across changelog_io.VALID_TAGS, the
schema enum and the client's ChangelogTag, and each tag needs a lang key in all locales.
scripts/release-notes/backfill-tags.py classified the pre-tag history by keyword rule; it is a no-op
on tagged entries. After tuning a rule, --retag <tag> recomputes that one tag across the ledger
(--show <tag> lists what a rule catches; --dry-run reports counts). Tags whose vocabulary doubles as
narration (multiplayer, train) match the title only (TITLE_ONLY). Open follow-up: #1385
audits the remaining body-matched tags.
scripts/notify-discord.sh accepts RELEASE_NOTES=<text> to preview an announcement without a tag β
post it to the dev channel with DISCORD_WEBHOOK_URL=<dev relay cap>/hook.
Two GitHub Actions workflows run automatically:
Triggers on every PR and every push to main. Steps:
- Check out code with full git history
- Cache Gradle wrapper + Forge Gradle home
./gradlew build- Upload
dungeontrain-*.jaras a workflow artifact (downloadable from the Actions tab)
Alongside it run three stdlib-Python guard jobs (modpack-checks, localization-checks, net-checks) β no Gradle, so they finish in seconds.
net-checks β packet layout changes must bump PROTOCOL_VERSION (v0.912.0, PR #1456)
DungeonTrainNet.PROTOCOL_VERSION (passed to PayloadRegistrar.versioned) is the only thing that keeps an older client off a newer server: NeoForge negotiates payload types, not their layouts, so a packet that gains a field still passes the login handshake and then kicks the client mid-session when the server reads past the end of the old buffer (IndexOutOfBoundsException). PR #1449 shipped exactly that β EditorPlotActionPacket gained a trailing boolean centre with the version left at "72".
Rule: any change to a packet's encode/decode body β a new field, a widened one, a reordered one, a new packet β bumps PROTOCOL_VERSION by one. A bump is always safe (mismatched pairs get the standard NeoForge mod-mismatch screen at login, naming both versions); a missed one is not.
scripts/net/check-protocol-version.py enforces it on pull requests: it diffs the PR against its base over src/main/java/games/brennan/dungeontrain/net/ and fails when any added/removed line touches a codec idiom (buf.readX/buf.writeX, ByteBufCodecs.X, StreamCodec.of/composite) in a file other than DungeonTrainNet.java while the PROTOCOL_VERSION = "β¦" line is not in the diff. Comment, handler and refactor edits don't trip it. There is no opt-out β if a flagged diff is genuinely wire-compatible, bump anyway. Run it locally with python3 scripts/net/check-protocol-version.py [--base origin/main] [--head HEAD].
Triggers on v*.*.* tag push, or manually via workflow_dispatch with a tag input.
Steps:
- Verify the tag matches
mod_versioningradle.properties - Build the release jar
-
Compute release type β tag MAJOR = 0 β GitHub Pre-release + Modrinth/CurseForge
beta; MAJOR β₯ 1 β Release +release;test_release=trueβalpha(PR #101). Since v0.904.0 (PR #1445) the rule lives in one script,scripts/release/release-type.sh, called byrelease.yml,reupload-curseforge.ymland both modpack publish scripts. It was previously copy-pasted per publisher and the CurseForge re-upload path had drifted to a hardcodedreleaseβ which is howdungeontrain-0.692.0.jarbecame the only full-release file on CurseForge (its GitHub/Modrinth copies are beta). Any new upload path must call the script rather than name a channel. - Create the GitHub Release (pre-release flag set automatically)
- Run
scripts/publish-wiki.shto updateDownloads.mdandDownloads-Archive.md(PR #96) - Publish to Modrinth + CurseForge via
Kir-Antipov/[email protected](PR #97) -
Push shipped-template fingerprints to the relay β
./gradlew bundledFingerprintswritesbuild/bundled-fingerprints.json(the relay-side SHA-256 of every shipped template, computed bytools/BundledFingerprintsthrough the samefromTemplateTag+encodepath a reconcile upload uses) andscripts/relay/push-bundled-fingerprints.pyPOSTs it to/<ADMIN_CAP>/carriages/bundled-fingerprints. The relay keeps the set cumulatively and uses it to tell an untouched copy of a stock template from a build (see Build Profiles).continue-on-errorβ never blocks a release; needs theRELAY_ADMIN_BASEsecret. To backfill older template versions by hand, extract them from git history into trees of the same layout and run./gradlew bundledFingerprints -PfingerprintExtra=<dir>,<dir>β¦(PR #1434).
Steps 5 and 6 emit workflow warnings (not failures) if the required secrets/variables are not configured: WIKI_TOKEN, MODRINTH_TOKEN, MODRINTH_PROJECT_ID, CURSEFORGE_TOKEN, CURSEFORGE_PROJECT_ID.
To retro-publish an existing tag on Modrinth/CurseForge after adding secrets, trigger workflow_dispatch with the tag name.
Two modpacks (CurseForge project 1556213, Modrinth bEFyz3ji) are published from one
config, modpack/modpack.config.json, after every real mod release β never by hand. Each
bundles that release's DT file + the pinned Sable and companion mods. Full detail lives in
modpack/README.md.
CurseForge approval and the catch-up (PR #1349, v0.830.0).
A CurseForge pack manifest references the DT jar by file id, and CurseForge rejects a pack
whose file it has not approved yet. release-modpack.yml therefore waits for approval
(scripts/modpack/wait-for-approval.py) β but approval routinely takes longer than an
hour, and until #1349 a timeout failed the run and nothing retried, so the pack fell 41
releases behind while every one of those files had since been approved.
Now a timeout defers: the run ends green with a warning, nothing is uploaded, and
modpack-reconcile.yml (every 6h) runs scripts/modpack/catch-up.py, which publishes the
newest release once its DT file is publicly listed (= approved). Newest release only β older
gaps are left as they are by design. Preview with python3 scripts/modpack/catch-up.py --dry-run
or gh workflow run modpack-reconcile.yml -f dry_run=true. The Modrinth pack has no approval
step and follows every release directly.
Set the CURSEFORGE_API_KEY secret (a read key; CURSEFORGE_TOKEN is upload-only) to make
the approval and publish checks authoritative instead of reading the lagging cfwidget mirror.
Since: dp-relay v0.15.0 Β· DT echo-usage reporting PR #635
A private web dashboard for the community telemetry the mod sends through the relay (via the bundled Discord Presence mod). Lives at https://brennan.games/dt-data/ and is gated by an admin capability β enter it once on the lock screen (stored in the browser afterwards).
Access key: the relay's ADMIN_CAP (falls back to LOGS_ADMIN_CAP) from
/home/bitnami/server/dp-relay/.env on the box β read it with
grep -E '^(ADMIN_CAP|LOGS_ADMIN_CAP)=' β¦/.env. It is not shipped in the
jar and never appears in a page URL.
What it shows:
- Players / Games β searchable, sortable: games started, estimated play time (per-session, derived from the event stream β the mod suppresses disconnect reports, so it's a heuristic), deaths, version.
-
Player detail β the player's Discord thread with each message badged
PLAYER SEES vs DEV ONLY (a faithful port of the in-game
MenuChatFilter), plus their sessions, deaths, and bug-report logs. - Game explorer β a session's event timeline interleaved with the matching thread messages / death photos.
- Deaths β every death report with the number of times its echo has been re-used in another world.
- Surveys β rating aggregates + the live feedback-answer feed.
-
Dimensional carriages (dashboard tile, since DT
v0.781.0 Β· dp-relay
#537) β share of portal
crossings that broke, with the connected/broke split and the leading reason.
Built from
POST /<CAP>/telemetry/portal-stats, which the mod'sPortalStatsReportersends once per death for lives that met a portal (connected,broken,reasons{TWIN_NOT_LOADED|NO_LANDING|NO_TWIN_STRUCTURE|SEVERED}). Follows the version filter, so it answers whether a release moved the rate. -
#/portalsβ Dimensional carriages (dp-relay #538, 0.175.0): break rate over time (by ISO week or day, gaps where nothing crossed), crossings per day, a per-build table (the "did the fix help" view) and the reasons with a gloss each. Backed byGET admin/stats/portals?from&to&vfrom&vto(portal-rollup.js), bucketed by each event's ownmodVersion. -
Flagged leaderboard runs (dp-relay #725,
0.244.0 Β· DT #1889, 0.1199.0): the relay
runs plausibility checks on every one-life run summary (impossible band for the position, a
version never released, an identical run under another player, displacement beyond what the
life's clock allows, a missing or disagreeing death record). Nothing is excluded automatically
β
LEADERBOARD_PLAUSIBILITY_MODE=report, enforce list empty. The Games page lists every flagged run with reason-code chips andcheat/legitverbs; each call is recorded andGET /<ADMIN_CAP>/leaderboard/plausibility/accuracyreports the checks' precision per code and per board, which is what decides whether a code is ever enforced. Free Play and status-less runs are now stored as non-eligible evidence too, so they show in the session's Runs card. The mod sendslifeSec(vanilla time-since-death) with each run summary becauserunSecis active time only. Retro report:scripts/leaderboard-plausibility-report.js. Design:docs/leaderboard-threat-model.mdΒ§7 in dp-relay. -
#/booksβ bundled books (dp-relay #581, 0.190.0): the random loot books, narrative stories and starting/welcome books that ship in the mod list here whether or not anyone has read them, with the Viewer text served by the relay. The relay pulls the corpus itself:bookcorpus.jschecks the latest dungeon-train-mc release tag hourly and, when it changes, walkssrc/main/resources/data/dungeontrain/narratives/{random_books,stories,starting_books/**}at that tag (GitHub Contents API) intodata/book-corpus.json. So: add a book JSON undernarratives/and it appears on the Books page after the next release β nothing to regenerate or commit on the relay side (the old hand-runtools/build-book-content.jsbundle is gone). Every folder understarting_books/is a context (StartingBookContext); the page header names the synced tag and β» Sync books re-pulls on demand (POST /<ADMIN_CAP>/book-corpus/refresh)./healthβbookCorpusshows the tag, counts and last error.
Where it lives / how to deploy: the site is the web/ directory of the
bh679/dp-relay repo, plus an
ADMIN_CAP-gated read API in that server (admin.js). Deploy = copy the relay
.js files to /home/bitnami/server/dp-relay/ and web/ to
/opt/bitnami/apache2/htdocs/dt-data/, then pm2 restart dp-relay β see the
relay repo's (gitignored) DEPLOY.local.md. Echo-usage counts populate because
the mod's EchoUsageReporter POSTs /<CAP>/reincarnations/used when a remote
echo spawns.
Since: DT v0.488.0 Β· dp-relay v0.51.7
The Discord invite, Patreon, direct-donation (Revolut, $25 AUD default) and
Kinetic Hosting affiliate URLs are no longer only hardcoded: the client fetches
GET /<CAP>/links from the relay once per session on the title screen
(anonymous β no uuid/session params, not gated on network consent) and overlays
the values on baked-in fallbacks. Offline or against an old relay, the baked
links keep working. This lets a rotated link reach already-shipped jars and
protects against redistributed jars pointing at stale destinations.
-
Client:
client/links/OfficialLinks(holder + per-key http(s) validation) andclient/links/OfficialLinksFetcher; call sites readOfficialLinks.*()at click/init time. Success logsofficial links updated from relay (N keys)at INFO. -
Server:
links.jsin dp-relay; values come from.env(DISCORD_INVITE_URL,PATREON_URL,PAYMENT_URL,AFFILIATE_URLβ unset β baked defaults, documented in.env.example). Rotate a link = edit.env+pm2 restart dp-relay. Invalid (non-http(s)) values are dropped server- and client-side, degrading to the baked fallback per key. -
Keep in lock-step: the baked fallbacks in
OfficialLinks.javaand theDEFAULTSin the relay'slinks.jsare intentionally identical. -
Local testing:
./gradlew runClient -PrelayBaseUrl=http://127.0.0.1:<port>/<CAP>against a local relay.
Since: DT v0.547.0 Β· dp-relay v0.126.1
Both donation surfaces β the title screen's Ways to Help (client/support/SupportScreen)
and the death screen's Contribute window (client/DonationOptionsScreen) β lead with three
named amounts, with Custom Amount (Revolut) and Recurring (Patreon) demoted to a shorter, narrower
row underneath.
-
A tier is a quantity, not a price.
client/support/SupportTiermaps each tier to aqty(1 / 3 / 7) whichPaymentLinks.tierUrl()appends to the relay's checkout route; the relay multiplies its Stripe unit price ($10 AUD, the "Dungeon Train Priority Book") by it. The reachable amounts are therefore exactly the multiples of that unit price β which is why the middle tier is $30 and not a rounder-sounding $25. Re-pricing the unit in the Stripe Dashboard silently re-prices every tier, so the figures live in the lang keys (gui.dungeontrain.support.tier.*), never in Java. -
One route, two audiences. The route is
GET /<CAP>/pay/cnβ China-named but not China-limited: its sessions already requestcardalongsidewechat_pay. The client reads it from the relay's newpayment_checkoutkey (envPAYMENT_CHECKOUT_URL), falling back topayment_cn_checkout. The two keys are separate because the latter doubles as the China route's kill switch β unsetting it to pull the China button must not also strip the price points from everyone else. -
Who loses the open-ended row: Simplified Chinese only (
zh_cn,lzh) viaPaymentLinks.hidesOpenEndedOptions(). This is deliberately narrower thanuseChinaPayment()'s whole-zh-family gate: that one decides what to offer, this one decides what to take away, and Taiwan/Hong Kong players genuinely can reach Patreon and Revolut. Don't collapse the two predicates. -
Degrade: no checkout route served β
tiersAvailable()is false and both screens render the pre-tier layout. There is no baked Stripe fallback on purpose β three buttons leading nowhere would be worse than the two they replace. -
Analytics:
donate_help_dev/donate_big_fan/donate_server_costs, which must stay in lock-step with the allowlist in the relay'sui-events.jsβ it 400sbad_targeton anything it doesn't know, so the relay ships first.donate_cngoes quiet wherever tiers are available, since the price points replace the WeChat button on the same route.
All player-facing GUI text resolves through translation keys in
src/main/resources/assets/dungeontrain/lang/en_us.json. 21 locales ship in
the jar β de_de, es_es, es_mx, fil_ph, fr_fr, id_id, it_it, ja_jp, ko_kr, ms_my,
nl_nl, pl_pl, pt_br, pt_pt, ro_ro, ru_ru, th_th, uk_ua (v0.1159.0, PR #1833),
vi_vn, zh_cn, zh_tw β each with a GUI lang file, sibling-mod lang files,
data/dungeontrain/narrative_localizations/<loc>/ (all 57 books) and
data/dungeontrain/ain_localizations/<loc>/ (item names). Adding a locale is
content plus one line in plural_rules.json and, for a new number system, a
speller in LocaleNumberWords / LocaleOrdinalWords; the importer, parity and
provenance checks discover locales from lang/*.json. A locale that is not
bundled cannot receive approved book translations β NarrativeContentLocale.isSupported
needs the narrative tree β which is how Ukrainian came to be added. As of v0.444.0 the last hard-coded strings on the config,
world-options, spawn-cinematic, and dev-message screens were moved into keys, so
the whole in-game UI can be translated with a resource pack β add an
assets/dungeontrain/lang/<locale>.json (e.g. es_es.json) overriding the keys
you want; no code or bytecode-patching mod required. Done/Cancel reuse
vanilla CommonComponents, so Minecraft already translates them.
Conventions:
-
GUI text uses the
gui.dungeontrain.*prefix; chat/system text useschat.dungeontrain.*. -
Placeholders are Minecraft-style
%s/ positional%1$s, substituted viaComponent.translatable(key, argsβ¦). -
A literal percent sign must be written
%%in the value (e.g."Behind %%:"rendersBehind %:) βComponent.translatableruns MC's format parser, and a bare%throwsTranslatableFormatExceptionat render. -
Mod vs modpack copy (v0.496.0): a key may declare a
.modpacksibling (e.g.gui.dungeontrain.support.share.desc.modpack).SupportScreen.copyKey()picks the variant when the install looks like the modpack (β₯2 of the companion mods appleskin/ferritecore/modernfix/advancementplaques loaded) and the current language defines it β otherwise the base key, so translations can adopt the variant independently per locale. Currently used by the three Support-page section descriptions (Chinese wording says ζ΄εε vs 樑η»/樑η΅).
Editor UI and command feedback β keyed as of v0.906.0 (PR #1447).
- The world-space editor menus (
client/menu/**β X-menu rows, plot labels, block-variant / container-contents / parts / stage-palette / template-blocks panels, Options, Packages, the status HUD) resolve every label throughclient/menu/MenuLangundergui.dungeontrain.editor_menu.<screen>.<label>. Labels stay plainStrings (the menus rebuild each tick, so a language switch shows on the next frame;MenuRowPainter.ellipsizetrims a label that outgrows its cell).MenuLang.named(group, id, fallback)translates data-driven ids (portal-room settings, stage-palette rows, part kinds),MenuLang.typeNametranslates the English type names the server pushes at the draw seam only β the English string stays the identity the roster compares by. Count-dependent labels go throughMenuLang.plural, which picks the.one/.few/.many/.otherform viaPluralRules.MenuLangKeysTestpins everyMenuLangliteral to anen_usline and everyeditor_menu.*key back to a call site. - Every player-facing line the editor commands send (
command/EditorCommand,SaveCommand,ResetCommand,PortalCommand,PortalTestCommand,PackageCommand,TrainCommand,ExportCommand/ImportCommand/DtpCommand, the label/builder sub-commands) isComponent.translatableunderchat.dungeontrain.{editor,save,portal,package,train,common}.*, resolved on each client in its own language. ON/OFF badges (chat.dungeontrain.common.on_capsβ¦) and counted nouns (chat.dungeontrain.common.noun.<x>.singular|plural) are translatable pieces slotted into%s; the server cannot know a client's plural rule, so those are singular/plural only. Dev-only listing dumps (/dt editor β¦ list,/dt debug β¦) stay literal. - The in-game Translate tool's From cycle has a Build editor body
(
TranslationFilters.isEditorKeyβ theeditor_menu.,editor_screen.,builder.,block_variant., β¦ roots), so Menus & messages is the game a player sees and the editor's jargon can be taken or left whole. - Unit tests that assert on menu labels run under
MenuTestLanguage(@ExtendWith), which installs the shippeden_us.jsonas the activeLanguagefor the class and restores it afterwards. A globalMETA-INF/servicesregistration does not work here β it loads in a different class loader from the tests under the dev classpath's union filesystem.
Beyond English, Dungeon Train bundles full translations (GUI + generated item names + in-world books + death-screen ledger + companion-mod compat packs) for:
- v0.464.0 β Simplified Chinese, Spanish (Spain & Mexico), Brazilian Portuguese, Indonesian, Malay, Filipino, Vietnamese, Thai.
- v0.467.0 β German, French, Italian, European Portuguese, Dutch, Polish, Russian, Japanese, Korean, Traditional Chinese.
-
v0.619.0 β no new languages; a catch-up wave bringing all 19 back to full
en_usparity after three weeks of features shipped English-only (see Keeping locales in sync with new text below).
All except the human-reviewed ones are AI-translated (Opus 4.8), disclosed on the
title screen ("Localized by β¦") and shown with a faded logo in the vanilla
language menu (solid = human-reviewed; set "human_reviewed": true in the
locale's assets/dungeontrain/localization_credits/<loc>.json). The goal is to
cover every vanilla MC 1.21.1 language, rolled out in per-family batches.
Locales drift the moment a feature adds a key, and for a long time nothing caught
it. Both gates were self-referential β validate-locale.py checked shape against
the es_es tree, and check-provenance.py checks each locale's sidecar against
its own lang file β so when a feature added keys to en_us only, every locale
(es_es included) stayed internally consistent and CI stayed green on a real
gap. That shipped twice: 109 keys by v0.619.0 and 144 more by 2026-08-30.
Fixed as of v0.740.0. localization-checks in build.yml now runs two things
on every PR:
-
check-lang-parity.pyβ the only check measured againsten_us, projected through each language's plural rules (Russian must carry.few/.manythat English has no word for; Japanese must not carry a.one). Missing keys fail; extra keys only warn, since zh_cn and zh_tw legitimately ship 14 English lacks. -
validate-locale.pywith no flags β the full structural suite against es_es: key sets, file sets, and shape. It ran--values-onlyfor a while because it was red on eight locales over unrelated AIN-overlay debt; that debt is paid off, so it asks the whole question again. If it goes red, fix the locale, don't narrow the gate β a permanently-failing check is one nobody reads.--values-onlystill exists for exactly that: unblocking work while a locale is being repaired.
The weekly relay import (import-approved-translations.yml) runs the full suite
too. It was narrowed for the same reason and for a sharper cost β a player's
approved Vietnamese translation was being refused over item_types missing
elsewhere in the vi_vn tree, which is a tool punishing today's correct work for
yesterday's unrelated debt.
The v0.619.0 wave cleared three weeks of that drift: 115 GUI keys and 10 in-world books that had accumulated since 2026-08-05. The catch-up recipe, if you are doing it again:
- Diff the locales against
en_us, not es_es β[k for k in en_us if k not in loc]β and diffnarrative_localizations/<loc>/against the Englishdata/dungeontrain/narratives/+death_lore/file set. The English source is the only honest reference; es_es is just as likely to be behind. -
git blamethe missing keys inen_us.jsonto see which features shipped untranslated β useful for the changelog and for spotting a feature that needs a term added to a locale's glossary. - Translate one locale per agent (both GUI and books together) so each language keeps one voice, reading that locale's existing lang file and books for established terminology.
- Verify against the English source, not es_es: key parity, printf placeholder
parity (and no bare
%), narrative shape including array lengths and the structuralid/ref/page/weight/generationfields,%TOKEN%parity, book-title limits, and line endings (zh_cn is CRLF, the rest LF). -
stamp-provenance.py --syncandstamp-narrative-provenance.py --sync, both with--author. The narrative one is easy to forget β its sidecars and the shippedlocalization_provenance/manifests are a separate body from the lang side.
Two things worth knowing for step 4: array-length drift is a real failure mode
(the English quiet_rules and train_glossary_entry had each grown two variants
past every locale copy), and structural string fields such as _translator_note
go missing quietly β six locales had lost the Death Note one, which carries the
15-character trigger-title rule to whoever translates it next.
A third, added in v0.740.0: in the AIN overlays
(data/dungeontrain/ain_localizations/<loc>/pools/type_synonyms.json), an entry
whose item_types is empty matches every item. A locale that drops the tag
does not crash or go blank β it silently names swords "Boots". vi_vn, fil_ph and
zh_cn shipped like that for months. type_synonyms is the only one of the 31 pools
whose entries carry anything besides text, so it is the only one where array
length is still enforced; the other 30 are flat word lists drawn at random, where a
locale holding a few more synonyms than Spanish is fine. The es_es tree is a
verified mirror of the English source in bh679/adventureitemnames-mc β when a
locale disagrees with it, the locale is wrong.
The overlay engine is ResourceManager-driven and locale-agnostic β a new locale
is pure content (mirror the es_es tree exactly), plus one optional code touch: a
cardinal number speller in narrative/LocaleNumberWords.java so death-ledger
figures spell out in-language (languages without one fall back to Arabic digits).
Two reusable scripts under scripts/localization/ support the workflow:
-
validate-locale.py <loc>β¦β structural gate: GUI key parity vsen_us, narrative/AIN shape identical toes_es, book-note offsets in range, and the β€15-char Death Note trigger-title limit. -
build-compat-pack.py <loc> --jars <dir> [--gaps <dir>]β assembles themodpack/overrides/resourcepacks/DungeonTrain-<loc>-compat.zipcompanion-mod pack deterministically (upstream mod translations where shipped, else supplied gap translations, else English runtime fallback).
Every localized line in the 19 non-English lang files has a repo-side provenance
record in localization/provenance/<locale>.json β {"author": β¦, "reviewer": β¦, "source_hash": β¦} per key, in lang-file order ("" reviewer = not human-reviewed;
source_hash = 16-hex SHA-256 of the English the line was last stamped against,
since v0.874.0, "" for the siblings whose English is not in this repo). Author names are
classified ai/human in localization/authors.json, which makes the headline
metric computable: how much of each locale is AI-generated text no human has
reviewed. Nothing under localization/ ships in the jar.
-
check-provenance.py [--report]β CI guard (localization-checksjob inbuild.yml): sidecars must stay in key set+order lockstep with their lang files, authors must be registered, reviewers must be registered humans.--reportprints per-locale AI-authored / AI-unreviewed / reviewed counts. -
stamp-provenance.pyβ the workflow tool. New wave:--sync --author 'Opus 4.8 (Claude)'(CI fails any PR that adds lang keys without this). Review pass:--reviewer <name>with--keys/--prefix/--all; restamping--authorresets the reviewer (a re-translation invalidates review). -
backfill-provenance.pyβ the one-off July 2026 git-history backfill, kept as the audit trail of the initial attribution (do not rerun). -
Outdated lines (v0.874.0, #1403).
Every stamp (
--author,--reviewer, or a new key under--sync) records the current English'ssource_hash;--syncleaves existing hashes alone, so anen_usedit shows up as a mismatch. The shippedlocalization_provenance/<locale>.jsonmanifests carrysource_changed/books_source_changedlists built from that comparison β the in-game editor's β» badge and "Outdated" filter. Consequence: after editingen_us.jsonor an English book, re-runstamp-provenance.py --sync(no--authorneeded when no key is new) orcheck-provenance.pyfails on manifest drift;import-english-edits.ymldoes this itself.build-review-package.pyclassifiessource_changed_since_reviewfrom the same hash (git only dates the CSV columns).backfill-source-hash.pyseeded the hashes from the English at each entry's last stamp commit (one-off, do not rerun) β it surfaced ~100 outdated lines and ~25 outdated books per locale that had never been re-translated.
See localization/README.md for the schema, registry rules, and the zh_cn/zh_tw
crediting decisions (θζ¬ι‘'s #770-attested review; ιΏδΈxAsh's #823 Support-page
revision pass).
Credited-name changes (v0.825.0). A translator can rename themself from the in-game
Credits page; the relay rewrites their rows at once and logs it
(GET /<ADMIN_CAP>/translations/renames). The import workflow runs
scripts/localization/apply-translator-renames.py before
import-approved-translations.py: it renames the authors.json key (keeping the object
form and url) and every author/reviewer in the lang and narrative sidecars, then
stamp-provenance.py --sync regenerates the shipped credits. Order matters β the approved
rows already carry the new name, and importing them against a registry still holding the old
one would register the new name as a second person. It never merges onto a name already in
authors.json and cannot rename a name the repo never credited; both are listed in the
import PR's body for a person. --dry-run / --from-file mirror the importer.
Opt-outs (v0.849.0). The same Edit button offers Remove my name: the translator keeps
their count but is listed as Anonymous everywhere the relay names them (reversible with
Restore). After the renames, apply-translator-renames.py reads
GET /<ADMIN_CAP>/credits/optouts?section=translations and writes the object form with
"credit": false for every registered name that uuid was credited under; the sidecars are not
touched, but build_contributors leaves the name out of translation_contributors.json. A
restore drops the flag on the next import; the PR body lists hidden and restored names.
--optouts-file mirrors the relay for tests.
SemVer lives in gradle.properties β mod_version. Per the project
convention:
- Every commit during development β PATCH bump
- Feature branch squash-merged to
main(Gate 3) β MINOR bump (reset patch), taggedvX.Y.0 - Breaking save format / API change β MAJOR bump
The MINOR bump is a separate commit on main after the squash-merge (see
10ece02 for
an example).
Since v0.491.0 (#818) the
per-world generationSeed in DungeonTrainWorldData is derived from the level
seed (salted splitmix64) instead of session entropy β the same level seed always
produces the same band front (NetherMountainTerrain.wavyX), highland biome
palette, mountain relief, chuncks-band gates, spheres-band field, stacks-band gates + tower picks, mirror dither, and track/tunnel
layout. Existing saves keep their persisted seed. The NetherBandContext
snapshot also publishes on every server-side LevelEvent.Load (before
spawn-region chunk generation), not just ServerStartedEvent, so the first
chunks can never bake without band context; silent biome-fallback errors are
logged via util/LogFirstN.
Determinism harness (headless): run ./gradlew runServer -PgenDetLog twice
against a fresh world with the same level-seed in run/server.properties, RCON
forceload the windows under test (e.g. forceload add 5400 0 5695 47), stop,
and diff (a) the region files per-chunk (section biome + block palettes, not raw
bytes β file layout/timestamps differ) and (b) the sorted [gen.det] log lines
(worldgen/GenDeterminismLog, enabled by the -PgenDetLog project property),
which record every decoration/stamping input per chunk β band-context state,
core biome lists, stairs anchor decisions, sweep coverage.
Since v0.495.0 (#822) all DT-owned block output is deterministic too: twin same-seed runs bake 0 differing DT blocks. Key mechanisms:
-
Stairs anchor grid β trackside stairs / down-stairs shafts place only at
seed-phased anchors (one per 100 blocks of X, phase = splitmix64(worldSeed)),
decided by the chunk containing the anchor from own-chunk terrain probes.
StairsRegistryDatarecords placements (and defers frontier chunks to pre-anchor saves' racing-era stairs) but no longer arbitrates. -
Sweep drain-on-stop β
CorridorCleanupEventsdrains its pending queue fully onServerStoppingEvent, so cleanly-stopped saves are always fully swept (the sweep is convergent: a crash can persist transient spillover, repaired on next load). -
StampRandomβ worldgen template stamps pass a position-pure random toplaceInWorld(1.21.1 consumes it only for containerLootTableSeeds), so chest loot is seed-reproducible.
Known & accepted residual: vanilla features (ore/stone blobs, lush-cave vegetation) write up to ~8 blocks into neighbour chunks; which chunk's write wins a shared cell depends on adjacent-chunk decoration order, which DT's variable band-chunk gen cost reshuffles per run. This is a vanilla-class last-writer-wins order sensitivity β DT writes none of those blocks β and is documented as intentional (fixing it would require deterministic cross-thread feature-stage scheduling). Runtime train-driven track painting is likewise deterministic in content but timing-dependent in coverage.
Relay PR #748 + mod PR #1942. The streamer still uploads one 1280x720 / 2.5 Mbit/s HLS stream, but viewers no longer pull or decode it. A pm2 worker on the relay box, dt-renditions (live-renditions.js, scripts/live-renditions-worker.js), follows the live channel like dt-twitch does (both are live-follower.js subclasses) and, while someone is live, runs one ffmpeg that pulls the source playlist, decodes it once and encodes 640x360 at 700 kbit/s and 320x180 at 220 kbit/s (libx264 veryfast/zerolatency, keyframe every 2 s, 10 s segments). An upload loop PUTs each complete segment and then the playlist beside the source β main/<session>/r640/β¦, r320/β¦ β through the same presigned PUTs and cache headers the streamer uses. GET /<cap>/live/status lists the ready ones as variants (width ascending); the mod (LiveFeedClient.Status#mediaUrlFor) feeds ffmpeg the smallest variant at least as wide as its decode width, else the source. A LiveHlsSession keeps the source playlist as its identity and reads a separate media URL, so a variant appearing (the worker needs ~25 s to warm up) or vanishing (worker down or stale β the relay stops listing after three missed polls) switches through the normal pending-session handoff. The streamer's own pinned box therefore decodes the 180p copy; a TV at the default 640 viewer width the 360p one. A rendition sits one segment (~10 s) further behind the streamer than the source. A finished session stays listed while the relay drains its tail. The worker is started once by hand (pm2 start scripts/live-renditions-worker.js --name dt-renditions --cwd /home/bitnami/server/dp-relay --kill-timeout 8000 && pm2 save); deploy-relay.sh restarts it afterwards; /health.renditions shows its state; .env knobs are LIVE_RENDITIONS (<width>:<kbps>,β¦), LIVE_RENDITIONS_SOURCE_CAP_LABEL (live|dev), LIVE_RENDITIONS_ENABLED. Costs ~0.5 core and ~150 MB on the box only while someone streams. The decoder logs live decoder: <media> at WxH (a rendition of <source>), which is how a client's pick is verified. Local test: fake R2 + scripts/dev-home-page.js --env + the worker with LIVE_RENDITIONS_SOURCE_CAP_LABEL=dev DEV_CAP=localdevcap00000 PORT=3998; plain-http variants are accepted only when the source playlist is plain http too.