1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-08-01 00:32:52 +00:00

Merge branch 'mc-1.20.x' into mc-1.21.x

This commit is contained in:
Jonathan Coates 2025-02-14 20:44:39 +00:00
commit 3f8c3b026a
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
41 changed files with 1206 additions and 131 deletions

View File

@ -12,7 +12,7 @@ neogradle.subsystems.conventions.runs.enabled=false
# Mod properties
isUnstable=true
modVersion=1.114.5
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

@ -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);

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

@ -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,14 @@
# 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:
@ -632,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,6 +1,12 @@
New features in CC: Tweaked 1.114.5
New features in CC: Tweaked 1.115.0
One bug fix:
* Fix `turtle.craft` crafting too many items for shapeless recipes.
* 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.
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

@ -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) {