-
Notifications
You must be signed in to change notification settings - Fork 0
Block Entities
Blocks/ExBlockEntity.cs and Blocks/ExBlockState.cs give a plain block entity its save/load pair
for free, so a field named once is enough - no hand-written ToTreeAttributes/FromTreeAttributes
pair that spells the same key twice and can drift out of sync.
A block entity base whose whole state is declared, not hand-written:
public abstract class ExBlockEntity : BlockEntity
{
protected ExBlockState Persisted { get; } // built lazily on first use
protected virtual void DeclareState(ExBlockState state) { }
}ToTreeAttributes, FromTreeAttributes, OnStoreCollectibleMappings and
OnLoadCollectibleMappings all call base then run through Persisted, so a declared field gets the
save, the load, the client sync and the schematic-paste collectible remap in one place.
A block entity whose base slot is already spent - a container, a multiblock, a network node - is
not locked out: BlockEntityProductionMachine, BlockEntityMultiblockStructure,
BlockEntityNetworkNode and BlockEntityMachineStation each carry the same Persisted/DeclareState
pair, layered on top of whatever they already write by hand (see
Production Machines, Multiblock Structures,
Block Networks); so does ExBlockEntityContainer for a plain BlockEntityContainer
(below). A block entity with none of those bases builds an ExBlockState directly and calls
ToTree/FromTree from its own overrides - ExBlockEntity is the convenience, not the mechanism.
A BlockEntityBehavior gets the same convenience through ExBlockEntityBehavior:
public abstract class ExBlockEntityBehavior : BlockEntityBehavior
{
protected ExBlockState Persisted { get; } // built lazily on first use
protected virtual void DeclareState(ExBlockState state) { }
}Vanilla fans a block entity's ToTreeAttributes/FromTreeAttributes out over its Behaviors against
the same flat tree the host itself writes into, so a behaviour's keys, its host's keys and a sibling
behaviour's keys all share one key space. ExBlockState only catches a key declared twice inside one
state; a key this behaviour declares that its host or another behaviour on the same host also happens
to write is not caught here and collides silently - pick keys that are unambiguous across the whole
host, not just within the behaviour.
A block entity based on BlockEntityContainer gets it through ExBlockEntityContainer:
public abstract class ExBlockEntityContainer : BlockEntityContainer
{
protected ExBlockState Persisted { get; } // built lazily on first use
protected virtual void DeclareState(ExBlockState state) { }
}ToTreeAttributes, FromTreeAttributes and the collectible-mapping pair call base first - which is
BlockEntityContainer's own inventory serialization - then run Persisted on top, so a declared field
sits beside the inventory without touching how it saves.
-
[Persist]- mark the field, write nothing else.[Persist] private float _tempC;
-
Persisted- overrideDeclareStatefor a custom key, a computed accessor, or a value with its own tree shape.protected override void DeclareState(ExBlockState s) => s.Float("temp", () => _tempC, v => _tempC = v);
-
Hand-written - override the four methods yourself and call
base. Nothing here forces the other two rungs onto an existing block entity.
The three combine on one type: a [Persist] field and a DeclareState entry both land in the
same Persisted, and a hand-written override still reaches it through base.
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public sealed class PersistAttribute(string? key = null) : Attribute
{
public string? Key { get; } // defaults to the member name, leading underscore stripped
public string? Legacy { get; init; } // an older key, read only when Key is absent; never written
}Supported member types: bool, int, long, float, double, string, an enum (stored as its
underlying int), BlockPos, ItemStack, and IPersistable. A field or auto-property of any
other type throws NotSupportedException naming the member the first time the block entity's
Persisted is built - a modder sees it on first placement, not silently.
PersistScan.Declare(this, state) runs before DeclareState on every base above, so an attribute
and a hand-written entry never conflict for the same key. The reflection walk (base types first)
and the compiled field/property accessors are built once per concrete type and cached; every
instance of that type reuses them.
private class RenamedField : ExBlockEntity
{
[Persist(Legacy = "isPouring")] private bool _plugged;
// A save from before the rename still loads: isPouring is read only when plugged is
// absent. Every save after this point writes plugged alone.
}public interface IPersistable
{
void ToTree(ITreeAttribute tree); // writes into a tree private to this member
void FromTree(ITreeAttribute tree, IWorldAccessor world); // mutates in place
}A [Persist] member of this type is stored under its own key as a nested tree - the same shape
ExBlockState.Tree gives a hand-declared field that manages several attributes at once (a
MoltenCharge, for instance). FromTree mutates the existing instance rather than replacing it,
so a field of this type is instantiated once at declaration and never reassigned by the scan.
The declaration surface both rungs above build on:
public sealed class ExBlockState
{
public ExBlockState Bool(string key, Func<bool> get, Action<bool> set);
public ExBlockState Int(string key, Func<int> get, Action<int> set);
public ExBlockState Long(string key, Func<long> get, Action<long> set);
public ExBlockState Float(string key, Func<float> get, Action<float> set);
public ExBlockState Double(string key, Func<double> get, Action<double> set);
public ExBlockState String(string key, Func<string?> get, Action<string?> set);
public ExBlockState Enum<T>(string key, Func<T> get, Action<T> set) where T : struct, Enum;
public ExBlockState Pos(string key, Func<BlockPos?> get, Action<BlockPos?> set);
public ExBlockState Stack(string key, Func<ItemStack?> get, Action<ItemStack?> set);
public ExBlockState Tree(string key, Action<ITreeAttribute> write,
Action<ITreeAttribute, IWorldAccessor> read);
public IReadOnlyList<string> Keys { get; } // declared keys, in declaration order
}Every method returns this, so a DeclareState override chains them. Stack also carries the
declared stack's collectible id mapping both ways - what makes a block entity survive being pasted
into another world. Tree is the escape hatch for a value that manages several attributes on the
same tree (a MoltenCharge) or builds a genuinely nested sub-tree itself (see IPersistable
above); key only names the declaration for the duplicate-key guard, so the callback is free to
choose its own attribute names. Declaring the same key twice throws InvalidOperationException at
declaration time, not on the first mismatched save.
Turning an existing ToTreeAttributes/FromTreeAttributes pair into [Persist]/Persisted must not
move, rename or retype a single key - a TreeKeys golden (below) is the proof. Per field:
- A field written under key
Kwith a plain get/set becomes[Persist("K")] private float _x;. The default key is the member name with a leading underscore stripped, so write the key explicitly whenever it differs, and keep it even when it matches if that makes the mapping obvious. - An enum stored as
intbecomes a[Persist]enum field;PersistScanalready stores it that way. - A
BlockPoswritten as three ints underkX/kY/kZis[Persist]on theBlockPos?member directly ifExBlockState.Pos's three-key shape matches; check before assuming it does. - A legacy fallback (an old key read only when the new one is absent, never written) is
[Persist("new", Legacy = "old")]when the semantics match exactly; anything more - a default read that differs from the primitive's own, a negated flag, several attributes read as one unit, a value written unconditionally whereExBlockState.Stack/Stringwould skip a null - stays astate.Tree(key, write, read)with the old body moved in unchanged. - A nested value's own
ToTree(tree, key)/FromTree(tree, key, world)pair that writes flat keys (not one sub-tree) stays aTreedeclaration too; only a value that nests under one key isIPersistable. - Anything the pair does besides reading and writing values - marking dirty, recomputing derived
state, calling
basein a subclass chain - stays as aFromTreeAttributesoverride that callsbasefirst, with no key reads left in it. A pair that only forwards tobaseis dropped entirely. - A subclass whose base already overrides
DeclareStatewith real declarations must callbase.DeclareState(state)before adding its own - most of thePersistedbases belowExBlockEntitymake itvirtual, notabstract, so skipping the call silently drops the base's fields.TreeKeys.AssertDeclaresBaseKeys(below) is the guard for exactly this. - A
BlockEntityBehaviorconverts throughExBlockEntityBehavior, aBlockEntityContainerthroughExBlockEntityContainer(see Behaviours and Containers above). A class on some other vanilla base with no exlib base carryingPersistedstays hand-written; converting it would mean re-parenting it, which is a behaviour change, not a persistence one.
ExpandedLib.Testing.TreeKeys.AssertGolden(be, domain) is the proof: it reads back the sorted,
type-tagged key list a fresh instance's ToTreeAttributes writes and compares it against a golden
blessed before the conversion. A changed golden after converting a class means the conversion moved
a key or a type, not that the golden is stale.
ExpandedLib.Testing.TreeKeys.AssertDeclaresBaseKeys(be) catches the other failure mode a golden
cannot: a subclass override that skips its base.DeclareState(state) call. For every type in be's
hierarchy that overrides DeclareState, it invokes that level's override alone - non-virtually,
bypassing whatever overrides it further down - against a fresh ExBlockState, and asserts every key
that level declares also shows up in the instance's real, built Persisted state. A golden alone
would not catch this: PersistScan's [Persist] scan runs independently of DeclareState and
always contributes its keys regardless of what the override chain does.
-
Production Machines - the
Persistedrung onBlockEntityProductionMachine. -
Multiblock Structures - the
Persistedrung onBlockEntityMultiblockStructure. -
Block Networks - the
Persistedrung onBlockEntityNetworkNode. -
Helpers & Renderers - "Finding block entities" (
ExBlockAccess), "Which side" (ExSide), "Reading a click" (ExInteraction) and "Block info lines" (ExInfo), the convenience layer over the lookups, side checks, interactions andGetBlockInfolines a block entity writes.
Expanded Library · framework mod for Vintage Story
exlib - Blocks
- Block Entities
- Block Networks
- Multiblock Structures
- Production Machines
- Construction (RCC)
- Migrations & Healing
exlib - Extending our mods
exlib - Registration
- Registries
- Modules
- Code-First Definitions
- Config System
- Commands
- Recipe Costs
- Source Generators
- Checks
exlib - Utilities
exlib.testing