Feature Block Variant Menu - bh679/dungeon-train-mc GitHub Wiki
Since: v0.56.0 โ PR #71
v0.61.0 โ PR #77 โ duplicate-variant guard on Add. Server now refuses to append a variant whose BlockState AND BlockEntity NBT already exist in the cell, with a Variant already in this cell action-bar hint. The same change adds a sibling guard to the part-position menu's ADD search list (already-added names are filtered out client-side and rejected server-side).
v0.64.0 โ PR #80 โ breaking a flagged block in any editor plot now auto-clears its sidecar entry. See ยง Auto-clear on block break.
v0.65.0 โ PR #81 โ paste now preserves the source cell's appearance instead of always placing a vanilla command block, and the cell's lock-id digit is rendered on all 6 faces while the player is in an editor plot. See ยง Pasted cell mirrors source's texture and ยง Lock-id badge on all 6 faces.
v0.66.1 โ PR #83 โ clicking an entry's name cell now previews that variant by swapping the world block at the cell. See ยง Preview a variant in-place.
v0.67.0 โ PR #86 โ menu no longer re-anchors on every click. The face and up-axis are frozen at open time and reused for every re-sync; only a fresh Z-tap re-anchors. Fixes the panel jumping to a wrapping side face when buttons hung past the block edge, and the panel rotating to track player heading on top/bottom faces.
v0.67.0 โ PR #89 โ per-entry rotation with editor preview. Each variant row gains an inline mode pill (L/R/O) and a direction cell; rotation applies at carriage spawn time. The editor world block animates 1 Hz to preview the chosen rotation. See ยง Per-entry rotation.
v0.104.0 โ PR #142 โ every variant row now shows an inventory-style block icon next to its name, so visually similar names (oak_log vs stripped_oak_log) are disambiguated at a glance. See ยง Block icons next to row names.
v0.106.0 โ PR #146 โ newly added variants now default to LOCK at the most recent predecessor's direction instead of RANDOM, so adding more directional blocks to a cell defaults to matching the existing facing. See ยง Default rotation on Add.
v0.1132.0 โ PR #1795 โ floor heads/skulls, standing banners and standing signs (ROTATION_16) get rotation over the 8 compass points, with a 3ร3 compass-rose Options popup. See ยง 8-direction (compass) rotation.
v0.799.0 โ PR #1304 โ the menu works in the Train Builder, and both menus now resolve from the block you are looking at rather than the plot you are standing in. See ยง Train Builder support.
v0.66.3 โ PR #88 โ every menu edit now auto-promotes the sidecar to source in dev mode. Previously BlockVariantPlot.save() (called on every Add/Remove/Clear/BumpWeight/CycleLockId in any of the four plot types) only wrote to run/config/dungeontrain/.../<id>.variants.json โ gitignored and per-worktree. Authored variants for vase, books, enchant, 2ndlevel, barrel, walls/{cracked, slotwindow, smallwindows}, and roof/windowed were silently lost when their authoring worktree was deleted; PR #88 recovers all 9 sidecars and wires EditorDevMode-gated source-tree write-through into all 4 plot impls (CarriagePlot / ContentsPlot / PartPlot / TrackPlot). Same defect-class as PR #75 for part-assignments and PR #73 for weights โ both already auto-promoted; only block-variants were missing.
A billboarded worldspace UI for managing per-cell block variants in any
editor plot. Tap the variant-place hotkey (default Z) while looking
at a flagged block (or any in-plot block) and a panel pops up against
the targeted face listing every candidate's icon-label and weight, with
a six-cell toolbar โ Copy | Add | Lock | Remove | Clear | X.
Mirrors the architecture of the part-position menu shipped in v0.53.0 (PR #67), and works alongside the existing Hold Z + right-click add flow from v0.31.0 / v0.50.0.
| Goal | Tool |
|---|---|
| Add the held block as a variant of one cell | Hold Z + right-click (or menu Add) |
| Tweak weights, remove entries, see existing variants | Tap Z โ menu |
| Copy a cell's whole variant set + paste at another position | Tap Z โ Copy โ place clipboard |
| Make several positions render the same random pick at spawn | Tap Z โ Lock, then Copy / paste โ see ยง Lock groups |
| Add an "empty / air" variant (the cell is sometimes a gap) | Tap Z โ Add with empty hand |
The Z keybinding produces two distinct actions:
- Tap (โค 8 ticks โ 400ms, no use-interaction during the press) โ opens the menu.
- Hold (longer press, or press + right-click during the press) โ preserves the v0.31.0 / v0.50.0 right-click-to-add flow. Holding Z while right-clicking still appends the held block to the variant list.
Tap-vs-hold detection runs client-side in VariantHotkeyClient.TickWatcher: a press timestamp is recorded on key-down; on key-up, if the elapsed time is under the threshold AND no InputEvent.InteractionKeyMappingTriggered fired during the press, the client sends BlockVariantMenuTogglePacket(open=true) and the server replies with a BlockVariantSyncPacket opening the menu. The hold-Z server-side state used by VariantBlockInteractions is unchanged, so both flows coexist on one keybinding.
| Cell | Action |
|---|---|
| Copy | Encode the current cell's variant list + lock-id into a VariantClipboardItem ItemStack and place it in the player's hotbar. The item visually mimics a vanilla command block (its model JSON parents minecraft:block/command_block). |
| Add | Capture whatever is in the player's main hand and append it as a variant. Empty main hand โ appends the air sentinel (rendered as nothing in the row label; serialised as minecraft:command_block and translated back to Blocks.AIR at spawn time per CarriageVariantBlocks.isEmptyPlaceholder). Block-entity NBT carried by the held ItemStack (e.g. an authored chest with contents) is captured. Duplicates rejected (v0.61.0): if a variant with the same BlockState AND BlockEntity NBT already exists in this cell, Add is refused with the action-bar hint Variant already in this cell. Two entries that differ only by per-entry weight count as the same variant. |
| Lock | See ยง Lock groups. Cycles the cell's lock-id 0 โ nextFreeLockId() โ 0. |
| Remove | Toggles remove-mode โ each variant row gets a red X chip; clicking it deletes that single entry. Entry counts below MIN_STATES_PER_ENTRY = 2 collapse the cell. |
| Clear | Drops the entire cell from the sidecar (the world block stays). |
| X | Closes the menu. Re-tapping Z while the menu is up does the same. |
Lock-ids are template-local positive integers; 0 means unlocked. A group is a set of cells that share the same non-zero lock-id.
-
Allocation. Clicking the toolbar
Lockbutton on an unlocked cell sets it to the smallest positive integer not currently used by any cell in the same template (BlockVariantPlot.nextFreeLockId). Clicking again sets it back to0. The button never lands on an already-used id โ the only way two cells share an id is via Copy / Paste of the clipboard item. -
Joining a group. When the player Copies a locked cell, the clipboard's NBT carries
dt_lockId. Pasting writes the cell and applies the carried lock-id, so the new cell joins the group. -
Propagation. Edits to one cell in a group (
ADD/REMOVE/CLEAR/BUMP_WEIGHT) propagate to every sibling cell โ the controller iteratesplot.positionsWithLockId(lockId)and writes the same state list to each. The shared list is the group's source of truth. -
Runtime pick. At carriage spawn, the picker hashes the lock-id where the unlocked path hashes the local position:
Every cell in the group draws the same bucket, and since they share the state list, they render the same block. Implemented in
seed = worldSeed ^ (carriageIndex * 0x9E3779B97F4A7C15L) ^ (lockId * 0xBF58476D1CE4E5B9L)CarriageVariantBlocks.pickIndexFromLockGroup.
The header line of the menu shows the cell's current lock-id when >0: Block Variants @ 5,1,2 ยท lock 1.
The menu opens against any of the four block-variant editor plot types via the BlockVariantPlot abstraction:
| Plot | Sidecar | Resolver |
|---|---|---|
| Carriage variant |
<id>.variants.json (CarriageVariantBlocks) |
CarriageEditor.plotContaining |
| Carriage contents |
<id>.variants.json under dungeontrain/contents/
|
CarriageContentsEditor.plotContaining |
| Carriage part |
<kind>/<name>.variants.json under dungeontrain/parts/
|
CarriagePartEditor.plotContaining |
| Track-side |
<kind>/<name>.variants.json under dungeontrain/<kind subdir>/
|
TrackPlotLocator.locate |
Resolution cascades in the same order as VariantOverlayRenderer.onLevelTick. The menu's open path uses a tolerant 1-block bounds check (BlockVariantPlot.inBoundsTolerant) matching the cage outline, then clamps to the strict footprint for cell lookup; edit / paste paths use strict bounds so we never write out-of-bounds entries.
Since v0.799.0 (PR #1304) the menu also opens in a Train Builder world, which has no plot grid at all. A fifth resolver arm answers there:
| Plot | Sidecar | Resolver |
|---|---|---|
| Train Builder build | <world>/dungeontrain/builder/build.variants.json |
BuilderCarriagePlot.of (world data, not position) |
Two things differ from an editor plot, both on purpose:
-
The document belongs to the build, not to a template. A builder draft may be saved under a
name it does not have yet, so what you author is held in the builder world's own save folder.
BuilderWorldSetupseeds it from the template you opened;BuilderSavewrites it onto whatever template you save as โ with the coordinate shift a carriage room (one block inside the shell) and a part (its first placement) need against the build volume. Per-world, so two builder worlds no longer overwrite each other's pools. - Position is not consulted. A builder world holds exactly one build, so the plot is resolved from world data. Standing on the platform beside the carriage is normal, and the menu works there.
Carriage group builds carry nothing โ a group is one NBT with no per-block sidecar behind it.
Also since v0.799.0, toggle (and the clipboard's copy-at-crosshair) resolve the plot from the
targeted block via BlockVariantPlot.resolveAtPos, not from the player's feet. Authoring into a
plot from outside it โ backing off a carriage to see the wall you are filling โ used to be refused
with "Not in a block-variant editor plot".
Once a menu is open it follows its own anchor: re-syncs and edits resolve the plot by the key the server recorded at open time. That is also stronger authorisation than the positional check it replaced, since the key comes from the server's own open-menu record rather than from the packet.
Since v0.64.0, breaking a block at a flagged position inside any of the four editor plot types (carriage / contents / part / track-side) automatically removes its variant entry from the sidecar JSON. Without this, the VariantOverlayRenderer kept emitting END_ROD outline particles at the now-empty cell because it iterates sidecar.entries() every 6 ticks.
-
Symmetric with the add path. Mirrors
VariantBlockInteractions.onRightClickBlockโ sameBlockVariantPlot.resolveAtcascade, same per-plotremove/saveAPI, same player-position resolution. Edits only happen in the plot the player is standing in. - No-op outside a plot. If the player breaks a block while not inside any editor plot, or breaks a block whose local position falls outside the resolved plot's footprint, the handler bails. Variants in other plots cannot be accidentally cleared by breaking blocks elsewhere.
-
Lock groups. Removes only the broken cell's entry. Other cells in the same lock group are untouched โ the user explicitly said "delete all variants for that position", not "tear down the whole group". To clear a full group, use the menu's
Clearbutton on each member or unlock first. -
Feedback. Action bar shows gold text
- removed N variants @ x,y,zand an emptyVariantHoverPacketis pushed so any open hover HUD clears immediately. -
Cancel-aware. The handler exits early when
BlockEvent.BreakEvent.isCanceled()is true โ if another mod cancels the break, the variant entry stays alive (the world block is still there).
Implementation: VariantBlockBreakHandler.java โ single @Mod.EventBusSubscriber on BlockEvent.BreakEvent, ~50 lines.
Since v0.65.0, the variant clipboard's useOn no longer hard-codes a vanilla command block as the placeholder for the new cell. The pasted position now takes its appearance from the first variant of the copied list โ the same block the source cell renders as, since the first variant is captured at cell-creation time via captureBaseVariant. Authors no longer have to re-place the base block at every paste position to get a Add-cell-like preview.
-
Empty-placeholder sentinel preserved. If the first variant is the air sentinel (any of the three command-block kinds โ see
CarriageVariantBlocks.isEmptyPlaceholder), paste still places a vanillaBlocks.COMMAND_BLOCKso "this position will be empty at spawn" cells stay visually distinct from real blocks. -
Block-entity NBT round-trips. When the first variant carries
BlockEntitydata โ chest with items, sign with text, banner with patterns โ the new world block restores that data viaBlockEntity.load. The position fields in the captured tag are patched to matchplacePosso the BE doesn't reject the load. -
No regression on lockId. The pasted cell still joins the source's lock group via the carried
dt_lockIdNBT key. Lock-group membership is independent of which placeholder the paste path chose. - Symmetric with Add on empty cell. That flow already kept the player's manually-placed base block as the visible placeholder; this fix just brings paste in line.
The fix is a 25-line change to VariantClipboardItem.useOn โ the rest of the paste pipeline (clipboard NBT decode, sidecar write, lockId restore) is unchanged.
Since v0.65.0, every cell with lockId > 0 shows a small backdrop-and-digit badge on each of its 6 block faces while the player is in an editor plot. Walk around the block and the digit reads upright from any of north / south / east / west / top / bottom. The badge appears immediately when a cell is locked or pasted and disappears immediately when the cell is unlocked, cleared, or the player exits the plot.
This is independent of the per-cell menu โ opening the menu is no longer required to see which cells share a lock-id. The header text and Lock toolbar cell from earlier versions still work as before; this overlay is in addition to them.
-
Server-driven snapshot.
VariantOverlayRenderer.pushLockIdSnapshotresolves the player's plot viaBlockVariantPlot.resolveAt, callsplot.allLockIds()to enumerate every locked cell, and sends aBlockVariantLockIdsPacketcarrying(plotKey, plotOriginWorldPos, [(localPos, lockId)]). Sent every server tick, but with a per-player dedup hash on the sorted(localPos, lockId)list โ steady-state idle in a plot generates zero snapshot packets. -
Immediate refresh. Mutations that change the locked-cell set push a fresh snapshot without waiting for the next tick:
BlockVariantMenuController.cycleLockId(lock toggle), the REMOVE / CLEAR drop-cell branches inapplyEdit, and the paste path inVariantClipboardItem.useOnwhen the clipboard carriesdt_lockId > 0. -
Plot-exit clears the overlay.
VariantOverlayRenderer.forget(player)and the every-tick "no plot resolved" branch sendBlockVariantLockIdsPacket.empty()so the client clears its cache when the player walks out of the plot. Walking back in re-pushes within one tick. -
Client-side rendering.
BlockVariantLockIdRenderersubscribes toRenderLevelStageEvent.AFTER_TRANSLUCENT_BLOCKS. For each cached locked cell it iteratesDirection.values()and draws a tinted backdrop quad + digit text on every face, anchored slightly outside the block surface (FACE_INSET = 0.51) to avoid z-fighting. Top/bottom faces use world+Zas the up-axis so the digit doesn't flip as the player rotates. -
No new sidecar fields.
lockIdalready exists in the v4 sidecar schema; this is a purely visual overlay.
Look-and-feel: a ~0.18m half-side dark backdrop quad with the digit centered in warm yellow text. Runs only when CACHE.isEmpty() == false, so non-editor gameplay pays nothing.
Since v0.66.1, left-clicking an entry's name cell in the menu's grid swaps the world block at that variant cell to the clicked entry's BlockState. This lets authors preview each candidate in-place by clicking through the list, instead of guessing what each variant looks like from the id alone.
- No sidecar mutation. The variant list, weights, and lockId are untouched โ preview is a pure world-space visual swap. Saved sidecar JSON is unaffected.
-
Block-entity preserved. Variants carrying NBT (chests with contents, signs with text, banners) round-trip through
SilentBlockOps.setBlockSilentso the previewed block keeps its data. -
Lock-group propagation. If the targeted cell is in a lock group (
lockId > 0), the world-block swap applies to every cell inplot.positionsWithLockId(lockId). This matches spawn-time semantics โ a locked group rolls one index across all sibling cells, so the preview shows what spawn will look like. -
Empty-placeholder sentinel. Clicking the row labelled
nothingsets the world to a vanillaCOMMAND_BLOCKโ the editor's existing visual for "this position will be air at spawn time." Symmetric with theAddempty-hand flow. - No "reset to base." Once a variant is previewed, the original world block is overwritten. To restore, the author can preview a different variant or rebuild the cell.
Implementation: Op.PREVIEW_ENTRY in BlockVariantEditPacket; server-side handler BlockVariantMenuController.previewEntry calls SilentBlockOps.setBlockSilent per target. Client wiring is a single case ENTRY_NAME branch in BlockVariantMenuInputHandler.dispatchRoot.
Since v0.67.0, each variant row gains two inline cells:
-
Mode pill โ three segments
L | R | O(Lock / Random / Options). Click to cycle modes. Each segment is ~20% of the column width so the pill is comfortably clickable. - Direction cell โ content depends on mode (see below). Hidden entirely when mode is R, so the row's name cell expands to fill its space.
Both cells are hidden for variants whose BlockState has no rotation property (plain stone, dirt, glass) โ see RotationApplier.canRotate.
| Mode | Direction cell | Spawn behaviour |
|---|---|---|
| L (Lock) | Single direction (XU / XD / YU / YD / ZU / ZD). Click cycles through directions valid for this block's rotation property. |
Always rotates to the locked direction. |
| R (Random) | Hidden (no per-direction concept). | Picks a random direction at spawn from the property's full valid set. Deterministic given world seed + carriage index + cell position. |
| O (Options) | Count n/6. Click opens a 3ร2 floating popup with toggleable direction buttons. |
Picks a random direction at spawn from the author-selected subset. |
The 6 short-names map to vanilla Direction: XU = EAST, XD = WEST, YU = UP, YD = DOWN, ZU = SOUTH, ZD = NORTH.
Since v0.106.0, a freshly added variant no longer defaults to RANDOM. The Add path picks the LOCK direction by walking the cell's predecessor list backwards and taking the first entry whose BlockState carries a directional property โ and the captured BlockState's facing is rotated to match, so the menu display, JSON serialisation, and the duplicate-variant guard all stay consistent. Authors can still cycle the row's mode pill to R / O after the fact.
| Cell state when Add runs | Held block | Resulting entry |
|---|---|---|
[furnace facing east] |
furnace | LOCK east, state furnace[facing=east] โ duplicate guard rejects |
[furnace facing east] |
campfire | LOCK east, state campfire[facing=east]
|
[furnace facing east] |
oak log | LOCK east, state oak_log[axis=x]
|
[dirt, log axis=Y] |
campfire | UP invalid for HORIZONTAL_FACING โ continues searching โ dirt has no direction โ end of list โ LOCK at campfire's own default (NORTH) |
[dirt] only |
furnace | dirt has no direction โ end of list โ LOCK at furnace's own default |
Implemented as RotationApplier.orientToPredecessors(BlockState, List<VariantState>) returning OrientedState(state, rotation). Called from both the world-space menu's BlockVariantMenuController.applyEdit ADD case (after the base seed is added so the loop sees it as the first predecessor on empty cells) and the older shift-click flow's VariantBlockInteractions.buildUpdatedList (after the predecessor list is finalised).
The fallback when no predecessor offers a usable direction is RotationApplier.lockToCurrent(newState) โ the new block is locked to whatever facing its BlockState already carries. Blocks with no rotation property at all keep VariantRotation.NONE (the menu hides the rotation cells anyway via canRotate).
Backwards compatible. Old JSON / clipboard entries with omitted rotation still load as NONE; only entries written after this change carry an explicit LOCK. Authors who want the previous "always randomize" behavior can click the row's mode pill to cycle to R.
RotationApplier.apply checks the candidate BlockState for these properties in priority order, takes the first match, and intersects the requested mask with the property's valid set:
-
BlockStateProperties.FACINGโ all 6 directions (Furnace, Observer, Piston, Dispenser). -
BlockStateProperties.HORIZONTAL_FACINGโ 4 horizontal (Stairs, Fence Gate, Chest). UP/DOWN bits in the mask are silently dropped. -
BlockStateProperties.AXISโ X/Y/Z (Logs, Pillars). Direction pairs collapse to axes. -
BlockStateProperties.HORIZONTAL_AXISโ X/Z (Nether Portal). Vertical bits dropped. -
BlockStateProperties.ROTATION_16โ floor heads/skulls, standing banners, standing signs. Offered as 8 compass slots โ see below.
Since v0.1132.0 (PR #1795), blocks whose rotation is the 16-step ROTATION_16 property show the same L | R | O controls, over 8 compass points instead of the 6 faces. Wall-mounted heads/banners/signs use HORIZONTAL_FACING and are unchanged.
-
L cycles
S โ SW โ W โ NW โ N โ NE โ E โ SE. -
O shows
n/8; clicking opens a 3ร3 compass rose (NW N NE / W ยท E / SW S SE, empty centre) instead of the 3ร2 grid. - Add locks a new head to its placed rotation snapped to the nearest of the 8; a predecessor's direction is only carried between blocks of the same kind (compass โ compass, face โ face).
- Mirror (V) and plot rotate/flip remap compass directions exactly as
BlockState.mirror/rotateturn the block.
Mask encoding. VariantRotation.dirMask is 8 bits of slots whose meaning depends on the block: Direction.ordinal() for facing/axis blocks (bits 0โ5, unchanged), compass slot i = ROTATION_16 = 2i for compass blocks (RotationApplier.isCompass). The mask still fits the sync-packet / clipboard byte, so there was no protocol change.
Sidecar JSON. Compass entries serialise as "rotation": {"mode": "options", "compass": ["s", "ne", "se"]}; facing blocks keep "dirs". Existing templates are untouched โ heads captured before this were stored as default Random, which leaves their placed rotation alone.
Shipped heads aimed (v0.1135.0, PR #1800). Every default-facing (rotation=0) player head in the shipped sidecars carries an Options arc: the compass point facing the template's footprint centre plus every point within 90ยฐ of it (5 of 8); a head within half a block of the centre gets all 8. Compass blocks ignore legacy 6-way "dirs" (heads often inherited a no-op lock from a neighbouring block), and ShippedHeadRotationTest fails the build if a shipped player head is left at rotation 0 without Options or a compass entry doesn't re-save unchanged.
Empty intersection โ no-op (returns base unchanged). LOCK with an invalid direction โ no-op. Mode set to LOCK or OPTIONS while the previous mask was 0 โ server seeds a sensible default (lowest valid bit for LOCK; full valid mask for OPTIONS) so the cycle visibly advances on every click.
When a cell sits in a lock group (lockId > 0), the rotation roll uses the same hash shape as the state-list pick โ every cell in the group rolls the same direction at spawn:
seed = worldSeed
^ (carriageIndex * 0x9E3779B97F4A7C15L)
^ (lockId * 0xBF58476D1CE4E5B9L)
^ ROT_SEED_SALT
ROT_SEED_SALT is a constant distinct from the state-pick salt so direction rolls don't correlate with which state was picked. Implemented in RotationApplier.pickWeightedFromMask.
Server-tick handler VariantEditorPreviewTicker animates the editor world block at 1 Hz so authors see what each rotation choice produces:
- Direction cycle โ for entries in RANDOM / OPTIONS, the visible direction rotates 1 Hz across the property's valid set (or the author's selection for OPTIONS). LOCK shows its single direction statically.
-
Entry cycle โ cells with multiple variant entries cycle between entries every 3 seconds. The world block flips through
stairs โ log โ fence โ stairs โ โฆ. -
Pin on click โ clicking any rotation control on a row pins that entry; the entry cycle stops at that entry while the direction cycle continues. Pin clears when the cell is dropped (CLEAR / REMOVE-to-empty). Tracked per
(plotKey, localPos)inVariantEditorPreviewState. -
Editor-only. The ticker only fires when an OP player is standing in a
BlockVariantPlot.resolveAtplot. Never runs at carriage spawn time โapplyVariantBlocksdoes its own deterministic pick. -
Empty-placeholder skip. Cells whose picked entry is the air sentinel (
isEmptyPlaceholder) are left alone so the cell marker stays visible.
When the OPTIONS popup is open, all clicks inside the menu panel are absorbed by the popup. Buttons toggle directions; clicks anywhere else inside the panel close the popup without leaking to the underlying toolbar / row cells. Clicks outside the panel return Hit.NONE (popup stays open, nothing fires). This prevents the previous bug where popup buttons overlapped the toolbar and accidentally hit Remove / Clear underneath.
-
VariantRotation.javaโrecord (Mode mode, int dirMask)+ Mode enum + bitmask helpers; canonical-constructor invariants re-clamp malformed inputs (LOCK with multi-bit mask collapses to lowest bit; OPTIONS with empty mask falls back to RANDOM). -
RotationApplier.javaโ property probe + deterministic picker +applyDirectionfor the preview ticker. -
VariantEditorPreviewTicker.javaโ 1 HzLevelTickEventhandler; iterates OP players' plots and updates the world block. -
VariantEditorPreviewState.javaโ per-cell pin storage.
Since: v0.1219.0 (PR #1924)
Fast Paintings paintings are blocks, one per square, so a variant on a painting used to vary a single square. Painting rows now vary the whole painting:
- Add a painting. Hold a painting item and Z + right-click (or press Add in the menu). A pick-blocked painting keeps its picture.
- Any square is the same cell. Z-clicking, opening the menu or copying on any square of a hung painting targets one cell: the block vanilla would have hung it from (centred, even sides shifted half a block along the painting / up). The seeded first row records the template painting's picture and size.
- Painting pill + popup. A painting row shows a Painting pill in the Grow slot. Its popup has On/Off, a Size stepper (Any, then every placeable size) and a Picture stepper (Random, then the pictures of that size). Picking a picture under Any adopts its size; changing the size drops a picture that no longer fits. Off places the captured block as before.
-
At spawn (
PaintingPass, ridingGrowthPass's scope so every placer gets it):- The template painting the cell was captured from is cleared first (
hungsize, untouched by the popup), so the pick replaces it whole. - A painting pick is hung centred on the cell, in world space, so mirrored carriages still face their wall.
- It needs free blocks inside the piece and a solid wall behind. Any size takes the largest pictures that fit, as vanilla does.
- If nothing fits (e.g. a window where the wall would be), the cell is left empty rather than half a painting.
- The template painting the cell was captured from is cleared first (
Sidecar keys (omitted at their defaults): "painting": "2x1 minecraft:kebab" / "any random", and "hung": "2x1" on the captured row. In the editor itself a non-base pick previews only on the anchor square; the whole painting shows on spawned trains.
Files: editor/VariantPainting, editor/HungPainting, editor/PaintingPass, compat/PaintingCatalog, client/menu/blockvariant/PaintingPopupLayout.
Since v0.104.0, every variant row in the menu's entries grid shows a small inventory-style block icon to the left of the name. Authors can disambiguate visually similar names (oak_log vs stripped_oak_log, mossy_cobblestone vs cobblestone) at a glance instead of reading the path-segment text. The same renderer is used by the dormant Add-search list (see ยง Known limitations) โ if that path is rewired in a future release, candidates already render with icons.
-
Inventory-style render. Uses
ItemRenderer.renderStaticwithItemDisplayContext.GUIinside the menu's world-spacePoseStackโ same 3-quarter view vanilla inventory slots use. Lit atLightTexture.FULL_BRIGHTso icons stay legible against the menu's translucent backdrop. -
Reuses the parsed-state cache. No per-frame state parsing;
BlockVariantMenu.parseStatealready memoises oneBlockStateper row stateString, so the only per-frame cost isstate.getBlock().asItem()and the render call itself. -
Item fallback. Blocks without a matching
Item(rare โ fluids, technical blocks) fall back toItems.BARRIER, mirroring theVariantHoverHudOverlaypattern. -
nothingsentinel skipped. The empty-placeholder row keeps just its label so the visual contract โ "this position will be air at spawn" โ stays unambiguous. -
No raycast or layout impact. The icon sits inside the existing name-cell rectangle. Click / hover behaviour is unchanged; only the row's text shifts right by
ICON_LEFT_PAD + ICON_SIZE + ICON_TEXT_GAP โ 0.30mto make room. -
Buffer-flush note. Items queue across multiple internal
RenderTypes (textured solid, glint), soonRenderLevelStagenow ends with an extrabuffer.endBatch()(no args) alongside the existingPANEL_QUADflush.
Renderer-only: a single drawBlockIcon helper added to BlockVariantMenuRenderer; no data, packet, sidecar, or save-format change.
First mod-registered item in the project (DeferredRegister<Item> in ModItems). Registry id: dungeontrain:variant_clipboard. Lives in the Tools & Utilities creative tab.
-
Visual. Item model parents
minecraft:block/command_blockso it reuses the vanilla atlas โ no custom textures. -
NBT keys.
dt_variants(list of{s: stateString, n?: beNbt, w?: weight}per entry) anddt_lockId(int, when the source cell was locked). -
Placement. OP-only.
useOnresolves the editor plot at the player's standing position, places the first variant'sBlockStateat the targeted face (so the pasted cell visually matches the source โ see ยง Pasted cell mirrors source's texture), writes the decoded states + lockId to the plot's sidecar viaBlockVariantPlot.put/setLockId, and persists. Since v0.65.0 โ earlier builds always placed a vanilla command block.
The cell value in *.variants.json is one of:
-
Bare states array (v3 shape). Used when
lockId == 0. Round-trips diff-clean against v1/v2/v3 files. -
Cell-object form. Used when
lockId > 0:"5,1,2": { "lockId": 1, "states": ["minecraft:stone", "minecraft:cobblestone"] }
Per-entry shape is {state, nbt?, weight?, rotation?}. Since v0.67.0, each entry can declare a rotation; the field is omitted when default (mode=random, mask=0) so v3/v4 files round-trip diff-clean.
{
"state": "minecraft:furnace[facing=east]",
"rotation": { "mode": "lock", "dirs": ["east"] }
}mode is one of "lock" | "random" | "options". dirs is an array of lowercase Direction names. The reader is case-insensitive and skips unknown names with a warn. The v3-era per-entry locked flag is silently dropped on read (locking moved to the cell level in v4).
Schema version bumped 4 โ 5 in CarriageVariantBlocks.CURRENT_SCHEMA_VERSION and shared across all four sidecars (carriage / contents / part / track) via parseCellValue / appendCellJson helpers.
Protocol bumped 4 โ 5 in v0.56.0 (initial menu cut), then 5 โ 6 in v0.65.0 when BlockVariantLockIdsPacket was added. Any client / server pair across the boundary won't connect.
| Packet | Direction | Payload |
|---|---|---|
BlockVariantSyncPacket |
S2C | plotKey, localPos (or null = close), entries [stateString, beNbt?, weight, rotMode, rotDirMask], cell lockId, anchor pos / right / up. rotMode / rotDirMask added in v0.67.0 (PR #89). |
BlockVariantEditPacket |
C2S | op (ADD / REMOVE / CLEAR / BUMP_WEIGHT / CYCLE_LOCK_ID / COPY / PREVIEW_ENTRY / SET_ROTATION_MODE / SET_ROTATION_DIRS), plotKey, localPos, entryIndex, stateString, delta. SET_ROTATION_MODE / SET_ROTATION_DIRS added in v0.67.0. |
BlockVariantMenuTogglePacket |
C2S | open boolean |
BlockVariantLockIdsPacket (v0.65.0) |
S2C | plotKey, plotOriginWorldPos, list of (localPos, lockId) for every locked cell in the player's current plot. Empty payload = client clears its overlay cache. |
/dungeontrain editor enter standard # carriage variant โ or contents / parts / tracks
[walk to a block]
Z (tap) # menu opens
Click "Add" with stone in hand # row added: stone | weight 1
Click "+" on the weight cell # โ 2
Click "Lock" # header reads "lock 1"
Click "Copy" # variant clipboard appears in hotbar
[Walk to another floor block]
Right-click (clipboard) # paste โ new cell joins lock group 1
Z (tap) on the new cell # header reads "lock 1" โ same group
Click "Add" with cobblestone in hand # both cells get the new variant (auto-propagated)
[Spawn carriage in real run] # both cells render the same block
-
BlockVariantMenu.javaโ client singleton state (active/inactive, screen, hovered cell, search buffer) -
BlockVariantMenuRenderer.javaโ billboarded panel + 6-cell toolbar; orientation matches player look on horizontal faces so the bottom always faces the player -
BlockVariantMenuRaycast.javaโ camera ray โ panel cell hit-test -
BlockVariantMenuInputHandler.javaโ left-click dispatch, attack/use cancel -
BlockVariantMenuController.javaโ server:toggle,applyEdit, lock-group propagation, COPY โ clipboard -
BlockVariantPlot.javaโ abstraction over the four sidecar types,resolveAtcascade, lock-id API -
VariantBlockBreakHandler.javaโ auto-clear sidecar entry on block break (v0.64.0) -
VariantClipboardItem.javaโ paste-aware item; encodes / decodes states + lockId in NBT; pastes the first variant's BlockState as placeholder (v0.65.0) -
BlockVariantLockIdRenderer.javaโ client-side all-faces lock-id badge overlay (v0.65.0) -
BlockVariantLockIdsPacket.javaโ server snapshot of the plot's locked cells (v0.65.0) -
ModItems.javaโ firstDeferredRegister<Item>in the project -
VariantHotkeyClient.javaโ Z-tap-vs-hold detection -
CarriageVariantBlocks.javaโparseCellValue/appendCellJsonshared by all four sidecars;pickIndexFromLockGrouplock-group picker
- Tap-vs-hold timing is fixed at 8 ticks. Players who press Z slowly may unintentionally trigger the menu. Currently no rebind for the threshold itself; rebind Z to a different key if the conflict matters.
- Lock-id 0 is "any non-locked cell" โ the lock button never targets it directly. To regroup cells, unlock one (button cycles to 0) then Copy + Paste from another locked cell.
- CYCLE_LOCK_ID only operates on the targeted cell, not the group. Unlocking one cell removes it from the group; the rest stay locked.
- Editing a locked cell is OK; deleting one is not propagated to siblings via REMOVE โ the targeted cell is removed, siblings lose their group member. If the user wanted "destroy group" they can hit Clear (drops the targeted cell, siblings stay grouped โ they still share state and pick).
-
Search-screen path is dormant. Earlier preview builds had a typing-search picker for Add; the v0.55.0 cut moved Add to "capture held block" and the search files (
BlockVariantSearchScreen, etc.) are unreachable from the toolbar. Files remain in case a directional-block picker is added back.