1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-10-22 17:37:38 +00:00

Compare commits

...

11 Commits

Author SHA1 Message Date
Jonathan Coates
3f8c3b026a Merge branch 'mc-1.20.x' into mc-1.21.x 2025-02-14 20:44:39 +00:00
Jonathan Coates
0a8d505323 Bump CC:T to 1.115.0 2025-02-14 20:20:30 +00:00
Jonathan Coates
237a0ac3bb Expose printout contents to the API
Closes #2099
2025-02-14 18:13:20 +00:00
Jonathan Coates
b185d088b3 Suggest alternative table keys on nil errors (#2097)
We now suggest alternative table keys when code errors with "attempt
to index/call 'foo' (a nil value)". For example: "redstone.getinput()",
will now suggest "Did you mean: getInput".

This is a bit tricky to get right! In the above example, our code reads
like:

   1    GETTABUP 0 0 0 ; r0 := _ENV["redstone"]
   2    GETFIELD 0 0 1 ; r0 := r0["getinput"]
   3    CALL 0 1 1     ; r0()

Note, that when we get to the problematic line, we don't have access to
the original table that we attempted to index. In order to do this, we
borrow ideas from Lua's getobjname — we effectively write an evaluator
that walks back over the code and tries to reconstruct the expression
that resulted in nil.

For example, in the above case:
 - We know an instruction happened at pc=3, so we try to find the
   expression that computed r0.
 - We know this was set at pc=2, so we step back one. This is a GETFIELD
   instruction, so we check the key (it's a constant, so worth
   reporting), and then try to evaluate the table.
 - This version of r0 was set at pc=1, so we step back again. It's a
   GETTABUP instruction, so we can just evaluate that directly.

We then use this information (indexing _ENV.redstone with "getinput") to
find alternative keys (e.g. getInput, getOutput, etc...) and then pick
some likely suggestions with Damerau-Levenshtein/OSD.

I'm not entirely thrilled by the implementation here. The core
interpretation logic is implemented in Java. Which is *fine*, but a)
feels a little cheaty and b) means we're limited to what Lua bytecode
can provide (for instance, we can't inspect outer functions, or list all
available names in scope). We obviously can expand the bytecode if
needed, but something we'd want to be careful with.

The alternative approach would be to handle all the parsing in
Lua. Unfortunately, this is quite hard to get right — I think we'd need
some lazy parsing strategy to avoid constructing the whole AST, while
still retaining all the scope information we need.

I don't know. We really could make this as complex as we like, and I
don't know what the right balance is. It'd be cool to detect patterns
like the following, but is it *useful*?

    local monitor = peripheral.wrap("left")
    monitor.write("Hello")
        -- ^ monitor is nil. Is there a peripheral to the left of the
        -- computer?

For now, the current approach feels the easiest, and should allow us to
prototype things and see what does/doesn't work.
2025-02-13 21:57:29 +00:00
Jonathan Coates
051c70a731 Propagate exceptions from parallel where possible (#2095)
In the original implementation of our prettier runtime errors (#1320), we
wrapped the errors thrown within parallel functions into an exception
object. This means the call-stack is available to the catching-code, and
so is able to report a pretty exception message.

Unfortunately, this was a breaking change, and so we had to roll that
back. Some people were pcalling the parallel function, and matching on
the result of the error.

This is a second attempt at this, using a technique I've affectionately
dubbed "magic throws". The parallel API is now aware of whether it is
being pcalled or not, and thus able to decide whether to wrap the error
into an exception or not:

 - Add a new `cc.internal.tiny_require` module. This is a tiny
   reimplementation of require, for use in our global APIs.

 - Add a new (global, in the debug registry) `cc_try_barrier` function.
   This acts as a marker function, and is used to store additional
   information about the current coroutine.

   Currently this stores the parent coroutine (used to walk the full call
   stack) and a cache of whether any `pcall`-like function is on the
   stack.

   Both `parallel` and `cc.internal.exception.try` add this function to
   the root of the call stack.

 - When an error occurs within `parallel`, we walk up the call stack,
   using `cc_try_barrier` to traverse up the parent coroutine's stack
   too. If we do not find any `pcall`-like functions, then we know the
   error is never intercepted by user code, and so its safe to throw a
   full exception.
2025-02-13 17:38:57 +00:00
Jonathan Coates
2e2f308ff3 Support placing pocket computers on lecterns (#2098)
This allows shift+clicking a pocket computer on to a lectern. These
computers can be right clicked, opening the no-term computer GUI.
Terminal contents is rendered in-world, and broadcast to everyone in
range.

 - Add a new lectern PocketHolder.
 - Refactor some of the `PocketItemComputer` code to allow ticking pocket
   computers from a non-player/entity source.
 - Add a new model for pocket computers. This requires several new
   textures (somewhat mirroring the item ones), which is a little
   unfortunate, but looks much better than reusing the map renderer or
   item form.
2025-02-13 17:31:08 +00:00
Jonathan Coates
0f123b5efd Ignore unrepresentable characters when typing
In 94ad6dab0e, we changed it so typing
characters outside of CC's codepage were replaced with '?' rather than
ignored. This can be quite annoying for non-European users (where latin1
isn't very helpful!), so it makes sense to revert this change.

See discussion in #860 for more context.
2025-02-12 18:45:40 +00:00
Jonathan Coates
1278246cf7 Add back MoreRed support
I removed this in fc834cd97f, way back in
late 2024. Looks like it's been updating in the meantime and I hadn't
noticed, so add it back.

I've simplified the code a little bit, to make use of our NeoForge's new
capability system, but otherwise it's almost exactly the same :D.
2025-02-12 13:40:58 +00:00
Jonathan Coates
88cb03be6b Clean up the parallel API
- Store the filter alongside the coroutine rather than in a separate
   table (like we do in multishell).

 - Remove the redudant (I think!) second loop that checks for dead
   coroutines. We already check for dead coroutines in the main loop.

 - Rename some variables to be a bit more consistent. This makes this
   commit look noisier than it is. Sorry!
2025-02-09 16:53:59 +00:00
Jonathan Coates
1e25fa9bc3 Bump CC:T to 1.114.5 2025-02-09 10:16:45 +00:00
Jonathan Coates
74f707aaea Create a new CraftingInput for each craft
It's not actually safe to reuse this, as we need to recompute the
internal StackedContents each time the inventory changes, otherwise
ShapelessRecipe.matches will continue to return true, even if the actual
inventory doesn't include the required items.

Fixes #2094
2025-02-07 09:45:14 +00:00
48 changed files with 1445 additions and 145 deletions

View File

@@ -48,7 +48,7 @@ repositories {
includeGroup("cc.tweaked")
// Things we mirror
includeGroup("com.simibubi.create")
includeGroup("commoble.morered")
includeGroup("net.commoble.morered")
includeGroup("dev.architectury")
includeGroup("dev.emi")
includeGroup("maven.modrinth")

View File

@@ -12,7 +12,7 @@ neogradle.subsystems.conventions.runs.enabled=false
# Mod properties
isUnstable=true
modVersion=1.114.4
modVersion=1.115.0
# Minecraft properties: We want to configure this here so we can read it in settings.gradle
mcVersion=1.21.1

View File

@@ -42,7 +42,7 @@ iris-fabric = "1.8.0-beta.3+1.21-fabric"
iris-forge = "1.8.0-beta.3+1.21-neoforge"
jei = "19.8.2.99"
modmenu = "11.0.0-rc.4"
moreRed = "4.0.0.4"
moreRed = "6.0.0.3"
rei = "16.0.729"
sodium-fabric = "mc1.21-0.6.0-beta.1-fabric"
sodium-forge = "mc1.21-0.6.0-beta.1-neoforge"
@@ -119,7 +119,7 @@ jei-forge = { module = "mezz.jei:jei-1.21-neoforge", version.ref = "jei" }
mixin = { module = "org.spongepowered:mixin", version.ref = "mixin" }
mixinExtra = { module = "io.github.llamalad7:mixinextras-common", version.ref = "mixinExtra" }
modmenu = { module = "com.terraformersmc:modmenu", version.ref = "modmenu" }
moreRed = { module = "commoble.morered:morered-1.20.1", version.ref = "moreRed" }
moreRed = { module = "net.commoble.morered:morered-1.21.1", version.ref = "moreRed" }
rei-api = { module = "me.shedaniel:RoughlyEnoughItems-api", version.ref = "rei" }
rei-builtin = { module = "me.shedaniel:RoughlyEnoughItems-default-plugin", version.ref = "rei" }
rei-fabric = { module = "me.shedaniel:RoughlyEnoughItems-fabric", version.ref = "rei" }

View File

@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.api.media;
import dan200.computercraft.impl.ComputerCraftAPIService;
import net.minecraft.world.item.ItemStack;
import javax.annotation.Nullable;
import java.util.stream.Stream;
/**
* The contents of a page (or book) created by a ComputerCraft printer.
*
* @since 1.115
*/
@Nullable
public interface PrintoutContents {
/**
* Get the (possibly empty) title for this printout.
*
* @return The title of this printout.
*/
String getTitle();
/**
* Get the text contents of this printout, as a sequence of lines.
* <p>
* The lines in the printout may include blank lines at the end of the document, as well as trailing spaces on each
* line.
*
* @return The text contents of this printout.
*/
Stream<String> getTextLines();
/**
* Get the printout contents for a particular stack.
*
* @param stack The stack to get the contents for.
* @return The printout contents, or {@code null} if this is not a printout item.
*/
static @Nullable PrintoutContents get(ItemStack stack) {
return ComputerCraftAPIService.get().getPrintoutContents(stack);
}
}

View File

@@ -13,6 +13,7 @@ import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.api.lua.GenericSource;
import dan200.computercraft.api.lua.ILuaAPIFactory;
import dan200.computercraft.api.media.MediaProvider;
import dan200.computercraft.api.media.PrintoutContents;
import dan200.computercraft.api.network.PacketNetwork;
import dan200.computercraft.api.network.wired.WiredElement;
import dan200.computercraft.api.network.wired.WiredNode;
@@ -84,6 +85,9 @@ public interface ComputerCraftAPIService {
DetailRegistry<BlockReference> getBlockInWorldDetailRegistry();
@Nullable
PrintoutContents getPrintoutContents(ItemStack stack);
final class Instance {
static final @Nullable ComputerCraftAPIService INSTANCE;
static final @Nullable Throwable ERROR;

View File

@@ -70,7 +70,7 @@ public class TerminalWidget extends AbstractWidget {
@Override
public boolean charTyped(char ch, int modifiers) {
var terminalChar = StringUtil.unicodeToTerminal(ch);
if (StringUtil.isTypableChar(terminalChar)) computer.charTyped(terminalChar);
if (StringUtil.isTypableChar(terminalChar)) computer.charTyped((byte) terminalChar);
return true;
}

View File

@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2024 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.client.model;
import com.mojang.blaze3d.vertex.PoseStack;
import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.client.pocket.PocketComputerData;
import dan200.computercraft.client.render.CustomLecternRenderer;
import dan200.computercraft.shared.computer.core.ComputerFamily;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.model.geom.PartPose;
import net.minecraft.client.model.geom.builders.CubeListBuilder;
import net.minecraft.client.model.geom.builders.MeshDefinition;
import net.minecraft.client.renderer.LightTexture;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.resources.model.Material;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.inventory.InventoryMenu;
import net.minecraft.world.item.component.DyedItemColor;
/**
* A model for {@linkplain PocketComputerItem pocket computers} placed on a lectern.
*
* @see CustomLecternRenderer
*/
public class LecternPocketModel {
public static final ResourceLocation TEXTURE_NORMAL = ResourceLocation.fromNamespaceAndPath(ComputerCraftAPI.MOD_ID, "entity/pocket_computer_normal");
public static final ResourceLocation TEXTURE_ADVANCED = ResourceLocation.fromNamespaceAndPath(ComputerCraftAPI.MOD_ID, "entity/pocket_computer_advanced");
public static final ResourceLocation TEXTURE_COLOUR = ResourceLocation.fromNamespaceAndPath(ComputerCraftAPI.MOD_ID, "entity/pocket_computer_colour");
public static final ResourceLocation TEXTURE_FRAME = ResourceLocation.fromNamespaceAndPath(ComputerCraftAPI.MOD_ID, "entity/pocket_computer_frame");
public static final ResourceLocation TEXTURE_LIGHT = ResourceLocation.fromNamespaceAndPath(ComputerCraftAPI.MOD_ID, "entity/pocket_computer_light");
private static final Material MATERIAL_NORMAL = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE_NORMAL);
private static final Material MATERIAL_ADVANCED = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE_ADVANCED);
private static final Material MATERIAL_COLOUR = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE_COLOUR);
private static final Material MATERIAL_FRAME = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE_FRAME);
private static final Material MATERIAL_LIGHT = new Material(InventoryMenu.BLOCK_ATLAS, TEXTURE_LIGHT);
// The size of the terminal within the model.
public static final float TERM_WIDTH = 12.0f / 32.0f;
public static final float TERM_HEIGHT = 14.0f / 32.0f;
// The size of the texture. The texture is 36x36, but is at 2x resolution.
private static final int TEXTURE_WIDTH = 36 / 2;
private static final int TEXTURE_HEIGHT = 36 / 2;
private final ModelPart root;
public LecternPocketModel() {
root = buildPages();
}
private static ModelPart buildPages() {
var mesh = new MeshDefinition();
var parts = mesh.getRoot();
parts.addOrReplaceChild(
"root",
CubeListBuilder.create().texOffs(0, 0).addBox(0f, -5.0f, -4.0f, 1f, 10.0f, 8.0f),
PartPose.ZERO
);
return mesh.getRoot().bake(TEXTURE_WIDTH, TEXTURE_HEIGHT);
}
/**
* Render the pocket computer model.
*
* @param poseStack The current pose stack.
* @param bufferSource The buffer source to draw to.
* @param packedLight The current light level.
* @param packedOverlay The overlay texture (used for entity hurt animation).
* @param family The computer family.
* @param frameColour The pocket computer's {@linkplain DyedItemColor colour}.
* @param lightColour The pocket computer's {@linkplain PocketComputerData#getLightState() light colour}.
*/
public void render(PoseStack poseStack, MultiBufferSource bufferSource, int packedLight, int packedOverlay, ComputerFamily family, int frameColour, int lightColour) {
if (frameColour != -1) {
root.render(poseStack, MATERIAL_FRAME.buffer(bufferSource, RenderType::entityCutout), packedLight, packedOverlay);
root.render(poseStack, MATERIAL_COLOUR.buffer(bufferSource, RenderType::entityCutout), packedLight, packedOverlay, frameColour);
} else {
var buffer = (family == ComputerFamily.ADVANCED ? MATERIAL_ADVANCED : MATERIAL_NORMAL).buffer(bufferSource, RenderType::entityCutout);
root.render(poseStack, buffer, packedLight, packedOverlay);
}
root.render(poseStack, MATERIAL_LIGHT.buffer(bufferSource, RenderType::entityCutout), LightTexture.FULL_BRIGHT, packedOverlay, lightColour);
}
}

View File

@@ -6,16 +6,30 @@ package dan200.computercraft.client.render;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import dan200.computercraft.client.model.LecternPocketModel;
import dan200.computercraft.client.model.LecternPrintoutModel;
import dan200.computercraft.client.pocket.ClientPocketComputers;
import dan200.computercraft.client.render.text.FixedWidthFontRenderer;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.core.util.Colour;
import dan200.computercraft.shared.lectern.CustomLecternBlockEntity;
import dan200.computercraft.shared.media.items.PrintoutData;
import dan200.computercraft.shared.media.items.PrintoutItem;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.blockentity.LecternRenderer;
import net.minecraft.util.FastColor;
import net.minecraft.world.item.component.DyedItemColor;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.phys.Vec3;
import static dan200.computercraft.client.render.ComputerBorderRenderer.MARGIN;
import static dan200.computercraft.client.render.text.FixedWidthFontRenderer.FONT_HEIGHT;
import static dan200.computercraft.client.render.text.FixedWidthFontRenderer.FONT_WIDTH;
/**
* A block entity renderer for our {@linkplain CustomLecternBlockEntity lectern}.
@@ -23,10 +37,17 @@ import net.minecraft.world.level.block.LecternBlock;
* This largely follows {@link LecternRenderer}, but with support for multiple types of item.
*/
public class CustomLecternRenderer implements BlockEntityRenderer<CustomLecternBlockEntity> {
private static final int POCKET_TERMINAL_RENDER_DISTANCE = 32;
private final BlockEntityRenderDispatcher berDispatcher;
private final LecternPrintoutModel printoutModel;
private final LecternPocketModel pocketModel;
public CustomLecternRenderer(BlockEntityRendererProvider.Context context) {
berDispatcher = context.getBlockEntityRenderDispatcher();
printoutModel = new LecternPrintoutModel();
pocketModel = new LecternPocketModel();
}
@Override
@@ -45,8 +66,46 @@ public class CustomLecternRenderer implements BlockEntityRenderer<CustomLecternB
} else {
printoutModel.renderPages(poseStack, vertexConsumer, packedLight, packedOverlay, PrintoutData.getOrEmpty(item).pages());
}
} else if (item.getItem() instanceof PocketComputerItem pocket) {
var computer = ClientPocketComputers.get(item);
pocketModel.render(
poseStack, buffer, packedLight, packedOverlay, pocket.getFamily(), DyedItemColor.getOrDefault(item, -1),
FastColor.ARGB32.opaque(computer == null || computer.getLightState() == -1 ? Colour.BLACK.getHex() : computer.getLightState())
);
// Jiggle the terminal about a bit, so (0, 0) is in the top left of the model's terminal hole.
poseStack.mulPose(Axis.YP.rotationDegrees(90f));
poseStack.translate(-0.5 * LecternPocketModel.TERM_WIDTH, 0.5 * LecternPocketModel.TERM_HEIGHT + 1f / 32.0f, 1 / 16.0f);
poseStack.mulPose(Axis.XP.rotationDegrees(180));
// Either render the terminal or a black screen, depending on how close we are.
var terminal = computer == null ? null : computer.getTerminal();
var quadEmitter = FixedWidthFontRenderer.toVertexConsumer(poseStack, buffer.getBuffer(RenderTypes.TERMINAL));
if (terminal != null && Vec3.atCenterOf(lectern.getBlockPos()).closerThan(berDispatcher.camera.getPosition(), POCKET_TERMINAL_RENDER_DISTANCE)) {
renderPocketTerminal(poseStack, quadEmitter, terminal);
} else {
FixedWidthFontRenderer.drawEmptyTerminal(quadEmitter, 0, 0, LecternPocketModel.TERM_WIDTH, LecternPocketModel.TERM_HEIGHT);
}
}
poseStack.popPose();
}
private static void renderPocketTerminal(PoseStack poseStack, FixedWidthFontRenderer.QuadEmitter quadEmitter, Terminal terminal) {
var width = terminal.getWidth() * FONT_WIDTH;
var height = terminal.getHeight() * FONT_HEIGHT;
// Scale the terminal down to fit in the available space.
var scaleX = LecternPocketModel.TERM_WIDTH / (width + MARGIN * 2);
var scaleY = LecternPocketModel.TERM_HEIGHT / (height + MARGIN * 2);
var scale = Math.min(scaleX, scaleY);
poseStack.scale(scale, scale, -1.0f);
// Convert the model dimensions to terminal space, then find out how large the margin should be.
var marginX = ((LecternPocketModel.TERM_WIDTH / scale) - width) / 2;
var marginY = ((LecternPocketModel.TERM_HEIGHT / scale) - height) / 2;
FixedWidthFontRenderer.drawTerminal(quadEmitter, marginX, marginY, terminal, marginY, marginY, marginX, marginX);
}
}

View File

@@ -43,7 +43,7 @@ public final class FixedWidthFontRenderer {
static final float BACKGROUND_END = (WIDTH - 4.0f) / WIDTH;
private static final int BLACK = FastColor.ARGB32.color(255, byteColour(Colour.BLACK.getR()), byteColour(Colour.BLACK.getR()), byteColour(Colour.BLACK.getR()));
private static final float Z_OFFSET = 1e-3f;
private static final float Z_OFFSET = 1e-4f;
private FixedWidthFontRenderer() {
}

View File

@@ -8,6 +8,7 @@ import com.mojang.serialization.Codec;
import dan200.computercraft.api.pocket.IPocketUpgrade;
import dan200.computercraft.api.turtle.ITurtleUpgrade;
import dan200.computercraft.client.gui.GuiSprites;
import dan200.computercraft.client.model.LecternPocketModel;
import dan200.computercraft.client.model.LecternPrintoutModel;
import dan200.computercraft.data.client.ExtraModelsProvider;
import dan200.computercraft.shared.turtle.TurtleOverlay;
@@ -71,7 +72,9 @@ public final class DataProviders {
out.accept(ResourceLocation.withDefaultNamespace("blocks"), makeSprites(Stream.of(
UpgradeSlot.LEFT_UPGRADE,
UpgradeSlot.RIGHT_UPGRADE,
LecternPrintoutModel.TEXTURE
LecternPrintoutModel.TEXTURE,
LecternPocketModel.TEXTURE_NORMAL, LecternPocketModel.TEXTURE_ADVANCED,
LecternPocketModel.TEXTURE_COLOUR, LecternPocketModel.TEXTURE_FRAME, LecternPocketModel.TEXTURE_LIGHT
)));
out.accept(GuiSprites.SPRITE_SHEET, makeSprites(
// Computers

View File

@@ -2,6 +2,11 @@
"sources": [
{"type": "minecraft:single", "resource": "computercraft:gui/turtle_upgrade_left"},
{"type": "minecraft:single", "resource": "computercraft:gui/turtle_upgrade_right"},
{"type": "minecraft:single", "resource": "computercraft:entity/printout"}
{"type": "minecraft:single", "resource": "computercraft:entity/printout"},
{"type": "minecraft:single", "resource": "computercraft:entity/pocket_computer_normal"},
{"type": "minecraft:single", "resource": "computercraft:entity/pocket_computer_advanced"},
{"type": "minecraft:single", "resource": "computercraft:entity/pocket_computer_colour"},
{"type": "minecraft:single", "resource": "computercraft:entity/pocket_computer_frame"},
{"type": "minecraft:single", "resource": "computercraft:entity/pocket_computer_light"}
]
}

View File

@@ -13,6 +13,7 @@ import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.api.lua.GenericSource;
import dan200.computercraft.api.lua.ILuaAPIFactory;
import dan200.computercraft.api.media.MediaProvider;
import dan200.computercraft.api.media.PrintoutContents;
import dan200.computercraft.api.network.PacketNetwork;
import dan200.computercraft.api.network.wired.WiredElement;
import dan200.computercraft.api.network.wired.WiredNode;
@@ -25,6 +26,7 @@ import dan200.computercraft.core.filesystem.WritableFileMount;
import dan200.computercraft.impl.detail.DetailRegistryImpl;
import dan200.computercraft.impl.network.wired.WiredNodeImpl;
import dan200.computercraft.impl.upgrades.TurtleToolSpec;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.computer.core.ResourceMount;
import dan200.computercraft.shared.computer.core.ServerContext;
import dan200.computercraft.shared.details.BlockDetails;
@@ -153,4 +155,9 @@ public abstract class AbstractComputerCraftAPI implements ComputerCraftAPIServic
public final DetailRegistry<BlockReference> getBlockInWorldDetailRegistry() {
return blockDetails;
}
@Override
public @Nullable PrintoutContents getPrintoutContents(ItemStack stack) {
return stack.get(ModRegistry.DataComponents.PRINTOUT.get());
}
}

View File

@@ -6,6 +6,8 @@ package dan200.computercraft.shared.lectern;
import dan200.computercraft.shared.ModRegistry;
import dan200.computercraft.shared.media.items.PrintoutItem;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import dan200.computercraft.shared.util.BlockEntityHelpers;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.stats.Stats;
@@ -20,8 +22,12 @@ import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import org.jetbrains.annotations.Nullable;
/**
* Extends {@link LecternBlock} with support for {@linkplain PrintoutItem printouts}.
@@ -48,7 +54,7 @@ public class CustomLecternBlock extends LecternBlock {
* @return Whether the item was placed or not.
*/
public static InteractionResult tryPlaceItem(Player player, Level level, BlockPos pos, BlockState blockState, ItemStack item) {
if (item.getItem() instanceof PrintoutItem) {
if (item.getItem() instanceof PrintoutItem || item.getItem() instanceof PocketComputerItem) {
if (!level.isClientSide) replaceLectern(player, level, pos, blockState, item);
return InteractionResult.sidedSuccess(level.isClientSide);
}
@@ -152,7 +158,7 @@ public class CustomLecternBlock extends LecternBlock {
clearLectern(level, pos, state);
} else {
// Otherwise open the screen.
player.openMenu(lectern);
lectern.openMenu(player);
}
player.awardStat(Stats.INTERACT_WITH_LECTERN);
@@ -160,4 +166,11 @@ public class CustomLecternBlock extends LecternBlock {
return InteractionResult.sidedSuccess(level.isClientSide);
}
@Override
public @Nullable <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
return level.isClientSide ? null : BlockEntityHelpers.createTickerHelper(type, ModRegistry.BlockEntities.LECTERN.get(), serverTicker);
}
private static final BlockEntityTicker<CustomLecternBlockEntity> serverTicker = (level, pos, state, lectern) -> lectern.tick();
}

View File

@@ -10,28 +10,26 @@ import dan200.computercraft.shared.container.SingleContainerData;
import dan200.computercraft.shared.media.PrintoutMenu;
import dan200.computercraft.shared.media.items.PrintoutData;
import dan200.computercraft.shared.media.items.PrintoutItem;
import dan200.computercraft.shared.pocket.core.PocketHolder;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import dan200.computercraft.shared.util.BlockEntityHelpers;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.Tag;
import net.minecraft.network.chat.Component;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.util.Mth;
import net.minecraft.world.Container;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ContainerData;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.LecternBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import org.jetbrains.annotations.Nullable;
import java.util.AbstractList;
import java.util.List;
@@ -41,7 +39,7 @@ import java.util.List;
*
* @see LecternBlockEntity
*/
public final class CustomLecternBlockEntity extends BlockEntity implements MenuProvider {
public final class CustomLecternBlockEntity extends BlockEntity {
private static final String NBT_ITEM = "Item";
private static final String NBT_PAGE = "Page";
@@ -83,6 +81,12 @@ public final class CustomLecternBlockEntity extends BlockEntity implements MenuP
}
}
void tick() {
if (item.getItem() instanceof PocketComputerItem pocket) {
pocket.tick(item, new PocketHolder.LecternHolder(this), false);
}
}
/**
* Set the current page, emitting a redstone pulse if needed.
*
@@ -125,24 +129,17 @@ public final class CustomLecternBlockEntity extends BlockEntity implements MenuP
return tag;
}
@Nullable
@Override
public AbstractContainerMenu createMenu(int containerId, Inventory playerInventory, Player player) {
void openMenu(Player player) {
var item = getItem();
if (item.getItem() instanceof PrintoutItem) {
return new PrintoutMenu(
containerId, new LecternContainer(), 0,
p -> Container.stillValidBlockEntity(this, player, Container.DEFAULT_DISTANCE_BUFFER),
player.openMenu(new SimpleMenuProvider((id, inventory, entity) -> new PrintoutMenu(
id, new LecternContainer(), 0,
p -> Container.stillValidBlockEntity(this, p, Container.DEFAULT_DISTANCE_BUFFER),
new PrintoutContainerData()
);
), getItem().getDisplayName()));
} else if (item.getItem() instanceof PocketComputerItem pocket) {
pocket.open(player, item, new PocketHolder.LecternHolder(this), true);
}
return null;
}
@Override
public Component getDisplayName() {
return getItem().getDisplayName();
}
/**

View File

@@ -7,6 +7,7 @@ package dan200.computercraft.shared.media.items;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import dan200.computercraft.api.media.PrintoutContents;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.shared.ModRegistry;
import io.netty.buffer.ByteBuf;
@@ -16,6 +17,7 @@ import net.minecraft.network.codec.StreamCodec;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
/**
* The contents of a printout.
@@ -25,7 +27,7 @@ import java.util.List;
* @see PrintoutItem
* @see dan200.computercraft.shared.ModRegistry.DataComponents#PRINTOUT
*/
public record PrintoutData(String title, List<Line> lines) {
public record PrintoutData(String title, List<Line> lines) implements PrintoutContents {
public static final int LINE_LENGTH = 25;
public static final int LINES_PER_PAGE = 21;
public static final int MAX_PAGES = 16;
@@ -107,4 +109,14 @@ public record PrintoutData(String title, List<Line> lines) {
public int pages() {
return Math.ceilDiv(lines.size(), LINES_PER_PAGE);
}
@Override
public String getTitle() {
return title();
}
@Override
public Stream<String> getTextLines() {
return lines().stream().map(Line::text);
}
}

View File

@@ -5,7 +5,9 @@
package dan200.computercraft.shared.pocket.core;
import dan200.computercraft.shared.computer.core.ServerComputer;
import dan200.computercraft.shared.lectern.CustomLecternBlockEntity;
import dan200.computercraft.shared.pocket.items.PocketComputerItem;
import dan200.computercraft.shared.util.BlockEntityHelpers;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
@@ -51,6 +53,15 @@ public sealed interface PocketHolder {
*/
void setChanged();
/**
* Whether the terminal is visible to all players in range, and so should be broadcast to everyone.
*
* @return Whether to send the terminal.
*/
default boolean isTerminalAlwaysVisible() {
return false;
}
/**
* An {@link Entity} holding a pocket computer.
*/
@@ -112,4 +123,41 @@ public sealed interface PocketHolder {
entity.setItem(entity.getItem().copy());
}
}
/**
* A pocket computer in a {@link CustomLecternBlockEntity}.
*
* @param lectern The lectern holding this item.
*/
record LecternHolder(CustomLecternBlockEntity lectern) implements PocketHolder {
@Override
public ServerLevel level() {
return (ServerLevel) lectern.getLevel();
}
@Override
public Vec3 pos() {
return Vec3.atCenterOf(lectern.getBlockPos());
}
@Override
public BlockPos blockPos() {
return lectern.getBlockPos();
}
@Override
public boolean isValid(ServerComputer computer) {
return !lectern().isRemoved() && PocketComputerItem.isServerComputer(computer, lectern.getItem());
}
@Override
public void setChanged() {
BlockEntityHelpers.updateBlock(lectern());
}
@Override
public boolean isTerminalAlwaysVisible() {
return true;
}
}
}

View File

@@ -72,7 +72,7 @@ public final class PocketServerComputer extends ServerComputer {
// Broadcast the state to new players.
var added = newTracking.stream().filter(x -> !tracking.contains(x)).toList();
if (!added.isEmpty()) {
ServerNetworking.sendToPlayers(new PocketComputerDataMessage(this, false), added);
ServerNetworking.sendToPlayers(new PocketComputerDataMessage(this, brain.holder().isTerminalAlwaysVisible()), added);
}
}
@@ -83,9 +83,15 @@ public final class PocketServerComputer extends ServerComputer {
protected void onTerminalChanged() {
super.onTerminalChanged();
if (brain.holder() instanceof PocketHolder.PlayerHolder holder && holder.isValid(this)) {
// Broadcast the terminal to the current player.
ServerNetworking.sendToPlayer(new PocketComputerDataMessage(this, true), holder.entity());
var holder = brain.holder() instanceof PocketHolder.PlayerHolder h && h.isValid(this) ? h.entity() : null;
if (brain.holder().isTerminalAlwaysVisible() && !tracking.isEmpty()) {
// If the terminal is always visible, send it to all players *and* the holder.
var packet = new PocketComputerDataMessage(this, true);
ServerNetworking.sendToPlayers(packet, tracking);
if (holder != null && !tracking.contains(holder)) ServerNetworking.sendToPlayer(packet, holder);
} else if (holder != null) {
// Otherwise just send it to the holder.
ServerNetworking.sendToPlayer(new PocketComputerDataMessage(this, true), holder);
}
}

View File

@@ -58,12 +58,20 @@ public class PocketComputerItem extends Item implements IMedia {
/**
* Tick a pocket computer.
*
* @param stack The current pocket computer stack.
* @param holder The entity holding the pocket item.
* @param brain The pocket computer brain.
* @param stack The current pocket computer stack.
* @param holder The entity holding the pocket item.
* @param passive If set, the pocket computer will not be created if it doesn't exist, and will not be kept alive.
*/
private void tick(ItemStack stack, PocketHolder holder, PocketBrain brain) {
brain.updateHolder(holder);
public void tick(ItemStack stack, PocketHolder holder, boolean passive) {
PocketBrain brain;
if (passive) {
var computer = getServerComputer(holder.level().getServer(), stack);
if (computer == null) return;
brain = computer.getBrain();
} else {
brain = getOrCreateBrain(holder.level(), holder, stack);
brain.computer().keepAlive();
}
// Update pocket upgrade
var upgrade = brain.getUpgrade();
@@ -109,11 +117,7 @@ public class PocketComputerItem extends Item implements IMedia {
if (slot < 0) return;
// If we're in the inventory, create a computer and keep it alive.
var holder = new PocketHolder.PlayerHolder(player, slot);
var brain = getOrCreateBrain((ServerLevel) world, holder, stack);
brain.computer().keepAlive();
tick(stack, holder, brain);
tick(stack, new PocketHolder.PlayerHolder(player, slot), false);
}
@ForgeOverride
@@ -123,8 +127,7 @@ public class PocketComputerItem extends Item implements IMedia {
// If we're an item entity, tick an already existing computer (as to update the position), but do not keep the
// computer alive.
var computer = getServerComputer(level.getServer(), stack);
if (computer != null) tick(stack, new PocketHolder.ItemEntityHolder(entity), computer.getBrain());
tick(stack, new PocketHolder.ItemEntityHolder(entity), true);
return false;
}
@@ -141,25 +144,39 @@ public class PocketComputerItem extends Item implements IMedia {
var stop = false;
var upgrade = getUpgrade(stack);
if (upgrade != null) {
brain.updateHolder(holder);
stop = upgrade.onRightClick(world, brain, computer.getPeripheral(ComputerSide.BACK));
// Sync back just in case. We don't need to setChanged, as we'll return the item anyway.
updateItem(stack, brain);
}
if (!stop) {
PlatformHelper.get().openMenu(
player, stack.getHoverName(),
(id, inventory, entity) -> new ComputerMenuWithoutInventory(
hand == InteractionHand.OFF_HAND ? ModRegistry.Menus.POCKET_COMPUTER_NO_TERM.get() : ModRegistry.Menus.COMPUTER.get(),
id, inventory, p -> isServerComputer(computer, p.getItemInHand(hand)), computer
),
new ComputerContainerData(computer, stack));
}
if (!stop) openImpl(player, stack, holder, hand == InteractionHand.OFF_HAND, computer);
}
return new InteractionResultHolder<>(InteractionResult.sidedSuccess(world.isClientSide), stack);
}
/**
* Open a container for this pocket computer.
*
* @param player The player to show the menu for.
* @param stack The pocket computer stack.
* @param holder The holder of the pocket computer.
* @param isTypingOnly Open the off-hand pocket screen (only supporting typing, with no visible terminal).
*/
public void open(Player player, ItemStack stack, PocketHolder holder, boolean isTypingOnly) {
var brain = getOrCreateBrain(holder.level(), holder, stack);
var computer = brain.computer();
computer.turnOn();
openImpl(player, stack, holder, isTypingOnly, computer);
}
private static void openImpl(Player player, ItemStack stack, PocketHolder holder, boolean isTypingOnly, ServerComputer computer) {
PlatformHelper.get().openMenu(player, stack.getHoverName(), (id, inventory, entity) -> new ComputerMenuWithoutInventory(
isTypingOnly ? ModRegistry.Menus.POCKET_COMPUTER_NO_TERM.get() : ModRegistry.Menus.COMPUTER.get(), id, inventory,
p -> holder.isValid(computer),
computer
), new ComputerContainerData(computer, stack));
}
@Override
public Component getName(ItemStack stack) {
var baseString = getDescriptionId(stack);
@@ -195,7 +212,11 @@ public class PocketComputerItem extends Item implements IMedia {
var registry = ServerContext.get(level.getServer()).registry();
{
var computer = getServerComputer(registry, stack);
if (computer != null) return computer.getBrain();
if (computer != null) {
var brain = computer.getBrain();
brain.updateHolder(holder);
return brain;
}
}
var computerID = NonNegativeId.getOrCreate(level.getServer(), stack, ModRegistry.DataComponents.COMPUTER_ID.get(), IDAssigner.COMPUTER);
@@ -209,8 +230,7 @@ public class PocketComputerItem extends Item implements IMedia {
stack.set(ModRegistry.DataComponents.COMPUTER.get(), new ServerComputerReference(registry.getSessionID(), computer.register()));
// Only turn on when initially creating the computer, rather than each tick.
if (isMarkedOn(stack) && holder instanceof PocketHolder.PlayerHolder) computer.turnOn();
if (isMarkedOn(stack)) computer.turnOn();
updateItem(stack, brain);

View File

@@ -39,7 +39,7 @@ public final class TurtleInventoryCrafting {
}
}
var input = CraftingInput.ofPositioned(WIDTH, HEIGHT, new AbstractList<>() {
List<ItemStack> items = new AbstractList<>() {
@Override
public ItemStack get(int index) {
var x = xStart + index % WIDTH;
@@ -53,9 +53,10 @@ public final class TurtleInventoryCrafting {
public int size() {
return WIDTH * HEIGHT;
}
});
var recipe = level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, input.input(), level).orElse(null);
return recipe == null ? null : new FoundRecipe(recipe.value(), input.input(), input.left() + xStart, input.top() + yStart);
};
var input = CraftingInput.of(WIDTH, HEIGHT, items);
var recipe = level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, input, level).orElse(null);
return recipe == null ? null : new FoundRecipe(recipe.value(), items, xStart, yStart);
}
@Nullable
@@ -76,18 +77,24 @@ public final class TurtleInventoryCrafting {
if (maxCount == 0) return List.of();
var recipe = candidate.recipe();
var input = candidate.input();
var xStart = candidate.xStart();
var yStart = candidate.yStart();
var items = candidate.items();
var results = new ArrayList<ItemStack>();
for (var i = 0; i < maxCount && recipe.matches(input, level); i++) {
for (var i = 0; i < maxCount; i++) {
var offsetInput = CraftingInput.ofPositioned(WIDTH, HEIGHT, items);
var input = offsetInput.input();
if (!recipe.matches(input, level)) break;
var result = recipe.assemble(input, level.registryAccess());
if (result.isEmpty()) break;
results.add(result);
result.onCraftedBySystem(level);
// Remove items from the inventory, and add back the remainders.
var xStart = candidate.xStart() + offsetInput.left();
var yStart = candidate.yStart() + offsetInput.top();
var remainders = recipe.getRemainingItems(input);
for (var y = 0; y < input.height(); y++) {
for (var x = 0; x < input.width(); x++) {
@@ -118,6 +125,6 @@ public final class TurtleInventoryCrafting {
return Collections.unmodifiableList(results);
}
private record FoundRecipe(Recipe<CraftingInput> recipe, CraftingInput input, int xStart, int yStart) {
private record FoundRecipe(Recipe<CraftingInput> recipe, List<ItemStack> items, int xStart, int yStart) {
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

View File

@@ -835,6 +835,29 @@ class Turtle_Test {
}
}
/**
* `turtle.craft` works on shapeless recipes
*
* @see [#2094](https://github.com/cc-tweaked/CC-Tweaked/issues/2094)
*/
@GameTest
fun Craft_shapeless(helper: GameTestHelper) = helper.sequence {
thenExecute {
val turtle = helper.getBlockEntity(BlockPos(2, 2, 2), ModRegistry.BlockEntities.TURTLE_NORMAL.get())
assertTrue(TurtleCraftCommand(64).execute(turtle.access).isSuccess, "Crafting succeeded")
helper.assertContainerExactly(
BlockPos(2, 2, 2),
listOf(
ItemStack(Items.ENDER_EYE, 1), ItemStack.EMPTY, ItemStack.EMPTY, ItemStack.EMPTY,
ItemStack.EMPTY, ItemStack.EMPTY, ItemStack.EMPTY, ItemStack.EMPTY,
ItemStack.EMPTY, ItemStack.EMPTY, ItemStack.EMPTY, ItemStack.EMPTY,
ItemStack.EMPTY, ItemStack.EMPTY, ItemStack.EMPTY, ItemStack.EMPTY,
),
)
}
}
/**
* `turtle.craft` leaves a remainder
*

View File

@@ -0,0 +1,137 @@
{
DataVersion: 3465,
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: "minecraft:air"},
{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:turtle_normal{facing:north,waterlogged:false}", nbt: {ComputerId: 1, Fuel: 0, Items: [{Count: 1b, Slot: 0b, id: "minecraft:ender_pearl"}, {Count: 1b, Slot: 1b, id: "minecraft:blaze_powder"}], Label: "turtle_test.craft_shapeless", LeftUpgrade: "minecraft:crafting_table", LeftUpgradeNbt: {}, On: 1b, Slot: 0, id: "computercraft:turtle_normal"}},
{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:turtle_normal{facing:north,waterlogged:false}"
]
}

View File

@@ -69,7 +69,7 @@ public class WebsocketHandle {
* Send a websocket message to the connected server.
*
* @param message The message to send.
* @param binary Whether this message should be treated as a
* @param binary Whether this message should be treated as a binary message.
* @throws LuaException If the message is too large.
* @throws LuaException If the websocket has been closed.
* @cc.changed 1.81.0 Added argument for binary mode.

View File

@@ -11,6 +11,7 @@ import dan200.computercraft.api.lua.ILuaFunction;
import dan200.computercraft.core.CoreConfig;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.computer.TimeoutState;
import dan200.computercraft.core.lua.errorinfo.ErrorInfoLib;
import dan200.computercraft.core.methods.LuaMethod;
import dan200.computercraft.core.methods.MethodSupplier;
import dan200.computercraft.core.util.LuaUtil;
@@ -77,6 +78,7 @@ public class CobaltLuaMachine implements ILuaMachine {
var globals = state.globals();
CoreLibraries.debugGlobals(state);
Bit32Lib.add(state, globals);
ErrorInfoLib.add(state);
globals.rawset("_HOST", ValueFactory.valueOf(environment.hostString()));
globals.rawset("_CC_DEFAULT_SETTINGS", ValueFactory.valueOf(CoreConfig.defaultComputerSettings));

View File

@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2009-2011 Luaj.org, 2015-2020 SquidDev
//
// SPDX-License-Identifier: MIT
package dan200.computercraft.core.lua.errorinfo;
import org.squiddev.cobalt.Prototype;
import static org.squiddev.cobalt.Lua.*;
/**
* Extracted parts of Cobalt's {@link org.squiddev.cobalt.debug.DebugHelpers}.
*/
final class DebugHelpers {
private DebugHelpers() {
}
private static int filterPc(int pc, int jumpTarget) {
return pc < jumpTarget ? -1 : pc;
}
/**
* Find the PC where a register was last set.
* <p>
* This makes some assumptions about the structure of the bytecode, namely that there are no back edges within the
* CFG. As a result, this is only valid for temporary values, and not locals.
*
* @param pt The function prototype.
* @param lastPc The PC to work back from.
* @param reg The register.
* @return The last instruction where the register was set, or {@code -1} if not defined.
*/
static int findSetReg(Prototype pt, int lastPc, int reg) {
var lastInsn = -1; // Last instruction that changed "reg";
var jumpTarget = 0; // Any code before this address is conditional
for (var pc = 0; pc < lastPc; pc++) {
var i = pt.code[pc];
var op = GET_OPCODE(i);
var a = GETARG_A(i);
switch (op) {
case OP_LOADNIL -> {
var b = GETARG_B(i);
if (a <= reg && reg <= a + b) lastInsn = filterPc(pc, jumpTarget);
}
case OP_TFORCALL -> {
if (a >= a + 2) lastInsn = filterPc(pc, jumpTarget);
}
case OP_CALL, OP_TAILCALL -> {
if (reg >= a) lastInsn = filterPc(pc, jumpTarget);
}
case OP_JMP -> {
var dest = pc + 1 + GETARG_sBx(i);
// If jump is forward and doesn't skip lastPc, update jump target
if (pc < dest && dest <= lastPc && dest > jumpTarget) jumpTarget = dest;
}
default -> {
if (testAMode(op) && reg == a) lastInsn = filterPc(pc, jumpTarget);
}
}
}
return lastInsn;
}
}

View File

@@ -0,0 +1,222 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.lua.errorinfo;
import com.google.common.annotations.VisibleForTesting;
import org.squiddev.cobalt.*;
import org.squiddev.cobalt.debug.DebugFrame;
import org.squiddev.cobalt.function.LuaFunction;
import org.squiddev.cobalt.function.RegisteredFunction;
import javax.annotation.Nullable;
import java.util.Objects;
import static org.squiddev.cobalt.Lua.*;
import static org.squiddev.cobalt.debug.DebugFrame.FLAG_ANY_HOOK;
/**
* Provides additional info about an error.
* <p>
* This is currently an internal and deeply unstable module. It's not clear if doing this via bytecode (rather than an
* AST) is the correct approach and/or, what the correct design is.
*/
public class ErrorInfoLib {
private static final int MAX_DEPTH = 8;
private static final RegisteredFunction[] functions = new RegisteredFunction[]{
RegisteredFunction.ofV("info_for_nil", ErrorInfoLib::getInfoForNil),
};
public static void add(LuaState state) throws LuaError {
state.registry().getSubTable(Constants.LOADED).rawset("cc.internal.error_info", RegisteredFunction.bind(functions));
}
private static Varargs getInfoForNil(LuaState state, Varargs args) throws LuaError {
var thread = args.arg(1).checkThread();
var level = args.arg(2).checkInteger();
var context = getInfoForNil(state, thread, level);
return context == null ? Constants.NIL : ValueFactory.varargsOf(
ValueFactory.valueOf(context.op()), ValueFactory.valueOf(context.source().isGlobal()),
context.source().table(), context.source().key()
);
}
/**
* Get some additional information about an {@code attempt to $OP (a nil value)} error. This often occurs as a
* result of a misspelled local, global or table index, and so we attempt to detect those cases.
*
* @param state The current Lua state.
* @param thread The thread which has errored.
* @param level The level where the error occurred. We currently expect this to always be 0.
* @return Some additional information about the error, where available.
*/
@VisibleForTesting
static @Nullable NilInfo getInfoForNil(LuaState state, LuaThread thread, int level) {
var frame = thread.getDebugState().getFrame(level);
if (frame == null || frame.closure == null || (frame.flags & FLAG_ANY_HOOK) != 0) return null;
var prototype = frame.closure.getPrototype();
var pc = frame.pc;
var insn = prototype.code[pc];
// Find what operation we're doing that errored.
return switch (GET_OPCODE(insn)) {
case OP_CALL, OP_TAILCALL ->
NilInfo.of("call", resolveValueSource(state, frame, prototype, pc, GETARG_A(insn), 0));
case OP_GETTABLE, OP_SETTABLE, OP_SELF ->
NilInfo.of("index", resolveValueSource(state, frame, prototype, pc, GETARG_A(insn), 0));
default -> null;
};
}
/**
* Information about an {@code attempt to $OP (a nil value)} error.
*
* @param op The operation we tried to perform.
* @param source The expression that resulted in a nil value.
*/
@VisibleForTesting
record NilInfo(String op, ValueSource source) {
public static @Nullable NilInfo of(String op, @Nullable ValueSource values) {
return values == null ? null : new NilInfo(op, values);
}
}
/**
* A partially-reconstructed Lua expression. This currently only is used for table indexing ({@code table[key]}.
*
* @param isGlobal Whether this is a global table access. This is a best-effort guess, and does not distinguish between
* {@code foo} and {@code _ENV.foo}.
* @param table The table being indexed.
* @param key The key we tried to index.
*/
@VisibleForTesting
record ValueSource(boolean isGlobal, LuaValue table, LuaString key) {
}
/**
* Attempt to partially reconstruct a Lua expression from the current debug state.
*
* @param state The current Lua state.
* @param frame The current debug frame.
* @param prototype The current function.
* @param pc The current program counter.
* @param register The register where this value was stored.
* @param depth The current depth. Starts at 0, and aborts once reaching {@link #MAX_DEPTH}.
* @return The reconstructed expression, or {@code null} if not available.
*/
@SuppressWarnings("NullTernary")
private static @Nullable ValueSource resolveValueSource(LuaState state, DebugFrame frame, Prototype prototype, int pc, int register, int depth) {
if (depth > MAX_DEPTH) return null;
if (prototype.getLocalName(register + 1, pc) != null) return null;
// Find where this register was set. If unknown, then abort.
pc = DebugHelpers.findSetReg(prototype, pc, register);
if (pc == -1) return null;
var insn = prototype.code[pc];
return switch (GET_OPCODE(insn)) {
case OP_MOVE -> {
var a = GETARG_A(insn);
var b = GETARG_B(insn); // move from `b' to `a'
yield b < a ? resolveValueSource(state, frame, prototype, pc, register, depth + 1) : null; // Resolve 'b' .
}
case OP_GETTABUP, OP_GETTABLE, OP_SELF -> {
var tableIndex = GETARG_B(insn);
var keyIndex = GETARG_C(insn);
// We're only interested in expressions of the form "foo.bar". Showing a "did you mean" hint for
// "foo[i]" isn't very useful!
if (!ISK(keyIndex)) yield null;
var key = prototype.constants[INDEXK(keyIndex)];
if (key.type() != Constants.TSTRING) yield null;
var table = GET_OPCODE(insn) == OP_GETTABUP
? frame.closure.getUpvalue(tableIndex).getValue()
: evaluate(state, frame, prototype, pc, tableIndex, depth);
if (table == null) yield null;
var isGlobal = GET_OPCODE(insn) == OP_GETTABUP && Objects.equals(prototype.getUpvalueName(tableIndex), Constants.ENV);
yield new ValueSource(isGlobal, table, (LuaString) key);
}
default -> null;
};
}
/**
* Attempt to reconstruct the value of a register.
*
* @param state The current Lua state.
* @param frame The current debug frame.
* @param prototype The current function
* @param pc The PC to evaluate at.
* @param register The register to evaluate.
* @param depth The current depth. Starts at 0, and aborts once reaching {@link #MAX_DEPTH}.
* @return The reconstructed value, or {@code null} if unavailable.
*/
@SuppressWarnings("NullTernary")
private static @Nullable LuaValue evaluate(LuaState state, DebugFrame frame, Prototype prototype, int pc, int register, int depth) {
if (depth >= MAX_DEPTH) return null;
// If this is a local, then return its contents.
if (prototype.getLocalName(register + 1, pc) != null) return frame.stack[register];
// Otherwise find where this register was set. If unknown, then abort.
pc = DebugHelpers.findSetReg(prototype, pc, register);
if (pc == -1) return null;
var insn = prototype.code[pc];
var opcode = GET_OPCODE(insn);
return switch (opcode) {
case OP_MOVE -> {
var a = GETARG_A(insn);
var b = GETARG_B(insn); // move from `b' to `a'
yield b < a ? evaluate(state, frame, prototype, pc, register, depth + 1) : null; // Resolve 'b'.
}
// Load constants
case OP_LOADK -> prototype.constants[GETARG_Bx(insn)];
case OP_LOADKX -> prototype.constants[GETARG_Ax(prototype.code[pc + 1])];
case OP_LOADBOOL -> GETARG_B(insn) == 0 ? Constants.FALSE : Constants.TRUE;
case OP_LOADNIL -> Constants.NIL;
// Upvalues and tables.
case OP_GETUPVAL -> frame.closure.getUpvalue(GETARG_B(insn)).getValue();
case OP_GETTABLE, OP_GETTABUP -> {
var table = opcode == OP_GETTABUP
? frame.closure.getUpvalue(GETARG_B(insn)).getValue()
: evaluate(state, frame, prototype, pc, GETARG_B(insn), depth + 1);
if (table == null) yield null;
var key = evaluateK(state, frame, prototype, pc, GETARG_C(insn), depth + 1);
yield key == null ? null : safeIndex(state, table, key);
}
default -> null;
};
}
private static @Nullable LuaValue evaluateK(LuaState state, DebugFrame frame, Prototype prototype, int pc, int registerOrConstant, int depth) {
return ISK(registerOrConstant) ? prototype.constants[INDEXK(registerOrConstant)] : evaluate(state, frame, prototype, pc, registerOrConstant, depth + 1);
}
private static @Nullable LuaValue safeIndex(LuaState state, LuaValue table, LuaValue key) {
var loop = 0;
do {
LuaValue metatable;
if (table instanceof LuaTable tbl) {
var res = tbl.rawget(key);
if (!res.isNil() || (metatable = tbl.metatag(state, CachedMetamethod.INDEX)).isNil()) return res;
} else if ((metatable = table.metatag(state, CachedMetamethod.INDEX)).isNil()) {
return null;
}
if (metatable instanceof LuaFunction) return null;
table = metatable;
}
while (++loop < Constants.MAXTAGLOOP);
return null;
}
}

View File

@@ -18,24 +18,25 @@ public final class StringUtil {
* Convert a Unicode character to a terminal one.
*
* @param chr The Unicode character.
* @return The terminal character.
* @return The terminal character. This is either in the range [0, 255] (if a valid character) or {@code -1} if
* it cannot be mapped to CC's charset.
*/
public static byte unicodeToTerminal(int chr) {
public static int unicodeToTerminal(int chr) {
// ASCII and latin1 map to themselves
if (chr == 0 || chr == '\t' || chr == '\n' || chr == '\r' || (chr >= ' ' && chr <= '~') || (chr >= 160 && chr <= 255)) {
return (byte) chr;
return chr;
}
// Teletext block mosaics are *fairly* contiguous.
if (chr >= 0x1FB00 && chr <= 0x1FB13) return (byte) (chr + (129 - 0x1fb00));
if (chr >= 0x1FB14 && chr <= 0x1FB1D) return (byte) (chr + (150 - 0x1fb14));
if (chr >= 0x1FB00 && chr <= 0x1FB13) return chr + (129 - 0x1fb00);
if (chr >= 0x1FB14 && chr <= 0x1FB1D) return chr + (150 - 0x1fb14);
// Everything else is just a manual lookup. For now, we just use a big switch statement, which we spin into a
// separate function to hopefully avoid inlining it here.
return unicodeToCraftOsFallback(chr);
}
private static byte unicodeToCraftOsFallback(int c) {
private static int unicodeToCraftOsFallback(int c) {
return switch (c) {
case 0x263A -> 1;
case 0x263B -> 2;
@@ -64,8 +65,8 @@ public final class StringUtil {
case 0x25B2 -> 30;
case 0x25BC -> 31;
case 0x1FB99 -> 127;
case 0x258C -> (byte) 149;
default -> '?';
case 0x258C -> 149;
default -> -1;
};
}
@@ -76,8 +77,8 @@ public final class StringUtil {
* @param chr The character to check.
* @return Whether this character can be typed.
*/
public static boolean isTypableChar(byte chr) {
return chr != 0 && chr != '\r' && chr != '\n';
public static boolean isTypableChar(int chr) {
return chr >= 0 && chr <= 255 && chr != 0 && chr != '\r' && chr != '\n';
}
private static boolean isAllowedInLabel(char c) {
@@ -110,8 +111,9 @@ public final class StringUtil {
var iterator = clipboard.codePoints().iterator();
while (iterator.hasNext() && idx <= output.length) {
var chr = unicodeToTerminal(iterator.next());
if (!isTypableChar(chr)) break;
output[idx++] = chr;
if (chr < 0) continue; // Strip out unconvertible characters
if (!isTypableChar(chr)) break; // Stop at untypable ones.
output[idx++] = (byte) chr;
}
return ByteBuffer.wrap(output, 0, idx).asReadOnlyBuffer();

View File

@@ -39,60 +39,55 @@ the other.
@since 1.2
]]
local exception = dofile("rom/modules/main/cc/internal/tiny_require.lua")("cc.internal.exception")
local function create(...)
local tFns = table.pack(...)
local tCos = {}
for i = 1, tFns.n, 1 do
local fn = tFns[i]
local barrier_ctx = { co = coroutine.running() }
local functions = table.pack(...)
local threads = {}
for i = 1, functions.n, 1 do
local fn = functions[i]
if type(fn) ~= "function" then
error("bad argument #" .. i .. " (function expected, got " .. type(fn) .. ")", 3)
end
tCos[i] = coroutine.create(fn)
threads[i] = { co = coroutine.create(function() return exception.try_barrier(barrier_ctx, fn) end), filter = nil }
end
return tCos
return threads
end
local function runUntilLimit(_routines, _limit)
local count = #_routines
local function runUntilLimit(threads, limit)
local count = #threads
if count < 1 then return 0 end
local living = count
local tFilters = {}
local eventData = { n = 0 }
local event = { n = 0 }
while true do
for n = 1, count do
local r = _routines[n]
if r then
if tFilters[r] == nil or tFilters[r] == eventData[1] or eventData[1] == "terminate" then
local ok, param = coroutine.resume(r, table.unpack(eventData, 1, eventData.n))
if not ok then
error(param, 0)
else
tFilters[r] = param
end
if coroutine.status(r) == "dead" then
_routines[n] = nil
living = living - 1
if living <= _limit then
return n
end
for i = 1, count do
local thread = threads[i]
if thread and (thread.filter == nil or thread.filter == event[1] or event[1] == "terminate") then
local ok, param = coroutine.resume(thread.co, table.unpack(event, 1, event.n))
if ok then
thread.filter = param
elseif type(param) == "string" and exception.can_wrap_errors() then
error(exception.make_exception(param, thread.co))
else
error(param, 0)
end
if coroutine.status(thread.co) == "dead" then
threads[i] = false
living = living - 1
if living <= limit then
return i
end
end
end
end
for n = 1, count do
local r = _routines[n]
if r and coroutine.status(r) == "dead" then
_routines[n] = nil
living = living - 1
if living <= _limit then
return n
end
end
end
eventData = table.pack(os.pullEventRaw())
event = table.pack(os.pullEventRaw())
end
end
@@ -120,8 +115,8 @@ from the [`parallel.waitForAny`] call.
print("Everything done!")
]]
function waitForAny(...)
local routines = create(...)
return runUntilLimit(routines, #routines - 1)
local threads = create(...)
return runUntilLimit(threads, #threads - 1)
end
--[[- Switches between execution of the functions, until all of them are
@@ -144,6 +139,6 @@ from the [`parallel.waitForAll`] call.
print("Everything done!")
]]
function waitForAll(...)
local routines = create(...)
return runUntilLimit(routines, 0)
local threads = create(...)
return runUntilLimit(threads, 0)
end

View File

@@ -7,9 +7,7 @@
-- @module textutils
-- @since 1.2
local pgk_env = setmetatable({}, { __index = _ENV })
pgk_env.require = dofile("rom/modules/main/cc/require.lua").make(pgk_env, "rom/modules/main")
local require = pgk_env.require
local require = dofile("rom/modules/main/cc/internal/tiny_require.lua")
local expect = require("cc.expect")
local expect, field = expect.expect, expect.field

View File

@@ -1,3 +1,19 @@
# New features in CC: Tweaked 1.115.0
* Support placing pocket computers on lecterns.
* Suggest alternative table keys on `nil` errors.
* Errors from inside `parallel` functions now have source information attached.
* Expose printout contents to the Java API.
* Add support for MoreRed bundled cables.
Several bug fixes:
* Ignore unrepresentable characters in `char`/`paste` events.
# New features in CC: Tweaked 1.114.5
One bug fix:
* Fix `turtle.craft` crafting too many items for shapeless recipes.
# New features in CC: Tweaked 1.114.4
* Allow typing/pasting any character in the CC charset.
@@ -627,7 +643,7 @@ And several bug fixes:
* Remove config option for the debug API.
* Allow setting the subprotocol header for websockets.
* Add basic JMX monitoring on dedicated servers.
* Add support for MoreRed bundled.
* Add support for MoreRed bundled cables.
* Allow uploading files by dropping them onto a computer.
And several bug fixes:

View File

@@ -1,10 +1,12 @@
New features in CC: Tweaked 1.114.4
New features in CC: Tweaked 1.115.0
* Allow typing/pasting any character in the CC charset.
* Add a new `computercraft:storage_capacity` to override a disk or computer's capacity.
* Support placing pocket computers on lecterns.
* Suggest alternative table keys on `nil` errors.
* Errors from inside `parallel` functions now have source information attached.
* Expose printout contents to the Java API.
* Add support for MoreRed bundled cables.
Several bug fixes:
* Fix command computers having NBT set when placed in a Create contraption.
* Use correct bounding box when checking for entities in turtle movement.
* Ignore unrepresentable characters in `char`/`paste` events.
Type "help changelog" to see the full version history.

View File

@@ -0,0 +1,167 @@
-- SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
--
-- SPDX-License-Identifier: MPL-2.0
--[[- Internal tools for diagnosing errors and suggesting fixes.
> [!DANGER]
> This is an internal module and SHOULD NOT be used in your own code. It may
> be removed or changed at any time.
@local
]]
local debug, type, rawget = debug, type, rawget
local sub, lower, find, min, abs = string.sub, string.lower, string.find, math.min, math.abs
--[[- Compute the Optimal String Distance between two strings.
@tparam string str_a The first string.
@tparam string str_b The second string.
@treturn number|nil The distance between two strings, or nil if they are two far
apart.
]]
local function osa_distance(str_a, str_b, threshold)
local len_a, len_b = #str_a, #str_b
-- If the two strings are too different in length, then bail now.
if abs(len_a - len_b) > threshold then return end
-- Zero-initialise our distance table.
local d = {}
for i = 1, (len_a + 1) * (len_b + 1) do d[i] = 0 end
-- Then fill the first row and column
local function idx(a, b) return a * (len_a + 1) + b + 1 end
for i = 0, len_a do d[idx(i, 0)] = i end
for j = 0, len_b do d[idx(0, j)] = j end
-- Then compute our distance
for i = 1, len_a do
local char_a = sub(str_a, i, i)
for j = 1, len_b do
local char_b = sub(str_b, j, j)
local sub_cost
if char_a == char_b then
sub_cost = 0
elseif lower(char_a) == lower(char_b) then
sub_cost = 0.5
else
sub_cost = 1
end
local new_cost = min(
d[idx(i - 1, j)] + 1, -- Deletion
d[idx(i, j - 1)] + 1, -- Insertion,
d[idx(i - 1, j - 1)] + sub_cost -- Substitution
)
-- Transposition
if i > 1 and j > 1 and char_a == sub(str_b, j - 1, j - 1) and char_b == sub(str_a, i - 1, i - 1) then
local trans_cost = d[idx(i - 2, j - 2)] + 1
if trans_cost < new_cost then new_cost = trans_cost end
end
d[idx(i, j)] = new_cost
end
end
local result = d[idx(len_a, len_b)]
if result <= threshold then return result else return nil end
end
--- Check whether this suggestion is useful.
local function useful_suggestion(str)
local len = #str
return len > 0 and len < 32 and find(str, "^[%a_]%w*$")
end
local function get_suggestions(is_global, value, key, thread, frame_offset)
if not useful_suggestion(key) then return end
-- Pick a maximum number of edits. We're more lenient on longer strings, but
-- still only allow two mistakes.
local threshold = #key >= 5 and 2 or 1
-- Find all items in the table, and see if they seem similar.
local suggestions = {}
local function process_suggestion(k)
if type(k) ~= "string" or not useful_suggestion(k) then return end
local distance = osa_distance(k, key, threshold)
if distance then
if distance < threshold then
-- If this is better than any existing match, then prefer it.
suggestions = { k }
threshold = distance
else
-- Otherwise distance==threshold, and so just add it.
suggestions[#suggestions + 1] = k
end
end
end
while type(value) == "table" do
for k in next, value do process_suggestion(k) end
local mt = debug.getmetatable(value)
if mt == nil then break end
value = rawget(mt, "__index")
end
-- If we're attempting to lookup a global, then also suggest any locals and
-- upvalues. Our upvalues will be incomplete, but maybe a little useful?
if is_global then
for i = 1, 200 do
local name = debug.getlocal(thread, frame_offset, i)
if not name then break end
process_suggestion(name)
end
local func = debug.getinfo(thread, frame_offset, "f").func
for i = 1, 255 do
local name = debug.getupvalue(func, i)
if not name then break end
process_suggestion(name)
end
end
table.sort(suggestions)
return suggestions
end
--[[- Get a tip to display at the end of an error.
@tparam string err The error message.
@tparam coroutine thread The current thread.
@tparam number frame_offset The offset into the thread where the current frame exists
@return An optional message to append to the error.
]]
local function get_tip(err, thread, frame_offset)
local nil_op = err:match("^attempt to (%l+) .* %(a nil value%)")
if not nil_op then return end
local has_error_info, error_info = pcall(require, "cc.internal.error_info")
if not has_error_info then return end
local op, is_global, table, key = error_info.info_for_nil(thread, frame_offset)
if op == nil or op ~= nil_op then return end
local suggestions = get_suggestions(is_global, table, key, thread, frame_offset)
if not suggestions or next(suggestions) == nil then return end
local pretty = require "cc.pretty"
local msg = "Did you mean: "
local n_suggestions = min(3, #suggestions)
for i = 1, n_suggestions do
if i > 1 then
if i == n_suggestions then msg = msg .. " or " else msg = msg .. ", " end
end
msg = msg .. pretty.text(suggestions[i], colours.lightGrey)
end
return msg .. "?"
end
return { get_tip = get_tip }

View File

@@ -12,7 +12,7 @@
]]
local expect = require "cc.expect".expect
local error_printer = require "cc.internal.error_printer"
local type, debug, coroutine = type, debug, coroutine
local function find_frame(thread, file, line)
-- Scan the first 16 frames for something interesting.
@@ -21,14 +21,14 @@ local function find_frame(thread, file, line)
if not frame then break end
if frame.short_src == file and frame.what ~= "C" and frame.currentline == line then
return frame
return offset, frame
end
end
end
--[[- Check whether this error is an exception.
Currently we don't provide a stable API for throwing (and propogating) rich
Currently we don't provide a stable API for throwing (and propagating) rich
errors, like those supported by this module. In lieu of that, we describe the
exception protocol, which may be used by user-written coroutine managers to
throw exceptions which are pretty-printed by the shell:
@@ -64,6 +64,86 @@ local function is_exception(exn)
return mt and mt.__name == "exception" and type(rawget(exn, "message")) == "string" and type(rawget(exn, "thread")) == "thread"
end
local exn_mt = {
__name = "exception",
__tostring = function(self) return self.message end,
}
--[[- Create a new exception from a message and thread.
@tparam string message The exception message.
@tparam coroutine thread The coroutine the error occurred on.
@return The constructed exception.
]]
local function make_exception(message, thread)
return setmetatable({ message = message, thread = thread }, exn_mt)
end
--[[- A marker function for [`try`] and the wider exception machinery.
This function is typically the first function on the call stack. It acts as both
a signifier that this function is exception aware, and allows us to store
additional information for the exception machinery on the call stack.
@see can_wrap_errors
]]
local try_barrier = debug.getregistry().cc_try_barrier
if not try_barrier then
-- We define an extra "bounce" function to prevent f(...) being treated as a
-- tail call, and so ensure the barrier remains on the stack.
local function bounce(...) return ... end
--- @tparam { co = coroutine, can_wrap ?= boolean } parent The parent coroutine.
-- @tparam function f The function to call.
-- @param ... The arguments to this function.
try_barrier = function(parent, f, ...) return bounce(f(...)) end
debug.getregistry().cc_try_barrier = try_barrier
end
-- Functions that act as a barrier for exceptions.
local pcall_functions = { [pcall] = true, [xpcall] = true, [load] = true }
--[[- Check to see whether we can wrap errors into an exception.
This scans the current thread (up to a limit), and any parent threads, to
determine if there is a pcall anywhere on the callstack. If not, then we know
the error message is not observed by user code, and so may be wrapped into an
exception.
@tparam[opt] coroutine The thread to check. Defaults to the current thread.
@treturn boolean Whether we can wrap errors into exceptions.
]]
local function can_wrap_errors(thread)
if not thread then thread = coroutine.running() end
for offset = 0, 31 do
local frame = debug.getinfo(thread, offset, "f")
if not frame then return false end
local func = frame.func
if func == try_barrier then
-- If we've a try barrier, then extract the parent coroutine and
-- check if it can wrap errors.
local _, parent = debug.getlocal(thread, offset, 1)
if type(parent) ~= "table" or type(parent.co) ~= "thread" then return false end
local result = parent.can_wrap
if result == nil then
result = can_wrap_errors(parent.co)
parent.can_wrap = result
end
return result
elseif pcall_functions[func] then
-- If we're a pcall, then abort.
return false
end
end
return false
end
--[[- Attempt to call the provided function `func` with the provided arguments.
@tparam function func The function to call.
@@ -79,8 +159,8 @@ end
local function try(func, ...)
expect(1, func, "function")
local co = coroutine.create(func)
local result = table.pack(coroutine.resume(co, ...))
local co = coroutine.create(try_barrier)
local result = table.pack(coroutine.resume(co, { co = co, can_wrap = true }, func, ...))
while coroutine.status(co) ~= "dead" do
local event = table.pack(os.pullEventRaw(result[2]))
@@ -111,11 +191,11 @@ local function report(err, thread, source_map)
if type(err) ~= "string" then return end
local file, line = err:match("^([^:]+):(%d+):")
local file, line, err = err:match("^([^:]+):(%d+): (.*)")
if not file then return end
line = tonumber(line)
local frame = find_frame(thread, file, line)
local frame_offset, frame = find_frame(thread, file, line)
if not frame or not frame.currentcolumn then return end
local column = frame.currentcolumn
@@ -152,16 +232,22 @@ local function report(err, thread, source_map)
-- Could not determine the line. Bail.
if not line_contents or #line_contents == "" then return end
error_printer({
require("cc.internal.error_printer")({
get_pos = function() return line, column end,
get_line = function() return line_contents end,
}, {
{ tag = "annotate", start_pos = column, end_pos = column, msg = "" },
require "cc.internal.error_hints".get_tip(err, thread, frame_offset),
})
end
return {
make_exception = make_exception,
try_barrier = try_barrier,
can_wrap_errors = can_wrap_errors,
try = try,
report = report,
}

View File

@@ -0,0 +1,37 @@
-- SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
--
-- SPDX-License-Identifier: MPL-2.0
--[[- A minimal implementation of require.
This is intended for use with APIs, and other internal code which is not run in
the [`shell`] environment. This allows us to avoid some of the overhead of
loading the full [`cc.require`] module.
> [!DANGER]
> This is an internal module and SHOULD NOT be used in your own code. It may
> be removed or changed at any time.
@local
@tparam string name The module to require.
@return The required module.
]]
local loaded = {}
local env = setmetatable({}, { __index = _G })
local function require(name)
local result = loaded[name]
if result then return result end
local path = "rom/modules/main/" .. name:gsub("%.", "/")
if fs.exists(path .. ".lua") then
result = assert(loadfile(path .. ".lua", nil, env))()
else
result = assert(loadfile(path .. "/init.lua", nil, env))()
end
loaded[name] = result
return result
end
env.require = require
return require

View File

@@ -31,7 +31,7 @@ setmetatable(tEnv, { __index = _ENV })
do
local make_package = require "cc.require".make
local dir = shell.dir()
_ENV.require, _ENV.package = make_package(_ENV, dir)
tEnv.require, tEnv.package = make_package(tEnv, dir)
end
if term.isColour() then

View File

@@ -413,7 +413,7 @@ public class ComputerTestDelegate {
var wholeMessage = new StringBuilder();
if (message != null) wholeMessage.append(message);
if (trace != null) {
if (wholeMessage.length() != 0) wholeMessage.append('\n');
if (!wholeMessage.isEmpty()) wholeMessage.append('\n');
wholeMessage.append(trace);
}

View File

@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.lua.errorinfo;
import org.intellij.lang.annotations.Language;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.squiddev.cobalt.*;
import org.squiddev.cobalt.compiler.CompileException;
import org.squiddev.cobalt.compiler.LoadState;
import org.squiddev.cobalt.lib.CoreLibraries;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class ErrorInfoLibTest {
@Test
public void testNilInfoForUnknownLibFunction() throws LuaError, CompileException {
var state = newState();
var thread = captureError(state, "string.forma()");
assertEquals(
new ErrorInfoLib.NilInfo(
"call",
new ErrorInfoLib.ValueSource(false, state.globals().rawget("string"), ValueFactory.valueOf("forma"))
),
ErrorInfoLib.getInfoForNil(state, thread, 0)
);
}
@Test
public void testNilInfoForUnknownGlobal() throws LuaError, CompileException {
var state = newState();
var thread = captureError(state, "pront()");
assertEquals(
new ErrorInfoLib.NilInfo(
"call",
new ErrorInfoLib.ValueSource(true, state.globals(), ValueFactory.valueOf("pront"))
),
ErrorInfoLib.getInfoForNil(state, thread, 0)
);
}
@Test
public void testNilInfoForComplexExpression() throws LuaError, CompileException {
var state = newState();
var thread = captureError(state, "x = { { y = 1 } }; for i = 1, #x do x[i].z() end");
var inner = ((LuaTable) state.globals().rawget("x")).rawget(1);
assertEquals(
new ErrorInfoLib.NilInfo(
"call",
new ErrorInfoLib.ValueSource(false, inner, ValueFactory.valueOf("z"))
),
ErrorInfoLib.getInfoForNil(state, thread, 0)
);
}
private static LuaState newState() throws LuaError {
var state = new LuaState();
CoreLibraries.standardGlobals(state);
return state;
}
private static LuaThread captureError(LuaState state, @Language("lua") String contents) throws CompileException, LuaError {
var fn = LoadState.load(state, new ByteArrayInputStream(contents.getBytes(StandardCharsets.UTF_8)), "=in.lua", state.globals());
var thread = new LuaThread(state, fn);
Assertions.assertThrows(LuaError.class, () -> LuaThread.run(thread, Constants.NIL));
return thread;
}
}

View File

@@ -193,7 +193,7 @@ local function format(value)
return "\"" .. escaped .. "\""
else
local ok, res = pcall(textutils.serialise, value)
if ok then return res else return tostring(value) end
if ok then return (res:gsub("\\\n", "\\n")) else return tostring(value) end
end
end
@@ -379,7 +379,7 @@ end
function expect_mt:str_match(pattern)
local actual_type = type(self.value)
if actual_type ~= "string" then
self:_fail(("Expected value of type string\nbut got %s"):format(actual_type))
self:_fail(("Expected value of type string\nbut got %s (of type %s)"):format(format(self.value), actual_type))
end
if not self.value:find(pattern) then
self:_fail(("Expected %q\n to match pattern %q"):format(self.value, pattern))

View File

@@ -129,4 +129,63 @@ describe("The parallel library", function()
expect(exitCount):eq(3)
end)
end)
describe("exceptions", function()
local try = require "cc.internal.exception".try
local function check_failure(fn, ...)
local ok, message, thread = try(fn, ...)
expect(ok):eq(false)
expect(message):str_match("/parallel_spec.lua:%d+: Oh no$")
return thread
end
it("throws an exception when within a try", function()
local expected_thread
local thread = check_failure(parallel.waitForAny, function()
expected_thread = coroutine.running()
error("Oh no")
end)
expect(thread):eq(expected_thread)
end)
it("throws an exception when within a try (nested)", function()
local expected_thread
local thread = check_failure(parallel.waitForAny, function()
parallel.waitForAny(function()
expected_thread = coroutine.running()
error("Oh no")
end)
end)
expect(thread):eq(expected_thread)
end)
it("throws the raw error when within a pcall", function()
local expected_thread
local thread = check_failure(function()
expected_thread = coroutine.running()
local ok, err = pcall(parallel.waitForAny, function() error("Oh no") end)
expect(ok):eq(false)
expect(err):str_match("/parallel_spec.lua:%d+: Oh no$")
error(err, 0)
end)
expect(thread):eq(expected_thread)
end)
it("throws the raw error when within a pcall (nested)", function()
local expected_thread
local thread = check_failure(function()
expected_thread = coroutine.running()
local ok, err = pcall(parallel.waitForAny, function()
parallel.waitForAny(function() error("Oh no") end)
end)
expect(ok):eq(false)
expect(err):str_match("/parallel_spec.lua:%d+: Oh no$")
error(err, 0)
end)
expect(thread):eq(expected_thread)
end)
end)
end)

View File

@@ -0,0 +1,37 @@
-- SPDX-FileCopyrightText: 2025 The CC: Tweaked Developers
--
-- SPDX-License-Identifier: MPL-2.0
describe("cc.internal.error_hints", function()
local error_hints = require "cc.internal.error_hints"
local function get_tip_for(code)
local fn = assert(load(code, "=input.lua"))
local co = coroutine.create(fn)
local ok, err = coroutine.resume(co)
expect(ok):eq(false)
local _, _, err = err:match("^([^:]+):(%d+): (.*)")
local tip = error_hints.get_tip(err, co, 0)
return tip and tostring(tip) or nil
end
describe("gives hints for 'attempt to OP (a nil value)' errors", function()
it("suggests alternative globals", function()
expect(get_tip_for("pront()")):eq("Did you mean: print?")
end)
it("suggests alternative locals", function()
expect(get_tip_for("local foo; fot()")):eq("Did you mean: foo?")
end)
it("suggests alternative table keys", function()
expect(get_tip_for("redstone.getinput()")):eq("Did you mean: getInput?")
end)
it("suggests multiple table keys", function()
expect(get_tip_for("redstone.getAnaloguInput()")):eq("Did you mean: getAnalogInput or getAnalogueInput?")
end)
end)
end)

View File

@@ -265,3 +265,6 @@ tasks.register("checkClient") {
modPublishing {
output = tasks.jar
}
// TODO: Remove once https://github.com/modrinth/minotaur/pull/72 is merged.
modrinth { loaders = listOf("neoforge") }

View File

@@ -20,6 +20,7 @@ import dan200.computercraft.shared.config.Config;
import dan200.computercraft.shared.config.ConfigSpec;
import dan200.computercraft.shared.details.FluidData;
import dan200.computercraft.shared.integration.CreateIntegration;
import dan200.computercraft.shared.integration.MoreRedIntegration;
import dan200.computercraft.shared.network.NetworkMessage;
import dan200.computercraft.shared.network.NetworkMessages;
import dan200.computercraft.shared.network.client.ClientNetworkContext;
@@ -70,6 +71,8 @@ public final class ComputerCraft {
var container = ModLoadingContext.get().getActiveContainer();
container.registerConfig(ModConfig.Type.SERVER, ((ForgeConfigFile) ConfigSpec.serverSpec).spec());
container.registerConfig(ModConfig.Type.CLIENT, ((ForgeConfigFile) ConfigSpec.clientSpec).spec());
if (ModList.get().isLoaded(MoreRedIntegration.MOD_ID)) MoreRedIntegration.setup(eventBus);
}
private static void withEventBus(IEventBus eventBus, Runnable task) {

View File

@@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2021 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.shared.integration;
import commoble.morered.api.MoreRedAPI;
import dan200.computercraft.api.ComputerCraftAPI;
import dan200.computercraft.shared.common.IBundledRedstoneBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.level.Level;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent;
public final class MoreRedIntegration {
public static final String MOD_ID = "morered";
private static void onRegisterCapabilities(RegisterCapabilitiesEvent event) {
for (var block : BuiltInRegistries.BLOCK) {
if (!(block instanceof IBundledRedstoneBlock bundledBlock)) continue;
event.registerBlock(
MoreRedAPI.CHANNELED_POWER_CAPABILITY,
(level, pos, state, blockEntity, context) -> (level2, wirePos, wireState, wireFace, channel) -> {
var outputLevel = bundledBlock.getBundledRedstoneOutput(level2, pos, context);
return (outputLevel & (1 << channel)) != 0 ? 31 : 0;
},
block
);
}
}
public static void setup(IEventBus bus) {
bus.addListener(MoreRedIntegration::onRegisterCapabilities);
ComputerCraftAPI.registerBundledRedstoneProvider(MoreRedIntegration::getBundledPower);
}
private static int getBundledPower(Level world, BlockPos pos, Direction side) {
var blockState = world.getBlockState(pos);
// Skip ones already handled by CC. We can do this more efficiently.
if (blockState.getBlock() instanceof IBundledRedstoneBlock) return -1;
var power = world.getCapability(MoreRedAPI.CHANNELED_POWER_CAPABILITY, pos, blockState, null, side);
if (power == null) return -1;
var mask = 0;
for (var i = 0; i < 16; i++) {
mask |= power.getPowerOnChannel(world, pos, blockState, side, i) > 0 ? (1 << i) : 0;
}
return mask;
}
}

View File

@@ -51,7 +51,7 @@ public class InputState {
public void onCharEvent(int codepoint) {
var terminalChar = StringUtil.unicodeToTerminal(codepoint);
if (StringUtil.isTypableChar(terminalChar)) ComputerEvents.charTyped(computer, terminalChar);
if (StringUtil.isTypableChar(terminalChar)) ComputerEvents.charTyped(computer, (byte) terminalChar);
}
public void onKeyEvent(long window, int key, int action, int modifiers) {