Skip to content

Fluid tanks and transfers (experimental)

The API now defines FluidTank and immutable FluidVolume. The shared runtime implements a single-fluid tank with capacity limits, simulated fill/drain queries, validated snapshot replacement and atomic transfers between two SDK tanks on the same owning game thread. Different fluids do not mix. Empty tanks can accept a new fluid. Simulation does not reserve capacity; execution checks the current contents.

PortableFluidTank.save() now produces a deterministic version-1 binary snapshot, bounded to 1,039 bytes. restore(byte[]) validates the entire payload before changing state: malformed IDs, unsupported versions, truncation, trailing data and quantities above the configured capacity are rejected. Capacity is never trusted from save data. dirty(), revision() and markPersisted(revision) let native owners acknowledge only snapshots actually saved; acknowledging a stale revision fails. Loading changed contents marks the tank dirty conservatively. These methods do not perform disk I/O.

Quantities use integer SDK units: FluidVolume.BUCKET is 81,000. This is an internal portable contract, not a claim that every loader uses that unit. Future native bridges must convert exactly and retain unrepresentable residual quantities, never round up. The first iteration identifies fluids by resource ID only; fluid-specific components are not supported and must not be silently discarded by future native bridges.

Block definitions can now declare up to 16 named FluidTankSpec entries with .tank(spec). Shared PortableBlockEntityStorage.tank(spec) validates the exact definition and returns a view whose actual mutations mark the owner dirty. Its version-2 envelope saves tanks together with inventory and integer fields and validates everything before replacement. Tankless definitions continue writing version 1. Old saves initialize newly declared tanks as empty; unknown saved tanks are rejected rather than discarded. Retained tank views remain valid after restore.

The central native generator now exposes state.tank(spec) to server tick callbacks. Every retained handle checks the server thread and that its original block entity is still attached. Actual fills/drains invoke the native dirty-save hook; reads, simulations and rejected operations do not. Existing native save envelopes carry the shared storage snapshot including tanks.

FluidTankSpec accepts an immutable map of absolute FluidFace values to INPUT, OUTPUT, BOTH or CLOSED modes. Unspecified faces are CLOSED. The generated fluidPort view maps native directions to this policy. This is not yet registered as a third-party loader fluid capability.

The generated block interaction handles vanilla water/lava buckets on configured faces. Filling or draining requires a whole bucket; insufficient space/fluid leaves both unchanged. Stacked empty buckets exchange one bucket at a time, using Minecraft's container helper to retain the remaining stack and add or drop the result when needed. Rejected recognized buckets do not fall through into world-fluid placement. Creative-mode item handling delegates to Minecraft's filled-container helper. Other fluid buckets, arbitrary fluid containers are not supported yet.

Native bucket interactions send a bounded server-owned action-bar snapshot after success or rejection, including fluid ID, percentage and exact quantities. Standalone tank blocks without a custom use handler also report contents on normal block use. This feedback does not replace custom menu handlers or add a continuously updating gauge. Creative held-item retention is vanilla behaviour, not an indicator of failure.

For example, declare a four-bucket reservoir with top input and bottom output:

var reservoir = new FluidTankSpec("reservoir", 4 * FluidVolume.BUCKET,
    Map.of(FluidFace.UP, FluidPortMode.INPUT, FluidFace.DOWN, FluidPortMode.OUTPUT));
var storage = BlockEntitySpec.builder(block).tank(reservoir).build();

state.pushFluid(reservoir, FluidFace.DOWN, amount, simulate) transfers to the first compatible adjacent SDK-owned tank, in definition order, respecting source output and destination input faces. It does not load chunks. Both owners are dirtied only after a real transfer. This path is SDK-to-SDK, not arbitrary third-party storage.

For synchronized text displays, build a server snapshot with MenuState.builder().tank("tank", state.tank(reservoir)).build() and send it using the existing MenuManager.open/update methods. Labels can use {tank.fluid}, {tank.amount}, {tank.capacity} and {tank.percent}. Amounts are SDK units; percentage calculation is overflow-safe. This helper alone does not poll tanks.

Experimental MenuManager.openLive(playerId, menu, intervalTicks, source) now owns the polling loop in shared runtime-core. A MenuStateSource returns an Optional<MenuState> snapshot on the server thread. Return empty when the player can no longer access the backing block, or it is unloaded/removed; never load a chunk to keep a screen alive. Validate access before reading a retained tank handle. An initially empty result leaves the existing menu unchanged. Later empty results or exceptions close the live session. Intervals are 1-1200 server ticks, evaluated at END phase; unchanged maps do not send update packets. Close, replacement, disconnect and server shutdown release the source. Sources must be read-only and must not call menu operations from inside snapshot().

For an SDK-owned persistent block, register its screen and call context.menus().bindTank(menu, block, tankSpec) during initialization, after registering the block. The integrated persistence profile binds ordinary block use to that screen and supplies the four tank.* fields every five server ticks when changed. Bucket interactions remain separate. Only one menu/use binding is allowed per block; unknown tanks and conflicting workbench bindings fail. The server closes the view on death, spectator mode, dimension change, distance over eight blocks, chunk unload, or replacement/removal of the original entity. No chunk is loaded to maintain the screen. This is read-only inspection, not tank mutation or a permission system for other machine actions.

The fixture's tank now uses this binding. /enderfall_gauge remains the independent synthetic renderer test. Live tank-screen gameplay validation remains pending.

The integrated persistence development runtime also supports a declarative gauge:

MenuSpec.builder("Tank")
    .gauge(new MenuGauge("tank.percent", 20, 35, 24, 80, 0xFF4488FF))
    .label(MenuLabel.text("{tank.fluid}: {tank.percent}%", 55, 40))
    .build();

The gauge fills from the bottom, has a contrasting border and empty background, and clamps percentages to 0..100. Missing/malformed values show empty. Layouts allow up to eight gauges and reject out-of-panel bounds. Registration fails on a runtime without gauge support. Use the integrated enderfall.persistence=true profile through the ordinary bootstrap; the original isolated preview bootstrap does not enable gauges. This first renderer is solid-colour, not a fluid texture.

This is not yet a verified in-game tank feature. Remaining work includes loader-native third-party transfer capability hooks, broader container handling, textured gauges and real bucket/save/reload/transfer/menu acceptance. Arbitrary third-party handlers are deliberately excluded from the atomic transfer operation until their transaction semantics are handled explicitly.