Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
03fdf77
feat(machine): plans that say what a slot or tank will end up holding
faceofcat Nov 3, 2026
3888fec
refactor(machine): move the fluid conversions to FluidStacks
faceofcat Nov 3, 2026
537005c
feat(capabilities): storages hold reservations and obey them
faceofcat Nov 3, 2026
a25a5b8
test(capabilities): cover partial-stack refusal and the empty reserve…
faceofcat Nov 3, 2026
00d0ba4
feat(machine): groups place, reserve and free by plan
faceofcat Nov 3, 2026
f47aee3
feat(machine): matching and groups respect reservations
faceofcat Nov 3, 2026
7d99181
style(machine): join the free-amount loop onto one line
faceofcat Nov 3, 2026
f702b12
fix(machine): place leaves room for pending takes, restore reports re…
faceofcat Nov 3, 2026
6aff879
refactor(machine): share the matcher's claim and available with plans
faceofcat Nov 3, 2026
c9c817b
feat(machine): a self-contained plan for one recipe cycle
faceofcat Nov 3, 2026
b168518
feat(machine): features a host ticks and saves by key
faceofcat Nov 3, 2026
0222c77
test(machine): a refining recipe type for the processor tests
faceofcat Nov 3, 2026
1a1fa8e
feat(machine): a recipe processor that holds what it needs while it runs
faceofcat Nov 3, 2026
d79addc
fix(machine): a stalled machine checks its energy before it draws
faceofcat Nov 3, 2026
de356aa
fix(machine): a cycle that can't finish checks before it opens a tran…
faceofcat Nov 3, 2026
eaab084
test(machine): a finish that fails inside its transaction is undone
faceofcat Nov 3, 2026
ce77635
feat(machine): save and reload a running recipe
faceofcat Nov 3, 2026
6c57f7f
feat(machine): save, reload and interrupt a running recipe
faceofcat Nov 3, 2026
e9c2bde
docs(machine): FeatureHost.load wants the storages loaded first
faceofcat Nov 3, 2026
0108e4e
fix(machine): progress survives a pause only for a recipe of the same…
faceofcat Nov 3, 2026
77570d8
test(machine): an example crusher that saves, reloads and breaks mid-…
faceofcat Nov 3, 2026
dead317
feat(gui): menus leave reserved items where they are
faceofcat Nov 3, 2026
a61fe74
feat(common): a machine block drops its block entity's contents when …
faceofcat Nov 3, 2026
87bb31a
test(machine): breaking a crusher mid-cycle drops what it holds
faceofcat Nov 3, 2026
dd0d741
feat(machine): a feature host can save and load the state its clients…
faceofcat Nov 3, 2026
22736f1
feat(machine): a recipe processor asks to be synced when its status c…
faceofcat Nov 3, 2026
e3d59a0
feat(machine): features can sync their state to nearby clients
faceofcat Nov 3, 2026
4dcbf24
test(machine): a nearby client sees a crusher run and pause over a re…
faceofcat Nov 3, 2026
7368fef
style(machine): wrap a long line in the GUI smoke
faceofcat Nov 3, 2026
9c4cd78
test(machine): a client load skips a feature that doesn't sync
faceofcat Nov 3, 2026
1ab40fb
test(machine): the bad-update check proves the update was spoiled and…
faceofcat Nov 3, 2026
19a2c41
fix(machine): back-to-back cycles don't read as idle on clients, and …
faceofcat Nov 3, 2026
59c37f7
style(machine): format the rig's feature host like its neighbours
faceofcat Nov 3, 2026
3e9bb11
fix(machine): clients never see the idle tick between two cycles, how…
faceofcat Nov 3, 2026
6c7c4fc
feat(gui): predict reservations on the client's machine menu
faceofcat Nov 3, 2026
d15bab8
test(gui): the real client menu predicts a reserved slot and lets it go
faceofcat Nov 3, 2026
dd0fbca
fix(gui): the client menu takes no plan past the storage, and checks …
faceofcat Nov 3, 2026
0d37961
test(gui): a client-shaped menu answers every click row like the serv…
faceofcat Nov 3, 2026
77114e5
test(gui): the smoke clicks a planned slot over the real connection
faceofcat Nov 3, 2026
5415840
docs: reservations, feature sync and drops in the public docs
faceofcat Nov 3, 2026
c931d4b
docs(gui): double-clicking another slot still takes a planned slot's …
faceofcat Nov 3, 2026
152f34d
fix(machine): a feature that cannot save or load is logged, not fatal
faceofcat Nov 3, 2026
b19a792
fix(gui): shift-click on a planned slot moves only what the storage g…
faceofcat Nov 3, 2026
a54ac09
test(machine): a cleared crusher drops nothing when its block is repl…
faceofcat Nov 3, 2026
08a8b2b
docs(machine): running a processor, interrupts, groups, onRemove and …
faceofcat Nov 3, 2026
8bcde58
test(machine): a failed fluid finish brings the drained tank and its …
faceofcat Nov 3, 2026
ebdcf69
test(machine): two running lanes through interrupt, reload and a reci…
faceofcat Nov 3, 2026
4befc5b
docs(machine): correct processor groups, resume and clearing examples
faceofcat Nov 3, 2026
4a651e9
feat(machine): add a playable four-machine companion mod
faceofcat Nov 3, 2026
689c4c2
feat(gui): open information drawers from bookmark tabs
faceofcat Nov 3, 2026
341db9f
feat(gui): match information drawers to the proposed design
faceofcat Nov 3, 2026
e7658ea
Add reusable energy gauge and revise Crusher screen layout
faceofcat Nov 3, 2026
bbfac23
Render energy as a battery on all companion screens
faceofcat Nov 3, 2026
afefc85
Add metallic highlights and glossy battery charge bars
faceofcat Nov 3, 2026
4ff210f
Shade progress arrows to match the machine UI reference
faceofcat Nov 3, 2026
7c99931
Add recessed borders and inner shading to slots
faceofcat Nov 3, 2026
a1552ad
Add a water and lava preview tank to the Crusher
faceofcat Nov 3, 2026
5824c7d
Remove numeric labels from tank gauge scales
faceofcat Nov 3, 2026
a855dcc
Shade tanks cylindrically and support cursor fluid containers
faceofcat Nov 3, 2026
8803bfe
Match energy and fluid gauge casings and lighten empty backgrounds
faceofcat Nov 3, 2026
b2f8eb3
Match gauge backgrounds to slots and soften energy shading
faceofcat Nov 4, 2026
a9dadea
Use thinner energy bars with a soft gloss
faceofcat Nov 4, 2026
0990122
Adjust energy bars to intermediate thickness
faceofcat Nov 4, 2026
4406a8e
Fix machine persistence after in-place shift-click transfers
faceofcat Nov 4, 2026
9392abc
Merge remote-tracking branch 'origin/master-2.0-1.21.1-neo' into mach…
faceofcat Nov 4, 2026
b8257a0
Integrate companion with ModDevGradle and consolidate CI
faceofcat Nov 4, 2026
0edcb20
Darken shared gauge backgrounds to an intermediate grey
faceofcat Nov 4, 2026
ee97f55
Use neutral #555555 gauge backgrounds
faceofcat Nov 4, 2026
f9b8104
Add compact pause and redstone controls above machine inventories
faceofcat Nov 4, 2026
49edfcc
Use smaller frameless icons for machine controls
faceofcat Nov 4, 2026
f42b90d
Give compact machine controls flat borders and backgrounds
faceofcat Nov 4, 2026
a4cc45c
Fix processor reset and idle searches; centralize reservation handling
faceofcat Nov 4, 2026
ae540bc
Keep companion artifacts out of library release uploads
faceofcat Nov 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/build_status.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,59 @@ jobs:
retention-days: 14
path: |
build/libs/*.jar

- name: Upload test reports and game test logs
if: always()
uses: actions/upload-artifact@v7
with:
name: check-reports
path: |
build/reports/tests/
build/gametest/logs/
build/companion-gametest/logs/
if-no-files-found: ignore

gui-smoke:
name: Client GUI smoke test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: gradle/actions/wrapper-validation@v6
- uses: actions/setup-java@v6
with:
distribution: 'microsoft'
java-version: ${{ env.JAVA_VERSION }}
- uses: gradle/actions/setup-gradle@v6

- name: Install Xvfb and Mesa
run: |
sudo apt-get update
sudo apt-get install -y xvfb libgl1-mesa-dri

# Run the companion before the library smoke, which injects test sources into main output.
- name: Companion GUI smoke test
timeout-minutes: 20
run: xvfb-run -a ./gradlew runCompanionClientSmoke

- name: GUI smoke test with JEI
timeout-minutes: 20
run: xvfb-run -a ./gradlew runClient -I scripts/gui-smoke.init.gradle

- name: GUI smoke test without JEI
timeout-minutes: 20
run: xvfb-run -a ./gradlew runClient -PwithoutJei -I scripts/gui-smoke.init.gradle

- name: Upload client logs and screenshots
if: always()
uses: actions/upload-artifact@v7
with:
name: gui-smoke-logs
path: |
build/companion-client-smoke/logs/
build/companion-client-smoke/screenshots/
build/gui-smoke/logs/
build/gui-smoke/screenshots/
build/gui-smoke-without-jei/logs/
build/gui-smoke-without-jei/screenshots/
if-no-files-found: ignore
70 changes: 0 additions & 70 deletions .github/workflows/ci.yml

This file was deleted.

15 changes: 9 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ jobs:

- name: Read gradle.properties
run: |
# Keeps the CurseForge ID defined in one place.
# Keep the CurseForge ID and archive name defined in gradle.properties.
prop() { grep -E "^$1\s*=" gradle.properties | cut -d= -f2- | tr -d '[:space:]'; }
echo "CURSEFORGE_ID=$(prop curseforge_id)" >> "$GITHUB_ENV"
echo "MOD_ARCHIVE_NAME=$(prop mod_archive_name)" >> "$GITHUB_ENV"

- name: Build and test
run: |
Expand All @@ -70,10 +71,11 @@ jobs:
with:
github-token: ${{ github.token }}
version: ${{ env.TAG }}
# Main jar first so it becomes the primary file, sources/javadoc attached as extras.
# Exact release filenames exclude companion jars and stale versions.
files: |
build/libs/!(*-@(sources|javadoc)).jar
build/libs/*-@(sources|javadoc).jar
build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}.jar
build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}-sources.jar
build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}-javadoc.jar

- name: Publish to CurseForge
if: env.HAS_CURSEFORGE == 'true' && env.CURSEFORGE_ID != ''
Expand All @@ -84,8 +86,9 @@ jobs:
curseforge-id: ${{ env.CURSEFORGE_ID }}
version: ${{ env.TAG }}
files: |
build/libs/!(*-@(sources|javadoc)).jar
build/libs/*-@(sources|javadoc).jar
build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}.jar
build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}-sources.jar
build/libs/${{ env.MOD_ARCHIVE_NAME }}-${{ env.TAG }}-javadoc.jar
loaders: |
neoforge
environment: client | server
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ configurations.named('runtimeTestCompileOnly') {
extendsFrom configurations.compileOnly
}

// Optional playable examples, packaged separately from MMDLib.
apply from: 'scripts/companion.gradle'

// Javadoc for extenders and maintainers: `./gradlew javadoc` (output in build/docs/javadoc).
// Records and fluent builders are described in prose, so per-parameter tags are not required.
tasks.named('javadoc', Javadoc) {
Expand Down
137 changes: 134 additions & 3 deletions docs/block-entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ prepared handle. MMDLib does not create or attach that register:

```java
// During construction, before registry events:
var types = DeferredRegister.<BlockEntityType<?>>create(Registries.BLOCK_ENTITY_TYPE, MODID);
var types = DeferredRegister.<BlockEntityType<?>>create(
net.minecraft.core.registries.Registries.BLOCK_ENTITY_TYPE, MODID);
types.register(modBus);
// After definitions have been queued, still before registry events:
registrar.blockEntityHandles().values().forEach(handle -> handle.register(types));
Expand Down Expand Up @@ -126,8 +127,138 @@ public CrusherBlock create(BlockContext context) {
```

The block delegates `newBlockEntity` and `getTicker` to `binding.prepared()`, as
`MaterialEntityBlock` does, and reads `binding.type()` only after registration (for
example when a block entity is created), never in its constructor.
`MaterialEntityBlock` does. It also overrides `onRemove` as `MaterialEntityBlock` does: without
it, `DropsContents` is never called and nothing reports the missing hook. It reads `binding.type()` only after registration (for example
when a block entity is created), never in its constructor.

## Machine features

The separately packaged [machine companion](machine-companion.md) provides playable examples of
the processor, a one-slot converter, shared lanes and a creative power source.

A `FeatureHost` runs a machine's `MachineFeature`s, such as a `RecipeProcessor`, and saves each
under its key. The block entity ticks it from `serverTick` with `features.tick(level)`, and saves
and loads it through a `DynamicOps`, after the storages, since a processor checks the loaded
contents against its saved cycle:

```java
final FeatureHost features = new FeatureHost(this::setChanged).add(processor);

// saveAdditional
tag.put("features", features.save(registries.createSerializationContext(NbtOps.INSTANCE)));
// loadAdditional, after the storages
features.load(registries.createSerializationContext(NbtOps.INSTANCE), tag.get("features"));
```

**Running recipes.** A `RecipeProcessor` is built from a feature key, a machine recipe type and a
`MachineInventory`, and added to the host; the block entity's ticker calls `features.tick(level)`:

```java
final RecipeProcessor processor = new RecipeProcessor("work", MyRecipes.CRUSHING, inventory);
final FeatureHost features = new FeatureHost(this::setChanged).add(processor);

// The factory's server-side ticker:
BlockEntityTicker<CrusherEntity> ticker =
(level, pos, state, entity) -> entity.features.tick(level);
```

`status()` is one of: `IDLE`, no cycle is running; `RUNNING`, a cycle is running; `STALLED`, a
cycle is waiting for energy and keeps its progress and reservations; `BLOCKED`, a cycle has run its
time but cannot finish, say an input was taken, and retries each tick; and `PAUSED`, held by an
interrupt. `progress()` and `time()` give the ticks done and the ticks the cycle takes, for a
screen; `time()` is 0 when idle or paused.

On `/reload` a running cycle finishes as planned, and the next cycle uses the new recipes.

Failed recipe searches retry every 20 ticks while inventory revisions stay unchanged. Built-in
item/fluid groups report changes to contents, rules and reservations, so new inputs or freed output
space trigger a search on the next tick. Custom groups can implement `SlotGroup.revision()` for the
same response; the periodic retry also catches recipe reloads and changes in untracked groups.
Running cycles and consecutive successful cycles are not delayed.

**Interrupting.** `interrupt()` stops the processor from drawing energy and starts nothing new
until `resume()`; `paused()` says whether it is held. It works without power and whatever the
status. It also releases the running cycle's reservations, so the items it locked can be taken
out. This is the trap for players: an unpowered machine keeps its inputs and outputs locked, and
the only ways to get them back are an interrupt or breaking the block. A mod should give the
player a button, an `Element.Button` in the screen XML with a `gui-click` handler (see
[GUI screens](gui-screens.md)) that calls `interrupt()` or `resume()`. Resuming reserves the paused
recipe again if it still can. Progress is kept only if that recipe still exists, can reserve its
inputs and output space again, and has the same `time`; otherwise progress resets.

**Groups.** The processor reaches storage through the inventory's group keys. Groups in one
inventory must not overlap: use one key for each set of slots or tanks. Multiple processor lanes
can share that inventory and reserve the same slot, provided enough unreserved inputs and output
space remain; each lane holds only its own share of the slot's plan.

A machine with one slot for both input and output must use one key for both. For a different-item
product, the cycle must also consume the entire stack: a recipe turning 1 iron into 1 gold cannot
start with 2 iron in the slot, because the leftover iron blocks the gold (`NO_ROOM`). A slot limit
of 1 makes that recipe usable without asking players to remove surplus iron. A product that stacks
with the remaining input can share the slot, subject to its limit.

**Clearing.** When replacing a block, `/setblock` and `/fill` normally call `Clearable.tryClear` on
its block entity first, which is how a chest's contents vanish instead of dropping. A
block entity with `DropsContents` that should do the same also implements `Clearable`, and
empties its storages (and their plans) in `clearContent()`. Without it, the command drops the
contents. Both commands' `destroy` mode destroys the old block before clearing, so its contents
still drop even when the block entity implements `Clearable`.

Call `RecipeProcessor.reset()` on each lane before clearing storage: it releases that lane's plans
and clears progress, the remembered recipe and pause state. Fresh inputs can then start new work.
Use `interrupt()` for a player-requested pause that should keep its place, not for clearing a machine.

**Dropping contents.** A block entity of a `MaterialEntityBlock` (which includes `mmdlib:machine`)
that implements `DropsContents` has `dropContents(level, pos)` called on the server when its block
is broken or replaced by another block, but not when only its state changes. Usually it is
`items.dropAll(level, pos)`. Reserved items are still in their slots, so nothing else needs to be
given back.

Custom blocks can call `DropsContents.onRemove(state, level, pos, replacement)` before their
superclass removal hook to use the same server-only, different-block guard.

**Syncing to clients.** Nothing is sent to clients unless the block entity opts in. A feature that
should be readable on the client, for a renderer, particles or a tooltip, returns true from
`syncsToClient()`; `RecipeProcessor` does. The block entity then wires `FeatureSync`:

```java
final FeatureHost features =
new FeatureHost(this::setChanged, () -> FeatureSync.send(this)).add(processor);

@Override
public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return FeatureSync.updateTag(features, registries);
}

@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}

@Override
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
if (FeatureSync.read(features, tag, registries)) return;
// the usual loading
}
```

The host sends at most once every 10 ticks (`FeatureHost.SYNC_INTERVAL`), and always sends the
latest change. Storages are not part of the update.

A feature controls what clients get with two methods. `clientState()` is the state to send, by
default what `state()` saves; a feature overrides it for a moment clients shouldn't see.
`restoreOnClient(state)` takes it on, by default as `restore`. `RecipeProcessor` overrides the
second to copy only its status, pause flag, progress and time, because a client has no storage
contents to check a cycle against, and never reserves anything.

On the client, `processor.status()` and `processor.time()` are current. `processor.progress()` is
as of the last status change, so a progress bar reads it from a screen getter instead; screens keep
their own getter sync (see [GUI screens](gui-screens.md#wiring-a-machine)). While paused, `time()`
is 0, so a bar must not divide by it without checking.

`FeatureSync.read` goes by the tag's key, not the side: a `/data merge` that includes the key
runs `restoreOnClient` on the server.

## Deferred registration and block hooks

Expand Down
35 changes: 33 additions & 2 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ directly, and automation gets per-face views of it.

| Class | Wraps | Main points |
|---|---|---|
| `ItemStorage` | `ItemStackHandler` | Per-slot `Role`, filter, limit; `insertResult` for products; `dropAll` |
| `FluidStorage` | several `FluidTank`s as one `IFluidHandler` | Per-tank `Role`, capacity, filter; `tank(i)` for the machine |
| `ItemStorage` | `ItemStackHandler` | Per-slot `Role`, filter, limit; `insertResult` for products; `dropAll`; reservations |
| `FluidStorage` | several `FluidTank`s as one `IFluidHandler` | Per-tank `Role`, capacity, filter; `tank(i)` for the machine; reservations |
| `EnergyBuffer` | `IEnergyStorage` | `long` internally; `int` values clamped for other mods; transfer rates |
| `SideConfig` | | Per-face `IoMode`, stored relative to the machine's front; codec |
| `MachineCapabilities` | `RegisterCapabilitiesEvent` | One-line registration with per-side modes |
Expand Down Expand Up @@ -37,6 +37,37 @@ directly, and automation gets per-face views of it.
machine facing up or down, `TOP` is the north side. `RelativeFace.facing(state)` reads the
`facing` or `horizontal_facing` property, and defaults to north when the state has neither.

## Reservations

`ItemStorage` and the client menu handler share `PlannedItemHandler` for insertion, extraction and
capacity rules. Item and fluid storages use `ReservationPlans` for plan merging, release and
transaction rollback. Direct content setters remain available to the owning machine and save loading.

Custom menu handlers and wrappers can implement `ReservedSlots`, including `plan(slot)`, to publish
the same snapshots as `ItemStorage`. During a click, menu slots expose only unreserved items to
vanilla; outside the interaction, rendering and synchronization still show the full stack. A number
key or offhand swap can therefore move the free portion without moving reserved items.

A machine that runs a recipe over many ticks can reserve what the recipe needs. `ItemStorage`
keeps one `SlotPlan` per slot and `FluidStorage` one `TankPlan` per tank: how much the machine will
take from it, and what it will add. `RecipeProcessor` makes and releases the plans itself; a mod
that runs its own cycles uses `reserve(slot, plan)`, `release(slot, plan)` and `plan(slot)`
(`tank` for fluids). A slot or tank with no plan behaves exactly as without this feature.

What a plan does to the calls on the storage:

| Call | With a plan |
|---|---|
| `extractItem`, `drain` | Never goes below the reserved amount. 50 iron with 1 reserved gives up 49 |
| `insertItem`, `fill` | Accepted only if the slot still ends up as one legal stack within its limit after the machine takes and adds what it planned, and the stack in the slot now can hold it |
| `insertResult` | Obeys the plan like `insertItem`; it skips roles and filters, not reservations |
| `tank(i)` | Its `fill` and `drain` obey the tank's plan |
| `isItemValid` | Answers for the planned end state, so a hopper is not offered what would break the plan |
| `setStackInSlot`, `setFluid` | Still bypass the plan. They stay available for undoing a transaction and loading a save, and they are the one way to break a running cycle |

Plans are not saved by the storages. A feature saves its own cycle and puts the plans back when it
loads, so load the storages first.

## Example

```java
Expand Down
Loading
Loading