Development - bh679/dungeon-train-mc GitHub Wiki

Development

Building, running, and hacking on the mod.

Requirements

  • JDK 21 (NeoForge 1.21.1 requires it; configured in build.gradle as JavaLanguageVersion.of(21). The wrapper uses org.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.0 will need the JDK 17 toolchain.

Build & run

./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.

Sibling mod dependencies (AIN/AIS/PMOB/DiscordPresence/ECP)

DT bundles five sibling mods by the same author (AdventureItemNames, AdventureItemStats, PlayerMob, DiscordPresence, EnderChestPersistence) via implementation "bh679:<mod>:${x_version}"

  • jarJar(...) in build.gradle, fetched from each repo's GitHub Releases through a custom Ivy patternLayout repo (no Maven/Modrinth/CurseForge required at build time). Versions are pinned per-mod in gradle.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.

⚠️ This failure mode is silent, and it cost two months. When AIN's asset name changed, nothing broke loudly β€” DT simply kept building against the last resolvable version. Each auto-release cascade tick that bumped 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.

Version HUD overlay

Since: v0.7.0 β€” PR #11

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 VersionHudOverlay via NeoForge's RegisterGuiLayersEvent β†’ registerBelowAll(...). (Renamed from RegisterGuiOverlaysEvent in 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.properties inside the jar, populated at build time β€” see next section.

Build-time branch capture

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.

Dev TitleScreen shortcut

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.

What a Train Builder world keeps on disk

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 editor world (dungeontrain:editor)

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. clearAllPlots is "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) calls EditorStampQueue.flush() first so it never races a fill; flush is 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's plotContaining (CarriageEditor, CarriagePartEditor, CarriageContentsEditor, TrackEditor, PillarEditor, TunnelEditor, PortalRoomEditor, TrackSidePlots.locate per kind). Strict: no resident category β‡’ no plot anywhere. EditorCategory.locateAt walks 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 after editor exit). Each erase job carries a BoundingBox footprint; runEnterCategory runs the ones under the landing plot synchronously before stamping it (EditorStampQueue.partitionOverlapping), queues the rest, then queues EditorLayerSweep ahead 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 cage TrackEditor.clearPlot misses). 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) call EditorCommand.ensureCategory first, so entering one template of another type does the full category switch rather than stamping into the resident type's plots.
  • Template.Contents.plotSize is the plot's own box (the corridor for the portal contents) and Template.Track.editorPlotOrigin the named tile's slot β€” the strays sweep, the erase footprints and the layer counts all read these.
  • Dev checks: /dungeontrain debug editor-locate prints the (category, model) the status HUD resolves for the running player plus resident=; editor-layer counts blocks inside vs outside the resident category's plots over loaded chunks (expect blocksOutsidePlots=0 once a switch has settled). Both work over RCON with execute 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 DevQuickWorldHandler using NeoForge's ScreenEvent.Init.Post (button install β€” New World is wired in visible=true and the vanilla Singleplayer button is set to visible=false at install time) + ScreenEvent.Render.Pre (per-frame visibility flip from Screen.hasShiftDown()).
  • Branch-gated identically to the Version HUD overlay β€” release jars (built with branch=main stamped into dungeontrain_version.properties) skip the handler entirely, so the title screen looks vanilla.
  • Calls Minecraft.createWorldOpenFlows().createFreshLevel(...) with GameType.CREATIVE, Difficulty.NORMAL, allowCommands=true, a random seed, and a Function<RegistryAccess, WorldDimensions> that resolves the dungeontrain:dungeon_train preset (falls back to WorldPresets.NORMAL if 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.

Dev first-launch defaults (no accessibility onboarding, muted)

Since: v0.641.0 β€” PR #1075

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 DevFirstLaunchDefaults on FMLClientSetupEvent. Mod loading finishes inside the initial LoadingOverlay reload, and onGameLoadFinished β†’ buildInitialScreens β†’ addInitialScreens all 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 onboardAccessibility still 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.txt to get the fresh-profile behaviour back.

Custom-content prompt: "I'm a dev β€” ignore" (dev builds)

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_ignore on DungeonTrainWorldData), 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 while isDevBuild() is true. A release build reads a carried-over DEV_IGNORE as ALLOW (Free Play); the join-time handler records ALLOW outright, 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). /customcontent reports ON … and the run still counts (dev build).
  • CustomContentChoicePacket now carries the choice id (string) rather than a boolean.

/new-world β€” re-roll a fresh world from inside the current world

Since: v0.181.0 β€” PR #227

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.

Worldgen perf debugging ([gen.timing] + band early-outs)

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.

Headless void-on-first-sight rides and JFR on the dev server (v0.1077.0)

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.

World-load cost of the band context (v0.1065.0)

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.

Vanilla biome twin lookup is lock-free and memoised (v0.1114.0)

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).

Attributing a slow tick from a Lag log ([mspt] load fields, v0.1020.0)

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 full heapUsedMb near heapMaxMb with large gcMs is 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 above avgTickMs means 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: presence line + -PnoSablePathfinder A/B seam (v0.1069.0)

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.

Log levels in player builds vs dev (v0.1024.0)

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.perf carries 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 off

To 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.

Live Eye performance line ([live], v0.1239.1)

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, or ended for the one trailing line in the minute activity stopped.
  • Streamer: grabs should be about 1,200 a minute at 20 fps; skipped counts grabs dropped because every read-back buffer was still waiting on the GPU (a GPU-bound machine); grabMs is render-thread time per grab; dup counts frames the encoder repeated because no new one arrived.
  • Viewer: decoded should be about 1,200 a minute; decoded=0 with rising runs means the decoder cannot reach the stream; stalls counts the 20 s no-frame watchdog firing; width is the decode width.
  • encCpu / decCpu are percent of one core from ProcessHandle.Info.totalCpuDuration() (no process spawned). Java reports them on Windows and Linux; on macOS they always read n/a.
  • fps and worstFrameMs are 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 %.

Editor tick-cost profiling ([editor.timing], v0.827.0)

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 a CUSTOMS mutation (customsChanged() at every mutation site). Previously every call rebuilt the list and a fresh Custom record per id, and the editor called it once per template per pass β€” O(nΒ²) per tick.
  • CarriageContentsEditor / CarriageEditor resolve plot origins through an idβ†’slot map memoised on the snapshot's reference identity (plus the group child-set identity for contents).
  • ProvenanceCache memoises UserContentPaths.provenanceOf (the user/imported/bundled tint), flushed by every template-store write and by TemplateStores.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.

Measuring memory over a long ride

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 runServer

run/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:25571

3. 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.txt

4. 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.

Backward train-generation trace ([bwdgen])

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.

Authoring carriage templates that ship with the mod

Since: v0.25.0 β€” PR #33

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:

  1. 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.
  2. Verify dev mode is on β€” /dungeontrain editor status shows Dev mode: ON. Since v0.142.0 (PR #183) the flag auto-defaults to on whenever the source tree is writable (which is exactly the ./gradlew runClient case), so you no longer need to type /editor devmode on before each session. The manual command still works as a mid-session override (/editor devmode off pauses auto-promotion); the override resets back to the writable-source default on the next world load. In a packaged jar the flag stays off regardless, since source writes are no-ops there anyway.
  3. Build a carriage β€” /dungeontrain editor enter <variant>, edit blocks inside the bedrock-cage footprint, /dungeontrain editor save. With dev mode on, each save writes BOTH config/dungeontrain/templates/<variant>.nbt (your local override) AND src/main/resources/data/dungeontrain/templates/<variant>.nbt (the bundled default for the next build).
  4. Verify β€” /dungeontrain editor list shows bundled: yes for any variant whose source-tree NBT exists. The mod logs Wrote bundled template <TYPE> to .../src/main/resources/... on every successful source write.
  5. Commit the NBTs β€” git add src/main/resources/data/dungeontrain/templates/*.nbt && git commit. Use the editor only for blueprint changes; the four .nbt files 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.

Deletes mirror the source tree too

Since: v0.831.0 β€” PR #1348

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).

New / duplicate carries every sidecar

Since: v0.855.0 β€” PR #1377

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.

Community item trade values

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
  • pullTradeValues is never part of build. 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.js fails 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.

Authoring weight tweaks that ship with the mod

Since: v0.58.0 β€” PR #73

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:

  1. Launch the dev client β€” ./gradlew runClient. Same source-tree resolution as the template editor.
  2. 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 shows Wrote bundled <kind> weights to .../src/main/resources/... on every successful source write.
  3. 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 player-side weights.json is an overlay of edits, not a snapshot

Since: v0.876.0 β€” PR #1404

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 while EditorContentIntegrity.isSuppressed(), matching what UserContentPaths.searchDirs already does for every other store; the reload barrier re-runs the three reload()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.

Renaming and re-parenting templates without moving files

Since: v0.825.0 β€” PR #1339

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's weights.json ("book": {"weight": 3, "name": "Tome"}), so it overlays a bundled entry from the user tier, follows the id through a real rename, and writes through to src/ 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 Β· id and the data sheet adds an Id: 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-changing rename verbs 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.

Crediting a template's builder

Since: v0.841.0 β€” PR #1363

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 BuildCredits creator 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 bundled weights.json and 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_URL or ~/.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 by scripts/advancements/import-requirement-edits.py. Lang descriptions carry %s for the number; scripts/localization/requirement-placeholders.py is the one-off that converted the locales.
  • Advancement flags (disabled / notRequired, v0.885.0) ride the same payload as flags, 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 local dp-relay run with CAP, DEV_CAP and ADMIN_CAP set.
  • Dev write-through gotcha: the whole merged store (bundled + your user tier) is promoted into src/ on every save, so stale test credits in run/config/dungeontrain/user/**/weights.json reappear in the diff until you clear them there. Keep the credits you meant to ship and git checkout the rest before Gate 3.

The editor screen: tabs, filter bar and the Layout table

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.layout takes a label function, not a positional list.
  • EditorFilterBar is the single row above both columns (InventoryEditorLayout.filter spans the full inner width; both columns start under it). Collapsed it shows the active chips from the pure activeChips(...); 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) builds CommandMenuEntry.Cells rows (the new N-column record; MenuRowPainter / MenuEntryDispatcher decompose it). Folds holds what is opened β€” sections and groups default to folded; Query is the filter state. EditorLayoutPane caches the rows on roster/folds identity + Query equality and handles the weight cell itself (inc / shift dec / cmd β†’ InlineEdit). Never route a typed value through EditorModalHost.beginTyping from a pane: its Enter path is runAndClose and shuts the screen.
  • Where X opens is EditorScreenGate.opensInventoryScreen(inPlot, inEditorWorld, screenspace); EditorRosterClient.prefetch() asks for the roster on the editor-world tick and on EditorStatusPacket, so the screen has rows on open. EditorRosterRequestPacket logs [editor.timing] roster … built in N ms at DEBUG (stdout of runClient).
  • HUD category is an id. EditorStatusPacket.category is EditorCategory.id() ("portals"), never displayName() ("Dimensions") β€” PlotCategory.fromId, EditorTemplateJumpBridge, EnterCategoryMenuScreen and BuilderProfileScreen all compare it to command tokens. EditorStatusHudOverlay.categoryLabel maps it to the label only at draw time (PR #1395).
  • Trap fixed: EditorTemplateJumpBridge used the HUD category, which is empty between plots, and runEnterCategory clears + restamps every plot unconditionally β€” it now falls back to EditorRosterIndex.stampedCategory(). Anything else that "switches category" must be sure it is actually switching.

Loading player builds from the relay as sub-variants

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.join runs after BuilderRelayInstall reports INSTALLED, with the same refusals as group add (self, single-hop nesting, cycles, built-in parents). A refused join logs installed at top level, not under '<parent>' β€” <why> and the build stays a top-level template.
  • A missing parent is created with selfWeight: 0 β€” the bundled contents/traps.group.json precedent, a parent that only exists to hold its members. A contents parent needs no .nbt (the registry lists group parents from .group.json basenames); a room parent must be a real room (rooms are discovered by .nbt scan and the editor stamps the parent plot), so user_builds.nbt is written from the first build loaded under it and never spawns as itself. Room-group writes go through PortalRoomEditor.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.nbt into src/main/resources/data/dungeontrain/… β€” git checkout / delete those before a PR, since the group would reference an .nbt that is not in the repo.

Reviewing player builds from a dev build (v0.1133.0)

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".

Whole carriages: stores, pools and the whole-group rate

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 / CarriageGroupTemplateStore are three-tier now (bundled /data/dungeontrain/whole/{room,group}/, user wholecarriages/ / carriagegroups/ β€” the old subdirs kept on purpose β€” dev promote). Registries are bundled + user via BundledNbtScanner.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 WholeWeights keyed by WholeKind {ROOM, GROUP} (modelled on TrackVariantWeights: one weights.json per kind, overlay-diffed, gates/stages/labels/credits). Since v0.1103.0 the whole-group rate is the weight of the wholegroup Group template (WholeGroupSelection.VARIANT_ID), and WholeGroupSettings keeps only the forced test cadence. Before that, every N lived in WholeGroupSettings β€” bundled whole/group/settings.json, whole-file user override carriagegroups/settings.json β€” editor content, not world SavedData, so it ships and travels in packages. WholeVariantBlocks is the Z/C sidecar (<id>.variants.json beside the template) and BlockVariantPlot.WholePlot (keys whole:<id> / whole_group:<id>) is what makes the Z and C menus resolve inside a whole plot.
  • Spawn WholeCarriageSelection.pickRoom runs beside tryLeaseShared in TrainAssembler.spawnGroup when the shell roll is the whole variant (portal slot β†’ null; pool empty / gated out / wrong dims β†’ null β†’ whole.nbt places as a plain carriage). WholeGroupSelection decides the group before the slot loop (SeededDraw.hit with GROUP_SALT; portal group wins; WholeGroupSettings.force(n) is a session-only periodic override) and stamps the run at the first enclosed slot before Shipyards.assemble; every slot then skips shell, lease, room and contents passes. WholeOverlay rolls 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=…, and whole=group=<id> / rooms=[…] on the Spawned group line.
  • Wire EditorTypeMenusPacket.wholeGroupEvery rides 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 id whole:room (never a valid template id) routes a CARRIAGE or CONTENTS download into the room pool; contents are baked onto the bundled standard shell 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 checkout those 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Γ—7 carriagegroups/<id>.nbt, portal carriage off, whole group force 1 β†’ every group logs GROUP_HIT; then whole group off + editor weight whole 100 β†’ ROOM_HIT on whole slots.

Loot prefabs travel with relay-saved builds

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 as lootPrefabsSkipped; never evicted, since an evicted prefab would break a live build's links. Reads: POST /<cap>/carriages/loot-prefabs {uuid} (metadata) and GET /<ADMIN_CAP>/carriages/loot-prefabs?cap=&uuid= (with json). Schema is created lazily by carriages.ready() on the first carriages call after boot.
  • The store seam is TemplateLootPrefabs.Library (defaults to LootPrefabStore), 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 at 127.0.0.1:<port> silently talks to whichever session's relay owns it. Pick a port per session.

Cloning the wood stages (clone-wood-stage.py)

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 missing

Per 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.

Authoring part-variant assignments that ship with the mod

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>.nbt is committed to src/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 runClient from 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.

Authoring creative-menu prefabs that ship with the mod

Since: v0.78.0 β€” PR #112

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:

  1. Launch the dev client β€” ./gradlew runClient. Same source-tree resolution as the rest of the editors.
  2. Verify dev mode is on β€” /dungeontrain editor status shows Dev mode: ON. Auto-on by default in ./gradlew runClient sessions since v0.142.0 (PR #183) β€” manual /editor devmode on|off only needed to override the default mid-session.
  3. 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)
  4. 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_UNCOMMITTED marker set on the creative-tab ItemStack when the server-side isCommitted() check returns false.
  5. 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.

Adding a new bundled template

Since: v0.59.0 β€” PR #74

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.

Legacy manifests (transitional)

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.

Implementation note

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.

Authoring Lost City buildings that ship with the mod (scripts/lost-city/, v0.1032.0)

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/ exposing ARCHETYPE = Archetype(SPEC, draw), listed in archetypes/__init__.py; its designs in lostcity/variants.py. ArchetypeSpec declares size, margin, weight and the exact floor_period / bay_period_x / bay_period_z the stretch may use (None when the building has no clean repeat β€” the wrecks). draw lays the pad (footprint_pad), the shell, interiors (furnish.kit, spawners_and_loot), then finish() from archetypes/common.py; anything that must survive the damage pass (a summit chest, dangling chunks) goes after finish().
  • Conventions the checks enforce (lostcity/check.py, LostCityTemplatesTest): pad at y = 0 over 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, as LostCityStretchProcessor#findBand measures).
  • Licensing rule: the generator never reads Big Lost City's jar; datagen.py refuses to write any file naming a big_lost_city: resource except the shared structure set. See scripts/lost-city/README.md.

Changelog ledger tags (v0.861.0)

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 train

The 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.

CI/CD

Since: v0.69.0 β€” PR #94

Two GitHub Actions workflows run automatically:

build.yml β€” PR and push validation

Triggers on every PR and every push to main. Steps:

  1. Check out code with full git history
  2. Cache Gradle wrapper + Forge Gradle home
  3. ./gradlew build
  4. Upload dungeontrain-*.jar as 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].

release.yml β€” tag-driven releases

Triggers on v*.*.* tag push, or manually via workflow_dispatch with a tag input.

Steps:

  1. Verify the tag matches mod_version in gradle.properties
  2. Build the release jar
  3. 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 by release.yml, reupload-curseforge.yml and both modpack publish scripts. It was previously copy-pasted per publisher and the CurseForge re-upload path had drifted to a hardcoded release β€” which is how dungeontrain-0.692.0.jar became 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.
  4. Create the GitHub Release (pre-release flag set automatically)
  5. Run scripts/publish-wiki.sh to update Downloads.md and Downloads-Archive.md (PR #96)
  6. Publish to Modrinth + CurseForge via Kir-Antipov/[email protected] (PR #97)
  7. Push shipped-template fingerprints to the relay β€” ./gradlew bundledFingerprints writes build/bundled-fingerprints.json (the relay-side SHA-256 of every shipped template, computed by tools/BundledFingerprints through the same fromTemplateTag + encode path a reconcile upload uses) and scripts/relay/push-bundled-fingerprints.py POSTs 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 the RELAY_ADMIN_BASE secret. 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.

Modpacks β€” release-modpack.yml / release-modpack-modrinth.yml

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.

Data Explorer

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's PortalStatsReporter sends 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 by GET admin/stats/portals?from&to&vfrom&vto (portal-rollup.js), bucketed by each event's own modVersion.
  • 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 and cheat / legit verbs; each call is recorded and GET /<ADMIN_CAP>/leaderboard/plausibility/accuracy reports 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 sends lifeSec (vanilla time-since-death) with each run summary because runSec is 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.js checks the latest dungeon-train-mc release tag hourly and, when it changes, walks src/main/resources/data/dungeontrain/narratives/{random_books,stories,starting_books/**} at that tag (GitHub Contents API) into data/book-corpus.json. So: add a book JSON under narratives/ and it appears on the Books page after the next release β€” nothing to regenerate or commit on the relay side (the old hand-run tools/build-book-content.js bundle is gone). Every folder under starting_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 β†’ bookCorpus shows 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.

Official Links (relay-served)

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) and client/links/OfficialLinksFetcher; call sites read OfficialLinks.*() at click/init time. Success logs official links updated from relay (N keys) at INFO.
  • Server: links.js in 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.java and the DEFAULTS in the relay's links.js are intentionally identical.
  • Local testing: ./gradlew runClient -PrelayBaseUrl=http://127.0.0.1:<port>/<CAP> against a local relay.

Named price points (support pages)

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/SupportTier maps each tier to a qty (1 / 3 / 7) which PaymentLinks.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 request card alongside wechat_pay. The client reads it from the relay's new payment_checkout key (env PAYMENT_CHECKOUT_URL), falling back to payment_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) via PaymentLinks.hidesOpenEndedOptions(). This is deliberately narrower than useChinaPayment()'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's ui-events.js β€” it 400s bad_target on anything it doesn't know, so the relay ships first. donate_cn goes quiet wherever tiers are available, since the price points replace the WeChat button on the same route.

Localization (translating the mod)

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 uses chat.dungeontrain.*.

  • Placeholders are Minecraft-style %s / positional %1$s, substituted via Component.translatable(key, args…).

  • A literal percent sign must be written %% in the value (e.g. "Behind %%:" renders Behind %:) β€” Component.translatable runs MC's format parser, and a bare % throws TranslatableFormatException at render.

  • Mod vs modpack copy (v0.496.0): a key may declare a .modpack sibling (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 through client/menu/MenuLang under gui.dungeontrain.editor_menu.<screen>.<label>. Labels stay plain Strings (the menus rebuild each tick, so a language switch shows on the next frame; MenuRowPainter.ellipsize trims a label that outgrows its cell). MenuLang.named(group, id, fallback) translates data-driven ids (portal-room settings, stage-palette rows, part kinds), MenuLang.typeName translates 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 through MenuLang.plural, which picks the .one/.few/.many/.other form via PluralRules. MenuLangKeysTest pins every MenuLang literal to an en_us line and every editor_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) is Component.translatable under chat.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 β€” the editor_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 shipped en_us.json as the active Language for the class and restores it afterwards. A global META-INF/services registration does not work here β€” it loads in a different class loader from the tests under the dev classpath's union filesystem.

Shipped languages

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_us parity 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.

Keeping locales in sync with new text (v0.619.0)

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 against en_us, projected through each language's plural rules (Russian must carry .few/.many that 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.py with no flags β€” the full structural suite against es_es: key sets, file sets, and shape. It ran --values-only for 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-only still 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:

  1. Diff the locales against en_us, not es_es β€” [k for k in en_us if k not in loc] β€” and diff narrative_localizations/<loc>/ against the English data/dungeontrain/narratives/ + death_lore/ file set. The English source is the only honest reference; es_es is just as likely to be behind.
  2. git blame the missing keys in en_us.json to see which features shipped untranslated β€” useful for the changelog and for spotting a feature that needs a term added to a locale's glossary.
  3. 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.
  4. Verify against the English source, not es_es: key parity, printf placeholder parity (and no bare %), narrative shape including array lengths and the structural id/ref/page/weight/generation fields, %TOKEN% parity, book-title limits, and line endings (zh_cn is CRLF, the rest LF).
  5. stamp-provenance.py --sync and stamp-narrative-provenance.py --sync, both with --author. The narrative one is easy to forget β€” its sidecars and the shipped localization_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.

Adding a locale (content-only)

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 vs en_us, narrative/AIN shape identical to es_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 the modpack/overrides/resourcepacks/DungeonTrain-<loc>-compat.zip companion-mod pack deterministically (upstream mod translations where shipped, else supplied gap translations, else English runtime fallback).

Per-line translation provenance (v0.498.0)

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-checks job in build.yml): sidecars must stay in key set+order lockstep with their lang files, authors must be registered, reviewers must be registered humans. --report prints 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 --author resets 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's source_hash; --sync leaves existing hashes alone, so an en_us edit shows up as a mismatch. The shipped localization_provenance/<locale>.json manifests carry source_changed / books_source_changed lists built from that comparison β€” the in-game editor's ↻ badge and "Outdated" filter. Consequence: after editing en_us.json or an English book, re-run stamp-provenance.py --sync (no --author needed when no key is new) or check-provenance.py fails on manifest drift; import-english-edits.yml does this itself. build-review-package.py classifies source_changed_since_review from the same hash (git only dates the CSV columns). backfill-source-hash.py seeded 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 and opt-outs

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.

Versioning

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), tagged vX.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).

Worldgen determinism (seed β†’ band layout)

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. StairsRegistryData records placements (and defers frontier chunks to pre-anchor saves' racing-era stairs) but no longer arbitrates.
  • Sweep drain-on-stop β€” CorridorCleanupEvents drains its pending queue fully on ServerStoppingEvent, 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 to placeInWorld (1.21.1 consumes it only for container LootTableSeeds), 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.

Live Feed renditions (v0.1239.0)

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.

⚠️ **GitHub.com Fallback** ⚠️