Skip to content

Add multiblocks, dynamic machine visuals, and recipe controls - #105

Merged
dshadowwolf merged 5 commits into
master-2.0-1.21.1-neofrom
codex/machine-runtime-expansion
Nov 12, 2026
Merged

dshadowwolf merged 5 commits into
master-2.0-1.21.1-neofrom
codex/machine-runtime-expansion

Conversation

@faceofcat

@faceofcat faceofcat commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

MMDLib consumers need reusable support for multi-block machines, state-driven appearances, richer recipe inputs, and physically unavailable resource/redstone faces. This adds those APIs and XML options, companion examples, documentation, and regression coverage in one delivery.

Changes

  • Add Java-defined fixed multiblocks with whole-machine placement, rotation, linked membership and persistence, guarded item/fluid/energy ports, teardown policies, and member-anchored menus. Include the companion Multiblock Test Bench and a focused client smoke runner.
  • Add authored-model appearances, conditional model parts and face textures, emissive masks, and synchronized presentation variables driven by machine state.
  • Add saved recipe selection with a GUI chooser and JEI integration, sample-only ghost inventories, and reusable item/fluid inputs (consumed: false) with reservation handling.
  • Update JEI ingredient grouping, reusable/ghost presentation, fluid scaling, and related interactions.
  • Enforce machine-owned unavailable faces across automation, live capability views, redstone, drawers, inline controls, and server-side edits.
  • Add inline XML resource colors and a default palette, manual fluid tanks excluded from automation, and configurable local Maven publishing.

Integration and compatibility

  • Includes the latest master-2.0-1.21.1-neo through 1ada8db (2.0.1 release preparation), including the updated formatting configuration.
  • Core screen protocol is 21; automation and redstone protocols are 17. Client and server must use matching versions.
  • Variable-size structure discovery and a configurable named-port system remain outside this delivery.

Validation

  • Passed spotlessApply, spotlessCheck, and git diff --check against the target branch.
  • Reviewed merge conflicts against upstream formatting changes; static token comparison confirmed the feature Java code was preserved apart from formatting/import ordering.
  • Compiled main, unit-test, companion-test, and runtime-test sources after the review fixes. No local test suites were run; unit tests, GameTests, client smoke tests, and the full build run in CI.

transition(Status.READY, "ready");
}

public boolean ready() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ready() calls tick() on every call, and the port wrappers call ready() on every getSlots/getStackInSlot/insertItem/fill/receiveEnergy. So each capability access re-validates the whole structure and can dismantle the machine (removing blocks under REMOVE_OWNED) from inside another block's capability call.

Failure scenario: A pipe or hopper next to the socket calls getSlots(), then getStackInSlot(0), then insertItem() in one tick. That is three full matchesAll() passes over every member (blockstate and BE lookups, plus capability invalidation on status changes). If a member broke this tick, the first call runs dismantle(RECOVERY) and setBlock(AIR) on every member while the pipe is still in its transfer loop. Cost is O(members) per handler call, for structures of up to 4096 members.

&& controller.multiblock().contains(pos, link))
instance = controller.multiblock();
if (instance != null)
instance.dismantle(MultiblockInstance.Cause.RECOVERY);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onRemove dismantles with Cause.RECOVERY for every non-player removal. Cause.EXPLOSION (and INVALIDATED) are never produced, so listeners can't tell an explosion from bookkeeping recovery, and the demo listener only returns the machine item for SURVIVAL.

Failure scenario: A creeper blows up one part of a formed REMOVE_OWNED Multiblock Demo. onRemove runs dismantle(RECOVERY), which removes every member. The listener drops the contents but not the MultiblockDemo item, and the removed blocks have no loot. The whole machine is lost, while breaking the same part by hand in survival returns it.

if (ghost.defaultItem() != null) {
if (!BuiltInRegistries.ITEM.containsKey(ghost.defaultItem()))
throw new IllegalArgumentException(
"Unknown ghost default item " + ghost.defaultItem());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A ghost slot's default-item is only checked against the item registry inside the block entity constructor, which throws IllegalArgumentException. A typo in the XML surfaces as a block-entity creation failure in the world, not as a definition-load error.

Failure scenario: An XML has . The definition loads cleanly. Placing the machine, or loading a chunk that already contains one, throws from the DefinedMachineEntity constructor, so the BE fails to load, the machine breaks, or the server crashes. loadMachineData separately falls back to AIR for the same id, so the two paths also disagree.

parts.forEach(part -> {
boolean valid = part instanceof MachineIngredient.ItemPart
? items.contains(key)
? items.contains(key) || ghostItems.contains(key)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recipe validation accepts any ItemPart on a ghost group, but GhostItemGroup holds one sample per slot (free(slot) is always 0 or 1). An ingredient with count > 1 on a ghost group can never be claimed, so the recipe loads but can never run.

Failure scenario: A recipe declares {"items":"#c:plates","count":2} on ghost group 'pattern'. validateGroups accepts it. RecipeMatcher.check and CyclePlan.of always return MISSING_INPUT because the claim finds only 1, so the machine sits idle with no load-time diagnostic.

costs.add(group.getKey() + ": " + fluid.amount()
+ " mB required, "
+ Math.max(0, fluid.amount() - returned)
+ " mB consumed");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chooser's cost text ignores MachineIngredient.consumed(). A reusable fluid input (consumed: false) is still listed as 'N mB consumed'.

Failure scenario: A recipe has {"fluids":"minecraft:lava","amount":1000,"consumed":false}. The recipe selector row shows '...: 1000 mB required, 1000 mB consumed', but finish() never drains it. Players are told the recipe spends a bucket that it actually keeps.

slots, groups, recipeType, processor, processorName, redstone,
screen, null);
}
/** Cube-front configuration retained for existing Java callers. */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old canonical MachineDefinition constructor (textures, power, fluids, ...) is gone. The overload documented as 'retained for existing Java callers' has a different signature (it also takes modelParts and faceTextures), so existing callers no longer compile or link.

Failure scenario: A 2.0.x consumer that builds MachineDefinition(id, checksum, name, hardness, mineable, requiresTool, textures, power, fluids, itemStorageId, slots, groups, recipeType, processor, processorName, redstone, screen) gets a compile error after updating, or NoSuchMethodError at runtime against the new jar.

multiblock().tick();
if (isRemoved())
return;
boolean structureReady = structureReady();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serverTick calls multiblock().tick() and then structureReady(), which calls multiblock().ready(), which calls tick() again. The full structure validation therefore runs twice per controller tick.

Failure scenario: For a formed multiblock, every server tick runs matchesAll() over all members twice (blockstate, BE and link lookups each time), on top of any extra passes triggered by port capability calls. That is double the steady-state cost for no benefit.

// Returning to the same
// amount leaves a failed match unchanged; a refill must bypass the
// idle retry delay.
return buffer.stored();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using stored FE as the energy group's revision means any change in stored energy invalidates the failed-search cache. An idle machine whose buffer is charging re-runs the recipe lookup every tick instead of every SEARCH_RETRY_INTERVAL.

Failure scenario: An idle powered machine with no matching input sits on a generator supplying 10 FE/t into a 100k buffer. stored changes every tick, so inventory.unchanged(revisions) is always false, and lookup.find scans every recipe of the type each tick for about 10,000 ticks. This hits many machines at once on a shared grid.

public static final Set<String> RESERVED = Set.of("automation", "features",
"redstone_control", "redstone_mode", "id", "components",
"mmdlib_features", "mmdlib_machine");
"mmdlib_features", "mmdlib_machine", "mmdlib_ghost_items", "multiblock",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding 'multiblock', 'multiblock_view' and 'mmdlib_ghost_items' to RESERVED makes MachineDefinitions reject existing XML machines whose items or fluids storage id uses one of those names.

Failure scenario: An existing 2.0.1 XML machine with or loaded before this change. After it, definition loading fails with a reserved-key error and the machine disappears from the pack.

features.reset();
if (definition
.dismantling() == zone.moddev.mc.lib.machine.multiblock.MultiblockDefinition.Dismantling.LEAVE_BLOCKS)
return;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a LEAVE_BLOCKS definition, the demo's dismantling listener returns before dropping its item storage, and MultiblockBlock has no DropsContents handling. The controller's contents are deleted when the controller is broken.

Failure scenario: A MultiblockDemoEntity is built with a LEAVE_BLOCKS definition (the constructor accepts one, and tests use this). A player breaks the controller while it holds items. dismantle runs the listener, which returns early, then vanilla removes the block and BE, and the stacks in 'items' never drop.

@dshadowwolf
dshadowwolf merged commit 7ba11aa into master-2.0-1.21.1-neo Nov 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants