Portable block entities: implementation status
The shared storage foundation and generated persistence stack compile on all nine targets: Fabric/NeoForge 1.21.1, 1.21.4 and 26.2, plus Fabric/Forge/NeoForge 1.20.1. This includes native registration, saved inventory, instant/timed recipes, stored outputs, menus, progress/status screens and sided-container automation.
These development sources are not packaged into normal runtime JARs yet.
The opt-in SDK development build now connects them to the ordinary consumer
bootstrap; the default build retains its previous unsupported-feature errors. The isolated
persistent-preview example includes instant and timed block-owned workbenches.
The user reported initial timed crafting working on Fabric 1.21.4. This is not
proof of save/reload, hopper, break/drop, multiplayer or other-target behaviour.
SDK target expansion
BlockEntityNativePolicy selects storage rules for all nine targets. They share
inventory, processing, tick, ownership and sided-automation logic. Loader rules
select immediate or deferred registration; version rules select native save APIs,
item codecs and block-removal hooks. No consumer source changes are involved.
The independent SDK compile lanes are:
.\gradlew.bat :runtime-generated-fabric-1.21.1:compileBlockEntityStorageJava :runtime-generated-fabric-1.21.4:compileBlockEntityStorageJava --no-daemon --console=plain
These generate only native storage under each project's build/generated/blockEntityStorage.
They do not install a preview mod, change published artifacts, or advertise complete
persistence support. compileBlockEntityPreviewJava additionally checks native
platform registration, instant/timed menus, recipe processing and client screens.
Compilation is separate from normal-runtime integration and gameplay acceptance.
Integrated development artifacts
From the SDK root in PowerShell:
.\gradlew.bat verifyPersistenceArtifacts '-Penderfall.persistence=true' --no-daemon --console=plain
This builds and inspects all nine loader-ready development runtime JARs, checking
that storage, processing and the feature-enabled consumer bootstrap are actually
packaged. Their filenames contain persistence-dev; Maven publishing is disabled
for this profile. Generated sources and compiled classes use separate directories
from the baseline parity build. The profile is an SDK build option, not a new
portable mod-author API or a promise of release-ready gameplay support.
Do not run baseline byte-parity checks with this profile enabled: these artifacts intentionally add behaviour. Run ordinary parity checks without the property.
One fixture across all nine targets
The fixture's three portable Java classes are compiled once against the API with
Java 17. PersistenceFixtureMain generates only loader glue and target-format
resources; no version branches or native imports are added to those classes.
.\gradlew.bat verifyPersistenceFixtures '-Penderfall.persistence=true' '-Penderfall.persistenceFixture=true' --no-daemon --console=plain
The verification builds nine development fixture JARs and compares their portable class bytes, including the Java 17 class-file version. These are development-mapped fixtures for the SDK's run tasks, not independently distributable release mods.
For example, launch the same fixture on an older Forge target:
.\gradlew.bat :runtime-generated-forge-1.20.1:runClient '-Penderfall.persistence=true' '-Penderfall.persistenceFixture=true' --no-daemon --console=plain
Or on NeoForge 26.2:
.\gradlew.bat :runtime-generated-neoforge-26.2:runClient '-Penderfall.persistence=true' '-Penderfall.persistenceFixture=true' --no-daemon --console=plain
Replace runClient with runServer for a dedicated-server fixture. Every target
uses its own run/persistence-fixture/client or server directory. Server EULA
acceptance and gameplay checks remain explicit; the fixture does not accept the
EULA, create acceptance results, or claim gameplay success automatically.
Shared definitions
The experimental Java 17 API defines a block's inventory size and bounded saved integer fields without importing Minecraft or loader classes:
BlockEntityInt progress = new BlockEntityInt("progress", 0, 0, 200);
BlockEntitySpec machine = BlockEntitySpec.builder(workbenchBlock)
.inventorySlots(3) // three persisted inputs; current instant-craft result is derived
.field(progress)
.build();
BlockEntityState exposes typed field reads/writes; field names are persistent keys.
Specifications are immutable. Duplicate names, undeclared fields, and invalid values
fail instead of silently creating mismatched state.
Portable registration and opening contract
The experimental entry points are now implemented in the API and shared routing layer:
context.blocks().registerPersistentWithItem("workbench", blockProperties, itemProperties, machine);
WorkbenchRef menu = context.workbenches().register("workbench_menu",
WorkbenchSpec.builder("Workbench", recipes).persistent(machine).build(), craft -> { });
context.workbenches().openAt(playerId, menu,
new BlockLocation(dimensionId, blockX, blockY, blockZ));
The storage's block ID must match the block being registered. This creates a new
block/type/item together, not a retrofit of an already registered plain block.
Registration respects consumer namespaces, duplicate IDs and lifecycle freezing.
Persistent menus require openAt; normal open cannot silently discard position.
Temporary menus retain their existing API and reject persistent opening.
This example is not runnable against shipped targets yet. Baseline adapters still
report persistent support disabled. Only the isolated Fabric 1.21.4 preview platform
reports support and wires these routes. Shared routing rejects unsupported registration
before reserving IDs, with the mod ID and target in the error.
InteractionEvent.blockLocation() now exposes
an optional exact position, supplied by the preview block-use hook. Existing constructors
and the baseline platform event hooks leave it empty; do not guess coordinates from
the block type or player's position.
Implemented shared runtime
PortableBlockEntityStorage<S> owns inventory independently of menus. It provides
defensive stack copies, saved integer state, revision-based dirty tracking, and a
bounded binary snapshot format. Successful serialization alone does not mark data as
persisted: the native world-save integration must acknowledge the saved revision.
Restore validates into temporary storage before replacing live state. Truncated, oversized, trailing, wrong-block, or incompatible data fails without emptying the inventory. Added integer fields receive defaults. Removed fields and changed slot counts require explicit migration. All access is on the owning game thread.
The internal BlockEntityStackCodec<S> preserves native item components/NBT as opaque
bytes. It is not a public item wrapper or Java serialization. Native implementations
must use registry-aware item serialization and enforce stack limits. This envelope
does not promise cross-Minecraft-version save conversion, and is not a network packet
format: saves allow up to 8 MiB total and 64 KiB per stack.
Native persistence development slice
BlockEntityPreviewSources centrally generates StoredBlockEntity, its block/type
registration binding, and a registry-aware native stack codec. It calls the shared
storage implementation from native NBT save/load hooks, uses the supplied registry
lookup even when loading before a level is attached, and calls setChanged after
server-side mutations. Client-side mutations are rejected. Item NBT encoding and
decoding are bounded; malformed data is not replaced with an empty stack.
The implementation follows the native registration and persistence hooks described in Fabric's 1.21.4 block-entity guide. Writing chunk NBT is not proof of a disk flush, so the hook does not falsely mark a snapshot as durably persisted. It does not send inventory contents in chunk updates.
The generated block now owns a native SimpleContainer. Vanilla slots can use live
stacks, while setChanged validates and copies the whole container into shared saved
storage before notifying viewers. A failed copy/validation leaves saved storage
unchanged and restores the native slots from that snapshot. Save also copies the
current native inventory. Direct SDK stack reads remain defensive copies.
WorkbenchMenuEmitter has a persistent development mode that uses this container
instead of creating temporary inputs. It retains the shared recipe/slot implementation,
requires exactly the recipe's input-slot count, checks the actual block entity and
eight-block access distance, and attaches/removes a container listener with the menu.
Closing this menu does not clear its inputs. The output remains a derived instant-craft
result, not a stored machine output; timed processing is a later implementation slice.
The native preview now includes an openAt helper. It checks the server thread,
player dimension, loaded chunk without requesting a load, block-entity identity,
storage schema, and player reach before creating the persistent server menu. It is
is now called by the preview platform's openPersistentWorkbench implementation.
bindBlock now connects the preview's block-use hook to that opener. Binding checks
the storage definition and recipe input-slot count, and rejects a second handler on
the same block. The hook constructs a portable event using the actual dimension and
clicked block coordinates, passes through unbound/spectator interactions, and preserves
handled/cancelled outcomes. A bound workbench opens on the server and acknowledges the
click on the client. This event is local to the preview binding; it is not additionally
published to the SDK-wide interaction bus. Platform wiring must avoid duplicate delivery.
The generated block's removal hook drops contents on the server when replaced by a different block, clears the inventory, and updates comparator neighbours. Same-block state changes do not trigger drops. These hooks need real break/reopen/multi-viewer validation before shipping; compilation alone cannot establish loss/duplication safety.
Compile this slice without changing the production runtime or reference-parity output:
.\gradlew.bat :runtime-generated-fabric-1.21.4:compileBlockEntityPreviewJava --no-daemon --console=plain --offline
Sources/classes live under that project's build/generated/blockEntityPreview and
build/classes/blockEntityPreview. The task is opt-in; it is not wired into release
JARs or publication. Unsupported preview targets fail explicitly before source output.
Do not use configuration-on-demand for this native task: the existing parity setup
also requires the reference project to be configured.
PlatformServiceEmitter now composes a FabricPersistentPlatformAdapter from the same
normal platform operations plus persistent registration/opening operations. It registers
the native block/type and block item, populates the existing block/item lookup maps,
reuses normal recipe/menu/client-screen registration, binds block clicks, and routes
explicit position-based opens. Unknown or mismatched storage and multiple workbench
bindings for one block are rejected. No baseline platform output is replaced.
FabricPersistentPreviewBootstrap.initialize(...) explicitly creates this platform,
initializes a portable mod and attaches the normal runtime services. Duplicate attempts
are rejected before registration, including retries after failed initialization. It
must be called instead of, never alongside, the normal consumer bootstrap. This helper
is development-only: normal generated consumer metadata does not select it and the
preview classes are still excluded from runtime JARs. An opt-in
persistent workbench fixture now selects
this bootstrap in a separate development-client directory. Its portable classes
compile against only the SDK API; Fabric bootstrap glue is kept outside that root.
Its resources are explicit 1.21.4 fixtures, not portable data-generation support.
The client registered the preview mod, entered a single-player world and exited
cleanly. Crafting, retained inventory and break/drop correctness are not established
by those startup logs. The ordinary demo remains unchanged.
It must not be enabled in ordinary gameplay until block breaking, container ownership,
and persistence behaviour are verified. Compilation
proves native method compatibility, not successful world save/reload.
Portable server ticking (preview only)
BlockEntitySpec.Builder.serverTicker(BlockEntityTicker) now installs optional
server-side behaviour on the generated Fabric 1.21.4 preview. Definitions without
a callback, client worlds, and mismatched native block-entity types receive no ticker.
BlockEntityInt elapsed = new BlockEntityInt("elapsed", 0, 0, 200);
BlockEntitySpec machine = BlockEntitySpec.builder(workbenchBlock)
.inventorySlots(3)
.field(elapsed)
.serverTicker(state -> {
int ticks = state.get(elapsed);
if (ticks < 200) state.set(elapsed, ticks + 1);
})
.build();
The callback runs once per native server block-entity tick while the block is ticking, not while unloaded. Saved values are per block; the callback itself is shared across instances. Reads/writes enforce the owning server thread and changed values mark the native block dirty. Never retain the state for asynchronous work.
A callback runtime exception is logged with the block ID (including mod namespace), target, dimension and position, and disables that instance's ticker until reloaded. Other instances keep ticking. Updates before failure are not rolled back; this is not a transactional recipe-processing API. Fatal JVM errors are not swallowed.
This callback currently exposes saved integer fields, not inventory operations or
client progress synchronization. The example above is a timer, not a crafting
machine. Timed recipes use the separate persistentTimed workbench binding described
below, which implements consumption, stored output and synchronized progress.
Implemented processing and remaining acceptance
The preview's timed machines also expose native WorldlyContainer ports, driven by
the shared PortableMachinePorts policy. Inputs 1..5 map to up/north/east/south/west;
down exposes only the stored output. Missing inputs expose no slot, insertion into
output and extraction from inputs are rejected. Untimed definitions expose no ports.
This is fixed positional routing, not recipe-aware sorting or configurable facing.
Native mutations delegate to the existing owned container and its server-side guards.
Live hopper throughput, save/reload and loss/duplication checks remain pending.
The shared runtime now includes PortableProcessingCycle, a pure timed-processing
decision engine. It advances only on supplied server ticks, pauses on output-capacity
failure, resets on missing recipes, and restarts when the effective recipe revision
or duration changes. Native recipe matching must supply that revision from the actual
definition, not just the recipe ID. Completion is a proposed transition, not a craft
callback or inventory mutation.
PortableBlockEntityStorage.replaceInventoryAndFields validates the full replacement
inventory and selected saved integer fields before committing either. This allows
input consumption, stored output and progress reset to share one storage revision.
Invalid stacks or field values leave both unchanged.
The generated preview now has a server-only commitProcessing operation: it validates
slot capacities, copies stacks and validates saved fields before replacing inventory
and processing state, marks the block dirty, then notifies viewers. Notification
failures are logged after commit rather than reporting a failed transaction to retry.
The native save/load hooks store active recipe identity, revision, duration and elapsed
ticks using the bounded, versioned ProcessingStateCodec. Malformed processing data
is rejected before restoring inventory; older snapshots without processing data load
as idle. This is implemented and compile-checked, not live save/reload proof.
FabricTimedWorkbenchProcessor is now generated alongside the preview. Its internal
binding requires one extra stored-output slot and installs a loaded-block server tick
handler independent of menu viewers. It selects native recipes, fingerprints their
serialized definitions, checks item/component-aware output capacity, and commits
consumption/output with the shared processing transition. Crafting remainders return
to their original input slot; if leftovers and remainders cannot coexist there, the
machine pauses without consuming anything. There is no overflow drop or hidden void.
It does not emit the player-owned instant-craft callback for unattended processing.
The preview now activates the processor through
WorkbenchSpec.builder(title, recipes).persistentTimed(storage, ticks). Storage must
have exactly the input count plus one output slot. Unsupported runtimes reject this
separately from instant persistent workbenches, before reserving the menu ID.
The isolated demo registers a new timed_workbench with four slots and a 100-tick
duration. The existing three-slot workbench is unchanged, avoiding a save-schema
change. Generated server/client menus share the same slot layout; the output rejects
insertion and does not perform another craft when taken. Server-owned progress is
scaled to 0..1000 for vanilla menu-data synchronization and rendered as a progress bar.
Closing removes the viewer listener without clearing block inventory. No live timed
crafting or save/reload correctness is claimed until the gameplay checks pass.
- Validate container, processing, hopper and break/drop behaviour with the shared fixture on all nine generated targets; their implementation and compilation are complete.
- Validate close, reopen, world save/reload and multiple viewers before enabling persistence in the default consumer runtime profile.
- Complete dedicated-server/client-isolation checks and record gameplay results separately from the passing compilation and portable fixture byte-parity checks.
This foundation is covered by focused Java tests. No live world persistence or native block-entity gameplay is claimed yet; the earlier eleven-check workbench matrix predates this feature and cannot establish those behaviours.