diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiController.java b/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiController.java
index 95700eb2f56..f1f4b0136b3 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiController.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiController.java
@@ -39,7 +39,7 @@ default MultiblockControllerMachine self() {
/**
* Check MultiBlock Pattern. Just checking pattern without any other logic.
- * You can override it but it's unsafe for calling. because it will also be called in an async thread.
+ * You can override it, but direct calls are unsafe because multiple lifecycle paths may request a check.
*
* you should always use {@link IMultiController#checkPatternWithLock()} and
* {@link IMultiController#checkPatternWithTryLock()} instead.
@@ -91,13 +91,68 @@ default BlockPattern getPattern() {
}
/**
- * Whether Multiblock Formed.
- *
- * NOTE: even machine is formed, it doesn't mean to workable!
- * Its parts maybe invalid due to chunk unload.
+ * Whether the multiblock is currently safe to expose as formed to gameplay code. Implementations with deferred
+ * validation should return false while that validation is pending.
*/
boolean isFormed();
+ /**
+ * The last successfully persisted or observed formation state, including while transient revalidation is pending.
+ */
+ boolean isStructureFormedSnapshot();
+
+ /**
+ * The mirrored orientation owned by the last completed structure validation.
+ */
+ boolean isStructureFlippedSnapshot();
+
+ /**
+ * Whether this controller is waiting for a full, server-thread structure validation.
+ *
+ * This is deliberately separate from {@link #isStructureFormedSnapshot()}: a controller may retain its formation
+ * snapshot while one of its structure chunks is temporarily unavailable, but it must not operate until validation
+ * succeeds.
+ */
+ boolean isStructureRevalidationPending();
+
+ /**
+ * Update the transient structure-validation state. Implementations must pause and resume work subscriptions when
+ * this value changes.
+ */
+ void setStructureRevalidationPending(boolean pending);
+
+ /**
+ * Whether the structure is both formed and fully validated for operation.
+ */
+ default boolean isStructureOperational() {
+ return isStructureFormedSnapshot() && !isStructureRevalidationPending();
+ }
+
+ /**
+ * Stable identity for the controller's current structure-ownership epoch. A definitive invalidation retires the
+ * epoch, while transient unloads retain it; the next successful formation receives a newer identity. Zero means
+ * no ownership epoch has been assigned. Callers must pair it with {@code self().getPos()}.
+ */
+ long getStructureInstanceId();
+
+ /**
+ * Assign a persistent ownership-epoch identity.
+ */
+ void setStructureInstanceId(long instanceId);
+
+ /**
+ * Called after this exact controller state has been installed in the world mapping. Controllers persist the
+ * participating chunk set so their first post-restart validation cannot select another geometry while an old
+ * structure chunk is unavailable.
+ */
+ void onStructureMappingInstalled(long[] mappedChunks);
+
+ /**
+ * Snapshot of chunks owned by the last confirmed structure validation. Schedulers use this to wait for the exact
+ * unloaded chunks without requesting them.
+ */
+ long[] getConfirmedStructureChunks();
+
/**
* Get MultiblockState. It records all structure-related information.
*/
@@ -105,10 +160,9 @@ default BlockPattern getPattern() {
MultiblockState getMultiblockState();
/**
- * Called in an async thread. It's unsafe, Don't modify anything of world but checking information.
- * It will be called per 5 tick.
+ * Called on the server thread for controllers waiting to form or recover from an unloaded chunk.
*
- * @param periodID period Tick
+ * @param periodID current level tick counter
*/
void asyncCheckPattern(long periodID);
diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiPart.java b/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiPart.java
index f08a17f5a37..3e6a79ac23f 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiPart.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/machine/feature/multiblock/IMultiPart.java
@@ -34,6 +34,11 @@ default boolean canShared() {
*/
boolean hasController(BlockPos controllerPos);
+ /**
+ * Whether this part belongs to this exact controller ownership epoch.
+ */
+ boolean hasController(IMultiController controller);
+
/**
* Whether it belongs to a formed Multiblock.
*/
@@ -52,6 +57,18 @@ default boolean canShared() {
*/
void removedFromController(IMultiController controller);
+ /**
+ * Unlink a controller whose block entity is unloading without treating the structure as broken.
+ */
+ void unloadedFromController(IMultiController controller);
+
+ /**
+ * Called after a persisted controller binding is known to have ended definitively (structure invalidation,
+ * controller retirement, or replacement by a newer ownership epoch). Transient chunk unloads never invoke this
+ * hook.
+ */
+ default void onControllerBindingRetired(BlockPos controllerPos, long instanceId) {}
+
/**
* Called when it was added to a multiblock.
*/
diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockControllerMachine.java b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockControllerMachine.java
index 5b61313df18..f5fc81ca152 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockControllerMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/MultiblockControllerMachine.java
@@ -7,6 +7,7 @@
import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity;
import com.gregtechceu.gtceu.api.machine.MetaMachine;
import com.gregtechceu.gtceu.api.machine.MultiblockMachineDefinition;
+import com.gregtechceu.gtceu.api.machine.feature.IWorkLogicMachine;
import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMaintenanceMachine;
import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController;
import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiPart;
@@ -31,7 +32,7 @@
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
-import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.phys.BlockHitResult;
import lombok.Getter;
@@ -42,7 +43,9 @@
import snownee.jade.api.config.IPluginConfig;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -55,18 +58,26 @@
@MethodsReturnNonnullByDefault
public class MultiblockControllerMachine extends MetaMachine implements IMultiController {
+ private static final String STRUCTURE_INSTANCE_ID = "gtceuStructureInstanceId";
+ private static final String CONFIRMED_STRUCTURE_CHUNKS = "gtceuConfirmedStructureChunks";
+ private static final long[] NO_CONFIRMED_STRUCTURE_CHUNKS = new long[0];
+
private MultiblockState multiblockState;
private final List parts = new ArrayList<>();
+ private long structureInstanceId;
+ private long[] confirmedStructureChunks = NO_CONFIRMED_STRUCTURE_CHUNKS;
@Getter
@DescSynced
@UpdateListener(methodName = "onPartsUpdated")
private BlockPos[] partPositions = new BlockPos[0];
- @Getter
@Persisted
@DescSynced
@RequireRerender
protected boolean isFormed;
@Getter
+ @DescSynced
+ protected boolean structureRevalidationPending = true;
+ @Getter
@Setter
@Persisted
@DescSynced
@@ -112,15 +123,124 @@ public MultiblockMachineDefinition getDefinition() {
public void onLoad() {
super.onLoad();
if (getLevel() instanceof ServerLevel serverLevel) {
- MultiblockWorldSavedData.getOrCreate(serverLevel).addAsyncLogic(this);
+ var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel);
+ if (isStructureFormedSnapshot()) {
+ // A persisted formation may be newer than SavedData. Repair/adopt its epoch before revalidation.
+ savedData.ensureControllerActive(this);
+ } else {
+ // An unformed controller must not publish or adopt an empty ACTIVE epoch. Tombstone the highest value
+ // known by its NBT or SavedData; a later successful formation alone may allocate the replacement.
+ savedData.retireControllerPosition(getPos(), structureInstanceId);
+ clearConfirmedStructureChunks();
+ }
+ savedData.addAsyncLogic(this);
+ }
+ }
+
+ @Override
+ public void saveCustomPersistedData(@NotNull CompoundTag tag, boolean forDrop) {
+ super.saveCustomPersistedData(tag, forDrop);
+ if (!forDrop && structureInstanceId > 0) {
+ tag.putLong(STRUCTURE_INSTANCE_ID, structureInstanceId);
+ } else {
+ // A dropped and placed controller cannot inherit the previous block's ownership epoch.
+ tag.remove(STRUCTURE_INSTANCE_ID);
+ }
+ if (!forDrop && isFormed && confirmedStructureChunks.length > 0) {
+ tag.putLongArray(CONFIRMED_STRUCTURE_CHUNKS, confirmedStructureChunks);
+ } else {
+ // A placed controller must validate its own new structure rather than inheriting the dropped block's
+ // geometry. Controllers without a confirmed structure have no geometry to persist.
+ tag.remove(CONFIRMED_STRUCTURE_CHUNKS);
+ }
+ }
+
+ @Override
+ public void loadCustomPersistedData(@NotNull CompoundTag tag) {
+ super.loadCustomPersistedData(tag);
+ structureInstanceId = Math.max(0, tag.getLong(STRUCTURE_INSTANCE_ID));
+ confirmedStructureChunks = normalizeStructureChunks(tag.getLongArray(CONFIRMED_STRUCTURE_CHUNKS));
+ }
+
+ @Override
+ public long getStructureInstanceId() {
+ return structureInstanceId;
+ }
+
+ @Override
+ public void setStructureInstanceId(long instanceId) {
+ structureInstanceId = Math.max(0, instanceId);
+ }
+
+ @Override
+ public void onStructureMappingInstalled(long[] mappedChunks) {
+ long[] normalized = normalizeStructureChunks(mappedChunks);
+ if (!Arrays.equals(confirmedStructureChunks, normalized)) {
+ confirmedStructureChunks = normalized;
+ markDirty();
+ }
+ }
+
+ @Override
+ public long[] getConfirmedStructureChunks() {
+ return Arrays.copyOf(confirmedStructureChunks, confirmedStructureChunks.length);
+ }
+
+ @Override
+ public void onMachineDestroyed() {
+ // MetaMachineBlock invokes this synchronously only when the controller block is actually replaced. It is the
+ // authoritative retirement point; ordinary chunk unloads never pass through here.
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel);
+ savedData.retireController(this);
+ savedData.removeAsyncLogic(this);
+ if (multiblockState != null) {
+ savedData.removeMapping(multiblockState);
+ }
+ }
+ if (isFormed || !parts.isEmpty()) {
+ onStructureInvalid();
+ } else {
+ clearConfirmedStructureChunks();
}
+ super.onMachineDestroyed();
}
@Override
public void onUnload() {
+ if (getLevel() instanceof ServerLevel) {
+ // Do not rely on ChunkEvent ordering: every server-side controller unload must pause gameplay and addon
+ // topology before traits and runtime part links disappear. The persisted formed snapshot remains intact.
+ setStructureRevalidationPending(true);
+ }
super.onUnload();
if (getLevel() instanceof ServerLevel serverLevel) {
- MultiblockWorldSavedData.getOrCreate(serverLevel).removeAsyncLogic(this);
+ var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel);
+ savedData.removeAsyncLogic(this);
+ if (multiblockState != null) {
+ savedData.removeMapping(multiblockState);
+ }
+ var controllerChunk = serverLevel.getChunkSource().getChunkNow(getPos().getX() >> 4, getPos().getZ() >> 4);
+ MetaMachine currentMachine = controllerChunk == null ? null : MetaMachine.getMachine(controllerChunk,
+ getPos());
+ boolean controllerRemoved = controllerChunk != null &&
+ (!controllerChunk.getBlockState(getPos()).is(getBlockState().getBlock()) ||
+ currentMachine != null && currentMachine != this);
+ for (IMultiPart part : List.copyOf(parts)) {
+ MetaMachine partMachine = part.self();
+ BlockPos partPos = partMachine.getPos();
+ var partChunk = serverLevel.getChunkSource().getChunkNow(partPos.getX() >> 4, partPos.getZ() >> 4);
+ if (!partMachine.isInValid() &&
+ partChunk != null && MetaMachine.getMachine(partChunk, partPos) == partMachine) {
+ if (controllerRemoved) {
+ part.removedFromController(this);
+ } else {
+ part.unloadedFromController(this);
+ }
+ }
+ }
+ parts.clear();
+ updatePartPositions();
}
}
@@ -133,6 +253,39 @@ public MultiblockState getMultiblockState() {
return multiblockState;
}
+ @Override
+ public boolean isFormed() {
+ return isFormed && !structureRevalidationPending;
+ }
+
+ @Override
+ public boolean isStructureFormedSnapshot() {
+ return isFormed;
+ }
+
+ @Override
+ public boolean isStructureFlippedSnapshot() {
+ return isFlipped;
+ }
+
+ @Override
+ public void setStructureRevalidationPending(boolean pending) {
+ if (structureRevalidationPending == pending) {
+ return;
+ }
+ structureRevalidationPending = pending;
+ if (this instanceof IWorkLogicMachine workMachine) {
+ workMachine.getWorkLogic().updateTickSubscription();
+ }
+ onStructureRevalidationChanged(pending);
+ }
+
+ /**
+ * Called after the transient validation state changes. Controllers with independent tick subscriptions should use
+ * this hook to pause them immediately and restore them after validation succeeds.
+ */
+ protected void onStructureRevalidationChanged(boolean pending) {}
+
@SuppressWarnings("unused")
protected void onPartsUpdated(BlockPos[] newValue, BlockPos[] oldValue) {
parts.clear();
@@ -162,6 +315,14 @@ public List getParts() {
return this.parts;
}
+ /**
+ * Identity membership in the already attached runtime part list. Unlike {@link #getParts()}, this never attempts
+ * to resolve persisted part positions and therefore cannot request an unavailable structure chunk.
+ */
+ public boolean hasRuntimePart(IMultiPart part) {
+ return parts.stream().anyMatch(candidate -> candidate == part);
+ }
+
@Override
public Optional getParallelHatch() {
return getTraitOptional(ParallelHatchTrait.class).map(ParallelHatchTrait::getParallelHatch);
@@ -175,41 +336,108 @@ public Optional getParallelHatch() {
@Override
public void asyncCheckPattern(long periodID) {
- if ((getMultiblockState().hasError() || !isFormed) && (getHolder().getOffset() + periodID) % 4 == 0 &&
- checkPatternWithTryLock()) { // per second
- if (getLevel() instanceof ServerLevel serverLevel) {
- serverLevel.getServer().execute(() -> {
- patternLock.lock();
- if (checkPatternWithLock()) { // formed
- setFlipped(getMultiblockState().isNeededFlip());
- onStructureFormed();
- var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel);
- mwsd.addMapping(getMultiblockState());
- mwsd.removeAsyncLogic(this);
- }
- patternLock.unlock();
- });
+ if (!(getLevel() instanceof ServerLevel serverLevel)) {
+ return;
+ }
+ var state = getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel);
+ if (!structureRevalidationPending && !state.hasError() && isFormed &&
+ savedData.mapping.get(getPos()) == state) {
+ savedData.removeAsyncLogic(this);
+ return;
+ }
+ boolean wasFormed = isFormed;
+ savedData.removeMapping(state);
+ if (wasFormed && (hasUnloadedConfirmedStructureChunk(serverLevel) ||
+ state.hasPatternCache() && state.hasUnloadedCachedPosition())) {
+ // Keep the last confirmed pattern and part ownership intact. Trying alternate orientations or dynamic
+ // patterns while an old structure chunk is unavailable can produce a valid but different match, orphaning
+ // parts and addon-side topology that belonged to the previous structure.
+ state.setError(MultiblockState.UNLOAD_ERROR);
+ setStructureRevalidationPending(true);
+ return;
+ }
+ if (checkPatternWithLock()) {
+ setFlipped(state.isNeededFlip());
+ state.setError(null);
+ setStructureRevalidationPending(false);
+ boolean mappingInstalled = false;
+ try {
+ onStructureFormed();
+ if (isFormed) {
+ mappingInstalled = savedData.tryAddMapping(state);
+ }
+ if (mappingInstalled) {
+ savedData.removeAsyncLogic(this);
+ }
+ } finally {
+ if (!mappingInstalled) {
+ savedData.removeMapping(state);
+ setStructureRevalidationPending(true);
+ }
+ }
+ } else if (state.error == MultiblockState.UNLOAD_ERROR) {
+ // Some dynamic pattern factories probe dimensions by mutating the raw formed field. An incomplete chunk
+ // is not authoritative, so retain the snapshot captured before entering arbitrary pattern code.
+ isFormed = wasFormed;
+ state.restoreConfirmedPatternCache();
+ } else {
+ // A persisted formed flag is provisional until the first post-load check. Only a definitive pattern
+ // failure invalidates it; an unloaded structure remains queued until all participating chunks return.
+ if (wasFormed) {
+ setFlipped(false);
+ onStructureInvalid();
}
+ // The controller stays in the retry queue so a later block placement can form it, but this check produced
+ // an authoritative result. Clear the transient pending state only after onStructureInvalid() has dropped
+ // the formed snapshot, keeping the operational facade unavailable without misclassifying a known-invalid
+ // structure as still waiting on unloaded chunks.
+ setStructureRevalidationPending(false);
}
}
@Override
public void onStructureFormed() {
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ // This must precede every part, trait, and addon formation callback. If a definitive invalidation retired
+ // the previous epoch, all claims made by this formation must carry the newly allocated identity.
+ var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel);
+ if (!isFormed) {
+ // A new formation must never adopt a stray ACTIVE record left by non-atomic save ordering. A
+ // formed-to-formed refresh skips this step and retains its current ACTIVE epoch.
+ savedData.retireControllerPosition(getPos(), structureInstanceId);
+ }
+ savedData.ensureControllerActive(this);
+ }
+ // Direct formation paths such as the pattern preview also represent a completed validation.
+ setStructureRevalidationPending(false);
isFormed = true;
MachineRenderState renderState = getRenderState();
if (renderState.hasProperty(GTMachineModelProperties.IS_FORMED)) {
setRenderState(renderState.setValue(GTMachineModelProperties.IS_FORMED, true));
}
- this.parts.clear();
+ List previousParts = List.copyOf(this.parts);
+ List nextParts = new ArrayList<>();
Set set = getMultiblockState().getMatchContext().getOrCreate("parts", Collections::emptySet);
for (IMultiPart part : set) {
if (shouldAddPartToController(part)) {
- this.parts.add(part);
+ nextParts.add(part);
}
}
- this.parts.sort(getPartSorter());
+ nextParts.sort(getPartSorter());
+ Set nextPartIdentities = Collections.newSetFromMap(new IdentityHashMap<>());
+ nextPartIdentities.addAll(nextParts);
+ this.parts.clear();
+ this.parts.addAll(nextParts);
updatePartPositions();
+ for (IMultiPart previousPart : previousParts) {
+ if (!nextPartIdentities.contains(previousPart)) {
+ previousPart.removedFromController(this);
+ }
+ }
+ // Retained parts deliberately receive the existing refresh callback as well. Several part implementations
+ // rebuild transient handlers and subscriptions after every successful structure check.
for (var part : parts) {
part.addedToController(this);
}
@@ -218,7 +446,13 @@ public void onStructureFormed() {
@Override
public void onStructureInvalid() {
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ // Structure invalidation is authoritative even though the controller block remains. Retire ownership
+ // before loaded parts are unlinked so unloaded parts can reconcile the same epoch without a timeout.
+ MultiblockWorldSavedData.getOrCreate(serverLevel).retireController(this);
+ }
isFormed = false;
+ clearConfirmedStructureChunks();
MachineRenderState renderState = getRenderState();
if (renderState.hasProperty(GTMachineModelProperties.IS_FORMED)) {
setRenderState(renderState.setValue(GTMachineModelProperties.IS_FORMED, false));
@@ -233,17 +467,21 @@ public void onStructureInvalid() {
}
/**
- * mark multiblockState as unload error first.
- * if it's actually cuz by block breaking.
- * {@link #onStructureInvalid()} will be called from
- * {@link MultiblockState#onBlockStateChanged(BlockPos, BlockState)}
+ * Mark the state as temporarily unloaded and queue one bounded validation. If the part was actually broken, the
+ * queued pattern check turns this into a definitive {@link #onStructureInvalid()} callback; a mere chunk unload
+ * keeps the persisted formed state intact until all chunks return.
*/
@Override
public void onPartUnload() {
parts.removeIf(part -> part.self().isInValid());
getMultiblockState().setError(MultiblockState.UNLOAD_ERROR);
if (getLevel() instanceof ServerLevel serverLevel) {
- MultiblockWorldSavedData.getOrCreate(serverLevel).addAsyncLogic(this);
+ var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel);
+ savedData.removeMapping(getMultiblockState());
+ var controllerChunk = serverLevel.getChunkSource().getChunkNow(getPos().getX() >> 4, getPos().getZ() >> 4);
+ if (!isInValid() && controllerChunk != null && MetaMachine.getMachine(controllerChunk, getPos()) == this) {
+ savedData.addAsyncLogic(this);
+ }
}
updatePartPositions();
getTraits(IMultiblockMachineTrait.class).forEach(IMultiblockMachineTrait::onPartUnload);
@@ -253,9 +491,9 @@ public void onPartUnload() {
public void onRotated(Direction oldFacing, Direction newFacing) {
if (oldFacing != newFacing && getLevel() instanceof ServerLevel serverLevel) {
// invalid structure
- this.onStructureInvalid();
var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel);
mwsd.removeMapping(getMultiblockState());
+ this.onStructureInvalid();
mwsd.addAsyncLogic(this);
}
}
@@ -314,4 +552,35 @@ public void setFrontFacing(Direction facing) {
checkPattern();
}
}
+
+ private boolean hasUnloadedConfirmedStructureChunk(ServerLevel serverLevel) {
+ for (long chunk : confirmedStructureChunks) {
+ if (!serverLevel.hasChunk(ChunkPos.getX(chunk), ChunkPos.getZ(chunk))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void clearConfirmedStructureChunks() {
+ if (confirmedStructureChunks.length > 0) {
+ confirmedStructureChunks = NO_CONFIRMED_STRUCTURE_CHUNKS;
+ markDirty();
+ }
+ }
+
+ private static long[] normalizeStructureChunks(long[] chunks) {
+ if (chunks.length == 0) {
+ return NO_CONFIRMED_STRUCTURE_CHUNKS;
+ }
+ long[] normalized = Arrays.copyOf(chunks, chunks.length);
+ Arrays.sort(normalized);
+ int unique = 1;
+ for (int i = 1; i < normalized.length; i++) {
+ if (normalized[i] != normalized[unique - 1]) {
+ normalized[unique++] = normalized[i];
+ }
+ }
+ return unique == normalized.length ? normalized : Arrays.copyOf(normalized, unique);
+ }
}
diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java
index f3eb6bc9c03..4695b949974 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/WorkableMultiblockMachine.java
@@ -114,15 +114,20 @@ public void clientTick() {
}
public void updateActiveBlocks(boolean active) {
- if (activeBlocks != null) {
- for (long pos : activeBlocks) {
- var blockPos = BlockPos.of(pos);
- var blockState = getLevel().getBlockState(blockPos);
- if (blockState.hasProperty(GTBlockStateProperties.ACTIVE)) {
- var newState = blockState.setValue(GTBlockStateProperties.ACTIVE, active);
- if (newState != blockState) {
- getLevel().setBlock(blockPos, newState, Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE);
- }
+ var level = getLevel();
+ if (activeBlocks == null || level == null) {
+ return;
+ }
+ for (long pos : activeBlocks) {
+ var blockPos = BlockPos.of(pos);
+ if (!level.isLoaded(blockPos)) {
+ continue;
+ }
+ var blockState = level.getBlockState(blockPos);
+ if (blockState.hasProperty(GTBlockStateProperties.ACTIVE)) {
+ var newState = blockState.setValue(GTBlockStateProperties.ACTIVE, active);
+ if (newState != blockState) {
+ level.setBlock(blockPos, newState, Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE);
}
}
}
@@ -186,6 +191,8 @@ public void setWorkingEnabled(boolean isWorkingAllowed) {
@Override
public boolean isWorkLogicAvailable() {
- return isFormed && !getMultiblockState().hasError();
+ // Pattern errors are server-owned and are not synchronized as a client-side MultiblockState. The pending
+ // facade is synchronized, so it remains safe to use for status rendering on either side.
+ return isStructureOperational() && (isRemote() || !getMultiblockState().hasError());
}
}
diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/part/MultiblockPartMachine.java b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/part/MultiblockPartMachine.java
index c69b4cc8a5c..75d65c726af 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/part/MultiblockPartMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/machine/multiblock/part/MultiblockPartMachine.java
@@ -4,10 +4,12 @@
import com.gregtechceu.gtceu.api.capability.recipe.IRecipeHandler;
import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity;
import com.gregtechceu.gtceu.api.machine.MetaMachine;
+import com.gregtechceu.gtceu.api.machine.TickableSubscription;
import com.gregtechceu.gtceu.api.machine.feature.multiblock.IDistinctPart;
import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController;
import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiPart;
import com.gregtechceu.gtceu.api.machine.property.GTMachineModelProperties;
+import com.gregtechceu.gtceu.api.pattern.MultiblockWorldSavedData;
import com.gregtechceu.gtceu.api.recipe.handler.RecipeHandlerList;
import com.gregtechceu.gtceu.client.model.machine.MachineRenderState;
@@ -17,6 +19,9 @@
import net.minecraft.MethodsReturnNonnullByDefault;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.state.BlockState;
@@ -28,7 +33,9 @@
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
@@ -38,10 +45,19 @@
@MethodsReturnNonnullByDefault
public class MultiblockPartMachine extends MetaMachine implements IMultiPart {
+ private static final String CONTROLLER_BINDINGS = "gtceuMultiblockControllerBindings";
+ private static final String CONTROLLER_POS = "pos";
+ private static final String CONTROLLER_INSTANCE_ID = "instanceId";
+
@DescSynced
@UpdateListener(methodName = "onControllersUpdated")
protected final Set controllerPositions = new ObjectOpenHashSet<>(8);
protected final SortedSet controllers = new ReferenceLinkedOpenHashSet<>(8);
+ private final Map controllerBindings = new HashMap<>();
+ @Nullable
+ private TickableSubscription controllerBindingSubscription;
+ private boolean controllerBindingReconciliationArmed;
+ private boolean controllerBindingInitialReconciliationComplete;
protected @Nullable RecipeHandlerList handlerList;
@@ -55,12 +71,34 @@ public MultiblockPartMachine(IMachineBlockEntity holder) {
@Override
public boolean hasController(BlockPos controllerPos) {
- return controllerPositions.contains(controllerPos);
+ return controllerPositions.contains(controllerPos) || controllerBindings.containsKey(controllerPos);
+ }
+
+ @Override
+ public boolean hasController(IMultiController controller) {
+ if (controllers.contains(controller)) {
+ return true;
+ }
+ BlockPos controllerPos = controller.self().getPos();
+ Long boundInstance = controllerBindings.get(controllerPos);
+ long controllerInstance = controller.getStructureInstanceId();
+ if (boundInstance != null && controllerInstance > 0 && boundInstance == controllerInstance) {
+ return true;
+ }
+ if (boundInstance != null && getLevel() instanceof ServerLevel serverLevel) {
+ var savedData = MultiblockWorldSavedData.getOrCreate(serverLevel);
+ if (savedData.isControllerBindingRetired(controllerPos, boundInstance)) {
+ retireControllerBinding(controllerPos, boundInstance);
+ }
+ }
+ return false;
}
@Override
public boolean isFormed() {
- return !controllerPositions.isEmpty();
+ // A persisted owner whose chunk is unavailable still owns an exclusive part. Treating the transient runtime
+ // set as authoritative would let another controller steal the part and inherit locked recipe contents.
+ return !controllerPositions.isEmpty() || !controllerBindings.isEmpty();
}
// Not sure if necessary, but added to match the Controller class
@@ -110,15 +148,28 @@ protected RecipeHandlerList getHandlerList() {
return handlerList;
}
+ @Override
+ public void onLoad() {
+ super.onLoad();
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ reconcileRetiredControllerBindings(MultiblockWorldSavedData.getOrCreate(serverLevel));
+ updateControllerBindingSubscription();
+ }
+ }
+
@Override
public void onUnload() {
+ unsubscribe(controllerBindingSubscription);
+ controllerBindingSubscription = null;
+ controllerBindingReconciliationArmed = false;
+ controllerBindingInitialReconciliationComplete = false;
super.onUnload();
if (getLevel() instanceof ServerLevel serverLevel) {
- // Need to copy if > 1 so that we can call removedFromController safely without CME
+ // Need to copy if > 1 so that we can unlink safely without CME
Set toIter = controllers.size() > 1 ? new ObjectOpenHashSet<>(controllers) : controllers;
for (IMultiController controller : toIter) {
if (serverLevel.isLoaded(controller.self().getPos())) {
- removedFromController(controller);
+ unloadedFromController(controller);
controller.onPartUnload();
}
}
@@ -127,6 +178,119 @@ public void onUnload() {
controllers.clear();
}
+ @Override
+ public void saveCustomPersistedData(CompoundTag tag, boolean forDrop) {
+ super.saveCustomPersistedData(tag, forDrop);
+ if (forDrop || controllerBindings.isEmpty()) {
+ // A placed part must establish ownership through a newly validated structure, never through item NBT.
+ tag.remove(CONTROLLER_BINDINGS);
+ return;
+ }
+ ListTag bindings = new ListTag();
+ for (var entry : controllerBindings.entrySet()) {
+ CompoundTag binding = new CompoundTag();
+ binding.putLong(CONTROLLER_POS, entry.getKey().asLong());
+ binding.putLong(CONTROLLER_INSTANCE_ID, entry.getValue());
+ bindings.add(binding);
+ }
+ tag.put(CONTROLLER_BINDINGS, bindings);
+ }
+
+ @Override
+ public void loadCustomPersistedData(CompoundTag tag) {
+ super.loadCustomPersistedData(tag);
+ controllerBindings.clear();
+ ListTag bindings = tag.getList(CONTROLLER_BINDINGS, Tag.TAG_COMPOUND);
+ for (int i = 0; i < bindings.size(); i++) {
+ CompoundTag binding = bindings.getCompound(i);
+ long instanceId = binding.getLong(CONTROLLER_INSTANCE_ID);
+ if (instanceId > 0 && binding.contains(CONTROLLER_POS, Tag.TAG_LONG)) {
+ controllerBindings.put(BlockPos.of(binding.getLong(CONTROLLER_POS)), instanceId);
+ }
+ }
+ }
+
+ /**
+ * Reconcile persisted owner pairs without requesting any owner chunk. Unknown registry state, an unloaded owner,
+ * and a controller still waiting for structure validation are deliberately inconclusive and retain ownership.
+ */
+ public void reconcileControllerBindings(MultiblockWorldSavedData savedData) {
+ for (var entry : List.copyOf(controllerBindings.entrySet())) {
+ BlockPos controllerPos = entry.getKey();
+ long instanceId = entry.getValue();
+ var status = savedData.getControllerBindingStatus(controllerPos, instanceId);
+ if (status == MultiblockWorldSavedData.ControllerBindingStatus.RETIRED) {
+ retireControllerBinding(controllerPos, instanceId);
+ continue;
+ }
+ IMultiController controller = savedData.getLoadedControllerInstance(controllerPos, instanceId);
+ if (controller == null) {
+ // Chunk NBT and SavedData are not saved atomically. Even an UNKNOWN pair can be newer than the
+ // registry and then outlive a controller whose deletion chunk was saved first. Once this position is
+ // entity-ticking, the absence of the exact ownership epoch is positive removal/replacement evidence. A
+ // missing or not-yet-ticking chunk remains inconclusive and never gets requested by this check.
+ if (savedData.isControllerPositionLoadedNoChunkRequest(controllerPos)) {
+ retireControllerBinding(controllerPos, instanceId);
+ }
+ continue;
+ }
+ if (controller.isStructureRevalidationPending()) {
+ continue;
+ }
+ if (!controller.isStructureFormedSnapshot() || controller.isStructureOperational() &&
+ !controller.self().hasRuntimePart(this)) {
+ retireControllerBinding(controllerPos, instanceId);
+ }
+ }
+ updateControllerBindingSubscription();
+ }
+
+ private void reconcileRetiredControllerBindings(MultiblockWorldSavedData savedData) {
+ for (var entry : List.copyOf(controllerBindings.entrySet())) {
+ if (savedData.getControllerBindingStatus(entry.getKey(), entry.getValue()) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.RETIRED) {
+ retireControllerBinding(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+
+ private void tickControllerBindings() {
+ if (!controllerBindingReconciliationArmed) {
+ // Block entities in the same freshly loaded chunk may not all have completed onLoad yet. Never interpret
+ // that first transient tick as evidence that an ACTIVE controller is absent.
+ controllerBindingReconciliationArmed = true;
+ return;
+ }
+ if (!controllerBindingInitialReconciliationComplete) {
+ // The second tick is already past the same-chunk onLoad window. Reconcile immediately instead of waiting
+ // up to another 20 ticks with a stale source or capability still attached to a positively absent owner.
+ controllerBindingInitialReconciliationComplete = true;
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ reconcileControllerBindings(MultiblockWorldSavedData.getOrCreate(serverLevel));
+ }
+ return;
+ }
+ if (getOffsetTimer() % 20 == 0 && getLevel() instanceof ServerLevel serverLevel) {
+ reconcileControllerBindings(MultiblockWorldSavedData.getOrCreate(serverLevel));
+ }
+ }
+
+ private void updateControllerBindingSubscription() {
+ if (!isRemote() && !controllerBindings.isEmpty()) {
+ controllerBindingSubscription = subscribeServerTick(controllerBindingSubscription,
+ this::tickControllerBindings);
+ } else if (controllerBindingSubscription != null) {
+ controllerBindingSubscription.unsubscribe();
+ controllerBindingSubscription = null;
+ controllerBindingReconciliationArmed = false;
+ controllerBindingInitialReconciliationComplete = false;
+ }
+ }
+
+ public long getControllerBindingInstanceId(BlockPos controllerPos) {
+ return controllerBindings.getOrDefault(controllerPos, 0L);
+ }
+
//////////////////////////////////////
// *** Multiblock LifeCycle ***//
//////////////////////////////////////
@@ -134,21 +298,76 @@ public void onUnload() {
@MustBeInvokedByOverriders
@Override
public void removedFromController(IMultiController controller) {
- controllerPositions.remove(controller.self().getPos());
+ BlockPos controllerPos = controller.self().getPos();
+ long instanceId = controller.getStructureInstanceId();
+ Long boundInstance = controllerBindings.get(controllerPos);
+ unlinkRuntimeController(controller);
+ if (boundInstance != null && instanceId > 0 && boundInstance == instanceId) {
+ controllerBindings.remove(controllerPos);
+ markDirty();
+ updateControllerBindingSubscription();
+ if (!isFormed()) {
+ onControllerBindingRetired(controllerPos, instanceId);
+ }
+ }
+ }
+
+ @Override
+ public void unloadedFromController(IMultiController controller) {
+ unlinkRuntimeController(controller);
+ }
+
+ private void unlinkRuntimeController(IMultiController controller) {
+ BlockPos controllerPos = controller.self().getPos();
controllers.remove(controller);
+ if (controllers.stream().noneMatch(candidate -> candidate.self().getPos().equals(controllerPos))) {
+ controllerPositions.remove(controllerPos);
+ }
- if (controllers.isEmpty()) {
- MachineRenderState renderState = getRenderState();
- if (renderState.hasProperty(GTMachineModelProperties.IS_FORMED)) {
- setRenderState(renderState.setValue(GTMachineModelProperties.IS_FORMED, false));
- }
+ updateFormedRenderState();
+ }
+
+ private void retireControllerBinding(BlockPos controllerPos, long instanceId) {
+ Long boundInstance = controllerBindings.get(controllerPos);
+ if (boundInstance == null || boundInstance != instanceId) {
+ return;
+ }
+ controllerBindings.remove(controllerPos);
+ controllers.removeIf(controller -> controller.self().getPos().equals(controllerPos) &&
+ controller.getStructureInstanceId() == instanceId);
+ if (controllers.stream().noneMatch(controller -> controller.self().getPos().equals(controllerPos))) {
+ controllerPositions.remove(controllerPos);
+ }
+ markDirty();
+ updateFormedRenderState();
+ updateControllerBindingSubscription();
+ onControllerBindingRetired(controllerPos, instanceId);
+ }
+
+ private void updateFormedRenderState() {
+ MachineRenderState renderState = getRenderState();
+ if (renderState.hasProperty(GTMachineModelProperties.IS_FORMED)) {
+ setRenderState(renderState.setValue(GTMachineModelProperties.IS_FORMED, isFormed()));
}
}
@MustBeInvokedByOverriders
@Override
public void addedToController(IMultiController controller) {
- controllerPositions.add(controller.self().getPos());
+ BlockPos controllerPos = controller.self().getPos().immutable();
+ long instanceId = controller.getStructureInstanceId();
+ if (getLevel() instanceof ServerLevel serverLevel) {
+ instanceId = MultiblockWorldSavedData.getOrCreate(serverLevel).ensureControllerActive(controller);
+ }
+ Long previousInstance = controllerBindings.get(controllerPos);
+ if (previousInstance != null && instanceId > 0 && previousInstance != instanceId) {
+ retireControllerBinding(controllerPos, previousInstance);
+ }
+ if (instanceId > 0 && !Long.valueOf(instanceId).equals(controllerBindings.put(controllerPos, instanceId))) {
+ markDirty();
+ }
+ updateControllerBindingSubscription();
+ controllerPositions.add(controllerPos);
controllers.add(controller);
MachineRenderState renderState = getRenderState();
diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/MultiblockComputationPortTrait.java b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/MultiblockComputationPortTrait.java
index 2c1e467d9a4..4f1711efd1a 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/MultiblockComputationPortTrait.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/MultiblockComputationPortTrait.java
@@ -34,6 +34,7 @@ public Optional getComputationConsumer() {
private Optional findProducer() {
if (!(machine instanceof IMultiPart part) || !part.isFormed()) return Optional.empty();
for (IMultiController controller : part.getControllers()) {
+ if (!controller.isStructureOperational()) continue;
if (controller instanceof ComputationProducer producer) {
return Optional.of(producer);
}
@@ -49,6 +50,7 @@ private Optional findProducer() {
private Optional findConsumer() {
if (!(machine instanceof IMultiPart part) || !part.isFormed()) return Optional.empty();
for (IMultiController controller : part.getControllers()) {
+ if (!controller.isStructureOperational()) continue;
if (controller instanceof ComputationConsumer consumer) {
return Optional.of(consumer);
}
diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java
index c0b036c1635..5d749baad81 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/RecipeLogic.java
@@ -141,6 +141,10 @@ public RecipeManager getRecipeManager() {
@Override
public void serverTick() {
+ if (!workMachine.isWorkLogicAvailable()) {
+ unsubscribeTick();
+ return;
+ }
if (!isSuspend()) {
if (!isIdle() && lastRecipe != null) {
if (progress < duration) {
diff --git a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/WorkLogic.java b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/WorkLogic.java
index c4f2e5f2156..7f76e9afae7 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/machine/trait/WorkLogic.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/machine/trait/WorkLogic.java
@@ -5,6 +5,7 @@
import com.gregtechceu.gtceu.api.machine.TickableSubscription;
import com.gregtechceu.gtceu.api.machine.feature.IComputationProgressMachine;
import com.gregtechceu.gtceu.api.machine.feature.IWorkLogicMachine;
+import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController;
import com.gregtechceu.gtceu.api.machine.property.GTMachineModelProperties;
import com.gregtechceu.gtceu.api.machine.trait.feature.IMultiblockMachineTrait;
@@ -103,6 +104,10 @@ public void unsubscribeTick() {
}
public void serverTick() {
+ if (!workMachine.isWorkLogicAvailable()) {
+ unsubscribeTick();
+ return;
+ }
if (!isSuspend()) {
workMachine.serverRunningTick();
}
@@ -132,7 +137,7 @@ public void setWaiting(@Nullable Component reason) {
protected void onWaiting() {}
public final boolean isWorking() {
- return status == Status.WORKING;
+ return status == Status.WORKING && isStructureOperational();
}
public final boolean isIdle() {
@@ -158,7 +163,11 @@ public void setWorkingEnabled(boolean isWorkingAllowed) {
}
public boolean isActive() {
- return isWorking() || isWaiting();
+ return isStructureOperational() && (status == Status.WORKING || status == Status.WAITING);
+ }
+
+ private boolean isStructureOperational() {
+ return !(workMachine instanceof IMultiController controller) || controller.isStructureOperational();
}
@Override
diff --git a/src/main/java/com/gregtechceu/gtceu/api/pattern/BlockPattern.java b/src/main/java/com/gregtechceu/gtceu/api/pattern/BlockPattern.java
index 7f420ec996d..8528332f2b9 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/pattern/BlockPattern.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/pattern/BlockPattern.java
@@ -90,7 +90,11 @@ public BlockPattern(TraceabilityPredicate[][][] predicatesIn, RelativeDirection[
public boolean checkPatternAt(MultiblockState worldState, boolean savePredicate) {
IMultiController controller = worldState.getController();
if (controller == null) {
- worldState.setError(new PatternStringError("no controller found"));
+ if (!worldState.world.isLoaded(worldState.controllerPos)) {
+ worldState.setError(MultiblockState.UNLOAD_ERROR);
+ } else {
+ worldState.setError(new PatternStringError("no controller found"));
+ }
return false;
}
BlockPos centerPos = controller.self().getPos();
@@ -99,13 +103,37 @@ public boolean checkPatternAt(MultiblockState worldState, boolean savePredicate)
new Direction[] { Direction.SOUTH, Direction.NORTH, Direction.EAST, Direction.WEST };
Direction upwardsFacing = controller.self().getUpwardsFacing();
boolean allowsFlip = controller.self().allowFlip();
+ boolean preserveOwnedFlip = allowsFlip && controller.isStructureFormedSnapshot();
+ boolean preferredFlip = preserveOwnedFlip && controller.isStructureFlippedSnapshot();
+ boolean sawUnloadedPosition = false;
for (Direction direction : facings) {
- boolean result = checkPatternAt(worldState, centerPos, direction, upwardsFacing, false, savePredicate);
- if (result) {
+ if (checkPatternAt(worldState, centerPos, direction, upwardsFacing, preferredFlip, savePredicate)) {
return true;
- } else if (allowsFlip) {
- return checkPatternAt(worldState, centerPos, direction, upwardsFacing, true, savePredicate);
}
+ sawUnloadedPosition |= worldState.error == MultiblockState.UNLOAD_ERROR;
+ if (allowsFlip) {
+ // A formed controller owns one persisted mirrored orientation. If that exact candidate cannot be
+ // inspected, accepting the opposite orientation could orphan its unloaded parts and addon state.
+ if (preserveOwnedFlip && sawUnloadedPosition) {
+ worldState.setError(MultiblockState.UNLOAD_ERROR);
+ return false;
+ }
+ if (checkPatternAt(worldState, centerPos, direction, upwardsFacing, !preferredFlip, savePredicate)) {
+ return true;
+ }
+ sawUnloadedPosition |= worldState.error == MultiblockState.UNLOAD_ERROR;
+ if (sawUnloadedPosition) {
+ worldState.setError(MultiblockState.UNLOAD_ERROR);
+ }
+ // Preserve the existing flippable-controller search space. Expanding it to all four facings would turn
+ // one queued validation into as many as eight complete structure scans.
+ return false;
+ }
+ }
+ if (sawUnloadedPosition) {
+ // A later orientation mismatch must not turn an earlier unloaded-chunk result into a definitive structure
+ // failure. Any uninspectable candidate can still be the valid orientation once its chunks return.
+ worldState.setError(MultiblockState.UNLOAD_ERROR);
}
return false;
}
@@ -117,6 +145,11 @@ public int[] getDimensions() {
public boolean checkPatternAt(MultiblockState worldState, BlockPos centerPos, Direction frontFacing,
Direction upwardsFacing, boolean isFlipped, boolean savePredicate) {
+ IMultiController controller = worldState.getController();
+ if (controller == null) {
+ worldState.setError(new PatternStringError("no controller found"));
+ return false;
+ }
boolean findFirstAisle = false;
int minZ = -centerOffset[4];
worldState.clean();
@@ -149,7 +182,7 @@ public boolean checkPatternAt(MultiblockState worldState, BlockPos centerPos, Di
machineBlockEntity.getMetaMachine() instanceof IMultiPart part) { // add detected parts
if (!predicate.isAny()) {
if (part.isFormed() && !part.canShared() &&
- !part.hasController(worldState.controllerPos)) { // check part can be shared
+ !part.hasController(controller)) { // check part can be shared
canPartShared = false;
worldState.setError(new PatternStringError("multiblocked.pattern.error.share"));
} else {
diff --git a/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockState.java b/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockState.java
index 5e3befeff39..6e815b1c00c 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockState.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockState.java
@@ -12,6 +12,7 @@
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
@@ -51,8 +52,13 @@ public class MultiblockState {
public final BlockPos controllerPos;
public IMultiController lastController;
- @Getter
private Long2ObjectOpenHashMap predicateMap;
+ private LongSet cachedChunks;
+ private boolean workingCacheExposed;
+ @Nullable
+ private Long2ObjectOpenHashMap confirmedPredicateMap;
+ @Nullable
+ private LongSet confirmedCachedChunks;
public MultiblockState(Level world, BlockPos controllerPos) {
this.world = world;
@@ -67,6 +73,8 @@ public void clean() {
this.layerCount = new Object2IntOpenHashMap<>();
predicateMap = new Long2ObjectOpenHashMap<>();
+ cachedChunks = new LongOpenHashSet();
+ workingCacheExposed = false;
}
public Map getPosPredicateMap() {
@@ -77,6 +85,16 @@ public Map getPosPredicateMap() {
Long2ObjectMap.Entry::getValue));
}
+ /**
+ * Return the mutable working predicate map retained by the historical API. If the working cache is currently the
+ * last confirmed cache, detach it first so an addon mutating the returned map cannot corrupt the confirmed shape.
+ */
+ public Long2ObjectOpenHashMap getPredicateMap() {
+ detachWorkingPatternCache();
+ workingCacheExposed = true;
+ return predicateMap;
+ }
+
public boolean update(BlockPos posIn, TraceabilityPredicate predicate) {
this.pos = posIn;
this.blockState = null;
@@ -84,6 +102,10 @@ public boolean update(BlockPos posIn, TraceabilityPredicate predicate) {
this.tileEntityInitialized = false;
this.predicate = predicate;
this.error = null;
+ if (world.isOutsideBuildHeight(posIn)) {
+ error = UNINIT_ERROR;
+ return false;
+ }
if (!world.isLoaded(posIn)) {
error = UNLOAD_ERROR;
return false;
@@ -109,7 +131,9 @@ public boolean hasError() {
public void setError(PatternError error) {
this.error = error;
- if (error != null) {
+ if (error != null && error != UNLOAD_ERROR && error != UNINIT_ERROR) {
+ // These two errors are static identity sentinels. Binding either one to this state would keep its Level
+ // reachable from a static root after a server world is closed.
error.setWorldState(this);
}
}
@@ -152,65 +176,138 @@ public BlockState getOffsetState(Direction face) {
}
public LongSet getCache() {
+ // keySet() is mutable (remove/clear write through to predicateMap), so preserve that API while protecting the
+ // confirmed alias. Internal read-only mapping code uses getCacheForMapping() and avoids this copy-on-access.
+ detachWorkingPatternCache();
+ workingCacheExposed = true;
return predicateMap.keySet();
}
+ /** Read-only-by-convention internal view used while installing a mapping. */
+ LongSet getCacheForMapping() {
+ return predicateMap == null ? LongSets.EMPTY_SET : predicateMap.keySet();
+ }
+
+ /** Whether this state has a predicate cache from a successfully mapped pattern scan. */
+ public boolean hasPatternCache() {
+ return confirmedPredicateMap != null;
+ }
+
+ /**
+ * A formed controller must not switch to another legal orientation or pattern while any position belonging to its
+ * last confirmed structure is unavailable. Positions outside the build height are definitive mismatches and are
+ * deliberately left for the normal pattern scan to diagnose.
+ */
+ public boolean hasUnloadedCachedPosition() {
+ if (confirmedCachedChunks == null) {
+ return false;
+ }
+ for (long cachedChunk : confirmedCachedChunks) {
+ if (!world.hasChunk(ChunkPos.getX(cachedChunk), ChunkPos.getZ(cachedChunk))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
public void addPosCache(BlockPos pos, TraceabilityPredicate predicate) {
+ detachWorkingPatternCache();
predicateMap.put(pos.asLong(), predicate);
+ // update() rejects out-of-height positions before they can enter a normal pattern cache. Keep the guard for
+ // compatibility callers which populate caches directly so those positions remain definitive mismatches.
+ if (!world.isOutsideBuildHeight(pos)) {
+ cachedChunks.add(ChunkPos.asLong(pos.getX() >> 4, pos.getZ() >> 4));
+ }
}
public boolean isPosInCache(BlockPos pos) {
- return predicateMap.keySet().contains(pos.asLong());
+ Long2ObjectOpenHashMap cache = confirmedPredicateMap != null ?
+ confirmedPredicateMap : predicateMap;
+ return cache != null && cache.containsKey(pos.asLong());
+ }
+
+ /**
+ * Commit the cache built by the current scan as the last fully validated structure. Failed scans must never
+ * replace this snapshot, because it is the authoritative set used to protect unloaded old structure chunks.
+ */
+ public void confirmPatternCache() {
+ if (predicateMap == null) {
+ predicateMap = new Long2ObjectOpenHashMap<>();
+ }
+ if (cachedChunks == null) {
+ cachedChunks = new LongOpenHashSet();
+ }
+ if (workingCacheExposed) {
+ // A caller can retain a mutable handle obtained before this commit. Detaching only when the getter is
+ // called after commit cannot protect against that handle. Copy once at this boundary, and rebuild chunk
+ // dependencies because direct map/key-set edits bypass addPosCache(). The normal scanner never exposes a
+ // mutable handle and therefore still commits without copying.
+ predicateMap = new Long2ObjectOpenHashMap<>(predicateMap);
+ cachedChunks = new LongOpenHashSet();
+ for (long packedPos : predicateMap.keySet()) {
+ BlockPos cachedPos = BlockPos.of(packedPos);
+ if (!world.isOutsideBuildHeight(cachedPos)) {
+ cachedChunks.add(ChunkPos.asLong(cachedPos.getX() >> 4, cachedPos.getZ() >> 4));
+ }
+ }
+ workingCacheExposed = false;
+ }
+ // A successful scan is immutable until the next clean() or a compatibility caller asks for a mutable view.
+ // Keep one resident copy in the common stable state; every write entry point detaches before mutation.
+ confirmedPredicateMap = predicateMap;
+ confirmedCachedChunks = cachedChunks;
+ }
+
+ /** Restore the working cache after an incomplete scan so legacy callers also keep seeing the confirmed shape. */
+ public void restoreConfirmedPatternCache() {
+ if (confirmedPredicateMap == null || confirmedCachedChunks == null) {
+ return;
+ }
+ predicateMap = confirmedPredicateMap;
+ cachedChunks = confirmedCachedChunks;
+ workingCacheExposed = false;
+ }
+
+ private void detachWorkingPatternCache() {
+ if (predicateMap != null && predicateMap == confirmedPredicateMap) {
+ predicateMap = new Long2ObjectOpenHashMap<>(predicateMap);
+ }
+ if (cachedChunks != null && cachedChunks == confirmedCachedChunks) {
+ cachedChunks = new LongOpenHashSet(cachedChunks);
+ }
}
public void onBlockStateChanged(BlockPos pos, BlockState state) {
if (world instanceof ServerLevel serverLevel) {
+ var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel);
if (pos.equals(controllerPos)) {
- if (lastController != null) {
+ if (lastController != null && lastController.getMultiblockState() == this) {
if (!state.is(lastController.self().getBlockState().getBlock())) {
- lastController.onStructureInvalid();
- var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel);
mwsd.removeMapping(this);
+ lastController.onStructureInvalid();
}
+ } else {
+ mwsd.removeMapping(this);
}
} else {
IMultiController controller = getController();
- if (controller == null && error == UNLOAD_ERROR) {
- if (!serverLevel.isLoaded(controllerPos)) {
+ if (controller == null || controller.getMultiblockState() != this) {
+ mwsd.removeMapping(this);
+ if (controller == null && error == UNLOAD_ERROR && !serverLevel.isLoaded(controllerPos)) {
GTCEu.LOGGER.info("Controller not loaded, pos {}", controllerPos);
}
+ return;
}
- if (controller != null) {
- if (controller.shouldIgnoreChange(pos, state)) {
- return;
- }
- if (controller.isFormed()) {
- var predicate = predicateMap.get(pos.asLong());
- if (predicate != null) {
- if (!update(pos, predicate) || !predicate.test(this)) {
- controller.self().setFlipped(false);
- controller.onStructureInvalid();
- var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel);
- mwsd.removeMapping(this);
- mwsd.addAsyncLogic(controller);
- return;
- }
- }
- }
-
- if (controller.checkPatternWithLock()) {
- // refresh structure
- controller.self().setFlipped(this.neededFlip);
- controller.onStructureFormed();
- } else {
- // invalid structure
- controller.self().setFlipped(false);
- controller.onStructureInvalid();
- var mwsd = MultiblockWorldSavedData.getOrCreate(serverLevel);
- mwsd.removeMapping(this);
- mwsd.addAsyncLogic(controller);
- }
+ if (controller.shouldIgnoreChange(pos, state)) {
+ return;
}
+ // A block update may arrive in a burst (for example while a chunk restores block entities). Do not
+ // mutate this state's match context or run a full pattern scan in the notification callback. Removing
+ // the mapping coalesces subsequent updates, and the bounded server-tick queue checks the final world
+ // state once. The queue marks the controller as pending immediately, pausing work without turning a
+ // loaded block update into a synthetic chunk-unload pattern error or unlinking any parts.
+ mwsd.removeMapping(this);
+ mwsd.addAsyncLogic(controller);
}
}
}
diff --git a/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedData.java b/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedData.java
index 76d2f83152e..99d1c8eadd0 100644
--- a/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedData.java
+++ b/src/main/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedData.java
@@ -1,28 +1,60 @@
package com.gregtechceu.gtceu.api.pattern;
import com.gregtechceu.gtceu.GTCEu;
+import com.gregtechceu.gtceu.api.machine.MetaMachine;
import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.nbt.Tag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.saveddata.SavedData;
-import com.google.common.util.concurrent.ThreadFactoryBuilder;
import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import java.util.*;
-import java.util.concurrent.*;
public class MultiblockWorldSavedData extends SavedData {
+ /** A pattern scan is unbounded internally, so never start more than one from a level tick. */
+ private static final int MAX_STRUCTURE_CHECKS_PER_TICK = 1;
+ private static final int MAX_QUEUE_POLLS_PER_TICK = 64;
+ /**
+ * Soft wall-clock budget for starting structure scans. The first due scan is always allowed; this guard prevents
+ * future increases to {@link #MAX_STRUCTURE_CHECKS_PER_TICK} from accidentally turning one slow tick into a scan
+ * storm.
+ */
+ private static final long STRUCTURE_CHECK_SOFT_BUDGET_NANOS = 2_000_000L;
+ private static final int INITIAL_INVALID_RETRY_DELAY_TICKS = 20;
+ private static final int MAX_INVALID_RETRY_DELAY_TICKS = 200;
+ private static final int INITIAL_UNLOAD_READINESS_DELAY_TICKS = 20;
+ private static final int MAX_UNLOAD_READINESS_DELAY_TICKS = 100;
+ private static final int CHUNK_LOAD_RECHECK_DELAY_TICKS = 2;
+ private static final String NEXT_CONTROLLER_INSTANCE_ID = "nextControllerInstanceId";
+ private static final String CONTROLLER_INSTANCES = "controllerInstances";
+ private static final String CONTROLLER_POS = "pos";
+ private static final String CONTROLLER_INSTANCE_ID = "instanceId";
+ private static final String CONTROLLER_ACTIVE = "active";
+
public static MultiblockWorldSavedData getOrCreate(ServerLevel serverLevel) {
- return serverLevel.getDataStorage()
+ MultiblockWorldSavedData data = serverLevel.getDataStorage()
.computeIfAbsent(MultiblockWorldSavedData::new, MultiblockWorldSavedData::new, "gtceu_multiblock");
+ data.serverLevel = serverLevel;
+ return data;
}
+ public enum ControllerBindingStatus {
+ UNKNOWN,
+ ACTIVE,
+ RETIRED
+ }
+
+ private record ControllerInstance(long instanceId, boolean active) {}
+
/**
* Store all formed multiblocks' structure info
*/
@@ -31,6 +63,11 @@ public static MultiblockWorldSavedData getOrCreate(ServerLevel serverLevel) {
* Chunk pos mapping.
*/
public final Map> chunkPosMapping;
+ private final Map> mappedChunks = new IdentityHashMap<>();
+ private final Map controllerInstances = new HashMap<>();
+ private long nextControllerInstanceId = 1;
+ @Nullable
+ private ServerLevel serverLevel;
private MultiblockWorldSavedData() {
this.mapping = new Object2ObjectOpenHashMap<>();
@@ -39,103 +76,715 @@ private MultiblockWorldSavedData() {
private MultiblockWorldSavedData(CompoundTag tag) {
this();
+ nextControllerInstanceId = Math.max(1, tag.getLong(NEXT_CONTROLLER_INSTANCE_ID));
+ ListTag instances = tag.getList(CONTROLLER_INSTANCES, Tag.TAG_COMPOUND);
+ for (int i = 0; i < instances.size(); i++) {
+ CompoundTag instanceTag = instances.getCompound(i);
+ long instanceId = instanceTag.getLong(CONTROLLER_INSTANCE_ID);
+ if (instanceId <= 0 || !instanceTag.contains(CONTROLLER_POS, Tag.TAG_LONG)) {
+ continue;
+ }
+ BlockPos pos = BlockPos.of(instanceTag.getLong(CONTROLLER_POS));
+ controllerInstances.put(pos, new ControllerInstance(instanceId,
+ instanceTag.getBoolean(CONTROLLER_ACTIVE)));
+ if (instanceId < Long.MAX_VALUE) {
+ nextControllerInstanceId = Math.max(nextControllerInstanceId, instanceId + 1);
+ }
+ }
}
public Set getControllersInChunk(ChunkPos chunkPos) {
return chunkPosMapping.getOrDefault(chunkPos, Collections.emptySet());
}
- public void addMapping(MultiblockState state) {
- this.mapping.put(state.controllerPos, state);
- for (long blockPos : state.getCache()) {
- chunkPosMapping.computeIfAbsent(new ChunkPos(BlockPos.of(blockPos)), c -> new HashSet<>()).add(state);
+ /**
+ * Install a mapping only when the controller at the persisted position still owns this exact state.
+ *
+ * @return whether the mapping was installed
+ */
+ public boolean tryAddMapping(MultiblockState state) {
+ if (!(state.world instanceof ServerLevel serverLevel)) {
+ return false;
+ }
+ MetaMachine currentMachine = getLoadedMachine(serverLevel, state.controllerPos);
+ if (!(currentMachine instanceof IMultiController currentController) || currentMachine.isInValid() ||
+ currentController.getMultiblockState() != state) {
+ removeMapping(state);
+ return false;
}
+ // Custom pattern implementations are not required to call MultiblockState#getController themselves.
+ state.lastController = currentController;
+ MultiblockState previous = this.mapping.put(state.controllerPos, state);
+ if (previous != null && previous != state) {
+ removeFromChunkMappings(previous);
+ }
+ // A dynamic pattern may have changed its cache since the last successful check.
+ removeFromChunkMappings(state);
+ Set chunks = new HashSet<>();
+ chunks.add(new ChunkPos(state.controllerPos));
+ for (long blockPos : state.getCacheForMapping()) {
+ chunks.add(new ChunkPos(BlockPos.of(blockPos)));
+ }
+ mappedChunks.put(state, chunks);
+ for (ChunkPos chunk : chunks) {
+ chunkPosMapping.computeIfAbsent(chunk, c -> new HashSet<>()).add(state);
+ }
+ state.confirmPatternCache();
+ currentController.onStructureMappingInstalled(chunks.stream()
+ .mapToLong(ChunkPos::toLong)
+ .sorted()
+ .toArray());
+ return true;
}
public void removeMapping(MultiblockState state) {
- this.mapping.remove(state.controllerPos);
- for (Set set : chunkPosMapping.values()) {
- set.remove(state);
+ if (this.mapping.get(state.controllerPos) == state) {
+ this.mapping.remove(state.controllerPos);
+ }
+ removeFromChunkMappings(state);
+ }
+
+ private void removeFromChunkMappings(MultiblockState state) {
+ Set chunks = mappedChunks.remove(state);
+ if (chunks == null) {
+ return;
+ }
+ for (ChunkPos chunk : chunks) {
+ Set states = chunkPosMapping.get(chunk);
+ if (states == null) {
+ continue;
+ }
+ states.removeIf(candidate -> candidate == state);
+ if (states.isEmpty()) {
+ chunkPosMapping.remove(chunk);
+ }
}
}
@NotNull
@Override
public CompoundTag save(@NotNull CompoundTag compound) {
+ compound.putLong(NEXT_CONTROLLER_INSTANCE_ID, nextControllerInstanceId);
+ ListTag instances = new ListTag();
+ for (var entry : controllerInstances.entrySet()) {
+ CompoundTag instanceTag = new CompoundTag();
+ instanceTag.putLong(CONTROLLER_POS, entry.getKey().asLong());
+ instanceTag.putLong(CONTROLLER_INSTANCE_ID, entry.getValue().instanceId());
+ instanceTag.putBoolean(CONTROLLER_ACTIVE, entry.getValue().active());
+ instances.add(instanceTag);
+ }
+ compound.put(CONTROLLER_INSTANCES, instances);
return compound;
}
- // ********************************* thread for searching ********************************* //
- private final CopyOnWriteArrayList controllers = new CopyOnWriteArrayList<>();
- private ScheduledExecutorService executorService;
- private final static ThreadFactory THREAD_FACTORY = new ThreadFactoryBuilder()
- .setNameFormat("GTCEu Multiblock Async Thread-%d")
- .setDaemon(true)
- .build();
- private static final ThreadLocal IN_SERVICE = ThreadLocal.withInitial(() -> false);
- private long periodID = Long.MIN_VALUE;
+ /**
+ * Ensure that a loaded base controller has a current, persistent structure-ownership epoch. A stale identity can
+ * never reactivate a tombstone or replace a newer epoch at the same position.
+ *
+ * @return the active identity
+ */
+ public long ensureControllerActive(IMultiController controller) {
+ BlockPos pos = controller.self().getPos().immutable();
+ ControllerInstance current = controllerInstances.get(pos);
+ long requested = controller.getStructureInstanceId();
+ boolean adoptActiveRegistryIdentity = requested <= 0 && current != null && current.active();
+ boolean allocate = !adoptActiveRegistryIdentity && (requested <= 0 || current != null &&
+ (requested < current.instanceId() || requested == current.instanceId() && !current.active()));
+ long activeId = adoptActiveRegistryIdentity ? current.instanceId() : requested;
+ if (allocate) {
+ activeId = allocateControllerInstanceId(current);
+ }
+ if (allocate || adoptActiveRegistryIdentity) {
+ controller.setStructureInstanceId(activeId);
+ controller.self().markDirty();
+ } else if (requested < Long.MAX_VALUE) {
+ nextControllerInstanceId = Math.max(nextControllerInstanceId, requested + 1);
+ }
+
+ ControllerInstance active = new ControllerInstance(activeId, true);
+ if (!active.equals(current)) {
+ controllerInstances.put(pos, active);
+ setDirty();
+ }
+ return activeId;
+ }
+
+ private long allocateControllerInstanceId(@Nullable ControllerInstance current) {
+ long minimum = current == null || current.instanceId() == Long.MAX_VALUE ? 1 : current.instanceId() + 1;
+ long allocated = Math.max(nextControllerInstanceId, minimum);
+ if (allocated <= 0 || allocated == Long.MAX_VALUE) {
+ throw new IllegalStateException("Exhausted multiblock controller ownership epoch identities");
+ }
+ nextControllerInstanceId = allocated + 1;
+ setDirty();
+ return allocated;
+ }
- public void createExecutorService() {
- if (executorService != null && !executorService.isShutdown()) return;
- executorService = Executors.newSingleThreadScheduledExecutor(THREAD_FACTORY);
- executorService.scheduleAtFixedRate(this::searchingTask, 0, 250, TimeUnit.MILLISECONDS); // per 5 tick
+ /** Mark a controller ownership epoch as definitively ended. Chunk unloads must never call this method. */
+ public void retireController(IMultiController controller) {
+ retireControllerPosition(controller.self().getPos(), controller.getStructureInstanceId());
}
/**
- * add a async logic runnable
+ * Mark a controller position definitively unowned, retaining the highest identity known by either SavedData or the
+ * controller chunk. This is used only when the loaded controller block is known to be unformed or destroyed; it
+ * closes both directions of non-atomic chunk/SavedData save ordering without reviving an older epoch.
+ */
+ public void retireControllerPosition(BlockPos controllerPos, long knownInstanceId) {
+ BlockPos immutablePos = controllerPos.immutable();
+ ControllerInstance current = controllerInstances.get(immutablePos);
+ long retiredInstanceId = Math.max(Math.max(0, knownInstanceId), current == null ? 0 : current.instanceId());
+ if (retiredInstanceId <= 0) {
+ return;
+ }
+ ControllerInstance retired = new ControllerInstance(retiredInstanceId, false);
+ if (!retired.equals(current)) {
+ controllerInstances.put(immutablePos, retired);
+ if (retiredInstanceId < Long.MAX_VALUE) {
+ nextControllerInstanceId = Math.max(nextControllerInstanceId, retiredInstanceId + 1);
+ }
+ setDirty();
+ }
+ }
+
+ /**
+ * Mark one exact position/epoch pair retired without overwriting a newer epoch already registered at that position.
+ */
+ public void retireControllerBinding(BlockPos controllerPos, long instanceId) {
+ if (instanceId <= 0) {
+ return;
+ }
+ BlockPos immutablePos = controllerPos.immutable();
+ ControllerInstance current = controllerInstances.get(immutablePos);
+ if (current != null && current.instanceId() > instanceId) {
+ return;
+ }
+ ControllerInstance retired = new ControllerInstance(instanceId, false);
+ if (!retired.equals(current)) {
+ controllerInstances.put(immutablePos, retired);
+ if (instanceId < Long.MAX_VALUE) {
+ nextControllerInstanceId = Math.max(nextControllerInstanceId, instanceId + 1);
+ }
+ setDirty();
+ }
+ }
+
+ /** Pure persisted-registry lookup; this method never requests or loads a chunk. */
+ public ControllerBindingStatus getControllerBindingStatus(BlockPos controllerPos, long instanceId) {
+ if (instanceId <= 0) {
+ return ControllerBindingStatus.UNKNOWN;
+ }
+ ControllerInstance current = controllerInstances.get(controllerPos);
+ if (current == null) {
+ return ControllerBindingStatus.UNKNOWN;
+ }
+ if (instanceId < current.instanceId()) {
+ return ControllerBindingStatus.RETIRED;
+ }
+ if (instanceId > current.instanceId()) {
+ // A chunk can be saved after this SavedData during a crash. A newer owner id is therefore not positive
+ // retirement evidence until that controller is loaded and advances the registry.
+ return ControllerBindingStatus.UNKNOWN;
+ }
+ return current.active() ? ControllerBindingStatus.ACTIVE : ControllerBindingStatus.RETIRED;
+ }
+
+ public boolean isControllerBindingRetired(BlockPos controllerPos, long instanceId) {
+ return getControllerBindingStatus(controllerPos, instanceId) == ControllerBindingStatus.RETIRED;
+ }
+
+ /**
+ * Exact loaded-controller check which uses {@code getChunkNow}; it never requests a missing owner chunk.
+ */
+ public boolean isLoadedControllerInstance(BlockPos controllerPos, long instanceId) {
+ return getLoadedControllerInstance(controllerPos, instanceId) != null;
+ }
+
+ /**
+ * Whether the controller position's chunk is already loaded and entity-ticking. Requiring both states prevents a
+ * FULL-but-not-yet-ticking chunk from looking like positive evidence that its controller block entity is absent.
+ * This method never requests the chunk.
+ */
+ public boolean isControllerPositionLoadedNoChunkRequest(BlockPos controllerPos) {
+ return serverLevel != null && serverLevel.getChunkSource()
+ .getChunkNow(controllerPos.getX() >> 4, controllerPos.getZ() >> 4) != null &&
+ serverLevel.shouldTickBlocksAt(controllerPos);
+ }
+
+ /**
+ * Return the exact loaded controller ownership epoch, or {@code null}. The lookup uses {@code getChunkNow} and
+ * never
+ * requests a missing owner chunk; callers may apply controller-specific membership rules to the result.
+ */
+ @Nullable
+ public IMultiController getLoadedControllerInstance(BlockPos controllerPos, long instanceId) {
+ if (instanceId <= 0 || serverLevel == null) {
+ return null;
+ }
+ MetaMachine machine = getLoadedMachine(serverLevel, controllerPos);
+ if (machine instanceof IMultiController controller && !machine.isInValid() &&
+ controller.getStructureInstanceId() == instanceId) {
+ return controller;
+ }
+ return null;
+ }
+
+ /**
+ * Whether a loaded, fully validated controller ownership epoch currently claims a cached structure position. This
+ * is
+ * an exact positive check; {@code false} may also mean that either chunk is merely unavailable.
+ */
+ public boolean isLoadedValidatedControllerMember(BlockPos controllerPos, long instanceId, BlockPos memberPos) {
+ if (getControllerBindingStatus(controllerPos, instanceId) != ControllerBindingStatus.ACTIVE ||
+ !isLoadedControllerInstance(controllerPos, instanceId)) {
+ return false;
+ }
+ MultiblockState state = mapping.get(controllerPos);
+ IMultiController controller = state == null ? null : state.lastController;
+ return controller != null && controller.isStructureOperational() &&
+ controller.getStructureInstanceId() == instanceId &&
+ controller.getMultiblockState() == state && state.hasPatternCache() && state.isPosInCache(memberPos);
+ }
+
+ // ******************************** structure check queue ******************************** //
+ private final Map controllers = new IdentityHashMap<>();
+ private final Set inFlightChecks = Collections.newSetFromMap(new IdentityHashMap<>());
+ private final NavigableSet controllerQueue = new TreeSet<>(Comparator
+ .comparingLong((PendingStructureCheck pending) -> pending.nextCheckTick)
+ .thenComparingLong(pending -> pending.sequence));
+ private final Map> checksWaitingForChunk = new HashMap<>();
+ private final Set checksWaitingForAnyChunk = Collections
+ .newSetFromMap(new IdentityHashMap<>());
+ private final Map> checksWaitingForBlockChange = new HashMap<>();
+ private long periodID;
+ private long nextSequence;
+
+ private static final class PendingStructureCheck {
+
+ private final IMultiController controller;
+ private long nextCheckTick;
+ private long sequence;
+ private int invalidRetryCount;
+ private int unloadReadinessRetryCount;
+ private boolean queued;
+ private boolean parkedForChunkLoad;
+ private boolean wakeRequested;
+ private final Set awaitedChunks = new HashSet<>();
+ private final Set blockWakeChunks = new HashSet<>();
+
+ private PendingStructureCheck(IMultiController controller, long nextCheckTick, long sequence) {
+ this.controller = controller;
+ this.nextCheckTick = nextCheckTick;
+ this.sequence = sequence;
+ this.queued = true;
+ }
+ }
+
+ /**
+ * Queue a controller for a structure check. All accesses are made from the server thread and the identity set makes
+ * repeated unload notifications idempotent.
*
* @param controller controller
*/
public void addAsyncLogic(IMultiController controller) {
- controllers.add(controller);
- createExecutorService();
+ controller.setStructureRevalidationPending(true);
+ PendingStructureCheck pending = controllers.get(controller);
+ if (pending == null) {
+ pending = new PendingStructureCheck(controller, periodID + 1, nextSequence++);
+ controllers.put(controller, pending);
+ controllerQueue.add(pending);
+ } else
+ if (!pending.parkedForChunkLoad || controller.getMultiblockState().error != MultiblockState.UNLOAD_ERROR) {
+ // A mapped block update or a manual controller notification should not wait for an old exponential
+ // retry.
+ // Repeated unload callbacks, however, must not wake a controller before its missing chunk returns.
+ wakeStructureCheck(pending, 1, true);
+ }
}
/**
- * remove async controller
+ * Remove a queued controller.
*
* @param controller controller
*/
public void removeAsyncLogic(IMultiController controller) {
- if (controllers.contains(controller)) {
- controllers.remove(controller);
- if (controllers.isEmpty()) {
- releaseExecutorService();
- }
+ PendingStructureCheck removed = controllers.remove(controller);
+ if (removed != null) {
+ unregisterWakeIndexes(removed);
+ removed.queued = false;
+ removed.parkedForChunkLoad = false;
+ }
+ if (controllers.isEmpty()) {
+ controllerQueue.clear();
+ checksWaitingForChunk.clear();
+ checksWaitingForAnyChunk.clear();
+ checksWaitingForBlockChange.clear();
+ } else if (removed != null && controllerQueue.size() > Math.max(64, controllers.size() * 2)) {
+ // Identity tokens make cancellation O(1), but cancelled tokens remain in the ordered queue until polled.
+ // Rebuild occasionally so a world with at least one permanently unloaded controller cannot accumulate
+ // stale tokens forever as other structures repeatedly form and unload. A token currently executing is
+ // still present in controllers, but must not be copied back into the queue: tick() alone owns its eventual
+ // requeue.
+ controllerQueue.clear();
+ controllers.values().stream()
+ .filter(pending -> !inFlightChecks.contains(pending))
+ .forEach(pending -> {
+ pending.queued = true;
+ controllerQueue.add(pending);
+ });
}
}
- private void searchingTask() {
- try {
- if (!GTCEu.canGetServerLevel()) return;
- IN_SERVICE.set(true);
- for (var controller : controllers) {
- try {
- controller.asyncCheckPattern(periodID);
- } catch (Throwable e) {
- GTCEu.LOGGER.error("Error while assembling multiblock {}: {}", controller, e.getMessage());
+ /**
+ * Process pending checks from the level tick. Pattern matching reads mutable world and block-entity state, so it
+ * must never run on a wall-clock executor. At most one full scan starts per tick. Definitive failures use bounded
+ * exponential backoff, while unloaded structures are parked until a relevant chunk-load event rather than scanned
+ * every 20 ticks. Both pattern checks and discarded stale queue tokens are bounded per tick.
+ */
+ public void tick() {
+ periodID++;
+ long tickStartNanos = System.nanoTime();
+ int polls = 0;
+ int checks = 0;
+ while (checks < MAX_STRUCTURE_CHECKS_PER_TICK && polls < MAX_QUEUE_POLLS_PER_TICK &&
+ !controllerQueue.isEmpty() && controllerQueue.first().nextCheckTick <= periodID &&
+ (checks == 0 || System.nanoTime() - tickStartNanos < STRUCTURE_CHECK_SOFT_BUDGET_NANOS)) {
+ PendingStructureCheck pending = controllerQueue.pollFirst();
+ pending.queued = false;
+ polls++;
+ IMultiController controller = pending.controller;
+ if (controllers.get(controller) != pending) {
+ continue;
+ }
+ var machine = controller.self();
+ if (machine.isInValid() || !(machine.getLevel() instanceof ServerLevel serverLevel) ||
+ getLoadedMachine(serverLevel, machine.getPos()) != machine) {
+ controllers.remove(controller);
+ unregisterWakeIndexes(pending);
+ removeMapping(controller.getMultiblockState());
+ continue;
+ }
+ if (pending.parkedForChunkLoad) {
+ if (!areAwaitedChunksReady(pending)) {
+ scheduleParkedReadiness(pending, nextUnloadReadinessDelay(pending));
+ continue;
+ }
+ unregisterChunkWake(pending);
+ pending.parkedForChunkLoad = false;
+ }
+ unregisterBlockWake(pending);
+ checks++;
+ inFlightChecks.add(pending);
+ try {
+ controller.asyncCheckPattern(periodID);
+ } catch (Throwable e) {
+ GTCEu.LOGGER.error("Error while assembling multiblock {}", controller, e);
+ } finally {
+ inFlightChecks.remove(pending);
+ }
+ // A remove-then-add during the callback installs a new identity token. Never append this stale token too.
+ if (controllers.get(controller) == pending) {
+ if (pending.wakeRequested) {
+ pending.wakeRequested = false;
+ wakeStructureCheck(pending, 1, true);
+ } else if (controller.getMultiblockState().error == MultiblockState.UNLOAD_ERROR) {
+ parkUntilChunkLoad(pending, null);
+ } else {
+ scheduleInvalidRetry(pending);
}
}
- } catch (Throwable e) {
- GTCEu.LOGGER.error("Error while assembling multiblocks: {}", e.getMessage());
- } finally {
- IN_SERVICE.set(false);
}
- periodID++;
}
- public static boolean isThreadService() {
- return IN_SERVICE.get() && GTCEu.canGetServerLevel();
+ private void scheduleInvalidRetry(PendingStructureCheck pending) {
+ pending.unloadReadinessRetryCount = 0;
+ int shift = Math.min(pending.invalidRetryCount, 30);
+ long exponentialDelay = (long) INITIAL_INVALID_RETRY_DELAY_TICKS << shift;
+ int delay = (int) Math.min(MAX_INVALID_RETRY_DELAY_TICKS, exponentialDelay);
+ pending.invalidRetryCount++;
+ registerBlockWake(pending);
+ enqueue(pending, periodID + delay);
}
- public void releaseExecutorService() {
- if (executorService != null) {
- executorService.shutdownNow();
+ private void wakeStructureCheck(PendingStructureCheck pending, int delay, boolean resetBackoff) {
+ if (controllers.get(pending.controller) != pending) {
+ return;
+ }
+ if (resetBackoff) {
+ pending.invalidRetryCount = 0;
+ pending.unloadReadinessRetryCount = 0;
+ }
+ if (inFlightChecks.contains(pending)) {
+ pending.wakeRequested = true;
+ return;
+ }
+ unregisterWakeIndexes(pending);
+ if (pending.queued) {
+ controllerQueue.remove(pending);
+ pending.queued = false;
+ }
+ pending.parkedForChunkLoad = false;
+ enqueue(pending, periodID + Math.max(1, delay));
+ }
+
+ private void enqueue(PendingStructureCheck pending, long nextCheckTick) {
+ if (pending.queued || pending.parkedForChunkLoad || controllers.get(pending.controller) != pending) {
+ return;
+ }
+ pending.nextCheckTick = nextCheckTick;
+ pending.sequence = nextSequence++;
+ pending.queued = true;
+ controllerQueue.add(pending);
+ }
+
+ private void parkUntilChunkLoad(PendingStructureCheck pending, @Nullable ChunkPos knownMissingChunk) {
+ if (controllers.get(pending.controller) != pending) {
+ return;
+ }
+ unregisterWakeIndexes(pending);
+ if (pending.queued) {
+ controllerQueue.remove(pending);
+ pending.queued = false;
+ }
+ pending.parkedForChunkLoad = true;
+ pending.invalidRetryCount = 0;
+
+ if (knownMissingChunk != null) {
+ pending.awaitedChunks.add(knownMissingChunk.toLong());
+ }
+ MultiblockState state = pending.controller.getMultiblockState();
+ long[] confirmedChunks = pending.controller.getConfirmedStructureChunks();
+ for (long chunk : confirmedChunks) {
+ pending.awaitedChunks.add(chunk);
+ }
+ if (confirmedChunks.length == 0) {
+ for (long blockPos : state.getCacheForMapping()) {
+ BlockPos pos = BlockPos.of(blockPos);
+ pending.awaitedChunks.add(ChunkPos.asLong(pos.getX() >> 4, pos.getZ() >> 4));
+ }
+ }
+ if (pending.awaitedChunks.isEmpty()) {
+ // Old persisted formed states have only controller-owned chunk metadata until their first successful scan.
+ // Wake those on the next chunk-load event; the one-scan-per-tick cap still prevents an unrelated load
+ // storm.
+ checksWaitingForAnyChunk.add(pending);
+ } else {
+ for (long chunk : pending.awaitedChunks) {
+ checksWaitingForChunk.computeIfAbsent(chunk,
+ ignored -> Collections.newSetFromMap(new IdentityHashMap<>())).add(pending);
+ }
+ }
+ scheduleParkedReadiness(pending, nextUnloadReadinessDelay(pending));
+ }
+
+ private void scheduleParkedReadiness(PendingStructureCheck pending, int delay) {
+ if (controllers.get(pending.controller) != pending || !pending.parkedForChunkLoad) {
+ return;
+ }
+ if (pending.queued) {
+ controllerQueue.remove(pending);
+ pending.queued = false;
+ }
+ pending.nextCheckTick = periodID + Math.max(1, delay);
+ pending.sequence = nextSequence++;
+ pending.queued = true;
+ controllerQueue.add(pending);
+ }
+
+ private static int nextUnloadReadinessDelay(PendingStructureCheck pending) {
+ int shift = Math.min(pending.unloadReadinessRetryCount, 30);
+ long exponentialDelay = (long) INITIAL_UNLOAD_READINESS_DELAY_TICKS << shift;
+ pending.unloadReadinessRetryCount++;
+ return (int) Math.min(MAX_UNLOAD_READINESS_DELAY_TICKS, exponentialDelay);
+ }
+
+ private boolean areAwaitedChunksReady(PendingStructureCheck pending) {
+ if (pending.awaitedChunks.isEmpty()) {
+ // Before the first confirmed geometry is available, use the bounded readiness timer for UNLOAD causes
+ // which have no corresponding Forge chunk event (for example a late entity).
+ return true;
+ }
+ for (long chunk : pending.awaitedChunks) {
+ if (!isChunkReadyForStructureCheck(chunk)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean isChunkReadyForStructureCheck(long chunk) {
+ if (serverLevel == null) {
+ return false;
+ }
+ int chunkX = ChunkPos.getX(chunk);
+ int chunkZ = ChunkPos.getZ(chunk);
+ // getChunkNow is a no-request FULL-chunk probe. A pattern-specific resource (for example a late contraption
+ // entity) can still report UNLOAD after this becomes true; that path parks again with the bounded timer below.
+ return serverLevel.getChunkSource().getChunkNow(chunkX, chunkZ) != null;
+ }
+
+ private void registerBlockWake(PendingStructureCheck pending) {
+ MultiblockState state = pending.controller.getMultiblockState();
+ pending.blockWakeChunks.add(new ChunkPos(state.controllerPos).toLong());
+ for (long blockPos : state.getCacheForMapping()) {
+ BlockPos pos = BlockPos.of(blockPos);
+ pending.blockWakeChunks.add(ChunkPos.asLong(pos.getX() >> 4, pos.getZ() >> 4));
+ }
+ for (long chunk : pending.blockWakeChunks) {
+ checksWaitingForBlockChange.computeIfAbsent(chunk,
+ ignored -> Collections.newSetFromMap(new IdentityHashMap<>())).add(pending);
+ }
+ }
+
+ private void unregisterWakeIndexes(PendingStructureCheck pending) {
+ unregisterChunkWake(pending);
+ unregisterBlockWake(pending);
+ }
+
+ private void unregisterChunkWake(PendingStructureCheck pending) {
+ checksWaitingForAnyChunk.remove(pending);
+ for (long chunk : pending.awaitedChunks) {
+ removeIndexedCheck(checksWaitingForChunk, chunk, pending);
+ }
+ pending.awaitedChunks.clear();
+ }
+
+ private void unregisterBlockWake(PendingStructureCheck pending) {
+ for (long chunk : pending.blockWakeChunks) {
+ removeIndexedCheck(checksWaitingForBlockChange, chunk, pending);
}
- executorService = null;
+ pending.blockWakeChunks.clear();
+ }
+
+ private static void removeIndexedCheck(Map> index, long key,
+ PendingStructureCheck pending) {
+ Set checks = index.get(key);
+ if (checks == null) {
+ return;
+ }
+ checks.remove(pending);
+ if (checks.isEmpty()) {
+ index.remove(key);
+ }
+ }
+
+ /**
+ * Pause every mapped structure touching a chunk that is about to unload. The reverse index is copied before any
+ * mapping is removed, and this path intentionally does not inspect blocks or block entities in the unloading
+ * chunk.
+ */
+ public void onChunkUnload(ChunkPos chunkPos) {
+ for (MultiblockState state : List.copyOf(getControllersInChunk(chunkPos))) {
+ IMultiController controller = state.lastController;
+ removeMapping(state);
+ if (controller == null || controller.getMultiblockState() != state || controller.self().isInValid()) {
+ continue;
+ }
+ state.setError(MultiblockState.UNLOAD_ERROR);
+ controller.setStructureRevalidationPending(true);
+ if (new ChunkPos(state.controllerPos).equals(chunkPos)) {
+ // The controller's own unload lifecycle removes it from the queue; do not create a doomed token.
+ continue;
+ }
+ addAsyncLogic(controller);
+ PendingStructureCheck pending = controllers.get(controller);
+ if (pending != null) {
+ // A scan cannot produce an authoritative result while this known structure chunk is absent. Park it
+ // immediately instead of spending one of the global scan slots just to rediscover the unload.
+ parkUntilChunkLoad(pending, chunkPos);
+ }
+ }
+ }
+
+ /**
+ * Wake parked structures after one of their unavailable chunks loads. Structures without known chunk dependencies
+ * are conservatively woken by the next chunk load.
+ */
+ public void onChunkLoad(ChunkPos chunkPos) {
+ long loadedChunk = chunkPos.toLong();
+ Set candidates = Collections.newSetFromMap(new IdentityHashMap<>());
+ Set exact = checksWaitingForChunk.get(loadedChunk);
+ if (exact != null) {
+ candidates.addAll(exact);
+ }
+ candidates.addAll(checksWaitingForAnyChunk);
+ for (PendingStructureCheck pending : candidates) {
+ if (controllers.get(pending.controller) != pending || !pending.parkedForChunkLoad) {
+ unregisterChunkWake(pending);
+ continue;
+ }
+ pending.unloadReadinessRetryCount = 0;
+ if (areAwaitedChunksReady(pending)) {
+ wakeStructureCheck(pending, CHUNK_LOAD_RECHECK_DELAY_TICKS, true);
+ } else {
+ // ChunkEvent.Load can precede all of a structure's other chunks. Keep this as a cheap readiness probe;
+ // do not run a full pattern scan until every confirmed structure chunk is resident.
+ scheduleParkedReadiness(pending, CHUNK_LOAD_RECHECK_DELAY_TICKS);
+ }
+ }
+ }
+
+ /**
+ * Expedite invalid-pattern retries whose previous scan touched the changed chunk. This preserves a low idle retry
+ * rate without making a player wait for the exponential timer after placing or breaking a structure block.
+ */
+ public void onBlockChanged(BlockPos pos) {
+ Set checks = checksWaitingForBlockChange.get(new ChunkPos(pos).toLong());
+ if (checks == null || checks.isEmpty()) {
+ return;
+ }
+ for (PendingStructureCheck pending : List.copyOf(checks)) {
+ wakeStructureCheck(pending, 1, true);
+ }
+ }
+
+ public void releaseExecutorService() {
+ controllers.clear();
+ controllerQueue.clear();
+ inFlightChecks.clear();
+ checksWaitingForChunk.clear();
+ checksWaitingForAnyChunk.clear();
+ checksWaitingForBlockChange.clear();
}
public long getPeriodID() {
return periodID;
}
+
+ boolean isStructureCheckPending(IMultiController controller) {
+ return controllers.containsKey(controller);
+ }
+
+ int getPendingStructureCheckCount() {
+ return controllers.size();
+ }
+
+ int getQueuedStructureCheckTokenCount() {
+ return controllerQueue.size();
+ }
+
+ int getQueuedStructureCheckTokenCount(IMultiController controller) {
+ return (int) controllerQueue.stream().filter(pending -> pending.controller == controller).count();
+ }
+
+ boolean isStructureCheckParked(IMultiController controller) {
+ PendingStructureCheck pending = controllers.get(controller);
+ return pending != null && pending.parkedForChunkLoad;
+ }
+
+ int getStructureCheckInvalidRetryCount(IMultiController controller) {
+ PendingStructureCheck pending = controllers.get(controller);
+ return pending == null ? 0 : pending.invalidRetryCount;
+ }
+
+ long getStructureCheckDelay(IMultiController controller) {
+ PendingStructureCheck pending = controllers.get(controller);
+ return pending == null || !pending.queued ? -1 : Math.max(0, pending.nextCheckTick - periodID);
+ }
+
+ @Nullable
+ private static MetaMachine getLoadedMachine(ServerLevel level, BlockPos pos) {
+ var chunk = level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4);
+ return chunk == null ? null : MetaMachine.getMachine(chunk, pos);
+ }
}
diff --git a/src/main/java/com/gregtechceu/gtceu/common/computation/ComputationNetworkManager.java b/src/main/java/com/gregtechceu/gtceu/common/computation/ComputationNetworkManager.java
index f613a521328..888a808d7f7 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/computation/ComputationNetworkManager.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/computation/ComputationNetworkManager.java
@@ -33,6 +33,11 @@ public static ComputationNetworkManager get(ServerLevel level) {
return MANAGERS.computeIfAbsent(level, ComputationNetworkManager::new);
}
+ /** Drop the strong level/port graph when a dimension closes. */
+ public static void release(ServerLevel level) {
+ MANAGERS.remove(level);
+ }
+
private final ServerLevel level;
private final Set ports = new HashSet<>();
private final List networks = new ArrayList<>();
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CentralMonitorMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CentralMonitorMachine.java
index 980ab5f9fab..9696b768088 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CentralMonitorMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CentralMonitorMachine.java
@@ -65,6 +65,12 @@
public class CentralMonitorMachine extends WorkableElectricMultiblockMachine
implements IMonitorComponent, IDataInfoProvider, IMachineLife, ICentralMonitor {
+ protected enum DimensionScanResult {
+ VALID,
+ INVALID,
+ UNLOADED
+ }
+
@Persisted
@DescSynced
@Getter
@@ -78,6 +84,8 @@ public class CentralMonitorMachine extends WorkableElectricMultiblockMachine
private final List selectedTargets = new ArrayList<>();
private MultiblockState patternFindingState;
+ @Nullable
+ private DimensionScanResult lastDimensionScanResult;
private static TraceabilityPredicate MULTI_PREDICATE = null;
@@ -190,6 +198,7 @@ protected MultiblockState getPatternFindingState() {
public boolean isValidMonitorBlock(Level level, BlockPos pos) {
if (level.isOutsideBuildHeight(pos)) return false;
+ if (!level.isLoaded(pos)) return false;
MultiblockState state = getPatternFindingState();
if (!state.update(pos, getMultiPredicate())) {
@@ -202,8 +211,12 @@ public boolean isValidMonitorBlock(Level level, BlockPos pos) {
}
public void updateStructureDimensions() {
+ lastDimensionScanResult = scanStructureDimensions();
+ }
+
+ protected DimensionScanResult scanStructureDimensions() {
Level level = getLevel();
- if (level == null) return;
+ if (level == null) return DimensionScanResult.INVALID;
Direction front = getFrontFacing();
Direction spin = getUpwardsFacing();
@@ -216,43 +229,98 @@ public void updateStructureDimensions() {
BlockPos.MutableBlockPos posRight = getPos().mutable().move(right);
BlockPos.MutableBlockPos posUp = getPos().mutable().move(up);
BlockPos.MutableBlockPos posDown = getPos().mutable().move(down);
- this.leftDist = 0;
- this.rightDist = 0;
- this.upDist = 0;
- this.downDist = 0;
- while (isValidMonitorBlock(level, posLeft)) {
+ int leftDist = 0;
+ int rightDist = 0;
+ int upDist = 0;
+ int downDist = 0;
+
+ DimensionScanResult result;
+ while ((result = scanMonitorBlock(level, posLeft)) == DimensionScanResult.VALID) {
posLeft.move(left);
leftDist++;
}
- while (isValidMonitorBlock(level, posRight)) {
+ if (result == DimensionScanResult.UNLOADED) return result;
+
+ while ((result = scanMonitorBlock(level, posRight)) == DimensionScanResult.VALID) {
posRight.move(right);
rightDist++;
}
- while (isValidMonitorBlockRow(level, posUp, leftDist, rightDist, left, right)) {
+ if (result == DimensionScanResult.UNLOADED) return result;
+
+ while ((result = scanMonitorBlockRow(level, posUp, leftDist, rightDist, left, right)) ==
+ DimensionScanResult.VALID) {
posUp.move(up);
upDist++;
}
- while (isValidMonitorBlockRow(level, posDown, leftDist, rightDist, left, right)) {
+ if (result == DimensionScanResult.UNLOADED) return result;
+
+ while ((result = scanMonitorBlockRow(level, posDown, leftDist, rightDist, left, right)) ==
+ DimensionScanResult.VALID) {
posDown.move(down);
downDist++;
}
+ if (result == DimensionScanResult.UNLOADED) return result;
+
+ if (leftDist + rightDist < 1 || upDist + downDist < 1) {
+ return DimensionScanResult.INVALID;
+ }
+
+ this.leftDist = leftDist;
+ this.rightDist = rightDist;
+ this.upDist = upDist;
+ this.downDist = downDist;
+ return DimensionScanResult.VALID;
}
- private boolean isValidMonitorBlockRow(Level level, BlockPos pos, int leftDist, int rightDist, Direction left,
- Direction right) {
+ private DimensionScanResult scanMonitorBlock(Level level, BlockPos pos) {
+ if (level.isOutsideBuildHeight(pos)) return DimensionScanResult.INVALID;
+ if (!level.isLoaded(pos)) return DimensionScanResult.UNLOADED;
+ return isValidMonitorBlock(level, pos) ? DimensionScanResult.VALID : DimensionScanResult.INVALID;
+ }
+
+ private DimensionScanResult scanMonitorBlockRow(Level level, BlockPos pos, int leftDist, int rightDist,
+ Direction left, Direction right) {
BlockPos.MutableBlockPos mutable = pos.mutable();
mutable.move(left, leftDist);
- for (int i = 0; i < leftDist + rightDist; i++) {
- if (!isValidMonitorBlock(level, mutable)) return false;
+ for (int i = 0; i <= leftDist + rightDist; i++) {
+ DimensionScanResult result = scanMonitorBlock(level, mutable);
+ if (result != DimensionScanResult.VALID) return result;
mutable.move(right);
}
- return isValidMonitorBlock(level, mutable);
+ return DimensionScanResult.VALID;
+ }
+
+ private DimensionScanResult updateStructureDimensionsForValidation() {
+ lastDimensionScanResult = null;
+ updateStructureDimensions();
+ // Keep subclasses overriding the long-standing public update hook functional. If they do not call super,
+ // their dimensions are still validated by the generated BlockPattern, just without the new early tri-state.
+ return lastDimensionScanResult == null ? DimensionScanResult.VALID : lastDimensionScanResult;
+ }
+
+ @Override
+ public boolean checkPattern() {
+ DimensionScanResult result = updateStructureDimensionsForValidation();
+ if (result != DimensionScanResult.VALID) {
+ getMultiblockState().setError(result == DimensionScanResult.UNLOADED ?
+ MultiblockState.UNLOAD_ERROR : MultiblockState.UNINIT_ERROR);
+ return false;
+ }
+ return buildPatternFromCurrentDimensions().checkPatternAt(getMultiblockState(), false);
}
@Override
public BlockPattern getPattern() {
updateStructureDimensions();
+ return buildPatternFromCurrentDimensions();
+ }
+
+ protected BlockPattern buildPatternFromCurrentDimensions() {
+ int leftDist = this.leftDist;
+ int rightDist = this.rightDist;
+ int upDist = this.upDist;
+ int downDist = this.downDist;
if (leftDist + rightDist < 1 || upDist + downDist < 1) {
leftDist = 3;
rightDist = 0;
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CleanroomMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CleanroomMachine.java
index 2fdaa126091..db86b05e9e8 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CleanroomMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/CleanroomMachine.java
@@ -23,6 +23,7 @@
import com.gregtechceu.gtceu.api.misc.EnergyContainerList;
import com.gregtechceu.gtceu.api.pattern.BlockPattern;
import com.gregtechceu.gtceu.api.pattern.FactoryBlockPattern;
+import com.gregtechceu.gtceu.api.pattern.MultiblockState;
import com.gregtechceu.gtceu.api.pattern.Predicates;
import com.gregtechceu.gtceu.api.pattern.TraceabilityPredicate;
import com.gregtechceu.gtceu.common.capability.EnvironmentalHazardSavedData;
@@ -77,6 +78,12 @@
public class CleanroomMachine extends WorkableElectricMultiblockMachine
implements ICleanroomProvider, IDisplayUIMachine, IDataInfoProvider {
+ protected enum DimensionScanResult {
+ VALID,
+ INVALID,
+ UNLOADED
+ }
+
public static final int CLEAN_AMOUNT_THRESHOLD = 95;
public static final int MIN_CLEAN_AMOUNT = 0;
@@ -87,6 +94,7 @@ public class CleanroomMachine extends WorkableElectricMultiblockMachine
@Persisted
@Getter
private int lDist = 0, rDist = 0, bDist = 0, fDist = 0, hDist = 0;
+ private DimensionScanResult lastDimensionScanResult = DimensionScanResult.INVALID;
@Nullable
private CleanroomType cleanroomType = null;
@Persisted
@@ -116,6 +124,7 @@ public void onStructureFormed() {
super.onStructureFormed();
tier = Math.min(GTValues.MAX, tier);
+ maintenanceMachine = null;
for (IMultiPart part : getParts()) {
if (part instanceof IMaintenanceMachine maintenance) {
maintenanceMachine = maintenance;
@@ -198,8 +207,12 @@ private static boolean isPartIgnored(IMultiPart part) {
* Scans for blocks around the controller to update the dimensions
*/
public void updateStructureDimensions() {
+ lastDimensionScanResult = scanStructureDimensions();
+ }
+
+ protected DimensionScanResult scanStructureDimensions() {
Level world = getLevel();
- if (world == null) return;
+ if (world == null) return DimensionScanResult.INVALID;
Direction front = getFrontFacing();
Direction back = front.getOpposite();
Direction left = front.getCounterClockWise();
@@ -222,27 +235,48 @@ public void updateStructureDimensions() {
// find the left, right, back, and front distances for the structure pattern
// maximum size is 15x15x15 including walls, so check 7 block radius around the controller for blocks
for (int i = 1; i < 8; i++) {
- if (lDist == 0 && isBlockEdge(world, lPos, left)) lDist = i;
- if (rDist == 0 && isBlockEdge(world, rPos, right)) rDist = i;
- if (bDist == 0 && isBlockEdge(world, bPos, back)) bDist = i;
- if (fDist == 0 && isBlockEdge(world, fPos, front)) fDist = i;
+ if (lDist == 0) {
+ BlockPos next = lPos.relative(left);
+ if (world.isOutsideBuildHeight(next)) return DimensionScanResult.INVALID;
+ if (!world.isLoaded(next)) return DimensionScanResult.UNLOADED;
+ if (isBlockEdge(world, lPos, left)) lDist = i;
+ }
+ if (rDist == 0) {
+ BlockPos next = rPos.relative(right);
+ if (world.isOutsideBuildHeight(next)) return DimensionScanResult.INVALID;
+ if (!world.isLoaded(next)) return DimensionScanResult.UNLOADED;
+ if (isBlockEdge(world, rPos, right)) rDist = i;
+ }
+ if (bDist == 0) {
+ BlockPos next = bPos.relative(back);
+ if (world.isOutsideBuildHeight(next)) return DimensionScanResult.INVALID;
+ if (!world.isLoaded(next)) return DimensionScanResult.UNLOADED;
+ if (isBlockEdge(world, bPos, back)) bDist = i;
+ }
+ if (fDist == 0) {
+ BlockPos next = fPos.relative(front);
+ if (world.isOutsideBuildHeight(next)) return DimensionScanResult.INVALID;
+ if (!world.isLoaded(next)) return DimensionScanResult.UNLOADED;
+ if (isBlockEdge(world, fPos, front)) fDist = i;
+ }
if (lDist != 0 && rDist != 0 && bDist != 0 && fDist != 0) break;
}
// height is diameter instead of radius, so it needs to be done separately
for (int i = 1; i < 15; i++) {
+ BlockPos next = hPos.relative(Direction.DOWN);
+ if (world.isOutsideBuildHeight(next)) return DimensionScanResult.INVALID;
+ if (!world.isLoaded(next)) return DimensionScanResult.UNLOADED;
if (isBlockFloor(world, hPos, Direction.DOWN)) hDist = i;
if (hDist != 0) break;
}
if (Math.abs(lDist - rDist) > 1 || Math.abs(bDist - fDist) > 1) {
- this.isFormed = false;
- return;
+ return DimensionScanResult.INVALID;
}
if (lDist < MIN_RADIUS || rDist < MIN_RADIUS || bDist < MIN_RADIUS || fDist < MIN_RADIUS || hDist < MIN_DEPTH) {
- this.isFormed = false;
- return;
+ return DimensionScanResult.INVALID;
}
this.lDist = lDist;
@@ -250,6 +284,17 @@ public void updateStructureDimensions() {
this.bDist = bDist;
this.fDist = fDist;
this.hDist = hDist;
+ return DimensionScanResult.VALID;
+ }
+
+ protected boolean isBlockEdgeAt(Level world, BlockPos pos) {
+ var state = world.getBlockState(pos);
+ return state == getCasingState() || state == getGlassState();
+ }
+
+ protected boolean isBlockFloorAt(Level world, BlockPos pos) {
+ var state = world.getBlockState(pos);
+ return state == getCasingState() || state == getGlassState() || state.is(CustomTags.CLEANROOM_FLOORS);
}
/**
@@ -259,8 +304,8 @@ public void updateStructureDimensions() {
* @return if a block is a valid wall block at pos moved in direction
*/
public boolean isBlockEdge(Level world, BlockPos.MutableBlockPos pos, Direction direction) {
- var state = world.getBlockState(pos.move(direction));
- return state == getCasingState() || state == getGlassState();
+ pos.move(direction);
+ return world.isLoaded(pos) && isBlockEdgeAt(world, pos);
}
/**
@@ -270,8 +315,22 @@ public boolean isBlockEdge(Level world, BlockPos.MutableBlockPos pos, Direction
* @return if a block is a valid floor block at pos moved in direction
*/
public boolean isBlockFloor(Level world, BlockPos.MutableBlockPos pos, Direction direction) {
- var state = world.getBlockState(pos.move(direction));
- return state == getCasingState() || state == getGlassState() || state.is(CustomTags.CLEANROOM_FLOORS);
+ pos.move(direction);
+ return world.isLoaded(pos) && isBlockFloorAt(world, pos);
+ }
+
+ @Override
+ public boolean checkPattern() {
+ // Assume compatibility implementations which override the legacy void hook completed their own scan.
+ lastDimensionScanResult = DimensionScanResult.VALID;
+ updateStructureDimensions();
+ DimensionScanResult result = lastDimensionScanResult;
+ if (result != DimensionScanResult.VALID) {
+ getMultiblockState().setError(result == DimensionScanResult.UNLOADED ?
+ MultiblockState.UNLOAD_ERROR : MultiblockState.UNINIT_ERROR);
+ return false;
+ }
+ return buildPatternFromCurrentDimensions().checkPatternAt(getMultiblockState(), false);
}
@Override
@@ -279,13 +338,16 @@ public BlockPattern getPattern() {
// return the default structure, even if there is no valid size found
// this means auto-build will still work, and prevents terminal crashes.
if (getLevel() != null) updateStructureDimensions();
+ return buildPatternFromCurrentDimensions();
+ }
+ protected BlockPattern buildPatternFromCurrentDimensions() {
// these can sometimes get set to 0 when loading the game, breaking JEI
- if (lDist < MIN_RADIUS) lDist = MIN_RADIUS;
- if (rDist < MIN_RADIUS) rDist = MIN_RADIUS;
- if (bDist < MIN_RADIUS) bDist = MIN_RADIUS;
- if (fDist < MIN_RADIUS) fDist = MIN_RADIUS;
- if (hDist < MIN_DEPTH) hDist = MIN_DEPTH;
+ int lDist = Math.max(this.lDist, MIN_RADIUS);
+ int rDist = Math.max(this.rDist, MIN_RADIUS);
+ int bDist = Math.max(this.bDist, MIN_RADIUS);
+ int fDist = Math.max(this.fDist, MIN_RADIUS);
+ int hDist = Math.max(this.hDist, MIN_DEPTH);
if (this.getFrontFacing() == Direction.EAST || this.getFrontFacing() == Direction.WEST) {
int tmp = lDist;
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/DistillationTowerMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/DistillationTowerMachine.java
index 9c476a61f69..540503cc9e5 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/DistillationTowerMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/DistillationTowerMachine.java
@@ -101,6 +101,7 @@ public void attachConfigurators(ConfiguratorPanel left, ConfiguratorPanel right)
@Override
public void onStructureFormed() {
super.onStructureFormed();
+ firstValid = null;
final int startY = getPos().getY() + yOffset;
List parts = getParts().stream()
.filter(part -> PartAbility.EXPORT_FLUIDS.isApplicable(part.self().getBlockState().getBlock()))
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/FusionReactorMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/FusionReactorMachine.java
index b2fc394de51..1d2ab082e5a 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/FusionReactorMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/FusionReactorMachine.java
@@ -144,7 +144,8 @@ public void onStructureInvalid() {
//////////////////////////////////////
protected void updatePreHeatSubscription() {
// do preheat logic for heat cool down and charge internal energy container
- if (heat > 0 || (inputEnergyContainers != null && inputEnergyContainers.getEnergyStored() > 0 &&
+ if (heat > 0 || (isStructureOperational() && inputEnergyContainers != null &&
+ inputEnergyContainers.getEnergyStored() > 0 &&
innerEnergyContainer.getEnergyStored() < innerEnergyContainer.getEnergyCapacity())) {
preHeatSubs = subscribeServerTick(preHeatSubs, this::updateHeat);
} else if (preHeatSubs != null) {
@@ -234,13 +235,13 @@ public void updateHeat() {
// Don't drain heat when there is not enough energy and there is still some recipe progress, as that makes it
// doubly hard to complete the recipe
// (Will have to recover heat and recipe progress)
- if ((getRecipeLogic().isIdle() || !isWorkingEnabled() ||
+ if ((!isStructureOperational() || getRecipeLogic().isIdle() || !isWorkingEnabled() ||
(getRecipeLogic().isWaiting() && getRecipeLogic().getProgress() == 0)) && heat > 0) {
heat = heat <= 10000 ? 0 : (heat - 10000);
}
// charge the internal energy storage
var leftStorage = innerEnergyContainer.getEnergyCapacity() - innerEnergyContainer.getEnergyStored();
- if (inputEnergyContainers != null && leftStorage > 0) {
+ if (isStructureOperational() && inputEnergyContainers != null && leftStorage > 0) {
innerEnergyContainer.addEnergy(inputEnergyContainers.removeEnergy(leftStorage));
}
updatePreHeatSubscription();
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/MultiblockTankMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/MultiblockTankMachine.java
index bb93ab8e15e..978ead9a445 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/MultiblockTankMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/MultiblockTankMachine.java
@@ -63,7 +63,7 @@ public InteractionResult onUse(BlockState state, Level world, BlockPos pos, Play
var superResult = super.onUse(state, world, pos, player, hand, hit);
if (superResult != InteractionResult.PASS) return superResult;
- if (!isFormed()) return InteractionResult.FAIL;
+ if (!isStructureOperational()) return InteractionResult.FAIL;
return InteractionResult.PASS; // Otherwise let MetaMachineBlock.use() open the UI
}
@@ -71,7 +71,7 @@ public InteractionResult onUse(BlockState state, Level world, BlockPos pos, Play
@Override
@Nullable
public IFluidHandlerModifiable getFluidHandlerCap(@Nullable Direction side, boolean useCoverCapability) {
- if (isFormed) {
+ if (isStructureOperational()) {
return super.getFluidHandlerCap(side, useCoverCapability);
}
return null;
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/gcym/LargeMacerationTowerMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/gcym/LargeMacerationTowerMachine.java
index 143f2af3fd9..0d69eea0356 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/gcym/LargeMacerationTowerMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/gcym/LargeMacerationTowerMachine.java
@@ -36,12 +36,13 @@ public LargeMacerationTowerMachine(IMachineBlockEntity holder) {
public void onStructureFormed() {
super.onStructureFormed();
updateBounds();
+ handlers.clear();
for (var holder : getCapabilitiesFlat(IO.IN, ItemRecipeCapability.CAP)) {
if (holder instanceof IItemHandler ih) {
handlers.add(ih);
}
}
- hurtSub = subscribeServerTick(this::spinWheels);
+ hurtSub = subscribeServerTick(hurtSub, this::spinWheels);
}
@Override
@@ -67,7 +68,7 @@ private void updateBounds() {
}
private void spinWheels() {
- if (isRemote() || getLevel() == null) return;
+ if (isRemote() || getLevel() == null || !isStructureOperational()) return;
if (getOffsetTimer() % 10 != 0) return;
List itemEntities = new ArrayList<>();
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java
index 027d456eb53..bb681b0ed82 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/DataBankMachine.java
@@ -71,6 +71,10 @@ protected void appendMachineJadeTooltip(CompoundTag data, ITooltip tooltip, Bloc
@Override
public void onStructureFormed() {
super.onStructureFormed();
+ dataAccesses.clear();
+ transmitters.clear();
+ receivers.clear();
+ maintenance = null;
for (IMultiPart part : getParts()) {
Block block = part.self().getBlockState().getBlock();
if (part instanceof IDataAccessMachine hatch && PartAbility.DATA_ACCESS.isApplicable(block)) {
@@ -123,6 +127,7 @@ protected int calculateEnergyUsage() {
public void onStructureInvalid() {
super.onStructureInvalid();
energyUsage = 0;
+ maintenance = null;
dataAccesses.clear();
transmitters.clear();
receivers.clear();
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java
index fcb9dfc06d5..13fba39a1f6 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/electric/research/HPCAMachine.java
@@ -90,6 +90,7 @@ public void onStructureFormed() {
super.onStructureFormed();
List coolantContainers = new ArrayList<>();
List componentHatches = new ArrayList<>();
+ maintenance = null;
for (IMultiPart part : getParts()) {
@@ -204,7 +205,7 @@ public Widget createUIWidget() {
// we need to know what components we have on the client
if (getLevel().isClientSide) {
- if (isFormed) {
+ if (isStructureOperational()) {
hpcaHandler.tryGatherClientComponents(this.getLevel(), this.getPos(), this.getFrontFacing(),
this.getUpwardsFacing(), this.isFlipped);
} else {
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/generator/LargeCombustionEngineMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/generator/LargeCombustionEngineMachine.java
index 9dee2807a39..29d162f26a7 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/generator/LargeCombustionEngineMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/generator/LargeCombustionEngineMachine.java
@@ -198,7 +198,7 @@ public void addDisplayText(List textList) {
builder.addFuelNeededLine(getRecipeFluidInputInfo(), recipeLogic.getMaxProgress());
- if (isFormed && isOxygenBoosted) {
+ if (isStructureOperational() && isOxygenBoosted) {
final var key = isExtreme() ? "gtceu.multiblock.large_combustion_engine.liquid_oxygen_boosted" :
"gtceu.multiblock.large_combustion_engine.oxygen_boosted";
builder.addCustom(tl -> tl.add(Component.translatable(key).withStyle(ChatFormatting.AQUA)));
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CleaningMaintenanceHatchPartMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CleaningMaintenanceHatchPartMachine.java
index 431c512283c..cf1b665a525 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CleaningMaintenanceHatchPartMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CleaningMaintenanceHatchPartMachine.java
@@ -48,6 +48,16 @@ public void addedToController(IMultiController controller) {
@Override
public void removedFromController(IMultiController controller) {
super.removedFromController(controller);
+ clearCleanroom(controller);
+ }
+
+ @Override
+ public void unloadedFromController(IMultiController controller) {
+ super.unloadedFromController(controller);
+ clearCleanroom(controller);
+ }
+
+ private void clearCleanroom(IMultiController controller) {
if (controller instanceof MetaMachine machine) {
var receiver = machine.getTrait(CleanroomReceiverTrait.class);
if (receiver != null && receiver.getCleanroom() == DUMMY_CLEANROOM) {
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CokeOvenHatch.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CokeOvenHatch.java
index c7d51cc9799..025daf907db 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CokeOvenHatch.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/CokeOvenHatch.java
@@ -48,9 +48,21 @@ public CokeOvenHatch(IMachineBlockEntity holder, Object... args) {
@Override
public void onUnload() {
super.onUnload();
+ clearControllerRuntime();
+ }
+
+ private void clearControllerRuntime() {
inputInventory.setProxy(null);
outputInventory.setProxy(null);
tank.setProxy(null);
+ unsubscribeChanges();
+ if (autoIOSubs != null) {
+ autoIOSubs.unsubscribe();
+ autoIOSubs = null;
+ }
+ }
+
+ private void unsubscribeChanges() {
if (outputInventorySubs != null) {
outputInventorySubs.unsubscribe();
outputInventorySubs = null;
@@ -65,6 +77,7 @@ public void onUnload() {
public void addedToController(IMultiController controller) {
super.addedToController(controller);
if (controller instanceof CokeOvenMachine cokeOven) {
+ unsubscribeChanges();
outputInventorySubs = cokeOven.exportItems.addChangedListener(this::updateAutoIOSubscription);
outputTankSubs = cokeOven.exportFluids.addChangedListener(this::updateAutoIOSubscription);
inputInventory.setProxy(cokeOven.importItems);
@@ -77,17 +90,13 @@ public void addedToController(IMultiController controller) {
@Override
public void removedFromController(IMultiController controller) {
super.removedFromController(controller);
- inputInventory.setProxy(null);
- outputInventory.setProxy(null);
- tank.setProxy(null);
- if (outputInventorySubs != null) {
- outputInventorySubs.unsubscribe();
- outputInventorySubs = null;
- }
- if (outputTankSubs != null) {
- outputTankSubs.unsubscribe();
- outputTankSubs = null;
- }
+ clearControllerRuntime();
+ }
+
+ @Override
+ public void unloadedFromController(IMultiController controller) {
+ super.unloadedFromController(controller);
+ clearControllerRuntime();
}
@Override
@@ -117,9 +126,9 @@ public void onRotated(Direction oldFacing, Direction newFacing) {
}
protected void updateAutoIOSubscription() {
- if ((!outputInventory.isEmpty() &&
+ if (hasOperationalController() && ((!outputInventory.isEmpty() &&
GTTransferUtils.hasAdjacentItemHandler(getLevel(), getPos(), getFrontFacing())) ||
- (!tank.isEmpty() && GTTransferUtils.hasAdjacentFluidHandler(getLevel(), getPos(), getFrontFacing()))) {
+ (!tank.isEmpty() && GTTransferUtils.hasAdjacentFluidHandler(getLevel(), getPos(), getFrontFacing())))) {
autoIOSubs = subscribeServerTick(autoIOSubs, this::autoIO);
} else if (autoIOSubs != null) {
autoIOSubs.unsubscribe();
@@ -128,6 +137,10 @@ protected void updateAutoIOSubscription() {
}
protected void autoIO() {
+ if (!hasOperationalController()) {
+ updateAutoIOSubscription();
+ return;
+ }
if (getOffsetTimer() % 5 == 0) {
outputInventory.exportToNearby(getFrontFacing());
tank.exportToNearby(getFrontFacing());
@@ -135,6 +148,10 @@ protected void autoIO() {
}
}
+ private boolean hasOperationalController() {
+ return controllers.stream().anyMatch(IMultiController::isStructureOperational);
+ }
+
//////////////////////////////////////
// ********* GUI *********//
//////////////////////////////////////
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/DataAccessHatchMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/DataAccessHatchMachine.java
index 537e0e61fda..ae7b38b2271 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/DataAccessHatchMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/DataAccessHatchMachine.java
@@ -67,11 +67,10 @@ protected NotifiableItemStackHandler createImportItemHandler() {
if (isCreative) return new NotifiableItemStackHandler(this, 0, IO.NONE);
var inventory = new NotifiableItemStackHandler(this, getInventorySize(), IO.NONE, IO.BOTH)
.setFilter(stack -> {
- boolean isDataBank = isFormed() && getControllers().first() instanceof DataBankMachine;
+ boolean isDataBank = hasOperationalDataBankController();
return ResearchManager.isStackDataItem(stack, isDataBank);
});
- inventory.addChangedListener(
- () -> rebuildData(isFormed() && getControllers().first() instanceof DataBankMachine));
+ inventory.addChangedListener(() -> rebuildData(hasOperationalDataBankController()));
return inventory;
}
@@ -133,7 +132,8 @@ private void rebuildData(boolean isDataBank) {
@Override
public boolean isRecipeAvailable(@NotNull GTRecipe recipe) {
- return isCreative || recipes.stream().anyMatch(definition -> definition.getId().equals(recipe.getId()));
+ return isCreative || hasOperationalController() &&
+ recipes.stream().anyMatch(definition -> definition.getId().equals(recipe.getId()));
}
@NotNull
@@ -168,8 +168,29 @@ public boolean canShared() {
@Override
public void addedToController(IMultiController controller) {
- rebuildData(controller instanceof DataBankMachine);
super.addedToController(controller);
+ rebuildData(hasOperationalDataBankController());
+ }
+
+ @Override
+ public void removedFromController(IMultiController controller) {
+ super.removedFromController(controller);
+ rebuildData(hasOperationalDataBankController());
+ }
+
+ @Override
+ public void unloadedFromController(IMultiController controller) {
+ super.unloadedFromController(controller);
+ rebuildData(hasOperationalDataBankController());
+ }
+
+ private boolean hasOperationalController() {
+ return controllers.stream().anyMatch(IMultiController::isStructureOperational);
+ }
+
+ private boolean hasOperationalDataBankController() {
+ return controllers.stream().anyMatch(controller -> controller.isStructureOperational() &&
+ controller instanceof DataBankMachine);
}
@Override
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/MufflerPartMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/MufflerPartMachine.java
index b65244e362d..9caea1cc817 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/MufflerPartMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/MufflerPartMachine.java
@@ -79,7 +79,7 @@ private boolean calculateChance() {
public void clientTick() {
super.clientTick();
for (IMultiController controller : getControllers()) {
- if (controller instanceof IWorkLogicMachine workLogicMachine &&
+ if (controller.isStructureOperational() && controller instanceof IWorkLogicMachine workLogicMachine &&
workLogicMachine.getWorkLogic().isWorking()) {
emitPollutionParticles();
break;
@@ -99,6 +99,16 @@ public void addedToController(IMultiController controller) {
@Override
public void removedFromController(IMultiController controller) {
super.removedFromController(controller);
+ unsubscribeSnowIfDetached();
+ }
+
+ @Override
+ public void unloadedFromController(IMultiController controller) {
+ super.unloadedFromController(controller);
+ unsubscribeSnowIfDetached();
+ }
+
+ private void unsubscribeSnowIfDetached() {
if (controllers.isEmpty()) {
unsubscribe(snowSubscription);
snowSubscription = null;
@@ -108,7 +118,7 @@ public void removedFromController(IMultiController controller) {
private void tryBreakSnow() {
if (getOffsetTimer() % 10 == 0) {
for (IMultiController controller : getControllers()) {
- if (controller instanceof IWorkLogicMachine workLogicMachine &&
+ if (controller.isStructureOperational() && controller instanceof IWorkLogicMachine workLogicMachine &&
workLogicMachine.getWorkLogic().isWorking()) {
BlockPos mufflerPos = getPos().relative(getFrontFacing());
GTUtil.tryBreakSnow(getLevel(), mufflerPos, getLevel().getBlockState(mufflerPos), true);
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/ObjectHolderMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/ObjectHolderMachine.java
index 3c80f5a6a27..a068058b2e9 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/ObjectHolderMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/ObjectHolderMachine.java
@@ -25,11 +25,11 @@
import com.lowdragmc.lowdraglib.utils.Position;
import net.minecraft.MethodsReturnNonnullByDefault;
+import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.items.IItemHandlerModifiable;
-import org.jetbrains.annotations.MustBeInvokedByOverriders;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -90,11 +90,18 @@ public void setFrontFacing(Direction frontFacing) {
}
}
- @MustBeInvokedByOverriders
@Override
- public void removedFromController(IMultiController controller) {
- super.removedFromController(controller);
- isLocked = false;
+ public void onControllerBindingRetired(BlockPos controllerPos, long instanceId) {
+ if (!isFormed()) {
+ isLocked = false;
+ }
+ }
+
+ @Override
+ public void unloadedFromController(IMultiController controller) {
+ // Keep the consumed research inputs locked while either side is merely unloaded. A successful revalidation
+ // reconnects this holder to the same in-progress recipe; only a definitive invalidation may unlock it.
+ super.unloadedFromController(controller);
}
private boolean isDataItemFacing(@Nullable Direction direction) {
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalComputationHatchMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalComputationHatchMachine.java
index 3597c53e5ca..a246830e8e6 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalComputationHatchMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalComputationHatchMachine.java
@@ -56,6 +56,12 @@ public void removedFromController(IMultiController controller) {
markComputationTopologyDirty();
}
+ @Override
+ public void unloadedFromController(IMultiController controller) {
+ super.unloadedFromController(controller);
+ markComputationTopologyDirty();
+ }
+
private void markComputationTopologyDirty() {
if (getLevel() instanceof ServerLevel serverLevel) {
ComputationNetworkManager.get(serverLevel).markTopologyDirty();
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalDataHatchMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalDataHatchMachine.java
index cef7a0b05f1..89ab0f378a6 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalDataHatchMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/OpticalDataHatchMachine.java
@@ -36,7 +36,7 @@ public OpticalDataHatchMachine(IMachineBlockEntity holder, boolean isTransmitter
@Override
public boolean isRecipeAvailable(@NotNull GTRecipe recipe) {
- if (!isFormed()) {
+ if (!isFormed() || getControllers().isEmpty()) {
return false;
}
if (isTransmitter()) {
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/RotorHolderPartMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/RotorHolderPartMachine.java
index 332702649a0..3c16c13a6e9 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/RotorHolderPartMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/RotorHolderPartMachine.java
@@ -159,7 +159,8 @@ protected void updateRotorSubscription() {
}
private void updateRotorSpeed() {
- if (isFormed() && getControllers().first() instanceof IWorkableMultiController workable) {
+ if (isFormed() && !getControllers().isEmpty() &&
+ getControllers().first() instanceof IWorkableMultiController workable) {
if (workable.getWorkLogic().isWorking()) return;
}
if (!hasRotor()) {
@@ -185,7 +186,8 @@ public boolean onWorking(IWorkableMultiController controller) {
}
if (self().getOffsetTimer() % 20 == 0) {
var numMaintenanceProblems = 0;
- if (isFormed() && getControllers().first() instanceof IMaintenanceMachine maintenance) {
+ if (isFormed() && !getControllers().isEmpty() &&
+ getControllers().first() instanceof IMaintenanceMachine maintenance) {
numMaintenanceProblems = maintenance.getNumMaintenanceProblems();
}
damageRotor(1 + numMaintenanceProblems);
@@ -194,7 +196,8 @@ public boolean onWorking(IWorkableMultiController controller) {
}
public int getTierDifference() {
- if (isFormed() && getControllers().first() instanceof ITieredMachine tieredMachine) {
+ if (isFormed() && !getControllers().isEmpty() &&
+ getControllers().first() instanceof ITieredMachine tieredMachine) {
return getTier() - tieredMachine.getTier();
}
return -1;
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/TankValvePartMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/TankValvePartMachine.java
index 3ab7533cd61..a6b81bc8bb8 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/TankValvePartMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/TankValvePartMachine.java
@@ -48,7 +48,7 @@ public boolean canShared() {
@Override
public void onUnload() {
super.onUnload();
- autoIOSubscription.updateSubscription();
+ clearControllerRuntime();
}
@Override
@@ -66,15 +66,26 @@ public void addedToController(IMultiController controller) {
@Override
public void removedFromController(IMultiController controller) {
super.removedFromController(controller);
+ clearControllerRuntime();
+ }
+
+ @Override
+ public void unloadedFromController(IMultiController controller) {
+ super.unloadedFromController(controller);
+ clearControllerRuntime();
+ }
+ private void clearControllerRuntime() {
tankProxy.setProxy(null);
- autoIOSubscription.updateSubscription();
+ autoIOSubscription.unsubscribe();
unsubscribeChanges();
}
private void unsubscribeChanges() {
- if (tankChangeListener != null)
+ if (tankChangeListener != null) {
tankChangeListener.unsubscribe();
+ tankChangeListener = null;
+ }
}
@Override
@@ -95,6 +106,10 @@ private IFluidHandler getTargetTank() {
}
private void autoIO() {
+ if (!hasOperationalController()) {
+ autoIOSubscription.updateSubscription();
+ return;
+ }
if (getOffsetTimer() % 5 == 0) {
tankProxy.exportToNearby(getFrontFacing());
}
@@ -103,11 +118,15 @@ private void autoIO() {
}
private boolean shouldAutoIO() {
- if (!isFormed()) return false;
+ if (!hasOperationalController()) return false;
if (getFrontFacing() != Direction.DOWN) return false;
if (tankProxy.isEmpty()) return false;
if (getTargetTank() == null) return false;
return true;
}
+
+ private boolean hasOperationalController() {
+ return controllers.stream().anyMatch(IMultiController::isStructureOperational);
+ }
}
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/monitor/AdvancedMonitorPartMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/monitor/AdvancedMonitorPartMachine.java
index 2694d20833a..51e44122ece 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/monitor/AdvancedMonitorPartMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/part/monitor/AdvancedMonitorPartMachine.java
@@ -79,5 +79,7 @@ public void onLoad() {
@Override
public void onUnload() {
unsubscribe(clickResetSubscription);
+ clickResetSubscription = null;
+ super.onUnload();
}
}
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/CharcoalPileIgniterMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/CharcoalPileIgniterMachine.java
index 515c8ff0f67..51584b475c6 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/CharcoalPileIgniterMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/CharcoalPileIgniterMachine.java
@@ -7,6 +7,7 @@
import com.gregtechceu.gtceu.api.machine.trait.WorkLogic;
import com.gregtechceu.gtceu.api.pattern.BlockPattern;
import com.gregtechceu.gtceu.api.pattern.FactoryBlockPattern;
+import com.gregtechceu.gtceu.api.pattern.MultiblockState;
import com.gregtechceu.gtceu.api.pattern.Predicates;
import com.gregtechceu.gtceu.api.pattern.TraceabilityPredicate;
import com.gregtechceu.gtceu.api.pattern.util.RelativeDirection;
@@ -49,6 +50,12 @@
public class CharcoalPileIgniterMachine extends WorkableMultiblockMachine {
+ protected enum DimensionScanResult {
+ VALID,
+ INVALID,
+ UNLOADED
+ }
+
private static final int MIN_RADIUS = 1;
private static final int MIN_DEPTH = 2;
private static final int MAX_HEIGHT = 5;
@@ -64,6 +71,7 @@ public class CharcoalPileIgniterMachine extends WorkableMultiblockMachine {
private int fDist = 0;
@DescSynced
private int hDist = 0;
+ private DimensionScanResult lastDimensionScanResult = DimensionScanResult.INVALID;
private boolean hasAir = false;
@@ -83,6 +91,7 @@ public CharcoalPileIgniterMachine(IMachineBlockEntity holder) {
public void onStructureFormed() {
super.onStructureFormed();
hasAir = false;
+ logPos.clear();
if (getMultiblockState().getMatchContext().containsKey("logPos")) {
Long2BooleanMap logPositions = getMultiblockState().getMatchContext().get("logPos");
for (var entry : logPositions.long2BooleanEntrySet()) {
@@ -107,12 +116,29 @@ public void setWorkingEnabled(boolean isWorkingAllowed) {}
@Override
public BlockPattern getPattern() {
updateDimensions();
+ return buildPatternFromCurrentDimensions();
+ }
- if (lDist < MIN_RADIUS) lDist = MIN_RADIUS;
- if (rDist < MIN_RADIUS) rDist = MIN_RADIUS;
- if (fDist < MIN_RADIUS) fDist = MIN_RADIUS;
- if (bDist < MIN_RADIUS) bDist = MIN_RADIUS;
- if (hDist < MIN_DEPTH) hDist = MIN_DEPTH;
+ @Override
+ public boolean checkPattern() {
+ // Assume compatibility implementations which override the legacy void hook completed their own scan.
+ lastDimensionScanResult = DimensionScanResult.VALID;
+ updateDimensions();
+ DimensionScanResult result = lastDimensionScanResult;
+ if (result != DimensionScanResult.VALID) {
+ getMultiblockState().setError(result == DimensionScanResult.UNLOADED ?
+ MultiblockState.UNLOAD_ERROR : MultiblockState.UNINIT_ERROR);
+ return false;
+ }
+ return buildPatternFromCurrentDimensions().checkPatternAt(getMultiblockState(), false);
+ }
+
+ protected BlockPattern buildPatternFromCurrentDimensions() {
+ int lDist = Math.max(this.lDist, MIN_RADIUS);
+ int rDist = Math.max(this.rDist, MIN_RADIUS);
+ int fDist = Math.max(this.fDist, MIN_RADIUS);
+ int bDist = Math.max(this.bDist, MIN_RADIUS);
+ int hDist = Math.max(this.hDist, MIN_DEPTH);
if (this.getFrontFacing().getAxis() == Direction.Axis.X) {
int tmp = lDist;
@@ -208,8 +234,12 @@ protected static TraceabilityPredicate logPredicate() {
}
public void updateDimensions() {
+ lastDimensionScanResult = scanDimensions();
+ }
+
+ protected DimensionScanResult scanDimensions() {
Level level = getLevel();
- if (level == null) return;
+ if (level == null) return DimensionScanResult.INVALID;
Direction front = getFrontFacing();
Direction back = front.getOpposite();
Direction left = RelativeDirection.LEFT.getRelative(front, getUpwardsFacing(), false);
@@ -230,22 +260,45 @@ public void updateDimensions() {
int hDist = 0;
for (int i = 1; i <= MAX_HEIGHT; i++) {
- if (lDist != 0 && rDist != 0 && hDist != 0) break;
- if (lDist == 0 && isBlockWall(level, lPos, left)) lDist = i;
- if (rDist == 0 && isBlockWall(level, rPos, right)) rDist = i;
- if (bDist == 0 && isBlockWall(level, bPos, back)) bDist = i;
- if (fDist == 0 && isBlockWall(level, fPos, front)) fDist = i;
- if (hDist == 0 && isBlockFloor(level, hPos)) hDist = i;
+ if (lDist != 0 && rDist != 0 && bDist != 0 && fDist != 0 && hDist != 0) break;
+ if (lDist == 0) {
+ lPos.move(left);
+ if (level.isOutsideBuildHeight(lPos)) return DimensionScanResult.INVALID;
+ if (!level.isLoaded(lPos)) return DimensionScanResult.UNLOADED;
+ if (isBlockWallAt(level, lPos)) lDist = i;
+ }
+ if (rDist == 0) {
+ rPos.move(right);
+ if (level.isOutsideBuildHeight(rPos)) return DimensionScanResult.INVALID;
+ if (!level.isLoaded(rPos)) return DimensionScanResult.UNLOADED;
+ if (isBlockWallAt(level, rPos)) rDist = i;
+ }
+ if (bDist == 0) {
+ bPos.move(back);
+ if (level.isOutsideBuildHeight(bPos)) return DimensionScanResult.INVALID;
+ if (!level.isLoaded(bPos)) return DimensionScanResult.UNLOADED;
+ if (isBlockWallAt(level, bPos)) bDist = i;
+ }
+ if (fDist == 0) {
+ fPos.move(front);
+ if (level.isOutsideBuildHeight(fPos)) return DimensionScanResult.INVALID;
+ if (!level.isLoaded(fPos)) return DimensionScanResult.UNLOADED;
+ if (isBlockWallAt(level, fPos)) fDist = i;
+ }
+ if (hDist == 0) {
+ hPos.move(Direction.DOWN);
+ if (level.isOutsideBuildHeight(hPos)) return DimensionScanResult.INVALID;
+ if (!level.isLoaded(hPos)) return DimensionScanResult.UNLOADED;
+ if (isBlockFloorAt(level, hPos)) hDist = i;
+ }
}
if (Math.abs(lDist - rDist) > 1 || Math.abs(bDist - fDist) > 1) {
- this.isFormed = false;
- return;
+ return DimensionScanResult.INVALID;
}
if (lDist < MIN_RADIUS || rDist < MIN_RADIUS || fDist < MIN_RADIUS || bDist < MIN_RADIUS || hDist < MIN_DEPTH) {
- this.isFormed = false;
- return;
+ return DimensionScanResult.INVALID;
}
this.lDist = lDist;
@@ -253,14 +306,15 @@ public void updateDimensions() {
this.fDist = fDist;
this.bDist = bDist;
this.hDist = hDist;
+ return DimensionScanResult.VALID;
}
- private static boolean isBlockWall(Level level, BlockPos.MutableBlockPos pos, Direction direction) {
- return level.getBlockState(pos.move(direction)).is(CustomTags.CHARCOAL_PILE_IGNITER_WALLS);
+ private static boolean isBlockWallAt(Level level, BlockPos pos) {
+ return level.getBlockState(pos).is(CustomTags.CHARCOAL_PILE_IGNITER_WALLS);
}
- private static boolean isBlockFloor(Level level, BlockPos.MutableBlockPos pos) {
- return level.getBlockState(pos.move(Direction.DOWN)).is(Blocks.BRICKS);
+ private static boolean isBlockFloorAt(Level level, BlockPos pos) {
+ return level.getBlockState(pos).is(Blocks.BRICKS);
}
@Override
@@ -303,7 +357,7 @@ private void convertLogBlocks() {
@Override
public InteractionResult onUse(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand,
BlockHitResult hit) {
- if (!isFormed() || hasAir) {
+ if (!isStructureOperational() || hasAir) {
return super.onUse(state, level, pos, player, hand, hit);
}
ItemStack stack = player.getItemInHand(hand);
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitiveBlastFurnaceMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitiveBlastFurnaceMachine.java
index 5032acb91ce..b3d14b61c22 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitiveBlastFurnaceMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitiveBlastFurnaceMachine.java
@@ -90,7 +90,7 @@ public void notifyWorkStatusChanged(WorkLogic.Status oldStatus, WorkLogic.Status
@OnlyIn(Dist.CLIENT)
public void clientTick() {
super.clientTick();
- if (isFormed) {
+ if (isStructureOperational()) {
var pos = this.getPos();
var facing = this.getFrontFacing().getOpposite();
float xPos = facing.getStepX() * 0.76F + pos.getX() + 0.5F;
@@ -178,6 +178,9 @@ public void animateTick(RandomSource random) {
}
private void hurtEntitiesAndBreakSnow() {
+ if (!isStructureOperational()) {
+ return;
+ }
BlockPos middlePos = self().getPos().offset(getFrontFacing().getOpposite().getNormal());
getLevel().getEntities(null, new AABB(middlePos)).forEach(e -> e.hurt(e.damageSources().lava(), 3.0f));
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitivePumpMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitivePumpMachine.java
index 0db73a90ded..6ac10f4b70d 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitivePumpMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/primitive/PrimitivePumpMachine.java
@@ -56,8 +56,10 @@ protected void appendMachineJadeTooltip(CompoundTag data, ITooltip tooltip, Bloc
@Override
public void onStructureFormed() {
super.onStructureFormed();
+ hatchModifier = 0;
+ fluidTank = null;
initializeTank();
- produceWaterSubscription = subscribeServerTick(this::produceWater);
+ produceWaterSubscription = subscribeServerTick(produceWaterSubscription, this::produceWater);
}
private void initializeTank() {
@@ -102,12 +104,13 @@ public void onUnload() {
private void resetState() {
unsubscribe(produceWaterSubscription);
+ produceWaterSubscription = null;
hatchModifier = 0;
fluidTank = null;
}
private void produceWater() {
- if (getOffsetTimer() % 20 == 0 && isFormed() && !getMultiblockState().hasError()) {
+ if (getOffsetTimer() % 20 == 0 && isStructureOperational() && !getMultiblockState().hasError()) {
if (biomeModifier == 0) {
biomeModifier = GTUtil.getPumpBiomeModifier(getLevel().getBiome(getPos()));
} else if (biomeModifier > 0) {
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/steam/LargeBoilerMachine.java b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/steam/LargeBoilerMachine.java
index b2b74e73967..92c01a65d09 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/steam/LargeBoilerMachine.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/multiblock/steam/LargeBoilerMachine.java
@@ -110,7 +110,8 @@ protected void updateSteamSubscription() {
}
protected void updateCurrentTemperature() {
- if (recipeLogic.isWorking()) {
+ boolean structureOperational = isStructureOperational() && !getMultiblockState().hasError();
+ if (structureOperational && recipeLogic.isWorking()) {
if (getOffsetTimer() % 10 == 0) {
if (currentTemperature < getMaxTemperature()) {
currentTemperature = Mth.clamp(currentTemperature + heatSpeed * 10, 0, getMaxTemperature());
@@ -120,7 +121,7 @@ protected void updateCurrentTemperature() {
currentTemperature -= getCoolDownRate();
}
- if (isFormed() && getOffsetTimer() % TICKS_PER_STEAM_GENERATION == 0) {
+ if (structureOperational && getOffsetTimer() % TICKS_PER_STEAM_GENERATION == 0) {
var maxDrain = currentTemperature * throttle * TICKS_PER_STEAM_GENERATION /
(ConfigHolder.INSTANCE.machines.largeBoilers.steamPerWater * 100);
if (currentTemperature < 100) {
diff --git a/src/main/java/com/gregtechceu/gtceu/common/machine/trait/multiblock/ParallelHatchTrait.java b/src/main/java/com/gregtechceu/gtceu/common/machine/trait/multiblock/ParallelHatchTrait.java
index f87aa3cf425..a7ea6a0297d 100644
--- a/src/main/java/com/gregtechceu/gtceu/common/machine/trait/multiblock/ParallelHatchTrait.java
+++ b/src/main/java/com/gregtechceu/gtceu/common/machine/trait/multiblock/ParallelHatchTrait.java
@@ -52,6 +52,7 @@ public void appendJadeTooltip(CompoundTag data, ITooltip tooltip, BlockAccessor
@Override
public void onStructureFormed() {
IMultiblockMachineTrait.super.onStructureFormed();
+ parallelHatch = null;
for (var part : getMultiMachine().getParts()) {
if (part instanceof IParallelHatch pHatch) {
parallelHatch = pHatch;
diff --git a/src/main/java/com/gregtechceu/gtceu/core/mixins/LevelMixin.java b/src/main/java/com/gregtechceu/gtceu/core/mixins/LevelMixin.java
index 637226a30e0..2562c1b04ae 100644
--- a/src/main/java/com/gregtechceu/gtceu/core/mixins/LevelMixin.java
+++ b/src/main/java/com/gregtechceu/gtceu/core/mixins/LevelMixin.java
@@ -40,7 +40,7 @@ public abstract class LevelMixin implements LevelAccessor {
private void gtceu$getBlockEntityOffThread(BlockPos pos, CallbackInfoReturnable cir) {
if (Thread.currentThread() == this.thread) return;
if (this.isClientSide) return;
- if (!MultiblockWorldSavedData.isThreadService() && !AsyncThreadData.isThreadService()) return;
+ if (!AsyncThreadData.isThreadService()) return;
int chunkX = pos.getX() >> 4, chunkZ = pos.getZ() >> 4;
if (!this.getChunkSource().hasChunk(chunkX, chunkZ)) return;
@@ -55,7 +55,7 @@ public abstract class LevelMixin implements LevelAccessor {
private void gtceu$getBlockStateOffThread(BlockPos pos, CallbackInfoReturnable cir) {
if (Thread.currentThread() == this.thread) return;
if (this.isClientSide) return;
- if (!MultiblockWorldSavedData.isThreadService() && !AsyncThreadData.isThreadService()) return;
+ if (!AsyncThreadData.isThreadService()) return;
int chunkX = pos.getX() >> 4, chunkZ = pos.getZ() >> 4;
if (!this.getChunkSource().hasChunk(chunkX, chunkZ)) return;
diff --git a/src/main/java/com/gregtechceu/gtceu/core/mixins/ServerChunkProviderMixin.java b/src/main/java/com/gregtechceu/gtceu/core/mixins/ServerChunkProviderMixin.java
index 45bb8c85423..391bc161893 100644
--- a/src/main/java/com/gregtechceu/gtceu/core/mixins/ServerChunkProviderMixin.java
+++ b/src/main/java/com/gregtechceu/gtceu/core/mixins/ServerChunkProviderMixin.java
@@ -1,7 +1,5 @@
package com.gregtechceu.gtceu.core.mixins;
-import com.gregtechceu.gtceu.api.pattern.MultiblockWorldSavedData;
-
import com.lowdragmc.lowdraglib.async.AsyncThreadData;
import net.minecraft.server.level.ChunkHolder;
@@ -60,8 +58,7 @@ private void injectClearCache(CallbackInfo ci) {
@Inject(method = "getChunkNow", at = @At(value = "HEAD"), cancellable = true)
private void getTileEntity(int pChunkX, int pChunkZ, CallbackInfoReturnable cir) {
- if (Thread.currentThread() != this.mainThread &&
- (MultiblockWorldSavedData.isThreadService() || AsyncThreadData.isThreadService())) {
+ if (Thread.currentThread() != this.mainThread && AsyncThreadData.isThreadService()) {
long i = ChunkPos.asLong(pChunkX, pChunkZ);
for (int j = 0; j < 4; ++j) {
diff --git a/src/main/java/com/gregtechceu/gtceu/forge/ForgeCommonEventListener.java b/src/main/java/com/gregtechceu/gtceu/forge/ForgeCommonEventListener.java
index 60cc91738ea..73964713e36 100644
--- a/src/main/java/com/gregtechceu/gtceu/forge/ForgeCommonEventListener.java
+++ b/src/main/java/com/gregtechceu/gtceu/forge/ForgeCommonEventListener.java
@@ -54,6 +54,7 @@
import com.gregtechceu.gtceu.integration.map.cache.server.ServerCache;
import com.gregtechceu.gtceu.utils.TaskHandler;
+import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
@@ -81,6 +82,7 @@
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.event.level.BlockEvent;
+import net.minecraftforge.event.level.ChunkEvent;
import net.minecraftforge.event.level.ChunkWatchEvent;
import net.minecraftforge.event.level.LevelEvent;
import net.minecraftforge.event.server.ServerStartingEvent;
@@ -233,6 +235,24 @@ public static void onBreakEvent(BlockEvent.BreakEvent event) {
}
}
+ @SubscribeEvent(priority = EventPriority.LOWEST)
+ public static void wakeMultiblocksAfterBreak(BlockEvent.BreakEvent event) {
+ if (event.getLevel() instanceof ServerLevel serverLevel) {
+ BlockPos changedPos = event.getPos().immutable();
+ runOnServerThread(serverLevel,
+ () -> MultiblockWorldSavedData.getOrCreate(serverLevel).onBlockChanged(changedPos));
+ }
+ }
+
+ @SubscribeEvent(priority = EventPriority.LOWEST)
+ public static void wakeMultiblocksAfterPlace(BlockEvent.EntityPlaceEvent event) {
+ if (event.getLevel() instanceof ServerLevel serverLevel) {
+ BlockPos changedPos = event.getPos().immutable();
+ runOnServerThread(serverLevel,
+ () -> MultiblockWorldSavedData.getOrCreate(serverLevel).onBlockChanged(changedPos));
+ }
+ }
+
@SubscribeEvent
public static void registerCommand(RegisterCommandsEvent event) {
GTCommands.register(event.getDispatcher(), event.getBuildContext());
@@ -253,6 +273,7 @@ public static void registerReloadListeners(AddReloadListenerEvent event) {
public static void levelTick(TickEvent.LevelTickEvent event) {
if (event.phase == TickEvent.Phase.END && event.level instanceof ServerLevel serverLevel) {
TaskHandler.onTickUpdate(serverLevel);
+ MultiblockWorldSavedData.getOrCreate(serverLevel).tick();
ComputationNetworkManager.get(serverLevel).tick();
if (ConfigHolder.INSTANCE.gameplay.environmentalHazards) {
EnvironmentalHazardSavedData.getOrCreate(serverLevel).tick();
@@ -261,6 +282,34 @@ public static void levelTick(TickEvent.LevelTickEvent event) {
}
}
+ @SubscribeEvent
+ public static void chunkUnload(ChunkEvent.Unload event) {
+ if (!(event.getLevel() instanceof ServerLevel serverLevel)) {
+ return;
+ }
+ ChunkPos chunkPos = event.getChunk().getPos();
+ runOnServerThread(serverLevel,
+ () -> MultiblockWorldSavedData.getOrCreate(serverLevel).onChunkUnload(chunkPos));
+ }
+
+ @SubscribeEvent
+ public static void chunkLoad(ChunkEvent.Load event) {
+ if (!(event.getLevel() instanceof ServerLevel serverLevel)) {
+ return;
+ }
+ ChunkPos chunkPos = event.getChunk().getPos();
+ runOnServerThread(serverLevel,
+ () -> MultiblockWorldSavedData.getOrCreate(serverLevel).onChunkLoad(chunkPos));
+ }
+
+ private static void runOnServerThread(ServerLevel serverLevel, Runnable action) {
+ if (serverLevel.getServer().isSameThread()) {
+ action.run();
+ } else {
+ serverLevel.getServer().execute(action);
+ }
+ }
+
@SubscribeEvent
public static void worldLoad(LevelEvent.Load event) {
if (event.getLevel().isClientSide()) {
@@ -275,6 +324,7 @@ public static void worldUnload(LevelEvent.Unload event) {
if (event.getLevel() instanceof ServerLevel serverLevel) {
TaskHandler.onWorldUnLoad(serverLevel);
MultiblockWorldSavedData.getOrCreate(serverLevel).releaseExecutorService();
+ ComputationNetworkManager.release(serverLevel);
ServerCache.instance.invalidateWorld(serverLevel);
} else if (event.getLevel().isClientSide()) {
ClientCacheManager.saveCaches();
diff --git a/src/test/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedDataTest.java b/src/test/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedDataTest.java
new file mode 100644
index 00000000000..9c19af8dfc7
--- /dev/null
+++ b/src/test/java/com/gregtechceu/gtceu/api/pattern/MultiblockWorldSavedDataTest.java
@@ -0,0 +1,1335 @@
+package com.gregtechceu.gtceu.api.pattern;
+
+import com.gregtechceu.gtceu.GTCEu;
+import com.gregtechceu.gtceu.api.machine.IMachineBlockEntity;
+import com.gregtechceu.gtceu.api.machine.MultiblockMachineDefinition;
+import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiController;
+import com.gregtechceu.gtceu.api.machine.feature.multiblock.IMultiPart;
+import com.gregtechceu.gtceu.api.machine.multiblock.MultiblockControllerMachine;
+import com.gregtechceu.gtceu.api.machine.multiblock.WorkableMultiblockMachine;
+import com.gregtechceu.gtceu.api.machine.multiblock.part.MultiblockPartMachine;
+import com.gregtechceu.gtceu.api.machine.trait.WorkLogic;
+import com.gregtechceu.gtceu.api.pattern.util.RelativeDirection;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.gametest.framework.GameTest;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.ListTag;
+import net.minecraft.world.level.ChunkPos;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraftforge.gametest.GameTestHolder;
+import net.minecraftforge.gametest.PrefixGameTestTemplate;
+
+import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Proxy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+@PrefixGameTestTemplate(false)
+@GameTestHolder(GTCEu.MOD_ID)
+public class MultiblockWorldSavedDataTest {
+
+ private static final BlockPos LCR_CONTROLLER_POS = new BlockPos(1, 2, 0);
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData")
+ public static void pendingCheckMembershipSurvivesRepeatedRequeue(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ savedData.removeAsyncLogic(controller);
+
+ for (int i = 0; i < 128; i++) {
+ savedData.addAsyncLogic(controller);
+ savedData.removeAsyncLogic(controller);
+ }
+ savedData.addAsyncLogic(controller);
+
+ helper.assertTrue(savedData.isStructureCheckPending(controller),
+ "The controller lost its pending check after repeated remove-and-add cycles");
+ savedData.removeAsyncLogic(controller);
+ helper.assertFalse(savedData.isStructureCheckPending(controller),
+ "Removing the controller left an active pending check");
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", timeoutTicks = 80)
+ public static void pendingChecksFormOnTheServerTick(GameTestHelper helper) {
+ var controller = getController(helper);
+ var state = controller.getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ savedData.removeAsyncLogic(controller);
+ controller.onStructureInvalid();
+ state.setError(MultiblockState.UNINIT_ERROR);
+ savedData.addAsyncLogic(controller);
+
+ helper.succeedWhen(() -> {
+ helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed by the tick queue");
+ helper.assertTrue(savedData.mapping.get(controller.getPos()) == state,
+ "The formed multiblock was not installed as the current mapping");
+ helper.assertFalse(savedData.isStructureCheckPending(controller),
+ "The formed controller remained in the pending queue");
+ helper.assertFalse(controller.isStructureRevalidationPending(),
+ "The formed controller remained marked for revalidation");
+ helper.assertTrue(hasWorkSubscription((WorkableMultiblockMachine) controller),
+ "A successful revalidation did not restore the work subscription");
+ });
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void cachedChangeQueuesWithoutReadingUnloadedPosition(GameTestHelper helper) {
+ var controller = getController(helper);
+ var state = controller.getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ BlockPos unloadedPos = findUnloadedPos(helper, controller.getPos());
+ helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the cached change");
+ BlockPos lastCheckedPos = state.getPos();
+ var previousError = state.error;
+ savedData.removeAsyncLogic(controller);
+ state.addPosCache(unloadedPos, new TraceabilityPredicate());
+ helper.assertTrue(savedData.tryAddMapping(state), "The current controller mapping was not installed");
+ var workable = (WorkableMultiblockMachine) controller;
+ workable.getWorkLogic().updateTickSubscription();
+ helper.assertTrue(hasWorkSubscription(workable),
+ "The work subscription was not active before the cached change");
+ state.onBlockStateChanged(unloadedPos, Blocks.AIR.defaultBlockState());
+
+ helper.assertTrue(state.getPos().equals(lastCheckedPos),
+ "The block-change callback read the unloaded cached position");
+ helper.assertTrue(state.error == previousError,
+ "The loaded block-change callback synthesized a chunk-unload pattern error");
+ helper.assertTrue(controller.isStructureFormedSnapshot(),
+ "The block-change callback discarded the controller's formation snapshot before its queued check");
+ helper.assertFalse(controller.isFormed(),
+ "The formed facade remained available before the queued check completed");
+ helper.assertFalse(workable.isWorkLogicAvailable(),
+ "Recipe logic remained available while the structure awaited validation");
+ helper.assertFalse(hasWorkSubscription(workable),
+ "The cached change did not immediately cancel the existing work subscription");
+ assertMappingRemovedAndCheckPending(helper, controller, savedData, "block-change callback");
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void chunkUnloadPausesMappedStructuresWithoutReadingTheChunk(GameTestHelper helper) {
+ var controller = getController(helper);
+ var state = controller.getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ BlockPos unloadedPos = findUnloadedPos(helper, controller.getPos());
+ ChunkPos unloadedChunk = new ChunkPos(unloadedPos);
+
+ savedData.removeAsyncLogic(controller);
+ state.addPosCache(unloadedPos, new TraceabilityPredicate());
+ helper.assertTrue(savedData.tryAddMapping(state), "The mapped structure was not installed before chunk unload");
+ savedData.onChunkUnload(unloadedChunk);
+
+ helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR,
+ "Chunk unload did not preserve the temporary unload reason");
+ helper.assertTrue(controller.isStructureFormedSnapshot(),
+ "Chunk unload invalidated the persisted formed state");
+ helper.assertFalse(controller.isFormed(),
+ "Chunk unload left the formed facade available before revalidation");
+ assertMappingRemovedAndCheckPending(helper, controller, savedData, "chunk-unload callback");
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void queuedFullRecheckUnloadKeepsControllerFormedAndQueued(GameTestHelper helper) {
+ var controller = getController(helper);
+ var state = controller.getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ BlockPos unloadedPos = findUnloadedPos(helper, controller.getPos());
+ var definition = (MultiblockMachineDefinition) controller.getDefinition();
+ Supplier originalPatternFactory = definition.getPatternFactory();
+ helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the queued recheck");
+ var confirmedCache = new LongOpenHashSet(state.getCache());
+ helper.assertFalse(confirmedCache.isEmpty(),
+ "The confirmed-cache regression requires an existing successful structure cache");
+
+ savedData.removeAsyncLogic(controller);
+ savedData.removeMapping(state);
+ state.setError(null);
+ definition.setPatternFactory(() -> new UnloadedPattern(unloadedPos));
+ try {
+ savedData.addAsyncLogic(controller);
+ // Exercise this controller directly while the global definition is replaced. Ticking the level-wide queue
+ // here would let the temporary pattern factory inspect other GameTests' controllers in the same batch.
+ controller.asyncCheckPattern(savedData.getPeriodID());
+ } finally {
+ definition.setPatternFactory(originalPatternFactory);
+ }
+
+ helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR,
+ "The queued full recheck did not encounter the unloaded structure position");
+ helper.assertTrue(controller.isStructureFormedSnapshot(),
+ "The queued full recheck invalidated the controller while a structure chunk was unloaded");
+ helper.assertFalse(controller.isFormed(),
+ "An unloaded queued check left the formed facade available");
+ helper.assertTrue(state.getCache().equals(confirmedCache),
+ "An incomplete scan replaced the last confirmed structure cache with its partial attempt");
+ assertMappingRemovedAndCheckPending(helper, controller, savedData, "queued full recheck");
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void unloadedCandidateCannotBeOverwrittenByLaterMismatch(GameTestHelper helper) {
+ var controller = getController(helper);
+ var state = controller.getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ var definition = (MultiblockMachineDefinition) controller.getDefinition();
+ Supplier originalPatternFactory = definition.getPatternFactory();
+ helper.assertTrue(controller.allowFlip(),
+ "The candidate-order regression test requires a flippable controller");
+ helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the candidate check");
+
+ savedData.removeAsyncLogic(controller);
+ savedData.removeMapping(state);
+ state.setError(null);
+ definition.setPatternFactory(UnloadedThenMismatchPattern::new);
+ try {
+ savedData.addAsyncLogic(controller);
+ controller.asyncCheckPattern(savedData.getPeriodID());
+ } finally {
+ definition.setPatternFactory(originalPatternFactory);
+ }
+
+ helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR,
+ "A later flipped mismatch overwrote an earlier unloaded-chunk result");
+ helper.assertTrue(controller.isStructureFormedSnapshot(),
+ "Candidate fallback invalidated the controller despite an uninspectable orientation");
+ helper.assertFalse(controller.isFormed(),
+ "An uninspectable candidate left the formed facade available");
+ assertMappingRemovedAndCheckPending(helper, controller, savedData, "candidate fallback");
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void persistedFlippedCandidateUnloadDoesNotSwitchOrientation(GameTestHelper helper) {
+ var controller = getController(helper);
+ var state = controller.getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ var definition = (MultiblockMachineDefinition) controller.getDefinition();
+ Supplier originalPatternFactory = definition.getPatternFactory();
+ var oppositeFlipChecks = new AtomicInteger();
+ helper.assertTrue(controller.allowFlip(),
+ "The persisted-flip regression test requires a flippable controller");
+ helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the flip check");
+
+ controller.setFlipped(true);
+ savedData.removeAsyncLogic(controller);
+ savedData.removeMapping(state);
+ state.setError(null);
+ definition.setPatternFactory(() -> new PersistedFlipUnloadPattern(oppositeFlipChecks));
+ try {
+ savedData.addAsyncLogic(controller);
+ controller.asyncCheckPattern(savedData.getPeriodID());
+ } finally {
+ definition.setPatternFactory(originalPatternFactory);
+ }
+
+ helper.assertTrue(oppositeFlipChecks.get() == 0,
+ "An unavailable persisted flipped structure fell back to the opposite orientation");
+ helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR,
+ "The unavailable persisted flipped orientation was not retained as a temporary unload");
+ helper.assertTrue(controller.isStructureFormedSnapshot(),
+ "The unavailable persisted flipped orientation discarded the formation snapshot");
+ helper.assertTrue(controller.isStructureFlippedSnapshot(),
+ "The unavailable persisted flipped orientation discarded its owned flip state");
+ assertMappingRemovedAndCheckPending(helper, controller, savedData, "persisted flip");
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void unloadedOldPatternCannotSwitchToLoadedFallback(GameTestHelper helper) {
+ var controller = getController(helper);
+ var state = controller.getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ BlockPos unloadedPos = findUnloadedPos(helper, controller.getPos());
+ var definition = (MultiblockMachineDefinition) controller.getDefinition();
+ Supplier originalPatternFactory = definition.getPatternFactory();
+ var fallbackChecks = new AtomicInteger();
+ helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the fallback check");
+
+ savedData.removeAsyncLogic(controller);
+ savedData.removeMapping(state);
+ state.addPosCache(unloadedPos, new TraceabilityPredicate());
+ helper.assertTrue(savedData.tryAddMapping(state),
+ "The augmented old pattern cache could not be confirmed for the preflight regression");
+ savedData.removeMapping(state);
+ definition.setPatternFactory(() -> new SuccessfulFallbackPattern(fallbackChecks));
+ try {
+ savedData.addAsyncLogic(controller);
+ controller.asyncCheckPattern(savedData.getPeriodID());
+ } finally {
+ definition.setPatternFactory(originalPatternFactory);
+ }
+
+ helper.assertTrue(fallbackChecks.get() == 0,
+ "The controller tried a new pattern while its previous structure cache was incomplete");
+ helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR,
+ "An incomplete previous structure was not retained as a temporary unload");
+ helper.assertTrue(controller.isStructureFormedSnapshot(),
+ "Trying a loaded fallback discarded the previous formation snapshot");
+ assertMappingRemovedAndCheckPending(helper, controller, savedData, "old-pattern preflight");
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData")
+ public static void confirmedPatternCacheUsesCopyOnWriteAndConstantTimeRestore(GameTestHelper helper) {
+ var state = new MultiblockState(helper.getLevel(), helper.absolutePos(LCR_CONTROLLER_POS));
+ BlockPos confirmedPos = helper.absolutePos(new BlockPos(2, 2, 0));
+ BlockPos attemptedPos = helper.absolutePos(new BlockPos(3, 2, 0));
+
+ state.clean();
+ state.addPosCache(confirmedPos, new TraceabilityPredicate());
+ state.confirmPatternCache();
+ assertCacheAliases(helper, state, true, "A confirmed cache retained a duplicate working copy");
+
+ state.addPosCache(attemptedPos, new TraceabilityPredicate());
+ assertCacheAliases(helper, state, false, "Writing through addPosCache mutated the confirmed alias");
+ helper.assertTrue(state.getPosPredicateMap().containsKey(attemptedPos),
+ "The detached working cache did not receive the attempted position");
+
+ state.restoreConfirmedPatternCache();
+ assertCacheAliases(helper, state, true, "Restoring a failed attempt copied instead of aliasing the cache");
+ helper.assertTrue(state.isPosInCache(confirmedPos), "Failed restoration lost the confirmed position");
+ helper.assertFalse(state.getPosPredicateMap().containsKey(attemptedPos),
+ "Failed restoration retained an unconfirmed attempted position");
+
+ state.clean();
+ state.addPosCache(attemptedPos, new TraceabilityPredicate());
+ state.restoreConfirmedPatternCache();
+ assertCacheAliases(helper, state, true, "Restoring a clean scan attempt did not reuse the confirmed cache");
+ helper.assertFalse(state.getPosPredicateMap().containsKey(attemptedPos),
+ "A clean failed scan replaced the confirmed predicate cache");
+
+ state.getPredicateMap().put(attemptedPos.asLong(), new TraceabilityPredicate());
+ assertCacheAliases(helper, state, false, "The mutable predicate-map getter exposed the confirmed alias");
+ state.restoreConfirmedPatternCache();
+ helper.assertFalse(state.getPosPredicateMap().containsKey(attemptedPos),
+ "Mutating the exposed predicate map corrupted the confirmed cache");
+
+ state.getCache().clear();
+ assertCacheAliases(helper, state, false, "The mutable cache getter exposed the confirmed alias");
+ state.restoreConfirmedPatternCache();
+ helper.assertTrue(state.isPosInCache(confirmedPos),
+ "Mutating the exposed cache key set corrupted the confirmed cache");
+
+ state.clean();
+ var retainedMap = state.getPredicateMap();
+ BlockPos unloadedPos = findUnloadedPos(helper, confirmedPos);
+ retainedMap.put(unloadedPos.asLong(), new TraceabilityPredicate());
+ state.confirmPatternCache();
+ helper.assertTrue(state.hasUnloadedCachedPosition(),
+ "Committing a directly edited map omitted its unloaded chunk dependency");
+ retainedMap.clear();
+ helper.assertTrue(state.isPosInCache(unloadedPos),
+ "A mutable handle retained before commit corrupted the confirmed snapshot afterward");
+ assertCacheAliases(helper, state, true, "Committing an exposed map retained two resident cache copies");
+
+ state.clean();
+ state.addPosCache(confirmedPos, new TraceabilityPredicate());
+ var retainedKeys = state.getCache();
+ state.confirmPatternCache();
+ retainedKeys.clear();
+ helper.assertTrue(state.isPosInCache(confirmedPos),
+ "A key-set handle retained before commit corrupted the confirmed snapshot afterward");
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void persistedConfirmedChunksGuardFirstRestartValidation(GameTestHelper helper) {
+ var controller = getController(helper);
+ var state = controller.getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ var definition = (MultiblockMachineDefinition) controller.getDefinition();
+ Supplier originalPatternFactory = definition.getPatternFactory();
+ var fallbackChecks = new AtomicInteger();
+ CompoundTag originalWorldTag = new CompoundTag();
+ controller.saveCustomPersistedData(originalWorldTag, false);
+ long[] originalChunks = originalWorldTag.getLongArray("gtceuConfirmedStructureChunks");
+ helper.assertTrue(originalChunks.length > 0,
+ "A successful structure mapping did not persist its confirmed chunk set");
+ helper.assertTrue(Arrays.stream(originalChunks)
+ .anyMatch(chunk -> chunk == new ChunkPos(controller.getPos()).toLong()),
+ "The persisted mapping omitted the controller chunk");
+
+ CompoundTag legacyTag = originalWorldTag.copy();
+ legacyTag.remove("gtceuConfirmedStructureChunks");
+ controller.loadCustomPersistedData(legacyTag);
+ CompoundTag legacyRoundTrip = new CompoundTag();
+ controller.saveCustomPersistedData(legacyRoundTrip, false);
+ helper.assertFalse(legacyRoundTrip.contains("gtceuConfirmedStructureChunks"),
+ "Loading an old save invented a confirmed chunk snapshot");
+
+ BlockPos unloadedPos = findUnloadedPos(helper, controller.getPos());
+ long unloadedChunk = new ChunkPos(unloadedPos).toLong();
+ long[] restartChunks = Arrays.copyOf(originalChunks, originalChunks.length + 2);
+ restartChunks[originalChunks.length] = unloadedChunk;
+ restartChunks[originalChunks.length + 1] = unloadedChunk;
+ CompoundTag restartTag = originalWorldTag.copy();
+ restartTag.putLongArray("gtceuConfirmedStructureChunks", restartChunks);
+ controller.loadCustomPersistedData(restartTag);
+ CompoundTag restartRoundTrip = new CompoundTag();
+ controller.saveCustomPersistedData(restartRoundTrip, false);
+ helper.assertTrue(Arrays.stream(restartRoundTrip.getLongArray("gtceuConfirmedStructureChunks"))
+ .filter(chunk -> chunk == unloadedChunk)
+ .count() == 1,
+ "The persisted chunk snapshot was not normalized during restart loading");
+
+ savedData.removeAsyncLogic(controller);
+ savedData.removeMapping(state);
+ state.setError(null);
+ definition.setPatternFactory(() -> new SuccessfulFallbackPattern(fallbackChecks));
+ try {
+ savedData.addAsyncLogic(controller);
+ controller.asyncCheckPattern(savedData.getPeriodID());
+
+ helper.assertTrue(fallbackChecks.get() == 0,
+ "The first restart validation scanned a fallback while an old structure chunk was unavailable");
+ helper.assertTrue(state.error == MultiblockState.UNLOAD_ERROR,
+ "The persisted restart preflight did not report a temporary unload");
+ helper.assertTrue(controller.isStructureFormedSnapshot(),
+ "The persisted restart preflight discarded the formation snapshot");
+ helper.assertTrue(controller.isStructureRevalidationPending(),
+ "The persisted restart preflight did not keep the controller pending");
+ } finally {
+ definition.setPatternFactory(originalPatternFactory);
+ controller.loadCustomPersistedData(originalWorldTag);
+ savedData.removeAsyncLogic(controller);
+ }
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData")
+ public static void positionsOutsideBuildHeightAreDefinitiveFailures(GameTestHelper helper) {
+ var level = helper.getLevel();
+ var state = new MultiblockState(level, helper.absolutePos(LCR_CONTROLLER_POS));
+ var predicate = new TraceabilityPredicate();
+ BlockPos belowWorld = new BlockPos(0, level.getMinBuildHeight() - 1, 0);
+ BlockPos aboveWorld = new BlockPos(0, level.getMaxBuildHeight(), 0);
+
+ helper.assertFalse(state.update(belowWorld, predicate),
+ "A position below the build height was accepted by the pattern state");
+ helper.assertTrue(state.error == MultiblockState.UNINIT_ERROR,
+ "A position below the build height was mistaken for a temporarily unloaded chunk");
+ helper.assertFalse(state.update(aboveWorld, predicate),
+ "A position above the build height was accepted by the pattern state");
+ helper.assertTrue(state.error == MultiblockState.UNINIT_ERROR,
+ "A position above the build height was mistaken for a temporarily unloaded chunk");
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void successfulReformationUnlinksPartsMissingFromNewPattern(GameTestHelper helper) {
+ var controller = getController(helper);
+ var removals = new AtomicInteger();
+ IMultiPart stalePart = removalTrackingPart(removals);
+ controller.getParts().add(stalePart);
+
+ controller.onStructureFormed();
+
+ helper.assertTrue(removals.get() == 1,
+ "A part missing from the refreshed pattern was not definitively unlinked");
+ helper.assertTrue(controller.getParts().stream().noneMatch(part -> part == stalePart),
+ "A part missing from the refreshed pattern remained in the controller's part list");
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void activeRegistryIdentityRepairsLaggingControllerNbt(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ long activeInstance = savedData.ensureControllerActive(controller);
+ helper.assertTrue(activeInstance > 0, "The loaded controller did not receive a persistent instance id");
+
+ controller.setStructureInstanceId(0);
+ long adoptedInstance = savedData.ensureControllerActive(controller);
+
+ helper.assertTrue(adoptedInstance == activeInstance,
+ "A lagging controller chunk replaced its still-active registry identity");
+ helper.assertTrue(controller.getStructureInstanceId() == activeInstance,
+ "The active registry identity was not written back to the lagging controller");
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void retiredAndOlderControllerIdentitiesCannotBeReactivated(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ BlockPos controllerPos = controller.getPos();
+ long originalInstance = savedData.ensureControllerActive(controller);
+ savedData.retireController(controller);
+ controller.setStructureInstanceId(0);
+
+ long replacementInstance = savedData.ensureControllerActive(controller);
+ helper.assertTrue(replacementInstance > originalInstance,
+ "A controller without item identity reactivated a tombstoned incarnation");
+ helper.assertTrue(savedData.isControllerBindingRetired(controllerPos, originalInstance),
+ "The replaced controller incarnation was not reported retired");
+ helper.assertTrue(savedData.getControllerBindingStatus(controllerPos, replacementInstance + 1) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.UNKNOWN,
+ "A registry identity newer than the saved world record was treated as retired");
+
+ controller.setStructureInstanceId(originalInstance);
+ long afterStaleChunk = savedData.ensureControllerActive(controller);
+ helper.assertTrue(afterStaleChunk > replacementInstance,
+ "An older controller chunk reactivated an already replaced incarnation");
+ controller.onStructureFormed();
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void newerCrashTombstoneAdvancesTheOwnerRegistry(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ long activeInstance = savedData.ensureControllerActive(controller);
+ long newerRemovedInstance = activeInstance + 2;
+
+ savedData.retireControllerBinding(controller.getPos(), newerRemovedInstance);
+
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), newerRemovedInstance) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.RETIRED,
+ "A controller epoch newer than crash-lagging SavedData lost its tombstone");
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), activeInstance) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.RETIRED,
+ "Advancing to a newer tombstone did not retire the older registry epoch");
+ controller.setStructureInstanceId(0);
+ long replacementInstance = savedData.ensureControllerActive(controller);
+ helper.assertTrue(replacementInstance > newerRemovedInstance,
+ "A replacement controller reused an identity at or below a newer crash tombstone");
+ controller.onStructureFormed();
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void definitiveInvalidationAdvancesOwnershipEpochOnReformation(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ long oldEpoch = savedData.ensureControllerActive(controller);
+ var offlinePart = getPersistentPart(controller);
+ savedData.removeAsyncLogic(controller);
+
+ offlinePart.unloadedFromController(controller);
+ controller.getParts().removeIf(candidate -> candidate == offlinePart);
+ controller.onStructureInvalid();
+
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), oldEpoch) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.RETIRED,
+ "A definitive structure invalidation did not retire its ownership epoch");
+ helper.assertTrue(offlinePart.getControllerBindingInstanceId(controller.getPos()) == oldEpoch,
+ "Invalidation could not preserve the offline part's old claim for tombstone reconciliation");
+
+ controller.onStructureFormed();
+ long newEpoch = controller.getStructureInstanceId();
+ helper.assertTrue(newEpoch > oldEpoch,
+ "Reforming a definitively invalidated structure reused its retired ownership epoch");
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), oldEpoch) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.RETIRED,
+ "Activating the new formation epoch revived the old epoch");
+ helper.assertTrue(offlinePart.getControllerBindingInstanceId(controller.getPos()) == newEpoch,
+ "The reformed structure did not migrate its offline part to the new ownership epoch");
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void formedControllerLoadRepairsATombstoneFirstSave(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ long staleFormedEpoch = savedData.ensureControllerActive(controller);
+ helper.assertTrue(controller.isStructureFormedSnapshot(),
+ "The tombstone-first regression requires persisted formed state");
+ savedData.retireController(controller);
+
+ controller.onLoad();
+
+ long repairedEpoch = controller.getStructureInstanceId();
+ helper.assertTrue(repairedEpoch > staleFormedEpoch,
+ "Loading a persisted formation reactivated its tombstoned ownership epoch");
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), repairedEpoch) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.ACTIVE,
+ "Loading a persisted formation did not publish its repaired ownership epoch");
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), staleFormedEpoch) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.RETIRED,
+ "Repairing a tombstone-first save revived the stale formed epoch");
+ savedData.removeAsyncLogic(controller);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void unformedControllerLoadKeepsNbtFirstEpochRetired(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ savedData.ensureControllerActive(controller);
+ controller.onStructureInvalid();
+ long nbtFirstEpoch = savedData.ensureControllerActive(controller);
+ helper.assertFalse(controller.isStructureFormedSnapshot(),
+ "The NBT-first regression requires an unformed controller snapshot");
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), nbtFirstEpoch) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.ACTIVE,
+ "The NBT-first regression did not create the simulated premature ACTIVE epoch");
+
+ controller.onLoad();
+
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), nbtFirstEpoch) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.RETIRED,
+ "Loading unformed controller NBT published an empty ACTIVE ownership epoch");
+ controller.onStructureFormed();
+ long formedEpoch = controller.getStructureInstanceId();
+ helper.assertTrue(formedEpoch > nbtFirstEpoch,
+ "The next successful formation reused the unformed NBT epoch");
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), formedEpoch) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.ACTIVE,
+ "The successful formation did not publish its new ownership epoch");
+ savedData.removeAsyncLogic(controller);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void unformedZeroNbtTombstonesActiveSavedData(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ savedData.ensureControllerActive(controller);
+ controller.onStructureInvalid();
+ long savedDataFirstEpoch = savedData.ensureControllerActive(controller);
+ controller.setStructureInstanceId(0);
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), savedDataFirstEpoch) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.ACTIVE,
+ "The rollback regression did not create ACTIVE SavedData ahead of controller NBT");
+
+ controller.onLoad();
+
+ helper.assertTrue(controller.getStructureInstanceId() == 0,
+ "Loading an unformed controller adopted an ACTIVE epoch missing from its NBT");
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), savedDataFirstEpoch) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.RETIRED,
+ "Unformed zero-id NBT did not tombstone the higher ACTIVE SavedData epoch");
+ controller.onStructureFormed();
+ helper.assertTrue(controller.getStructureInstanceId() > savedDataFirstEpoch,
+ "A successful formation did not advance beyond the rolled-back ACTIVE epoch");
+ savedData.removeAsyncLogic(controller);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void controllerAndPartOwnerIdentitiesAreNotSavedIntoDrops(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ long controllerInstance = savedData.ensureControllerActive(controller);
+ var part = (MultiblockPartMachine) controller.getParts().stream()
+ .filter(MultiblockPartMachine.class::isInstance)
+ .findFirst()
+ .orElseThrow();
+ helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == controllerInstance,
+ "A formed part did not persist the exact controller incarnation");
+
+ part.unloadedFromController(controller);
+ helper.assertTrue(part.isFormed(),
+ "A transient runtime unlink made an owned exclusive part available to another controller");
+ helper.assertTrue(part.hasController(controller),
+ "A transient runtime unlink lost the exact persisted owner pair");
+
+ CompoundTag controllerWorldTag = new CompoundTag();
+ CompoundTag controllerDropTag = new CompoundTag();
+ CompoundTag partWorldTag = new CompoundTag();
+ CompoundTag partDropTag = new CompoundTag();
+ controller.saveCustomPersistedData(controllerWorldTag, false);
+ controller.saveCustomPersistedData(controllerDropTag, true);
+ part.saveCustomPersistedData(partWorldTag, false);
+ part.saveCustomPersistedData(partDropTag, true);
+
+ helper.assertTrue(controllerWorldTag.getLong("gtceuStructureInstanceId") == controllerInstance,
+ "A world save omitted the controller incarnation");
+ helper.assertFalse(controllerDropTag.contains("gtceuStructureInstanceId"),
+ "A controller item inherited the old physical incarnation");
+ helper.assertTrue(controllerWorldTag.contains("gtceuConfirmedStructureChunks"),
+ "A world save omitted the controller's confirmed structure chunks");
+ helper.assertFalse(controllerDropTag.contains("gtceuConfirmedStructureChunks"),
+ "A controller item inherited the old structure geometry");
+ helper.assertTrue(partWorldTag.contains("gtceuMultiblockControllerBindings"),
+ "A world save omitted the part owner pair");
+ helper.assertFalse(partDropTag.contains("gtceuMultiblockControllerBindings"),
+ "A part item inherited a structure owner pair");
+
+ part.addedToController(controller);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation",
+ batch = "MultiblockWorldSavedData",
+ setupTicks = 40,
+ timeoutTicks = 40)
+ public static void retiredControllerBindingReleasesAStillLoadedPart(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ long controllerInstance = savedData.ensureControllerActive(controller);
+ var part = getPersistentPart(controller);
+ savedData.removeAsyncLogic(controller);
+ helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == controllerInstance,
+ "The retirement regression requires an exact persisted owner pair");
+
+ // This is the part-side state after its controller unloads while the part chunk remains loaded. Destruction of
+ // the controller later supplies the positive tombstone; no owner chunk lookup is needed to release the part.
+ part.unloadedFromController(controller);
+ savedData.retireController(controller);
+
+ helper.succeedWhen(() -> {
+ helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0,
+ "A controller tombstone did not release its still-loaded part on the periodic reconciliation");
+ helper.assertFalse(part.isFormed(),
+ "A part stayed exclusively locked after its exact controller incarnation was retired");
+ });
+ }
+
+ @GameTest(template = "lcr_input_separation",
+ batch = "MultiblockWorldSavedData",
+ setupTicks = 40,
+ timeoutTicks = 40)
+ public static void loadedPositionWithoutExactControllerReleasesActiveClaim(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ long claimedInstance = savedData.ensureControllerActive(controller);
+ var part = getPersistentPart(controller);
+ savedData.removeAsyncLogic(controller);
+
+ part.unloadedFromController(controller);
+ controller.setStructureInstanceId(claimedInstance + 1);
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), claimedInstance) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.ACTIVE,
+ "The crash-order regression requires a stale ACTIVE registry entry");
+ helper.assertTrue(savedData.isControllerPositionLoadedNoChunkRequest(controller.getPos()),
+ "The controller position was not available for a non-loading absence check");
+
+ helper.succeedWhen(() -> {
+ helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0,
+ "A loaded position holding a different controller generation kept an ACTIVE claim forever");
+ helper.assertFalse(part.isFormed(),
+ "A part stayed exclusive after its claimed controller generation was positively absent");
+ });
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void firstBindingReconciliationRunsImmediatelyAfterTheOnLoadGraceTick(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ long claimedInstance = savedData.ensureControllerActive(controller);
+ var part = getPersistentPart(controller);
+ savedData.removeAsyncLogic(controller);
+
+ part.unloadedFromController(controller);
+ controller.setStructureInstanceId(claimedInstance + 1);
+ resetBindingReconciliationDelay(part);
+
+ tickControllerBindings(part);
+ helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == claimedInstance,
+ "The first reconciliation tick did not preserve the same-chunk block-entity load grace period");
+ tickControllerBindings(part);
+ helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0,
+ "The second reconciliation tick waited for the 20-tick cadence before releasing an absent owner");
+
+ controller.setStructureInstanceId(claimedInstance);
+ part.addedToController(controller);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation",
+ batch = "MultiblockWorldSavedData",
+ setupTicks = 40,
+ timeoutTicks = 40)
+ public static void loadedPositionWithoutExactControllerReleasesUnknownNewerClaim(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ long registryInstance = savedData.ensureControllerActive(controller);
+ long newerClaim = registryInstance + 1;
+ var part = getPersistentPart(controller);
+ savedData.removeAsyncLogic(controller);
+
+ part.unloadedFromController(controller);
+ CompoundTag persistedPart = new CompoundTag();
+ ListTag bindings = new ListTag();
+ CompoundTag binding = new CompoundTag();
+ binding.putLong("pos", controller.getPos().asLong());
+ binding.putLong("instanceId", newerClaim);
+ bindings.add(binding);
+ persistedPart.put("gtceuMultiblockControllerBindings", bindings);
+ part.loadCustomPersistedData(persistedPart);
+ controller.setStructureInstanceId(newerClaim + 1);
+
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), newerClaim) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.UNKNOWN,
+ "A claim newer than the crash-lagging registry was not kept UNKNOWN");
+ helper.assertTrue(savedData.isControllerPositionLoadedNoChunkRequest(controller.getPos()),
+ "The controller position was not entity-ticking for a positive absence check");
+
+ helper.succeedWhen(() -> {
+ helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0,
+ "An UNKNOWN newer claim survived even though its exact owner was positively absent");
+ helper.assertFalse(part.isFormed(),
+ "An UNKNOWN orphan kept a part exclusive after the owner position was fully ticking");
+ });
+ }
+
+ @GameTest(template = "lcr_input_separation",
+ batch = "MultiblockWorldSavedData",
+ setupTicks = 40,
+ timeoutTicks = 40)
+ public static void activeButDefinitivelyInvalidControllerReleasesAnOfflinePart(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ long controllerInstance = savedData.ensureControllerActive(controller);
+ var part = getPersistentPart(controller);
+ savedData.removeAsyncLogic(controller);
+
+ part.unloadedFromController(controller);
+ controller.getParts().removeIf(candidate -> candidate == part);
+ controller.onStructureInvalid();
+ helper.assertTrue(savedData.getControllerBindingStatus(controller.getPos(), controllerInstance) ==
+ MultiblockWorldSavedData.ControllerBindingStatus.RETIRED,
+ "A definitive invalidation left the controller ownership epoch active");
+
+ helper.succeedWhen(() -> {
+ helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0,
+ "A loaded, definitively unformed controller kept an offline part locked");
+ helper.assertFalse(part.isFormed(),
+ "A part stayed exclusive after its loaded owner completed an invalid structure check");
+ });
+ }
+
+ @GameTest(template = "lcr_input_separation",
+ batch = "MultiblockWorldSavedData",
+ setupTicks = 40,
+ timeoutTicks = 40)
+ public static void operationalControllerCannotKeepAnUnattachedPartClaim(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ savedData.ensureControllerActive(controller);
+ var part = getPersistentPart(controller);
+ savedData.removeAsyncLogic(controller);
+
+ part.unloadedFromController(controller);
+ controller.getParts().removeIf(candidate -> candidate == part);
+ helper.assertTrue(controller.isStructureOperational(),
+ "The membership regression requires a fully validated controller");
+
+ helper.succeedWhen(() -> {
+ helper.assertTrue(part.getControllerBindingInstanceId(controller.getPos()) == 0,
+ "An operational controller retained a part absent from its runtime identity membership");
+ helper.assertFalse(part.isFormed(),
+ "A part stayed exclusive after its operational owner stopped attaching it");
+ });
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData", setupTicks = 40)
+ public static void definitiveMismatchInvalidatesButKeepsRetryQueued(GameTestHelper helper) {
+ var controller = getController(helper);
+ var state = controller.getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ var definition = (MultiblockMachineDefinition) controller.getDefinition();
+ Supplier originalPatternFactory = definition.getPatternFactory();
+ helper.assertTrue(controller.isFormed(), "The intact multiblock was not formed before the mismatch check");
+ CompoundTag formedTag = new CompoundTag();
+ controller.saveCustomPersistedData(formedTag, false);
+ helper.assertTrue(formedTag.contains("gtceuConfirmedStructureChunks"),
+ "The invalidation regression requires a persisted confirmed chunk snapshot");
+
+ savedData.removeAsyncLogic(controller);
+ savedData.removeMapping(state);
+ state.setError(null);
+ definition.setPatternFactory(DefinitiveMismatchPattern::new);
+ try {
+ savedData.addAsyncLogic(controller);
+ controller.asyncCheckPattern(savedData.getPeriodID());
+ } finally {
+ definition.setPatternFactory(originalPatternFactory);
+ }
+
+ helper.assertFalse(controller.isStructureFormedSnapshot(),
+ "A definitive mismatch retained the previous formation snapshot");
+ helper.assertFalse(controller.isFormed(), "A definitive mismatch left the formed facade available");
+ helper.assertTrue(state.error != MultiblockState.UNLOAD_ERROR,
+ "A definitive mismatch was misclassified as a temporary unload");
+ helper.assertFalse(savedData.mapping.containsKey(controller.getPos()),
+ "The definitive mismatch left an unverified structure mapping installed");
+ helper.assertTrue(savedData.isStructureCheckPending(controller),
+ "The definitive mismatch did not leave the controller queued for a retry");
+ helper.assertFalse(controller.isStructureRevalidationPending(),
+ "A definitive mismatch remained marked as transiently awaiting revalidation");
+ CompoundTag invalidTag = new CompoundTag();
+ controller.saveCustomPersistedData(invalidTag, false);
+ helper.assertFalse(invalidTag.contains("gtceuConfirmedStructureChunks"),
+ "A definitive structure invalidation retained the old confirmed chunk snapshot");
+ savedData.removeAsyncLogic(controller);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData")
+ public static void staleMappingCannotRemoveItsReplacement(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ BlockPos controllerPos = controller.getPos();
+ var stale = new MultiblockState(helper.getLevel(), controllerPos);
+ var current = controller.getMultiblockState();
+ stale.clean();
+ current.clean();
+ BlockPos indexedPos = helper.absolutePos(new BlockPos(48, 2, 0));
+ current.addPosCache(indexedPos, new TraceabilityPredicate());
+
+ helper.assertTrue(savedData.tryAddMapping(current), "The current controller mapping was not installed");
+ helper.assertFalse(savedData.tryAddMapping(stale), "A stale controller mapping was accepted");
+ savedData.removeMapping(stale);
+
+ helper.assertTrue(savedData.mapping.get(controllerPos) == current,
+ "A stale state replaced or removed the current controller's mapping");
+ helper.assertTrue(savedData.getControllersInChunk(new ChunkPos(indexedPos)).contains(current),
+ "Removing a stale state also removed the current state's chunk index");
+ savedData.removeMapping(current);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData")
+ public static void remappingRefreshesDynamicChunkIndexes(GameTestHelper helper) {
+ var controller = getController(helper);
+ var state = controller.getMultiblockState();
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ BlockPos oldPos = helper.absolutePos(new BlockPos(48, 2, 0));
+ BlockPos newPos = helper.absolutePos(new BlockPos(80, 2, 0));
+ ChunkPos oldChunk = new ChunkPos(oldPos);
+ ChunkPos newChunk = new ChunkPos(newPos);
+
+ state.clean();
+ state.addPosCache(oldPos, new TraceabilityPredicate());
+ helper.assertTrue(savedData.tryAddMapping(state), "The original controller mapping was not installed");
+ state.clean();
+ state.addPosCache(newPos, new TraceabilityPredicate());
+ helper.assertTrue(savedData.tryAddMapping(state), "The refreshed controller mapping was not installed");
+
+ helper.assertFalse(savedData.getControllersInChunk(oldChunk).contains(state),
+ "Refreshing a dynamic pattern left its old chunk index behind");
+ helper.assertTrue(savedData.getControllersInChunk(newChunk).contains(state),
+ "Refreshing a dynamic pattern did not install its new chunk index");
+ savedData.removeMapping(state);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData")
+ public static void cancelledQueueTokensRemainBoundedWithAnActiveSentinel(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ int baselinePending = savedData.getPendingStructureCheckCount();
+ IMultiController sentinel = countingController(controller, new AtomicInteger(),
+ Collections.newSetFromMap(new IdentityHashMap<>()));
+ IMultiController churn = countingController(controller, new AtomicInteger(),
+ Collections.newSetFromMap(new IdentityHashMap<>()));
+ savedData.addAsyncLogic(sentinel);
+
+ for (int i = 0; i < 256; i++) {
+ savedData.addAsyncLogic(churn);
+ savedData.removeAsyncLogic(churn);
+ }
+
+ helper.assertTrue(savedData.getPendingStructureCheckCount() == baselinePending + 1,
+ "Cancelled controllers leaked into the active pending set");
+ helper.assertTrue(savedData.getQueuedStructureCheckTokenCount() <=
+ Math.max(64, savedData.getPendingStructureCheckCount() * 2),
+ "Cancelled controller tokens grew the queue beyond its rebuild bound");
+ savedData.removeAsyncLogic(sentinel);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData")
+ public static void queueCompactionDoesNotDuplicateInFlightToken(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ var actorCalls = new AtomicInteger();
+ IMultiController churn = countingController(controller, new AtomicInteger(),
+ Collections.newSetFromMap(new IdentityHashMap<>()));
+ IMultiController actor = callbackController(controller, actorCalls, () -> {
+ // Force removeAsyncLogic() to rebuild the ordered queue while actor's token is out but still active.
+ // Copying that in-flight token here would make tick() append the same mutable token twice.
+ for (int i = 0; i < 128; i++) {
+ savedData.addAsyncLogic(churn);
+ savedData.removeAsyncLogic(churn);
+ }
+ });
+ savedData.addAsyncLogic(actor);
+
+ for (int tick = 0; tick < 128 && actorCalls.get() == 0; tick++) {
+ savedData.tick();
+ }
+
+ helper.assertTrue(actorCalls.get() == 1, "The in-flight controller was not checked exactly once");
+ helper.assertTrue(savedData.getQueuedStructureCheckTokenCount(actor) == 1,
+ "Heap compaction duplicated the in-flight controller token");
+ savedData.removeAsyncLogic(actor);
+ savedData.removeAsyncLogic(churn);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockScheduler", setupTicks = 40)
+ public static void invalidRetriesBackOffAndBlockChangesWakeImmediately(GameTestHelper helper) {
+ var machine = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ var calls = new AtomicInteger();
+ var state = new MultiblockState(helper.getLevel(), machine.getPos());
+ IMultiController candidate = statefulCallbackController(machine, state, calls,
+ () -> state.setError(MultiblockState.UNINIT_ERROR));
+ savedData.addAsyncLogic(candidate);
+
+ tickUntilCalls(helper, savedData, calls, 1, 64);
+ helper.assertTrue(savedData.getStructureCheckInvalidRetryCount(candidate) == 1,
+ "The first definitive mismatch did not enter exponential backoff");
+ helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 20,
+ "The first definitive mismatch did not use the 20-tick base delay");
+
+ tickUntilCalls(helper, savedData, calls, 2, 64);
+ helper.assertTrue(savedData.getStructureCheckInvalidRetryCount(candidate) == 2,
+ "A repeated definitive mismatch did not advance the backoff exponent");
+ helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 40,
+ "The second definitive mismatch did not double the retry delay");
+
+ savedData.onBlockChanged(machine.getPos());
+ helper.assertTrue(savedData.getStructureCheckInvalidRetryCount(candidate) == 0,
+ "A relevant block change did not reset the mismatch backoff");
+ helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 1,
+ "A relevant block change did not expedite validation to the next scheduler tick");
+ tickUntilCalls(helper, savedData, calls, 3, 64);
+ helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 20,
+ "The retry after a block-change wake did not restart at the base delay");
+
+ savedData.removeAsyncLogic(candidate);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockScheduler", setupTicks = 40)
+ public static void unloadedChecksWaitOnTheirConfirmedChunksWithoutScanning(GameTestHelper helper) {
+ var machine = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ var calls = new AtomicInteger();
+ var state = new MultiblockState(helper.getLevel(), machine.getPos());
+ long missingChunk = new ChunkPos(findUnloadedPos(helper, machine.getPos())).toLong();
+ IMultiController candidate = statefulCallbackController(machine, state, calls, new long[] { missingChunk },
+ () -> state.setError(MultiblockState.UNLOAD_ERROR));
+ savedData.addAsyncLogic(candidate);
+
+ tickUntilCalls(helper, savedData, calls, 1, 64);
+ helper.assertTrue(savedData.isStructureCheckParked(candidate),
+ "An unloaded confirmed structure was not parked after its first scan");
+ for (int tick = 0; tick < 80; tick++) {
+ savedData.tick();
+ }
+ helper.assertTrue(calls.get() == 1,
+ "Cheap unloaded-chunk readiness probes unexpectedly ran another full pattern scan");
+ savedData.onChunkLoad(new ChunkPos(machine.getPos()));
+ helper.assertTrue(savedData.isStructureCheckParked(candidate),
+ "An unrelated chunk-load event woke a precisely parked structure");
+
+ savedData.removeAsyncLogic(candidate);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockScheduler", setupTicks = 40)
+ public static void unloadWithoutChunkMetadataHasEventAndTimerFallbacks(GameTestHelper helper) {
+ var machine = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ var calls = new AtomicInteger();
+ var state = new MultiblockState(helper.getLevel(), machine.getPos());
+ IMultiController candidate = statefulCallbackController(machine, state, calls,
+ () -> state.setError(MultiblockState.UNLOAD_ERROR));
+ savedData.addAsyncLogic(candidate);
+
+ tickUntilCalls(helper, savedData, calls, 1, 64);
+ helper.assertTrue(savedData.isStructureCheckParked(candidate),
+ "A compatibility controller with an unloaded result was not parked");
+ helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 20,
+ "A parked compatibility controller did not retain a bounded readiness fallback");
+ tickUntilCalls(helper, savedData, calls, 2, 64);
+ helper.assertTrue(savedData.isStructureCheckParked(candidate),
+ "The readiness fallback lost the parked state after another unloaded result");
+ helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 40,
+ "Repeated non-chunk UNLOAD results did not back off their full-scan fallback");
+
+ savedData.onChunkLoad(new ChunkPos(machine.getPos()));
+ helper.assertTrue(savedData.getStructureCheckDelay(candidate) == 2,
+ "A chunk-load event did not expedite a compatibility controller's fallback");
+ tickUntilCalls(helper, savedData, calls, 3, 64);
+
+ savedData.removeAsyncLogic(candidate);
+ helper.succeed();
+ }
+
+ @GameTest(template = "lcr_input_separation", batch = "MultiblockWorldSavedData")
+ public static void queuedChecksAreBoundedAndEventuallyReachEveryController(GameTestHelper helper) {
+ var controller = getController(helper);
+ var savedData = MultiblockWorldSavedData.getOrCreate(helper.getLevel());
+ var calls = new AtomicInteger();
+ Set checked = Collections.newSetFromMap(new IdentityHashMap<>());
+ List queued = new ArrayList<>();
+ for (int i = 0; i < 12; i++) {
+ IMultiController candidate = countingController(controller, calls, checked);
+ queued.add(candidate);
+ savedData.addAsyncLogic(candidate);
+ }
+
+ for (int tick = 0; tick < 128 && checked.size() < queued.size(); tick++) {
+ int before = calls.get();
+ savedData.tick();
+ helper.assertTrue(calls.get() - before <= 1,
+ "One level tick performed more than one full structure check");
+ }
+
+ helper.assertTrue(checked.size() == queued.size(),
+ "The bounded scheduler starved one or more queued controllers");
+ queued.forEach(savedData::removeAsyncLogic);
+ helper.succeed();
+ }
+
+ private static void tickUntilCalls(GameTestHelper helper, MultiblockWorldSavedData savedData,
+ AtomicInteger calls, int expectedCalls, int maxTicks) {
+ for (int tick = 0; tick < maxTicks && calls.get() < expectedCalls; tick++) {
+ savedData.tick();
+ }
+ helper.assertTrue(calls.get() == expectedCalls,
+ "The scheduler did not perform the expected number of full checks within its bounded test window");
+ }
+
+ private static MultiblockControllerMachine getController(GameTestHelper helper) {
+ return (MultiblockControllerMachine) ((IMachineBlockEntity) helper.getBlockEntity(LCR_CONTROLLER_POS))
+ .getMetaMachine();
+ }
+
+ private static MultiblockPartMachine getPersistentPart(MultiblockControllerMachine controller) {
+ return (MultiblockPartMachine) controller.getParts().stream()
+ .filter(MultiblockPartMachine.class::isInstance)
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private static BlockPos findUnloadedPos(GameTestHelper helper, BlockPos origin) {
+ for (int chunkOffset = 32; chunkOffset <= 2048; chunkOffset += 32) {
+ BlockPos candidate = origin.offset(chunkOffset * 16, 0, 0);
+ if (!helper.getLevel().isLoaded(candidate)) {
+ return candidate;
+ }
+ }
+ throw new IllegalStateException("Could not find an unloaded chunk for the multiblock regression test");
+ }
+
+ private static void assertMappingRemovedAndCheckPending(GameTestHelper helper,
+ MultiblockControllerMachine controller,
+ MultiblockWorldSavedData savedData, String action) {
+ helper.assertFalse(savedData.mapping.containsKey(controller.getPos()),
+ "The " + action + " left an unverified structure mapping installed");
+ helper.assertTrue(savedData.isStructureCheckPending(controller),
+ "The " + action + " did not leave the controller queued for a retry");
+ helper.assertTrue(controller.isStructureRevalidationPending(),
+ "The " + action + " did not mark the controller as awaiting revalidation");
+ savedData.removeAsyncLogic(controller);
+ helper.succeed();
+ }
+
+ private static boolean hasWorkSubscription(WorkableMultiblockMachine controller) {
+ try {
+ Field subscription = WorkLogic.class.getDeclaredField("subscription");
+ subscription.setAccessible(true);
+ return subscription.get(controller.getWorkLogic()) != null;
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError("Could not inspect the work subscription", e);
+ }
+ }
+
+ private static void resetBindingReconciliationDelay(MultiblockPartMachine part) {
+ setBooleanField(part, "controllerBindingReconciliationArmed", false);
+ setBooleanField(part, "controllerBindingInitialReconciliationComplete", false);
+ }
+
+ private static void assertCacheAliases(GameTestHelper helper, MultiblockState state, boolean expected,
+ String message) {
+ boolean aliases = getFieldValue(state, "predicateMap") == getFieldValue(state, "confirmedPredicateMap") &&
+ getFieldValue(state, "cachedChunks") == getFieldValue(state, "confirmedCachedChunks");
+ helper.assertTrue(aliases == expected, message);
+ }
+
+ private static Object getFieldValue(MultiblockState state, String fieldName) {
+ try {
+ Field field = MultiblockState.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ return field.get(state);
+ } catch (ReflectiveOperationException exception) {
+ throw new AssertionError("Could not inspect multiblock pattern cache", exception);
+ }
+ }
+
+ private static void setBooleanField(MultiblockPartMachine part, String fieldName, boolean value) {
+ try {
+ Field field = MultiblockPartMachine.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.setBoolean(part, value);
+ } catch (ReflectiveOperationException exception) {
+ throw new AssertionError("Could not configure controller-binding reconciliation", exception);
+ }
+ }
+
+ private static void tickControllerBindings(MultiblockPartMachine part) {
+ try {
+ var method = MultiblockPartMachine.class.getDeclaredMethod("tickControllerBindings");
+ method.setAccessible(true);
+ method.invoke(part);
+ } catch (ReflectiveOperationException exception) {
+ throw new AssertionError("Could not invoke controller-binding reconciliation", exception);
+ }
+ }
+
+ private static IMultiController countingController(MultiblockControllerMachine machine, AtomicInteger calls,
+ Set checked) {
+ MultiblockState state = new MultiblockState(machine.getLevel(), machine.getPos());
+ return statefulCallbackController(machine, state, calls,
+ () -> checked.add(state.lastController));
+ }
+
+ private static IMultiController statefulCallbackController(MultiblockControllerMachine machine,
+ MultiblockState state, AtomicInteger calls,
+ Runnable callback) {
+ return statefulCallbackController(machine, state, calls, new long[0], callback);
+ }
+
+ private static IMultiController statefulCallbackController(MultiblockControllerMachine machine,
+ MultiblockState state, AtomicInteger calls,
+ long[] confirmedChunks, Runnable callback) {
+ return (IMultiController) Proxy.newProxyInstance(IMultiController.class.getClassLoader(),
+ new Class>[] { IMultiController.class }, (proxy, method, args) -> {
+ return switch (method.getName()) {
+ case "self" -> machine;
+ case "getMultiblockState" -> state;
+ case "getConfirmedStructureChunks" -> Arrays.copyOf(confirmedChunks, confirmedChunks.length);
+ case "asyncCheckPattern" -> {
+ calls.incrementAndGet();
+ state.lastController = (IMultiController) proxy;
+ callback.run();
+ yield null;
+ }
+ case "isStructureRevalidationPending", "isFormed", "isStructureFormedSnapshot", "isStructureOperational" -> false;
+ case "setStructureRevalidationPending" -> null;
+ case "toString" -> "queued-test-controller";
+ default -> defaultValue(method.getReturnType());
+ };
+ });
+ }
+
+ private static IMultiController callbackController(MultiblockControllerMachine machine, AtomicInteger calls,
+ Runnable callback) {
+ return statefulCallbackController(machine, new MultiblockState(machine.getLevel(), machine.getPos()), calls,
+ callback);
+ }
+
+ private static IMultiPart removalTrackingPart(AtomicInteger removals) {
+ return (IMultiPart) Proxy.newProxyInstance(IMultiPart.class.getClassLoader(),
+ new Class>[] { IMultiPart.class },
+ (proxy, method, args) -> {
+ return switch (method.getName()) {
+ case "removedFromController" -> {
+ removals.incrementAndGet();
+ yield null;
+ }
+ case "toString" -> "stale-test-part";
+ default -> defaultValue(method.getReturnType());
+ };
+ });
+ }
+
+ private static Object defaultValue(Class> type) {
+ if (!type.isPrimitive() || type == void.class) return null;
+ if (type == boolean.class) return false;
+ if (type == char.class) return '\0';
+ if (type == byte.class) return (byte) 0;
+ if (type == short.class) return (short) 0;
+ if (type == int.class) return 0;
+ if (type == long.class) return 0L;
+ if (type == float.class) return 0F;
+ return 0D;
+ }
+
+ private static final class UnloadedPattern extends BlockPattern {
+
+ private final BlockPos unloadedPos;
+
+ private UnloadedPattern(BlockPos unloadedPos) {
+ super(new TraceabilityPredicate[0][][], new RelativeDirection[0], new int[0][], new int[5]);
+ this.unloadedPos = unloadedPos;
+ }
+
+ @Override
+ public boolean checkPatternAt(MultiblockState state, boolean savePredicate) {
+ state.clean();
+ state.addPosCache(state.controllerPos, new TraceabilityPredicate());
+ return state.update(unloadedPos, new TraceabilityPredicate());
+ }
+ }
+
+ private static final class UnloadedThenMismatchPattern extends BlockPattern {
+
+ private UnloadedThenMismatchPattern() {
+ super(new TraceabilityPredicate[0][][], new RelativeDirection[0], new int[0][], new int[5]);
+ }
+
+ @Override
+ public boolean checkPatternAt(MultiblockState state, BlockPos centerPos, Direction frontFacing,
+ Direction upwardsFacing, boolean isFlipped, boolean savePredicate) {
+ state.setError(isFlipped ? MultiblockState.UNINIT_ERROR : MultiblockState.UNLOAD_ERROR);
+ return false;
+ }
+ }
+
+ private static final class DefinitiveMismatchPattern extends BlockPattern {
+
+ private DefinitiveMismatchPattern() {
+ super(new TraceabilityPredicate[0][][], new RelativeDirection[0], new int[0][], new int[5]);
+ }
+
+ @Override
+ public boolean checkPatternAt(MultiblockState state, boolean savePredicate) {
+ state.setError(MultiblockState.UNINIT_ERROR);
+ return false;
+ }
+ }
+
+ private static final class PersistedFlipUnloadPattern extends BlockPattern {
+
+ private final AtomicInteger oppositeFlipChecks;
+
+ private PersistedFlipUnloadPattern(AtomicInteger oppositeFlipChecks) {
+ super(new TraceabilityPredicate[0][][], new RelativeDirection[0], new int[0][], new int[5]);
+ this.oppositeFlipChecks = oppositeFlipChecks;
+ }
+
+ @Override
+ public boolean checkPatternAt(MultiblockState state, BlockPos centerPos, Direction frontFacing,
+ Direction upwardsFacing, boolean isFlipped, boolean savePredicate) {
+ if (isFlipped) {
+ state.setError(MultiblockState.UNLOAD_ERROR);
+ return false;
+ }
+ oppositeFlipChecks.incrementAndGet();
+ state.setError(null);
+ return true;
+ }
+ }
+
+ private static final class SuccessfulFallbackPattern extends BlockPattern {
+
+ private final AtomicInteger checks;
+
+ private SuccessfulFallbackPattern(AtomicInteger checks) {
+ super(new TraceabilityPredicate[0][][], new RelativeDirection[0], new int[0][], new int[5]);
+ this.checks = checks;
+ }
+
+ @Override
+ public boolean checkPatternAt(MultiblockState state, boolean savePredicate) {
+ checks.incrementAndGet();
+ state.setError(null);
+ return true;
+ }
+ }
+}