Feature Block Variant Menu - bh679/dungeon-train-mc GitHub Wiki

World-space block-variant menu

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.

When to use which

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

Tap vs hold

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.

Toolbar

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 groups

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 Lock button 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 to 0. 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 iterates plot.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:
    seed = worldSeed
         ^ (carriageIndex * 0x9E3779B97F4A7C15L)
         ^ (lockId        * 0xBF58476D1CE4E5B9L)
    
    Every cell in the group draws the same bucket, and since they share the state list, they render the same block. Implemented in CarriageVariantBlocks.pickIndexFromLockGroup.

The header line of the menu shows the cell's current lock-id when >0: Block Variants @ 5,1,2 ยท lock 1.

Cross-plot support

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.

Train Builder support

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. BuilderWorldSetup seeds it from the template you opened; BuilderSave writes 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.

Looking at, not standing in

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.

Auto-clear on block break

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 โ€” same BlockVariantPlot.resolveAt cascade, same per-plot remove / save API, 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 Clear button on each member or unlock first.
  • Feedback. Action bar shows gold text - removed N variants @ x,y,z and an empty VariantHoverPacket is 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.

Pasted cell mirrors source's texture

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 vanilla Blocks.COMMAND_BLOCK so "this position will be empty at spawn" cells stay visually distinct from real blocks.
  • Block-entity NBT round-trips. When the first variant carries BlockEntity data โ€” chest with items, sign with text, banner with patterns โ€” the new world block restores that data via BlockEntity.load. The position fields in the captured tag are patched to match placePos so 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_lockId NBT 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.

Lock-id badge on all 6 faces

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.pushLockIdSnapshot resolves the player's plot via BlockVariantPlot.resolveAt, calls plot.allLockIds() to enumerate every locked cell, and sends a BlockVariantLockIdsPacket carrying (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 in applyEdit, and the paste path in VariantClipboardItem.useOn when the clipboard carries dt_lockId > 0.
  • Plot-exit clears the overlay. VariantOverlayRenderer.forget(player) and the every-tick "no plot resolved" branch send BlockVariantLockIdsPacket.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. BlockVariantLockIdRenderer subscribes to RenderLevelStageEvent.AFTER_TRANSLUCENT_BLOCKS. For each cached locked cell it iterates Direction.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 +Z as the up-axis so the digit doesn't flip as the player rotates.
  • No new sidecar fields. lockId already 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.

Preview a variant in-place

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.setBlockSilent so 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 in plot.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 nothing sets the world to a vanilla COMMAND_BLOCK โ€” the editor's existing visual for "this position will be air at spawn time." Symmetric with the Add empty-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.

Per-entry rotation

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.

Modes

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.

Default rotation on Add

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.

Property probe order

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:

  1. BlockStateProperties.FACING โ€” all 6 directions (Furnace, Observer, Piston, Dispenser).
  2. BlockStateProperties.HORIZONTAL_FACING โ€” 4 horizontal (Stairs, Fence Gate, Chest). UP/DOWN bits in the mask are silently dropped.
  3. BlockStateProperties.AXIS โ€” X/Y/Z (Logs, Pillars). Direction pairs collapse to axes.
  4. BlockStateProperties.HORIZONTAL_AXIS โ€” X/Z (Nether Portal). Vertical bits dropped.
  5. BlockStateProperties.ROTATION_16 โ€” floor heads/skulls, standing banners, standing signs. Offered as 8 compass slots โ€” see below.

8-direction (compass) rotation

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

Lock-group sharing

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.

Editor preview

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) in VariantEditorPreviewState.
  • Editor-only. The ticker only fires when an OP player is standing in a BlockVariantPlot.resolveAt plot. Never runs at carriage spawn time โ€” applyVariantBlocks does 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.

Options popup is modal

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.

Files

  • 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 + applyDirection for the preview ticker.
  • VariantEditorPreviewTicker.java โ€” 1 Hz LevelTickEvent handler; iterates OP players' plots and updates the world block.
  • VariantEditorPreviewState.java โ€” per-cell pin storage.

Paintings โ€” size and picture

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, riding GrowthPass's scope so every placer gets it):
    • The template painting the cell was captured from is cleared first (hung size, 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.

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.

Block icons next to row names

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.renderStatic with ItemDisplayContext.GUI inside the menu's world-space PoseStack โ€” same 3-quarter view vanilla inventory slots use. Lit at LightTexture.FULL_BRIGHT so icons stay legible against the menu's translucent backdrop.
  • Reuses the parsed-state cache. No per-frame state parsing; BlockVariantMenu.parseState already memoises one BlockState per row stateString, so the only per-frame cost is state.getBlock().asItem() and the render call itself.
  • Item fallback. Blocks without a matching Item (rare โ€” fluids, technical blocks) fall back to Items.BARRIER, mirroring the VariantHoverHudOverlay pattern.
  • nothing sentinel 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.30m to make room.
  • Buffer-flush note. Items queue across multiple internal RenderTypes (textured solid, glint), so onRenderLevelStage now ends with an extra buffer.endBatch() (no args) alongside the existing PANEL_QUAD flush.

Renderer-only: a single drawBlockIcon helper added to BlockVariantMenuRenderer; no data, packet, sidecar, or save-format change.

VariantClipboardItem

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_block so it reuses the vanilla atlas โ€” no custom textures.
  • NBT keys. dt_variants (list of {s: stateString, n?: beNbt, w?: weight} per entry) and dt_lockId (int, when the source cell was locked).
  • Placement. OP-only. useOn resolves the editor plot at the player's standing position, places the first variant's BlockState at 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 via BlockVariantPlot.put / setLockId, and persists. Since v0.65.0 โ€” earlier builds always placed a vanilla command block.

Schema v5

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.

Network

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.

Authoring flow

/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

Files

Known limitations

  • 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.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ