1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2024-06-26 07:03:22 +00:00

Atomic update of disk drive item stacks

Disk drives have had a long-standing issue with mutating their contents
on the computer thread, potentially leading to all sorts of odd bugs.

We tried to fix this by moving setDiskLabel and the mounting code to run
on the main thread. Unfortunately, this means there is a slight delay to
mounts being attached, breaking disk startup.

This commit implements an alternative solution - we now do mounting on
the computer thread again. If the disk's stack is modified, we update it
in the peripheral-facing item, but not the actual inventory. The next
time the disk drive is ticked, we then sync the two items.

This does mean that there is a fraction of a tick where the two will be
out-of-sync. This isn't ideal - it would potentially be possible to
cycle through disk ids - but I don't really think that's avoidable
without significantly complicating the IMedia API.

Fixes #1649, fixes #1686.
This commit is contained in:
Jonathan Coates 2024-01-20 18:46:43 +00:00
parent bc03090ca4
commit f695f22d8a
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
7 changed files with 348 additions and 110 deletions

View File

@ -7,6 +7,7 @@
import com.google.errorprone.annotations.concurrent.GuardedBy;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.api.media.IMedia;
import dan200.computercraft.api.peripheral.IComputerAccess;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.shared.common.AbstractContainerBlockEntity;
@ -32,6 +33,28 @@
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* The underlying block entity for disk drives. This holds the main logic for the {@linkplain DiskDrivePeripheral disk
* drive peripheral}, such as handling mounts and {@linkplain DiskDrivePeripheral#playAudio() playing audio}.
* <p>
* Most disk drive peripheral methods execute on the computer thread (largely due to historic reasons). This causes some
* problems, as the disk item could be read by both the computer thread (via peripheral calls) and main thread (via
* Minecraft inventory interaction).
* <p>
* To solve this, we use an immutable {@link MediaStack}, which holds an immutable version of the current
* {@link ItemStack} (and its corresponding {@link IMedia}). When the {@linkplain #setChanged() inventory is changed},
* we {@linkplain #updateMedia() update the media stack} and recompute mounts.
* <p>
* This is somewhat complicated by {@link #attach(IComputerAccess)}. As that can happen on the computer thread and
* may mutate the stack (when {@link IMedia#createDataMount(ItemStack, ServerLevel)} assigns an ID for the first time),
* we need a way to safely update the inventory. To solve this, all internal non-inventory interactions with disk drives
* treat the media stack as the "primary" stack. This allows us to atomically update it, and then sync it back to the
* main inventory ({@link #updateMediaStack(ItemStack, boolean)}) either directly ({@link #updateDiskFromMedia()}) or
* on the next block tick ({@link #stackDirty}). This does mean there's a one-tick delay where the inventory may be
* out-of-date, but that should happen very rarely.
*
* @see DiskDrivePeripheral
*/
public final class DiskDriveBlockEntity extends AbstractContainerBlockEntity {
private static final String NBT_ITEM = "Item";
@ -42,11 +65,13 @@ private static final class MountInfo {
private final DiskDrivePeripheral peripheral = new DiskDrivePeripheral(this);
private final @GuardedBy("this") Map<IComputerAccess, MountInfo> computers = new HashMap<>();
private final NonNullList<ItemStack> inventory = NonNullList.withSize(1, ItemStack.EMPTY);
@GuardedBy("this")
private final Map<IComputerAccess, MountInfo> computers = new HashMap<>();
@GuardedBy("this")
private MediaStack media = MediaStack.EMPTY;
@GuardedBy("this")
private @Nullable Mount mount;
private boolean recordPlaying = false;
@ -54,7 +79,12 @@ private static final class MountInfo {
// then read them when ticking.
private final AtomicReference<RecordCommand> recordQueued = new AtomicReference<>(null);
private final AtomicBoolean ejectQueued = new AtomicBoolean(false);
private final AtomicBoolean mountQueued = new AtomicBoolean(false);
/**
* Whether the stack in {@link #media} has been modified on the computer thread, and needs to be written back to the
* inventory on the main thread.
*/
private final AtomicBoolean stackDirty = new AtomicBoolean(false);
public DiskDriveBlockEntity(BlockEntityType<DiskDriveBlockEntity> type, BlockPos pos, BlockState state) {
super(type, pos, state);
@ -66,7 +96,7 @@ public IPeripheral peripheral() {
@Override
public void clearRemoved() {
updateItem();
updateMedia();
}
@Override
@ -93,12 +123,14 @@ public void saveAdditional(CompoundTag tag) {
}
void serverTick() {
if (stackDirty.getAndSet(false)) updateDiskFromMedia();
if (ejectQueued.getAndSet(false)) ejectContents();
var recordQueued = this.recordQueued.getAndSet(null);
if (recordQueued != null) {
switch (recordQueued) {
case PLAY -> {
var media = getMedia();
var record = media.getAudio();
if (record != null) {
recordPlaying = true;
@ -112,12 +144,6 @@ var record = media.getAudio();
}
}
}
if (mountQueued.get()) {
synchronized (this) {
mountAll();
}
}
}
@Override
@ -127,38 +153,46 @@ public NonNullList<ItemStack> getContents() {
@Override
public void setChanged() {
if (level != null && !level.isClientSide) updateItem();
if (level != null && !level.isClientSide) updateMedia();
super.setChanged();
}
private void updateItem() {
var newDisk = getDiskStack();
if (ItemStack.isSameItemSameTags(newDisk, media.stack)) return;
/**
* Called on the server after the item has changed. This unmounts the old media and mounts the new one.
*/
private synchronized void updateMedia() {
var newStack = getDiskStack();
if (ItemStack.isSameItemSameTags(newStack, media.stack())) return;
var media = MediaStack.of(newDisk);
var newMedia = MediaStack.of(newStack);
if (newDisk.isEmpty()) {
if (newStack.isEmpty()) {
updateBlockState(DiskDriveState.EMPTY);
} else {
updateBlockState(media.media != null ? DiskDriveState.FULL : DiskDriveState.INVALID);
updateBlockState(newMedia.media() != null ? DiskDriveState.FULL : DiskDriveState.INVALID);
}
synchronized (this) {
// Unmount old disk
if (!this.media.stack.isEmpty()) {
for (var computer : computers.entrySet()) unmountDisk(computer.getKey(), computer.getValue());
// Unmount old disk
if (!media.stack().isEmpty()) {
for (var computer : computers.entrySet()) unmountDisk(computer.getKey(), computer.getValue());
}
// Stop music
if (recordPlaying) {
stopRecord();
recordPlaying = false;
}
// Use our new media, and (if needed) mount the new disk.
mount = null;
media = newMedia;
stackDirty.set(false);
if (!newStack.isEmpty() && !computers.isEmpty()) {
var mount = getOrCreateMount(true);
for (var entry : computers.entrySet()) {
mountDisk(entry.getKey(), entry.getValue(), mount);
}
// Stop music
if (recordPlaying) {
stopRecord();
recordPlaying = false;
}
mount = null;
this.media = media;
mountAll();
}
}
@ -166,7 +200,7 @@ ItemStack getDiskStack() {
return getItem(0);
}
MediaStack getMedia() {
synchronized MediaStack getMedia() {
return media;
}
@ -181,16 +215,31 @@ void setDiskStack(ItemStack stack) {
}
/**
* Update the current disk stack, assuming the underlying item does not change. Unlike
* {@link #setDiskStack(ItemStack)} this will not change any mounts.
*
* @param stack The new disk stack.
* Update the inventory's disk stack from the media stack. Unlike {@link #setDiskStack(ItemStack)} this will not
* change any mounts.
*/
void updateDiskStack(ItemStack stack) {
setItem(0, stack);
if (!ItemStack.isSameItemSameTags(stack, media.stack)) {
media = MediaStack.of(stack);
super.setChanged();
private synchronized void updateDiskFromMedia() {
// Write back the item to the main inventory, and then mark it as dirty.
setItem(0, media.stack().copy());
super.setChanged();
}
/**
* Atomically update {@link #media}'s stack, then sync it back to the main inventory.
*
* @param stack The original stack.
* @param immediate Whether to do this immediately (when called from the main thread) or asynchronously (when called
* from the computer thread).
*/
@GuardedBy("this")
private void updateMediaStack(ItemStack stack, boolean immediate) {
if (ItemStack.isSameItemSameTags(media.stack(), stack)) return;
media = new MediaStack(stack, media.media());
if (immediate) {
updateDiskFromMedia();
} else {
stackDirty.set(true);
}
}
@ -212,7 +261,9 @@ void attach(IComputerAccess computer) {
synchronized (this) {
var info = new MountInfo();
computers.put(computer, info);
mountQueued.set(true);
if (!media.stack().isEmpty()) {
mountDisk(computer, info, getOrCreateMount(level instanceof ServerLevel l && l.getServer().isSameThread()));
}
}
}
@ -234,53 +285,50 @@ void ejectDisk() {
ejectQueued.set(true);
}
/**
* Add our mount to all computers.
*/
@GuardedBy("this")
private void mountAll() {
doMountAll();
mountQueued.set(false);
synchronized MountResult setDiskLabel(@Nullable String label) {
if (media.media() == null) return MountResult.NO_MEDIA;
// Set the label, and write it back to the media stack.
var stack = media.stack().copy();
if (!media.media().setLabel(stack, label)) return MountResult.NOT_ALLOWED;
updateMediaStack(stack, true);
return MountResult.CHANGED;
}
/**
* The worker for {@link #mountAll()}. This is responsible for creating the mount and placing it on all computers.
*/
@GuardedBy("this")
private void doMountAll() {
if (computers.isEmpty() || media.media == null) return;
private @Nullable Mount getOrCreateMount(boolean immediate) {
if (media.media() == null) return null;
if (mount != null) return mount;
if (mount == null) {
var stack = getDiskStack();
mount = media.media.createDataMount(stack, (ServerLevel) level);
setDiskStack(stack);
}
// Set the id (if needed) and write it back to the media stack.
var stack = media.stack().copy();
mount = media.media().createDataMount(stack, (ServerLevel) level);
updateMediaStack(stack, immediate);
if (mount == null) return;
return mount;
}
for (var entry : computers.entrySet()) {
var computer = entry.getKey();
var info = entry.getValue();
if (info.mountPath != null) continue;
if (mount instanceof WritableMount writable) {
// Try mounting at the lowest numbered "disk" name we can
var n = 1;
while (info.mountPath == null) {
info.mountPath = computer.mountWritable(n == 1 ? "disk" : "disk" + n, writable);
n++;
}
} else {
// Try mounting at the lowest numbered "disk" name we can
var n = 1;
while (info.mountPath == null) {
info.mountPath = computer.mount(n == 1 ? "disk" : "disk" + n, mount);
n++;
}
private static void mountDisk(IComputerAccess computer, MountInfo info, @Nullable Mount mount) {
if (mount instanceof WritableMount writable) {
// Try mounting at the lowest numbered "disk" name we can
var n = 1;
while (info.mountPath == null) {
info.mountPath = computer.mountWritable(n == 1 ? "disk" : "disk" + n, writable);
n++;
}
computer.queueEvent("disk", computer.getAttachmentName());
} else if (mount != null) {
// Try mounting at the lowest numbered "disk" name we can
var n = 1;
while (info.mountPath == null) {
info.mountPath = computer.mount(n == 1 ? "disk" : "disk" + n, mount);
n++;
}
} else {
assert info.mountPath == null : "Mount path should be null";
}
computer.queueEvent("disk", computer.getAttachmentName());
}
private static void unmountDisk(IComputerAccess computer, MountInfo info) {
@ -327,4 +375,10 @@ private enum RecordCommand {
PLAY,
STOP,
}
enum MountResult {
NO_MEDIA,
NOT_ALLOWED,
CHANGED,
}
}

View File

@ -51,7 +51,7 @@ public String getType() {
*/
@LuaFunction
public final boolean isDiskPresent() {
return !diskDrive.getMedia().stack.isEmpty();
return !diskDrive.getMedia().stack().isEmpty();
}
/**
@ -64,7 +64,7 @@ public final boolean isDiskPresent() {
@LuaFunction
public final Object[] getDiskLabel() {
var media = diskDrive.getMedia();
return media.media == null ? null : new Object[]{ media.media.getLabel(media.stack) };
return media.media() == null ? null : new Object[]{ media.media().getLabel(media.stack()) };
}
/**
@ -80,15 +80,11 @@ public final Object[] getDiskLabel() {
*/
@LuaFunction(mainThread = true)
public final void setDiskLabel(Optional<String> label) throws LuaException {
var media = diskDrive.getMedia();
if (media.media == null) return;
// We're on the main thread so the stack and media should be in sync.
var stack = diskDrive.getDiskStack();
if (!media.media.setLabel(stack, label.map(StringUtil::normaliseLabel).orElse(null))) {
throw new LuaException("Disk label cannot be changed");
switch (diskDrive.setDiskLabel(label.map(StringUtil::normaliseLabel).orElse(null))) {
case NOT_ALLOWED -> throw new LuaException("Disk label cannot be changed");
case CHANGED, NO_MEDIA -> {
}
}
diskDrive.updateDiskStack(stack);
}
/**
@ -172,7 +168,7 @@ public final void ejectDisk() {
@Nullable
@LuaFunction
public final Object[] getDiskID() {
var disk = diskDrive.getMedia().stack;
var disk = diskDrive.getMedia().stack();
return disk.getItem() instanceof DiskItem ? new Object[]{ DiskItem.getDiskID(disk) } : null;
}

View File

@ -13,19 +13,14 @@
/**
* An immutable snapshot of the current disk. This allows us to read the stack in a thread-safe manner.
*
* @param stack An immutable {@link ItemStack}.
* @param media The associated {@link IMedia} instance for this stack.
*/
final class MediaStack {
record MediaStack(ItemStack stack, @Nullable IMedia media) {
static final MediaStack EMPTY = new MediaStack(ItemStack.EMPTY, null);
final ItemStack stack;
final @Nullable IMedia media;
private MediaStack(ItemStack stack, @Nullable IMedia media) {
this.stack = stack;
this.media = media;
}
public static MediaStack of(ItemStack stack) {
static MediaStack of(ItemStack stack) {
if (stack.isEmpty()) return EMPTY;
var freshStack = stack.copy();

View File

@ -5,6 +5,7 @@
package dan200.computercraft.gametest
import dan200.computercraft.core.apis.FSAPI
import dan200.computercraft.core.util.Colour
import dan200.computercraft.gametest.api.*
import dan200.computercraft.shared.ModRegistry
import dan200.computercraft.shared.media.items.DiskItem
@ -19,6 +20,9 @@
import net.minecraft.world.item.ItemStack
import net.minecraft.world.item.Items
import net.minecraft.world.level.block.RedStoneWireBlock
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.array
import org.hamcrest.Matchers.equalTo
import org.junit.jupiter.api.Assertions.assertEquals
class Disk_Drive_Test {
@ -45,14 +49,48 @@ fun Ejects_disk(helper: GameTestHelper) = helper.sequence {
thenWaitUntil { helper.assertItemEntityPresent(Items.MUSIC_DISC_13, stackAt, 0.0) }
}
/**
* A mount is initially attached, and then removed when the disk is ejected.
*/
@GameTest
fun Queues_event(helper: GameTestHelper) = helper.sequence {
val pos = BlockPos(1, 2, 2)
var started = false
var disk = false
var ejected = false
thenStartComputer {
// thenOnComputer discards events, so instead we need to track our state transitions.
started = true
val diskEvent = pullEvent("disk")
assertThat(diskEvent, array(equalTo("disk"), equalTo("right")))
disk = true
val ejectEvent = pullEvent("disk_eject")
assertThat(ejectEvent, array(equalTo("disk_eject"), equalTo("right")))
ejected = true
}
thenWaitUntil { helper.assertTrue(started, "Computer not started") }
thenExecute { helper.setContainerItem(pos, 0, ItemStack(Items.DIRT)) }
thenWaitUntil { helper.assertTrue(disk, "disk not inserted") }
thenExecute { helper.setContainerItem(pos, 0, ItemStack.EMPTY) }
thenWaitUntil { helper.assertTrue(ejected, "disk not ejected") }
}
/**
* A mount is initially attached, and then removed when the disk is ejected.
*/
@GameTest
fun Adds_removes_mount(helper: GameTestHelper) = helper.sequence {
thenOnComputer { } // Wait for the computer to start up
thenIdle(2) // Let the disk drive tick once to create the mount
thenOnComputer { // Then actually assert things!
thenExecute {
helper.setContainerItem(BlockPos(1, 2, 2), 0, DiskItem.createFromIDAndColour(1, null, Colour.BLACK.hex))
}
thenOnComputer {
getApi<FSAPI>().getDrive("disk").assertArrayEquals("right")
callPeripheral("right", "ejectDisk")
}

View File

@ -168,6 +168,16 @@ override fun getMessageToShowAtBlock(): String = message!!.lineSequence().first(
}
}
/**
* Get a [Container] at a given position.
*/
fun GameTestHelper.getContainerAt(pos: BlockPos): Container =
when (val container = getBlockEntity(pos)) {
is Container -> container
null -> failVerbose("Expected a container at $pos, found nothing", pos)
else -> failVerbose("Expected a container at $pos, found ${getName(container.type)}", pos)
}
/**
* Assert a container contains exactly these items and no more.
*
@ -176,10 +186,7 @@ override fun getMessageToShowAtBlock(): String = message!!.lineSequence().first(
* first `n` slots - the remaining are required to be empty.
*/
fun GameTestHelper.assertContainerExactly(pos: BlockPos, items: List<ItemStack>) =
when (val container = getBlockEntity(pos) ?: failVerbose("Expected a container at $pos, found nothing", pos)) {
is Container -> assertContainerExactlyImpl(pos, container, items)
else -> failVerbose("Expected a container at $pos, found ${getName(container.type)}", pos)
}
assertContainerExactlyImpl(pos, getContainerAt(pos), items)
/**
* Assert an container contains exactly these items and no more.
@ -287,3 +294,13 @@ private fun getName(type: BlockEntityType<*>): ResourceLocation = RegistryWrappe
fun GameTestHelper.modifyBlock(pos: BlockPos, modify: (BlockState) -> BlockState) {
setBlock(pos, modify(getBlockState(pos)))
}
/**
* Update items in the container at [pos], setting the item in the specified [slot] to [item], and then marking it
* changed.
*/
fun GameTestHelper.setContainerItem(pos: BlockPos, slot: Int, item: ItemStack) {
val container = getContainerAt(pos)
container.setItem(slot, item)
container.setChanged()
}

View File

@ -34,7 +34,7 @@
{pos: [0, 1, 4], state: "minecraft:air"},
{pos: [1, 1, 0], state: "minecraft:air"},
{pos: [1, 1, 1], state: "minecraft:air"},
{pos: [1, 1, 2], state: "computercraft:disk_drive{facing:north,state:full}", nbt: {Item: {Count: 1b, id: "computercraft:disk", tag: {Color: 1118481, DiskId: 0}}, id: "computercraft:disk_drive"}},
{pos: [1, 1, 2], state: "computercraft:disk_drive{facing:north,state:full}", nbt: {id: "computercraft:disk_drive"}},
{pos: [1, 1, 3], state: "minecraft:air"},
{pos: [1, 1, 4], state: "minecraft:air"},
{pos: [2, 1, 0], state: "minecraft:air"},

View File

@ -0,0 +1,138 @@
{
DataVersion: 2975,
size: [5, 5, 5],
data: [
{pos: [0, 0, 0], state: "minecraft:polished_andesite"},
{pos: [0, 0, 1], state: "minecraft:polished_andesite"},
{pos: [0, 0, 2], state: "minecraft:polished_andesite"},
{pos: [0, 0, 3], state: "minecraft:polished_andesite"},
{pos: [0, 0, 4], state: "minecraft:polished_andesite"},
{pos: [1, 0, 0], state: "minecraft:polished_andesite"},
{pos: [1, 0, 1], state: "minecraft:polished_andesite"},
{pos: [1, 0, 2], state: "minecraft:polished_andesite"},
{pos: [1, 0, 3], state: "minecraft:polished_andesite"},
{pos: [1, 0, 4], state: "minecraft:polished_andesite"},
{pos: [2, 0, 0], state: "minecraft:polished_andesite"},
{pos: [2, 0, 1], state: "minecraft:polished_andesite"},
{pos: [2, 0, 2], state: "minecraft:polished_andesite"},
{pos: [2, 0, 3], state: "minecraft:polished_andesite"},
{pos: [2, 0, 4], state: "minecraft:polished_andesite"},
{pos: [3, 0, 0], state: "minecraft:polished_andesite"},
{pos: [3, 0, 1], state: "minecraft:polished_andesite"},
{pos: [3, 0, 2], state: "minecraft:polished_andesite"},
{pos: [3, 0, 3], state: "minecraft:polished_andesite"},
{pos: [3, 0, 4], state: "minecraft:polished_andesite"},
{pos: [4, 0, 0], state: "minecraft:polished_andesite"},
{pos: [4, 0, 1], state: "minecraft:polished_andesite"},
{pos: [4, 0, 2], state: "minecraft:polished_andesite"},
{pos: [4, 0, 3], state: "minecraft:polished_andesite"},
{pos: [4, 0, 4], state: "minecraft:polished_andesite"},
{pos: [0, 1, 0], state: "minecraft:air"},
{pos: [0, 1, 1], state: "minecraft:air"},
{pos: [0, 1, 2], state: "minecraft:air"},
{pos: [0, 1, 3], state: "minecraft:air"},
{pos: [0, 1, 4], state: "minecraft:air"},
{pos: [1, 1, 0], state: "minecraft:air"},
{pos: [1, 1, 1], state: "minecraft:air"},
{pos: [1, 1, 2], state: "computercraft:disk_drive{facing:north,state:full}", nbt: {id: "computercraft:disk_drive"}},
{pos: [1, 1, 3], state: "minecraft:air"},
{pos: [1, 1, 4], state: "minecraft:air"},
{pos: [2, 1, 0], state: "minecraft:air"},
{pos: [2, 1, 1], state: "minecraft:air"},
{pos: [2, 1, 2], state: "computercraft:computer_advanced{facing:north,state:blinking}", nbt: {ComputerId: 1, Label: "disk_drive_test.queues_event", On: 1b, id: "computercraft:computer_advanced"}},
{pos: [2, 1, 3], state: "minecraft:air"},
{pos: [2, 1, 4], state: "minecraft:air"},
{pos: [3, 1, 0], state: "minecraft:air"},
{pos: [3, 1, 1], state: "minecraft:air"},
{pos: [3, 1, 2], state: "minecraft:air"},
{pos: [3, 1, 3], state: "minecraft:air"},
{pos: [3, 1, 4], state: "minecraft:air"},
{pos: [4, 1, 0], state: "minecraft:air"},
{pos: [4, 1, 1], state: "minecraft:air"},
{pos: [4, 1, 2], state: "minecraft:air"},
{pos: [4, 1, 3], state: "minecraft:air"},
{pos: [4, 1, 4], state: "minecraft:air"},
{pos: [0, 2, 0], state: "minecraft:air"},
{pos: [0, 2, 1], state: "minecraft:air"},
{pos: [0, 2, 2], state: "minecraft:air"},
{pos: [0, 2, 3], state: "minecraft:air"},
{pos: [0, 2, 4], state: "minecraft:air"},
{pos: [1, 2, 0], state: "minecraft:air"},
{pos: [1, 2, 1], state: "minecraft:air"},
{pos: [1, 2, 2], state: "minecraft:air"},
{pos: [1, 2, 3], state: "minecraft:air"},
{pos: [1, 2, 4], state: "minecraft:air"},
{pos: [2, 2, 0], state: "minecraft:air"},
{pos: [2, 2, 1], state: "minecraft:air"},
{pos: [2, 2, 2], state: "minecraft:air"},
{pos: [2, 2, 3], state: "minecraft:air"},
{pos: [2, 2, 4], state: "minecraft:air"},
{pos: [3, 2, 0], state: "minecraft:air"},
{pos: [3, 2, 1], state: "minecraft:air"},
{pos: [3, 2, 2], state: "minecraft:air"},
{pos: [3, 2, 3], state: "minecraft:air"},
{pos: [3, 2, 4], state: "minecraft:air"},
{pos: [4, 2, 0], state: "minecraft:air"},
{pos: [4, 2, 1], state: "minecraft:air"},
{pos: [4, 2, 2], state: "minecraft:air"},
{pos: [4, 2, 3], state: "minecraft:air"},
{pos: [4, 2, 4], state: "minecraft:air"},
{pos: [0, 3, 0], state: "minecraft:air"},
{pos: [0, 3, 1], state: "minecraft:air"},
{pos: [0, 3, 2], state: "minecraft:air"},
{pos: [0, 3, 3], state: "minecraft:air"},
{pos: [0, 3, 4], state: "minecraft:air"},
{pos: [1, 3, 0], state: "minecraft:air"},
{pos: [1, 3, 1], state: "minecraft:air"},
{pos: [1, 3, 2], state: "minecraft:air"},
{pos: [1, 3, 3], state: "minecraft:air"},
{pos: [1, 3, 4], state: "minecraft:air"},
{pos: [2, 3, 0], state: "minecraft:air"},
{pos: [2, 3, 1], state: "minecraft:air"},
{pos: [2, 3, 2], state: "minecraft:air"},
{pos: [2, 3, 3], state: "minecraft:air"},
{pos: [2, 3, 4], state: "minecraft:air"},
{pos: [3, 3, 0], state: "minecraft:air"},
{pos: [3, 3, 1], state: "minecraft:air"},
{pos: [3, 3, 2], state: "minecraft:air"},
{pos: [3, 3, 3], state: "minecraft:air"},
{pos: [3, 3, 4], state: "minecraft:air"},
{pos: [4, 3, 0], state: "minecraft:air"},
{pos: [4, 3, 1], state: "minecraft:air"},
{pos: [4, 3, 2], state: "minecraft:air"},
{pos: [4, 3, 3], state: "minecraft:air"},
{pos: [4, 3, 4], state: "minecraft:air"},
{pos: [0, 4, 0], state: "minecraft:air"},
{pos: [0, 4, 1], state: "minecraft:air"},
{pos: [0, 4, 2], state: "minecraft:air"},
{pos: [0, 4, 3], state: "minecraft:air"},
{pos: [0, 4, 4], state: "minecraft:air"},
{pos: [1, 4, 0], state: "minecraft:air"},
{pos: [1, 4, 1], state: "minecraft:air"},
{pos: [1, 4, 2], state: "minecraft:air"},
{pos: [1, 4, 3], state: "minecraft:air"},
{pos: [1, 4, 4], state: "minecraft:air"},
{pos: [2, 4, 0], state: "minecraft:air"},
{pos: [2, 4, 1], state: "minecraft:air"},
{pos: [2, 4, 2], state: "minecraft:air"},
{pos: [2, 4, 3], state: "minecraft:air"},
{pos: [2, 4, 4], state: "minecraft:air"},
{pos: [3, 4, 0], state: "minecraft:air"},
{pos: [3, 4, 1], state: "minecraft:air"},
{pos: [3, 4, 2], state: "minecraft:air"},
{pos: [3, 4, 3], state: "minecraft:air"},
{pos: [3, 4, 4], state: "minecraft:air"},
{pos: [4, 4, 0], state: "minecraft:air"},
{pos: [4, 4, 1], state: "minecraft:air"},
{pos: [4, 4, 2], state: "minecraft:air"},
{pos: [4, 4, 3], state: "minecraft:air"},
{pos: [4, 4, 4], state: "minecraft:air"}
],
entities: [],
palette: [
"minecraft:polished_andesite",
"minecraft:air",
"computercraft:disk_drive{facing:north,state:full}",
"computercraft:computer_advanced{facing:north,state:blinking}"
]
}