From f695f22d8a6ae5987ad59ac69489071a4ab6e47b Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Sat, 20 Jan 2024 18:46:43 +0000 Subject: [PATCH] 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. --- .../diskdrive/DiskDriveBlockEntity.java | 218 +++++++++++------- .../diskdrive/DiskDrivePeripheral.java | 18 +- .../peripheral/diskdrive/MediaStack.java | 15 +- .../computercraft/gametest/Disk_Drive_Test.kt | 42 +++- .../gametest/api/TestExtensions.kt | 25 +- .../disk_drive_test.adds_removes_mount.snbt | 2 +- .../disk_drive_test.queues_event.snbt | 138 +++++++++++ 7 files changed, 348 insertions(+), 110 deletions(-) create mode 100644 projects/common/src/testMod/resources/data/cctest/structures/disk_drive_test.queues_event.snbt diff --git a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java index 0ea28530a..1cf33ac44 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDriveBlockEntity.java @@ -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}. + *

+ * 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). + *

+ * 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. + *

+ * 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 computers = new HashMap<>(); - private final NonNullList inventory = NonNullList.withSize(1, ItemStack.EMPTY); + @GuardedBy("this") + private final Map 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 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 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 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, + } } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDrivePeripheral.java b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDrivePeripheral.java index 395bf02f2..31cb03b20 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDrivePeripheral.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/DiskDrivePeripheral.java @@ -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 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; } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/MediaStack.java b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/MediaStack.java index 026b8587a..dc56eabcc 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/MediaStack.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/peripheral/diskdrive/MediaStack.java @@ -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(); diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Disk_Drive_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Disk_Drive_Test.kt index 8758b4f17..11fd18344 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Disk_Drive_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Disk_Drive_Test.kt @@ -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().getDrive("disk").assertArrayEquals("right") callPeripheral("right", "ejectDisk") } diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt index 103e9fca7..2d68bb8aa 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt @@ -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) = - 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() +} diff --git a/projects/common/src/testMod/resources/data/cctest/structures/disk_drive_test.adds_removes_mount.snbt b/projects/common/src/testMod/resources/data/cctest/structures/disk_drive_test.adds_removes_mount.snbt index a495031e6..207e6cd34 100644 --- a/projects/common/src/testMod/resources/data/cctest/structures/disk_drive_test.adds_removes_mount.snbt +++ b/projects/common/src/testMod/resources/data/cctest/structures/disk_drive_test.adds_removes_mount.snbt @@ -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"}, diff --git a/projects/common/src/testMod/resources/data/cctest/structures/disk_drive_test.queues_event.snbt b/projects/common/src/testMod/resources/data/cctest/structures/disk_drive_test.queues_event.snbt new file mode 100644 index 000000000..be71c4493 --- /dev/null +++ b/projects/common/src/testMod/resources/data/cctest/structures/disk_drive_test.queues_event.snbt @@ -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}" + ] +}