1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-10-27 03:47:38 +00:00

Move dan200.computercraft.core into a separate module

This is a very big diff in changed files, but very small in actual
changes.
This commit is contained in:
Jonathan Coates
2022-11-04 22:31:56 +00:00
parent a17b001950
commit acc254a1ef
766 changed files with 193 additions and 131 deletions

View File

@@ -0,0 +1,110 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core;
import dan200.computercraft.core.computer.ComputerThread;
import dan200.computercraft.core.computer.GlobalEnvironment;
import dan200.computercraft.core.computer.mainthread.MainThreadScheduler;
import dan200.computercraft.core.lua.CobaltLuaMachine;
import dan200.computercraft.core.lua.ILuaMachine;
import javax.annotation.CheckReturnValue;
import java.util.concurrent.TimeUnit;
/**
* The global context under which computers run.
*/
public final class ComputerContext {
private final GlobalEnvironment globalEnvironment;
private final ComputerThread computerScheduler;
private final MainThreadScheduler mainThreadScheduler;
private final ILuaMachine.Factory factory;
public ComputerContext(
GlobalEnvironment globalEnvironment, ComputerThread computerScheduler,
MainThreadScheduler mainThreadScheduler, ILuaMachine.Factory factory
) {
this.globalEnvironment = globalEnvironment;
this.computerScheduler = computerScheduler;
this.mainThreadScheduler = mainThreadScheduler;
this.factory = factory;
}
/**
* Create a default {@link ComputerContext} with the given global environment.
*
* @param environment The current global environment.
* @param threads The number of threads to use for the {@link #computerScheduler()}
* @param mainThreadScheduler The main thread scheduler to use.
*/
public ComputerContext(GlobalEnvironment environment, int threads, MainThreadScheduler mainThreadScheduler) {
this(environment, new ComputerThread(threads), mainThreadScheduler, CobaltLuaMachine::new);
}
/**
* The global environment.
*
* @return The current global environment.
*/
public GlobalEnvironment globalEnvironment() {
return globalEnvironment;
}
/**
* The {@link ComputerThread} instance under which computers are run. This is closed when the context is closed, and
* so should be unique per-context.
*
* @return The current computer thread manager.
*/
public ComputerThread computerScheduler() {
return computerScheduler;
}
/**
* The {@link MainThreadScheduler} instance used to run main-thread tasks.
*
* @return The current main thread scheduler.
*/
public MainThreadScheduler mainThreadScheduler() {
return mainThreadScheduler;
}
/**
* The factory to create new Lua machines.
*
* @return The current Lua machine factory.
*/
public ILuaMachine.Factory luaFactory() {
return factory;
}
/**
* Close the current {@link ComputerContext}, disposing of any resources inside.
*
* @param timeout The maximum time to wait.
* @param unit The unit {@code timeout} is in.
* @return Whether the context was successfully shut down.
* @throws InterruptedException If interrupted while waiting.
*/
@CheckReturnValue
public boolean close(long timeout, TimeUnit unit) throws InterruptedException {
return computerScheduler().stop(timeout, unit);
}
/**
* Close the current {@link ComputerContext}, disposing of any resources inside.
*
* @param timeout The maximum time to wait.
* @param unit The unit {@code timeout} is in.
* @throws IllegalStateException If the computer thread was not shut down in time.
* @throws InterruptedException If interrupted while waiting.
*/
public void ensureClosed(long timeout, TimeUnit unit) throws InterruptedException {
if (!computerScheduler().stop(timeout, unit)) {
throw new IllegalStateException("Failed to shutdown ComputerContext in time.");
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core;
import dan200.computercraft.core.apis.http.options.Action;
import dan200.computercraft.core.apis.http.options.AddressRule;
import java.util.List;
import java.util.OptionalInt;
import java.util.concurrent.TimeUnit;
/**
* Config options for ComputerCraft's Lua runtime.
*/
public final class CoreConfig {
// TODO: Ideally this would be an instance in {@link ComputerContext}, but sharing this everywhere it needs to be is
// tricky.
private CoreConfig() {
}
public static int maximumFilesOpen = 128;
public static boolean disableLua51Features = false;
public static String defaultComputerSettings = "";
public static long maxMainGlobalTime = TimeUnit.MILLISECONDS.toNanos(10);
public static long maxMainComputerTime = TimeUnit.MILLISECONDS.toNanos(5);
public static boolean httpEnabled = true;
public static boolean httpWebsocketEnabled = true;
public static List<AddressRule> httpRules = List.of(
AddressRule.parse("$private", OptionalInt.empty(), Action.DENY.toPartial()),
AddressRule.parse("*", OptionalInt.empty(), Action.ALLOW.toPartial())
);
public static int httpMaxRequests = 16;
public static int httpMaxWebsockets = 4;
public static int httpDownloadBandwidth = 32 * 1024 * 1024;
public static int httpUploadBandwidth = 32 * 1024 * 1024;
}

View File

@@ -0,0 +1,26 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core;
import org.slf4j.Marker;
import org.slf4j.MarkerFactory;
/**
* Shared log markers for ComputerCraft.
*/
public final class Logging {
public static final Marker COMPUTER_ERROR = MarkerFactory.getMarker("COMPUTER_ERROR");
public static final Marker HTTP_ERROR = MarkerFactory.getMarker("COMPUTER_ERROR.HTTP");
public static final Marker JAVA_ERROR = MarkerFactory.getMarker("COMPUTER_ERROR.JAVA");
static {
HTTP_ERROR.add(COMPUTER_ERROR);
JAVA_ERROR.add(JAVA_ERROR);
}
private Logging() {
}
}

View File

@@ -0,0 +1,31 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.ILuaAPIFactory;
import javax.annotation.Nonnull;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Objects;
public final class ApiFactories {
private ApiFactories() {
}
private static final Collection<ILuaAPIFactory> factories = new LinkedHashSet<>();
private static final Collection<ILuaAPIFactory> factoriesView = Collections.unmodifiableCollection(factories);
public static synchronized void register(@Nonnull ILuaAPIFactory factory) {
Objects.requireNonNull(factory, "provider cannot be null");
factories.add(factory);
}
public static Iterable<ILuaAPIFactory> getAll() {
return factoriesView;
}
}

View File

@@ -0,0 +1,130 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.filesystem.IMount;
import dan200.computercraft.api.filesystem.IWritableMount;
import dan200.computercraft.api.peripheral.IComputerAccess;
import dan200.computercraft.api.peripheral.IWorkMonitor;
import dan200.computercraft.core.filesystem.FileSystemException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public abstract class ComputerAccess implements IComputerAccess {
private static final Logger LOG = LoggerFactory.getLogger(ComputerAccess.class);
private final IAPIEnvironment environment;
private final Set<String> mounts = new HashSet<>();
protected ComputerAccess(IAPIEnvironment environment) {
this.environment = environment;
}
public void unmountAll() {
var fileSystem = environment.getFileSystem();
if (!mounts.isEmpty()) {
LOG.warn("Peripheral or API called mount but did not call unmount for {}", mounts);
}
for (var mount : mounts) {
fileSystem.unmount(mount);
}
mounts.clear();
}
@Override
public synchronized String mount(@Nonnull String desiredLoc, @Nonnull IMount mount, @Nonnull String driveName) {
Objects.requireNonNull(desiredLoc, "desiredLocation cannot be null");
Objects.requireNonNull(mount, "mount cannot be null");
Objects.requireNonNull(driveName, "driveName cannot be null");
// Mount the location
String location;
var fileSystem = environment.getFileSystem();
if (fileSystem == null) throw new IllegalStateException("File system has not been created");
synchronized (fileSystem) {
location = findFreeLocation(desiredLoc);
if (location != null) {
try {
fileSystem.mount(driveName, location, mount);
} catch (FileSystemException ignored) {
}
}
}
if (location != null) mounts.add(location);
return location;
}
@Override
public synchronized String mountWritable(@Nonnull String desiredLoc, @Nonnull IWritableMount mount, @Nonnull String driveName) {
Objects.requireNonNull(desiredLoc, "desiredLocation cannot be null");
Objects.requireNonNull(mount, "mount cannot be null");
Objects.requireNonNull(driveName, "driveName cannot be null");
// Mount the location
String location;
var fileSystem = environment.getFileSystem();
if (fileSystem == null) throw new IllegalStateException("File system has not been created");
synchronized (fileSystem) {
location = findFreeLocation(desiredLoc);
if (location != null) {
try {
fileSystem.mountWritable(driveName, location, mount);
} catch (FileSystemException ignored) {
}
}
}
if (location != null) mounts.add(location);
return location;
}
@Override
public void unmount(String location) {
if (location == null) return;
if (!mounts.contains(location)) throw new IllegalStateException("You didn't mount this location");
environment.getFileSystem().unmount(location);
mounts.remove(location);
}
@Override
public int getID() {
return environment.getComputerID();
}
@Override
public void queueEvent(@Nonnull String event, Object... arguments) {
Objects.requireNonNull(event, "event cannot be null");
environment.queueEvent(event, arguments);
}
@Nonnull
@Override
public IWorkMonitor getMainThreadMonitor() {
return environment.getMainThreadMonitor();
}
private String findFreeLocation(String desiredLoc) {
try {
var fileSystem = environment.getFileSystem();
if (!fileSystem.exists(desiredLoc)) return desiredLoc;
// We used to check foo2, foo3, foo4, etc here but the disk drive does this itself now
return null;
} catch (FileSystemException e) {
return null;
}
}
}

View File

@@ -0,0 +1,528 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.apis.handles.BinaryReadableHandle;
import dan200.computercraft.core.apis.handles.BinaryWritableHandle;
import dan200.computercraft.core.apis.handles.EncodedReadableHandle;
import dan200.computercraft.core.apis.handles.EncodedWritableHandle;
import dan200.computercraft.core.filesystem.FileSystem;
import dan200.computercraft.core.filesystem.FileSystemException;
import dan200.computercraft.core.metrics.Metrics;
import java.nio.file.attribute.FileTime;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
/**
* Interact with the computer's files and filesystem, allowing you to manipulate files, directories and paths. This
* includes:
*
* <ul>
* <li>**Reading and writing files:** Call {@link #open} to obtain a file "handle", which can be used to read from or
* write to a file.</li>
* <li>**Path manipulation:** {@link #combine}, {@link #getName} and {@link #getDir} allow you to manipulate file
* paths, joining them together or extracting components.</li>
* <li>**Querying paths:** For instance, checking if a file exists, or whether it's a directory. See {@link #getSize},
* {@link #exists}, {@link #isDir}, {@link #isReadOnly} and {@link #attributes}.</li>
* <li>**File and directory manipulation:** For instance, moving or copying files. See {@link #makeDir}, {@link #move},
* {@link #copy} and {@link #delete}.</li>
* </ul>
* <p>
* :::note
* All functions in the API work on absolute paths, and do not take the @{shell.dir|current directory} into account.
* You can use @{shell.resolve} to convert a relative path into an absolute one.
* :::
* <p>
* ## Mounts
* While a computer can only have one hard drive and filesystem, other filesystems may be "mounted" inside it. For
* instance, the {@link dan200.computercraft.shared.peripheral.diskdrive.DiskDrivePeripheral drive peripheral} mounts
* its disk's contents at {@code "disk/"}, {@code "disk1/"}, etc...
* <p>
* You can see which mount a path belongs to with the {@link #getDrive} function. This returns {@code "hdd"} for the
* computer's main filesystem ({@code "/"}), {@code "rom"} for the rom ({@code "rom/"}).
* <p>
* Most filesystems have a limited capacity, operations which would cause that capacity to be reached (such as writing
* an incredibly large file) will fail. You can see a mount's capacity with {@link #getCapacity} and the remaining
* space with {@link #getFreeSpace}.
*
* @cc.module fs
*/
public class FSAPI implements ILuaAPI {
private final IAPIEnvironment environment;
private FileSystem fileSystem = null;
public FSAPI(IAPIEnvironment env) {
environment = env;
}
@Override
public String[] getNames() {
return new String[]{ "fs" };
}
@Override
public void startup() {
fileSystem = environment.getFileSystem();
}
@Override
public void shutdown() {
fileSystem = null;
}
/**
* Returns a list of files in a directory.
*
* @param path The path to list.
* @return A table with a list of files in the directory.
* @throws LuaException If the path doesn't exist.
* @cc.usage List all files under {@code /rom/}
* <pre>{@code
* local files = fs.list("/rom/")
* for i = 1, #files do
* print(files[i])
* end
* }</pre>
*/
@LuaFunction
public final String[] list(String path) throws LuaException {
environment.observe(Metrics.FS_OPS);
try {
return fileSystem.list(path);
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Combines several parts of a path into one full path, adding separators as
* needed.
*
* @param arguments The paths to combine.
* @return The new path, with separators added between parts as needed.
* @throws LuaException On argument errors.
* @cc.tparam string path The first part of the path. For example, a parent directory path.
* @cc.tparam string ... Additional parts of the path to combine.
* @cc.changed 1.95.0 Now supports multiple arguments.
* @cc.usage Combine several file paths together
* <pre>{@code
* fs.combine("/rom/programs", "../apis", "parallel.lua")
* -- => rom/apis/parallel.lua
* }</pre>
*/
@LuaFunction
public final String combine(IArguments arguments) throws LuaException {
var result = new StringBuilder();
result.append(FileSystem.sanitizePath(arguments.getString(0), true));
for (int i = 1, n = arguments.count(); i < n; i++) {
var part = FileSystem.sanitizePath(arguments.getString(i), true);
if (result.length() != 0 && !part.isEmpty()) result.append('/');
result.append(part);
}
return FileSystem.sanitizePath(result.toString(), true);
}
/**
* Returns the file name portion of a path.
*
* @param path The path to get the name from.
* @return The final part of the path (the file name).
* @cc.since 1.2
* @cc.usage Get the file name of {@code rom/startup.lua}
* <pre>{@code
* fs.getName("rom/startup.lua")
* -- => startup.lua
* }</pre>
*/
@LuaFunction
public final String getName(String path) {
return FileSystem.getName(path);
}
/**
* Returns the parent directory portion of a path.
*
* @param path The path to get the directory from.
* @return The path with the final part removed (the parent directory).
* @cc.since 1.63
* @cc.usage Get the directory name of {@code rom/startup.lua}
* <pre>{@code
* fs.getDir("rom/startup.lua")
* -- => rom
* }</pre>
*/
@LuaFunction
public final String getDir(String path) {
return FileSystem.getDirectory(path);
}
/**
* Returns the size of the specified file.
*
* @param path The file to get the file size of.
* @return The size of the file, in bytes.
* @throws LuaException If the path doesn't exist.
* @cc.since 1.3
*/
@LuaFunction
public final long getSize(String path) throws LuaException {
try {
return fileSystem.getSize(path);
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Returns whether the specified path exists.
*
* @param path The path to check the existence of.
* @return Whether the path exists.
*/
@LuaFunction
public final boolean exists(String path) {
try {
return fileSystem.exists(path);
} catch (FileSystemException e) {
return false;
}
}
/**
* Returns whether the specified path is a directory.
*
* @param path The path to check.
* @return Whether the path is a directory.
*/
@LuaFunction
public final boolean isDir(String path) {
try {
return fileSystem.isDir(path);
} catch (FileSystemException e) {
return false;
}
}
/**
* Returns whether a path is read-only.
*
* @param path The path to check.
* @return Whether the path cannot be written to.
*/
@LuaFunction
public final boolean isReadOnly(String path) {
try {
return fileSystem.isReadOnly(path);
} catch (FileSystemException e) {
return false;
}
}
/**
* Creates a directory, and any missing parents, at the specified path.
*
* @param path The path to the directory to create.
* @throws LuaException If the directory couldn't be created.
*/
@LuaFunction
public final void makeDir(String path) throws LuaException {
try {
environment.observe(Metrics.FS_OPS);
fileSystem.makeDir(path);
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Moves a file or directory from one path to another.
* <p>
* Any parent directories are created as needed.
*
* @param path The current file or directory to move from.
* @param dest The destination path for the file or directory.
* @throws LuaException If the file or directory couldn't be moved.
*/
@LuaFunction
public final void move(String path, String dest) throws LuaException {
try {
environment.observe(Metrics.FS_OPS);
fileSystem.move(path, dest);
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Copies a file or directory to a new path.
* <p>
* Any parent directories are created as needed.
*
* @param path The file or directory to copy.
* @param dest The path to the destination file or directory.
* @throws LuaException If the file or directory couldn't be copied.
*/
@LuaFunction
public final void copy(String path, String dest) throws LuaException {
try {
environment.observe(Metrics.FS_OPS);
fileSystem.copy(path, dest);
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Deletes a file or directory.
* <p>
* If the path points to a directory, all of the enclosed files and
* subdirectories are also deleted.
*
* @param path The path to the file or directory to delete.
* @throws LuaException If the file or directory couldn't be deleted.
*/
@LuaFunction
public final void delete(String path) throws LuaException {
try {
environment.observe(Metrics.FS_OPS);
fileSystem.delete(path);
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
// FIXME: Add individual handle type documentation
/**
* Opens a file for reading or writing at a path.
* <p>
* The {@code mode} string can be any of the following:
* <ul>
* <li><strong>"r"</strong>: Read mode</li>
* <li><strong>"w"</strong>: Write mode</li>
* <li><strong>"a"</strong>: Append mode</li>
* </ul>
* <p>
* The mode may also have a "b" at the end, which opens the file in "binary
* mode". This allows you to read binary files, as well as seek within a file.
*
* @param path The path to the file to open.
* @param mode The mode to open the file with.
* @return A file handle object for the file, or {@code nil} + an error message on error.
* @throws LuaException If an invalid mode was specified.
* @cc.treturn [1] table A file handle object for the file.
* @cc.treturn [2] nil If the file does not exist, or cannot be opened.
* @cc.treturn string|nil A message explaining why the file cannot be opened.
* @cc.usage Read the contents of a file.
* <pre>{@code
* local file = fs.open("/rom/help/intro.txt", "r")
* local contents = file.readAll()
* file.close()
*
* print(contents)
* }</pre>
* @cc.usage Open a file and read all lines into a table. @{io.lines} offers an alternative way to do this.
* <pre>{@code
* local file = fs.open("/rom/motd.txt", "r")
* local lines = {}
* while true do
* local line = file.readLine()
*
* -- If line is nil then we've reached the end of the file and should stop
* if not line then break end
*
* lines[#lines + 1] = line
* end
*
* file.close()
*
* print(lines[math.random(#lines)]) -- Pick a random line and print it.
* }</pre>
* @cc.usage Open a file and write some text to it. You can run {@code edit out.txt} to see the written text.
* <pre>{@code
* local file = fs.open("out.txt", "w")
* file.write("Just testing some code")
* file.close() -- Remember to call close, otherwise changes may not be written!
* }</pre>
*/
@LuaFunction
public final Object[] open(String path, String mode) throws LuaException {
environment.observe(Metrics.FS_OPS);
try {
switch (mode) {
case "r" -> {
// Open the file for reading, then create a wrapper around the reader
var reader = fileSystem.openForRead(path, EncodedReadableHandle::openUtf8);
return new Object[]{ new EncodedReadableHandle(reader.get(), reader) };
}
case "w" -> {
// Open the file for writing, then create a wrapper around the writer
var writer = fileSystem.openForWrite(path, false, EncodedWritableHandle::openUtf8);
return new Object[]{ new EncodedWritableHandle(writer.get(), writer) };
}
case "a" -> {
// Open the file for appending, then create a wrapper around the writer
var writer = fileSystem.openForWrite(path, true, EncodedWritableHandle::openUtf8);
return new Object[]{ new EncodedWritableHandle(writer.get(), writer) };
}
case "rb" -> {
// Open the file for binary reading, then create a wrapper around the reader
var reader = fileSystem.openForRead(path, Function.identity());
return new Object[]{ BinaryReadableHandle.of(reader.get(), reader) };
}
case "wb" -> {
// Open the file for binary writing, then create a wrapper around the writer
var writer = fileSystem.openForWrite(path, false, Function.identity());
return new Object[]{ BinaryWritableHandle.of(writer.get(), writer) };
}
case "ab" -> {
// Open the file for binary appending, then create a wrapper around the reader
var writer = fileSystem.openForWrite(path, true, Function.identity());
return new Object[]{ BinaryWritableHandle.of(writer.get(), writer) };
}
default -> throw new LuaException("Unsupported mode");
}
} catch (FileSystemException e) {
return new Object[]{ null, e.getMessage() };
}
}
/**
* Returns the name of the mount that the specified path is located on.
*
* @param path The path to get the drive of.
* @return The name of the drive that the file is on; e.g. {@code hdd} for local files, or {@code rom} for ROM files.
* @throws LuaException If the path doesn't exist.
* @cc.treturn string The name of the drive that the file is on; e.g. {@code hdd} for local files, or {@code rom} for ROM files.
* @cc.usage Print the drives of a couple of mounts:
*
* <pre>{@code
* print("/: " .. fs.getDrive("/"))
* print("/rom/: " .. fs.getDrive("rom"))
* }</pre>
*/
@LuaFunction
public final Object[] getDrive(String path) throws LuaException {
try {
return fileSystem.exists(path) ? new Object[]{ fileSystem.getMountLabel(path) } : null;
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Returns the amount of free space available on the drive the path is
* located on.
*
* @param path The path to check the free space for.
* @return The amount of free space available, in bytes.
* @throws LuaException If the path doesn't exist.
* @cc.treturn number|"unlimited" The amount of free space available, in bytes, or "unlimited".
* @cc.since 1.4
* @see #getCapacity To get the capacity of this drive.
*/
@LuaFunction
public final Object getFreeSpace(String path) throws LuaException {
try {
var freeSpace = fileSystem.getFreeSpace(path);
return freeSpace >= 0 ? freeSpace : "unlimited";
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Searches for files matching a string with wildcards.
* <p>
* This string is formatted like a normal path string, but can include any
* number of wildcards ({@code *}) to look for files matching anything.
* For example, <code>rom/&#42;/command*</code> will look for any path starting with
* {@code command} inside any subdirectory of {@code /rom}.
*
* @param path The wildcard-qualified path to search for.
* @return A list of paths that match the search string.
* @throws LuaException If the path doesn't exist.
* @cc.since 1.6
*/
@LuaFunction
public final String[] find(String path) throws LuaException {
try {
environment.observe(Metrics.FS_OPS);
return fileSystem.find(path);
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Returns the capacity of the drive the path is located on.
*
* @param path The path of the drive to get.
* @return The drive's capacity.
* @throws LuaException If the capacity cannot be determined.
* @cc.treturn number|nil This drive's capacity. This will be nil for "read-only" drives, such as the ROM or
* treasure disks.
* @cc.since 1.87.0
* @see #getFreeSpace To get the free space available on this drive.
*/
@LuaFunction
public final Object getCapacity(String path) throws LuaException {
try {
var capacity = fileSystem.getCapacity(path);
return capacity.isPresent() ? capacity.getAsLong() : null;
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Get attributes about a specific file or folder.
* <p>
* The returned attributes table contains information about the size of the file, whether it is a directory,
* when it was created and last modified, and whether it is read only.
* <p>
* The creation and modification times are given as the number of milliseconds since the UNIX epoch. This may be
* given to {@link OSAPI#date} in order to convert it to more usable form.
*
* @param path The path to get attributes for.
* @return The resulting attributes.
* @throws LuaException If the path does not exist.
* @cc.treturn { size = number, isDir = boolean, isReadOnly = boolean, created = number, modified = number } The resulting attributes.
* @cc.since 1.87.0
* @cc.changed 1.91.0 Renamed `modification` field to `modified`.
* @cc.changed 1.95.2 Added `isReadOnly` to attributes.
* @see #getSize If you only care about the file's size.
* @see #isDir If you only care whether a path is a directory or not.
*/
@LuaFunction
public final Map<String, Object> attributes(String path) throws LuaException {
try {
var attributes = fileSystem.getAttributes(path);
Map<String, Object> result = new HashMap<>();
result.put("modification", getFileTime(attributes.lastModifiedTime()));
result.put("modified", getFileTime(attributes.lastModifiedTime()));
result.put("created", getFileTime(attributes.creationTime()));
result.put("size", attributes.isDirectory() ? 0 : attributes.size());
result.put("isDir", attributes.isDirectory());
result.put("isReadOnly", fileSystem.isReadOnly(path));
return result;
} catch (FileSystemException e) {
throw new LuaException(e.getMessage());
}
}
private static long getFileTime(FileTime time) {
return time == null ? 0 : time.toMillis();
}
}

View File

@@ -0,0 +1,32 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.LuaException;
import javax.annotation.Nullable;
import java.io.Serial;
/**
* A Lua exception which does not contain its stack trace.
*/
public class FastLuaException extends LuaException {
@Serial
private static final long serialVersionUID = 5957864899303561143L;
public FastLuaException(@Nullable String message) {
super(message);
}
public FastLuaException(@Nullable String message, int level) {
super(message, level);
}
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
}

View File

@@ -0,0 +1,177 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.CoreConfig;
import dan200.computercraft.core.apis.http.*;
import dan200.computercraft.core.apis.http.request.HttpRequest;
import dan200.computercraft.core.apis.http.websocket.Websocket;
import io.netty.handler.codec.http.DefaultHttpHeaders;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpMethod;
import javax.annotation.Nonnull;
import java.util.Collections;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import static dan200.computercraft.core.apis.TableHelper.*;
/**
* Placeholder description, please ignore.
*
* @cc.module http
* @hidden
*/
public class HTTPAPI implements ILuaAPI {
private final IAPIEnvironment apiEnvironment;
private final ResourceGroup<CheckUrl> checkUrls = new ResourceGroup<>(ResourceGroup.DEFAULT);
private final ResourceGroup<HttpRequest> requests = new ResourceQueue<>(() -> CoreConfig.httpMaxRequests);
private final ResourceGroup<Websocket> websockets = new ResourceGroup<>(() -> CoreConfig.httpMaxWebsockets);
public HTTPAPI(IAPIEnvironment environment) {
apiEnvironment = environment;
}
@Override
public String[] getNames() {
return new String[]{ "http" };
}
@Override
public void startup() {
checkUrls.startup();
requests.startup();
websockets.startup();
}
@Override
public void shutdown() {
checkUrls.shutdown();
requests.shutdown();
websockets.shutdown();
}
@Override
public void update() {
// It's rather ugly to run this here, but we need to clean up
// resources as often as possible to reduce blocking.
Resource.cleanup();
}
@LuaFunction
public final Object[] request(IArguments args) throws LuaException {
String address, postString, requestMethod;
Map<?, ?> headerTable;
boolean binary, redirect;
if (args.get(0) instanceof Map) {
var options = args.getTable(0);
address = getStringField(options, "url");
postString = optStringField(options, "body", null);
headerTable = optTableField(options, "headers", Collections.emptyMap());
binary = optBooleanField(options, "binary", false);
requestMethod = optStringField(options, "method", null);
redirect = optBooleanField(options, "redirect", true);
} else {
// Get URL and post information
address = args.getString(0);
postString = args.optString(1, null);
headerTable = args.optTable(2, Collections.emptyMap());
binary = args.optBoolean(3, false);
requestMethod = null;
redirect = true;
}
var headers = getHeaders(headerTable);
HttpMethod httpMethod;
if (requestMethod == null) {
httpMethod = postString == null ? HttpMethod.GET : HttpMethod.POST;
} else {
httpMethod = HttpMethod.valueOf(requestMethod.toUpperCase(Locale.ROOT));
if (httpMethod == null || requestMethod.equalsIgnoreCase("CONNECT")) {
throw new LuaException("Unsupported HTTP method");
}
}
try {
var uri = HttpRequest.checkUri(address);
var request = new HttpRequest(requests, apiEnvironment, address, postString, headers, binary, redirect);
// Make the request
if (!request.queue(r -> r.request(uri, httpMethod))) {
throw new LuaException("Too many ongoing HTTP requests");
}
return new Object[]{ true };
} catch (HTTPRequestException e) {
return new Object[]{ false, e.getMessage() };
}
}
@LuaFunction
public final Object[] checkURL(String address) throws LuaException {
try {
var uri = HttpRequest.checkUri(address);
if (!new CheckUrl(checkUrls, apiEnvironment, address, uri).queue(CheckUrl::run)) {
throw new LuaException("Too many ongoing checkUrl calls");
}
return new Object[]{ true };
} catch (HTTPRequestException e) {
return new Object[]{ false, e.getMessage() };
}
}
@LuaFunction
public final Object[] websocket(String address, Optional<Map<?, ?>> headerTbl) throws LuaException {
if (!CoreConfig.httpWebsocketEnabled) {
throw new LuaException("Websocket connections are disabled");
}
var headers = getHeaders(headerTbl.orElse(Collections.emptyMap()));
try {
var uri = Websocket.checkUri(address);
if (!new Websocket(websockets, apiEnvironment, uri, address, headers).queue(Websocket::connect)) {
throw new LuaException("Too many websockets already open");
}
return new Object[]{ true };
} catch (HTTPRequestException e) {
return new Object[]{ false, e.getMessage() };
}
}
@Nonnull
private HttpHeaders getHeaders(@Nonnull Map<?, ?> headerTable) throws LuaException {
HttpHeaders headers = new DefaultHttpHeaders();
for (Map.Entry<?, ?> entry : headerTable.entrySet()) {
var value = entry.getValue();
if (entry.getKey() instanceof String && value instanceof String) {
try {
headers.add((String) entry.getKey(), value);
} catch (IllegalArgumentException e) {
throw new LuaException(e.getMessage());
}
}
}
if (!headers.contains(HttpHeaderNames.USER_AGENT)) {
headers.set(HttpHeaderNames.USER_AGENT, apiEnvironment.getGlobalEnvironment().getUserAgent());
}
return headers;
}
}

View File

@@ -0,0 +1,79 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.api.peripheral.IWorkMonitor;
import dan200.computercraft.core.computer.ComputerEnvironment;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.core.computer.GlobalEnvironment;
import dan200.computercraft.core.filesystem.FileSystem;
import dan200.computercraft.core.metrics.Metric;
import dan200.computercraft.core.terminal.Terminal;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
public interface IAPIEnvironment {
String TIMER_EVENT = "timer";
@FunctionalInterface
interface IPeripheralChangeListener {
void onPeripheralChanged(ComputerSide side, @Nullable IPeripheral newPeripheral);
}
int getComputerID();
@Nonnull
ComputerEnvironment getComputerEnvironment();
@Nonnull
GlobalEnvironment getGlobalEnvironment();
@Nonnull
IWorkMonitor getMainThreadMonitor();
@Nonnull
Terminal getTerminal();
FileSystem getFileSystem();
void shutdown();
void reboot();
void queueEvent(String event, Object... args);
void setOutput(ComputerSide side, int output);
int getOutput(ComputerSide side);
int getInput(ComputerSide side);
void setBundledOutput(ComputerSide side, int output);
int getBundledOutput(ComputerSide side);
int getBundledInput(ComputerSide side);
void setPeripheralChangeListener(@Nullable IPeripheralChangeListener listener);
@Nullable
IPeripheral getPeripheral(ComputerSide side);
@Nullable
String getLabel();
void setLabel(@Nullable String label);
int startTimer(long ticks);
void cancelTimer(int id);
void observe(@Nonnull Metric.Event event, long change);
void observe(@Nonnull Metric.Counter counter);
}

View File

@@ -0,0 +1,178 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.LuaException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.TextStyle;
import java.time.temporal.*;
import java.util.HashMap;
import java.util.Map;
import java.util.function.LongUnaryOperator;
final class LuaDateTime {
private LuaDateTime() {
}
static void format(DateTimeFormatterBuilder formatter, String format) throws LuaException {
for (var i = 0; i < format.length(); ) {
char c;
switch (c = format.charAt(i++)) {
case '\n' -> formatter.appendLiteral('\n');
default -> formatter.appendLiteral(c);
case '%' -> {
if (i >= format.length()) break;
switch (c = format.charAt(i++)) {
default -> throw new LuaException("bad argument #1: invalid conversion specifier '%" + c + "'");
case '%' -> formatter.appendLiteral('%');
case 'a' -> formatter.appendText(ChronoField.DAY_OF_WEEK, TextStyle.SHORT);
case 'A' -> formatter.appendText(ChronoField.DAY_OF_WEEK, TextStyle.FULL);
case 'b', 'h' -> formatter.appendText(ChronoField.MONTH_OF_YEAR, TextStyle.SHORT);
case 'B' -> formatter.appendText(ChronoField.MONTH_OF_YEAR, TextStyle.FULL);
case 'c' -> format(formatter, "%a %b %e %H:%M:%S %Y");
case 'C' -> formatter.appendValueReduced(CENTURY, 2, 2, 0);
case 'd' -> formatter.appendValue(ChronoField.DAY_OF_MONTH, 2);
case 'D', 'x' -> format(formatter, "%m/%d/%y");
case 'e' -> formatter.padNext(2).appendValue(ChronoField.DAY_OF_MONTH);
case 'F' -> format(formatter, "%Y-%m-%d");
case 'g' -> formatter.appendValueReduced(IsoFields.WEEK_BASED_YEAR, 2, 2, 0);
case 'G' -> formatter.appendValue(IsoFields.WEEK_BASED_YEAR);
case 'H' -> formatter.appendValue(ChronoField.HOUR_OF_DAY, 2);
case 'I' -> formatter.appendValue(ChronoField.HOUR_OF_AMPM, 2);
case 'j' -> formatter.appendValue(ChronoField.DAY_OF_YEAR, 3);
case 'm' -> formatter.appendValue(ChronoField.MONTH_OF_YEAR, 2);
case 'M' -> formatter.appendValue(ChronoField.MINUTE_OF_HOUR, 2);
case 'n' -> formatter.appendLiteral('\n');
case 'p' -> formatter.appendText(ChronoField.AMPM_OF_DAY);
case 'r' -> format(formatter, "%I:%M:%S %p");
case 'R' -> format(formatter, "%H:%M");
case 'S' -> formatter.appendValue(ChronoField.SECOND_OF_MINUTE, 2);
case 't' -> formatter.appendLiteral('\t');
case 'T', 'X' -> format(formatter, "%H:%M:%S");
case 'u' -> formatter.appendValue(ChronoField.DAY_OF_WEEK);
case 'U' -> formatter.appendValue(ChronoField.ALIGNED_WEEK_OF_YEAR, 2);
case 'V' -> formatter.appendValue(IsoFields.WEEK_OF_WEEK_BASED_YEAR, 2);
case 'w' -> formatter.appendValue(ZERO_WEEK);
case 'W' -> formatter.appendValue(WeekFields.ISO.weekOfYear(), 2);
case 'y' -> formatter.appendValueReduced(ChronoField.YEAR, 2, 2, 0);
case 'Y' -> formatter.appendValue(ChronoField.YEAR);
case 'z' -> formatter.appendOffset("+HHMM", "+0000");
case 'Z' -> formatter.appendChronologyId();
}
}
}
}
}
static long fromTable(Map<?, ?> table) throws LuaException {
var year = getField(table, "year", -1);
var month = getField(table, "month", -1);
var day = getField(table, "day", -1);
var hour = getField(table, "hour", 12);
var minute = getField(table, "min", 12);
var second = getField(table, "sec", 12);
var time = LocalDateTime.of(year, month, day, hour, minute, second);
var isDst = getBoolField(table, "isdst");
if (isDst != null) {
boolean requireDst = isDst;
for (var possibleOffset : ZoneOffset.systemDefault().getRules().getValidOffsets(time)) {
var instant = time.toInstant(possibleOffset);
if (possibleOffset.getRules().getDaylightSavings(instant).isZero() == requireDst) {
return instant.getEpochSecond();
}
}
}
var offset = ZoneOffset.systemDefault().getRules().getOffset(time);
return time.toInstant(offset).getEpochSecond();
}
static Map<String, ?> toTable(TemporalAccessor date, ZoneId offset, Instant instant) {
var table = new HashMap<String, Object>(9);
table.put("year", date.getLong(ChronoField.YEAR));
table.put("month", date.getLong(ChronoField.MONTH_OF_YEAR));
table.put("day", date.getLong(ChronoField.DAY_OF_MONTH));
table.put("hour", date.getLong(ChronoField.HOUR_OF_DAY));
table.put("min", date.getLong(ChronoField.MINUTE_OF_HOUR));
table.put("sec", date.getLong(ChronoField.SECOND_OF_MINUTE));
table.put("wday", date.getLong(WeekFields.SUNDAY_START.dayOfWeek()));
table.put("yday", date.getLong(ChronoField.DAY_OF_YEAR));
table.put("isdst", offset.getRules().isDaylightSavings(instant));
return table;
}
private static int getField(Map<?, ?> table, String field, int def) throws LuaException {
var value = table.get(field);
if (value instanceof Number) return ((Number) value).intValue();
if (def < 0) throw new LuaException("field \"" + field + "\" missing in date table");
return def;
}
private static Boolean getBoolField(Map<?, ?> table, String field) throws LuaException {
var value = table.get(field);
if (value instanceof Boolean || value == null) return (Boolean) value;
throw new LuaException("field \"" + field + "\" missing in date table");
}
private static final TemporalField CENTURY = map(ChronoField.YEAR, ValueRange.of(0, 99), x -> (x / 100) % 100);
private static final TemporalField ZERO_WEEK = map(WeekFields.SUNDAY_START.dayOfWeek(), ValueRange.of(0, 6), x -> x - 1);
private static TemporalField map(TemporalField field, ValueRange range, LongUnaryOperator convert) {
return new TemporalField() {
@Override
public TemporalUnit getBaseUnit() {
return field.getBaseUnit();
}
@Override
public TemporalUnit getRangeUnit() {
return field.getRangeUnit();
}
@Override
public ValueRange range() {
return range;
}
@Override
public boolean isDateBased() {
return field.isDateBased();
}
@Override
public boolean isTimeBased() {
return field.isTimeBased();
}
@Override
public boolean isSupportedBy(TemporalAccessor temporal) {
return field.isSupportedBy(temporal);
}
@Override
public ValueRange rangeRefinedBy(TemporalAccessor temporal) {
return range;
}
@Override
public long getFrom(TemporalAccessor temporal) {
return convert.applyAsLong(temporal.getLong(field));
}
@Override
@SuppressWarnings("unchecked")
public <R extends Temporal> R adjustInto(R temporal, long newValue) {
return (R) temporal.with(field, newValue);
}
};
}
}

View File

@@ -0,0 +1,441 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.util.StringUtil;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import javax.annotation.Nonnull;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatterBuilder;
import java.util.*;
import static dan200.computercraft.api.lua.LuaValues.checkFinite;
/**
* The {@link OSAPI} API allows interacting with the current computer.
*
* @cc.module os
*/
public class OSAPI implements ILuaAPI {
private final IAPIEnvironment apiEnvironment;
private final Int2ObjectMap<Alarm> alarms = new Int2ObjectOpenHashMap<>();
private int clock;
private double time;
private int day;
private int nextAlarmToken = 0;
private record Alarm(double time, int day) implements Comparable<Alarm> {
@Override
public int compareTo(@Nonnull Alarm o) {
var t = day * 24.0 + time;
var ot = day * 24.0 + time;
return Double.compare(t, ot);
}
}
public OSAPI(IAPIEnvironment environment) {
apiEnvironment = environment;
}
@Override
public String[] getNames() {
return new String[]{ "os" };
}
@Override
public void startup() {
time = apiEnvironment.getComputerEnvironment().getTimeOfDay();
day = apiEnvironment.getComputerEnvironment().getDay();
clock = 0;
synchronized (alarms) {
alarms.clear();
}
}
@Override
public void update() {
clock++;
// Wait for all of our alarms
synchronized (alarms) {
var previousTime = time;
var previousDay = day;
var time = apiEnvironment.getComputerEnvironment().getTimeOfDay();
var day = apiEnvironment.getComputerEnvironment().getDay();
if (time > previousTime || day > previousDay) {
var now = this.day * 24.0 + this.time;
Iterator<Int2ObjectMap.Entry<Alarm>> it = alarms.int2ObjectEntrySet().iterator();
while (it.hasNext()) {
var entry = it.next();
var alarm = entry.getValue();
var t = alarm.day * 24.0 + alarm.time;
if (now >= t) {
apiEnvironment.queueEvent("alarm", entry.getIntKey());
it.remove();
}
}
}
this.time = time;
this.day = day;
}
}
@Override
public void shutdown() {
synchronized (alarms) {
alarms.clear();
}
}
private static float getTimeForCalendar(Calendar c) {
float time = c.get(Calendar.HOUR_OF_DAY);
time += c.get(Calendar.MINUTE) / 60.0f;
time += c.get(Calendar.SECOND) / (60.0f * 60.0f);
return time;
}
private static int getDayForCalendar(Calendar c) {
var g = c instanceof GregorianCalendar ? (GregorianCalendar) c : new GregorianCalendar();
var year = c.get(Calendar.YEAR);
var day = 0;
for (var y = 1970; y < year; y++) {
day += g.isLeapYear(y) ? 366 : 365;
}
day += c.get(Calendar.DAY_OF_YEAR);
return day;
}
private static long getEpochForCalendar(Calendar c) {
return c.getTime().getTime();
}
/**
* Adds an event to the event queue. This event can later be pulled with
* os.pullEvent.
*
* @param name The name of the event to queue.
* @param args The parameters of the event.
* @cc.tparam string name The name of the event to queue.
* @cc.param ... The parameters of the event.
* @cc.see os.pullEvent To pull the event queued
*/
@LuaFunction
public final void queueEvent(String name, IArguments args) {
apiEnvironment.queueEvent(name, args.drop(1).getAll());
}
/**
* Starts a timer that will run for the specified number of seconds. Once
* the timer fires, a {@code timer} event will be added to the queue with
* the ID returned from this function as the first parameter.
* <p>
* As with @{os.sleep|sleep}, {@code timer} will automatically be rounded up
* to the nearest multiple of 0.05 seconds, as it waits for a fixed amount
* of world ticks.
*
* @param timer The number of seconds until the timer fires.
* @return The ID of the new timer. This can be used to filter the
* {@code timer} event, or {@link #cancelTimer cancel the timer}.
* @throws LuaException If the time is below zero.
* @see #cancelTimer To cancel a timer.
*/
@LuaFunction
public final int startTimer(double timer) throws LuaException {
return apiEnvironment.startTimer(Math.round(checkFinite(0, timer) / 0.05));
}
/**
* Cancels a timer previously started with startTimer. This will stop the
* timer from firing.
*
* @param token The ID of the timer to cancel.
* @see #startTimer To start a timer.
*/
@LuaFunction
public final void cancelTimer(int token) {
apiEnvironment.cancelTimer(token);
}
/**
* Sets an alarm that will fire at the specified in-game time. When it
* fires, * an {@code alarm} event will be added to the event queue with the
* ID * returned from this function as the first parameter.
*
* @param time The time at which to fire the alarm, in the range [0.0, 24.0).
* @return The ID of the new alarm. This can be used to filter the
* {@code alarm} event, or {@link #cancelAlarm cancel the alarm}.
* @throws LuaException If the time is out of range.
* @cc.since 1.2
* @see #cancelAlarm To cancel an alarm.
*/
@LuaFunction
public final int setAlarm(double time) throws LuaException {
checkFinite(0, time);
if (time < 0.0 || time >= 24.0) throw new LuaException("Number out of range");
synchronized (alarms) {
var day = time > this.time ? this.day : this.day + 1;
alarms.put(nextAlarmToken, new Alarm(time, day));
return nextAlarmToken++;
}
}
/**
* Cancels an alarm previously started with setAlarm. This will stop the
* alarm from firing.
*
* @param token The ID of the alarm to cancel.
* @cc.since 1.2
* @see #setAlarm To set an alarm.
*/
@LuaFunction
public final void cancelAlarm(int token) {
synchronized (alarms) {
alarms.remove(token);
}
}
/**
* Shuts down the computer immediately.
*/
@LuaFunction("shutdown")
public final void doShutdown() {
apiEnvironment.shutdown();
}
/**
* Reboots the computer immediately.
*/
@LuaFunction("reboot")
public final void doReboot() {
apiEnvironment.reboot();
}
/**
* Returns the ID of the computer.
*
* @return The ID of the computer.
*/
@LuaFunction({ "getComputerID", "computerID" })
public final int getComputerID() {
return apiEnvironment.getComputerID();
}
/**
* Returns the label of the computer, or {@code nil} if none is set.
*
* @return The label of the computer.
* @cc.treturn string The label of the computer.
* @cc.since 1.3
*/
@LuaFunction({ "getComputerLabel", "computerLabel" })
public final Object[] getComputerLabel() {
var label = apiEnvironment.getLabel();
return label == null ? null : new Object[]{ label };
}
/**
* Set the label of this computer.
*
* @param label The new label. May be {@code nil} in order to clear it.
* @cc.since 1.3
*/
@LuaFunction
public final void setComputerLabel(Optional<String> label) {
apiEnvironment.setLabel(StringUtil.normaliseLabel(label.orElse(null)));
}
/**
* Returns the number of seconds that the computer has been running.
*
* @return The computer's uptime.
* @cc.since 1.2
*/
@LuaFunction
public final double clock() {
return clock * 0.05;
}
/**
* Returns the current time depending on the string passed in. This will
* always be in the range [0.0, 24.0).
* <p>
* * If called with {@code ingame}, the current world time will be returned.
* This is the default if nothing is passed.
* * If called with {@code utc}, returns the hour of the day in UTC time.
* * If called with {@code local}, returns the hour of the day in the
* timezone the server is located in.
* <p>
* This function can also be called with a table returned from {@link #date},
* which will convert the date fields into a UNIX timestamp (number of
* seconds since 1 January 1970).
*
* @param args The locale of the time, or a table filled by {@code os.date("*t")} to decode. Defaults to {@code ingame} locale if not specified.
* @return The hour of the selected locale, or a UNIX timestamp from the table, depending on the argument passed in.
* @throws LuaException If an invalid locale is passed.
* @cc.tparam [opt] string|table locale The locale of the time, or a table filled by {@code os.date("*t")} to decode. Defaults to {@code ingame} locale if not specified.
* @cc.see textutils.formatTime To convert times into a user-readable string.
* @cc.usage Print the current in-game time.
* <pre>{@code
* textutils.formatTime(os.time())
* }</pre>
* @cc.since 1.2
* @cc.changed 1.80pr1 Add support for getting the local local and UTC time.
* @cc.changed 1.82.0 Arguments are now case insensitive.
* @cc.changed 1.83.0 {@link #time(IArguments)} now accepts table arguments and converts them to UNIX timestamps.
* @see #date To get a date table that can be converted with this function.
*/
@LuaFunction
public final Object time(IArguments args) throws LuaException {
var value = args.get(0);
if (value instanceof Map) return LuaDateTime.fromTable((Map<?, ?>) value);
var param = args.optString(0, "ingame");
return switch (param.toLowerCase(Locale.ROOT)) {
case "utc" -> getTimeForCalendar(Calendar.getInstance(TimeZone.getTimeZone("UTC")));
case "local" -> getTimeForCalendar(Calendar.getInstance());
case "ingame" -> time;
default -> throw new LuaException("Unsupported operation");
};
}
/**
* Returns the day depending on the locale specified.
* <p>
* * If called with {@code ingame}, returns the number of days since the
* world was created. This is the default.
* * If called with {@code utc}, returns the number of days since 1 January
* 1970 in the UTC timezone.
* * If called with {@code local}, returns the number of days since 1
* January 1970 in the server's local timezone.
*
* @param args The locale to get the day for. Defaults to {@code ingame} if not set.
* @return The day depending on the selected locale.
* @throws LuaException If an invalid locale is passed.
* @cc.since 1.48
* @cc.changed 1.82.0 Arguments are now case insensitive.
*/
@LuaFunction
public final int day(Optional<String> args) throws LuaException {
return switch (args.orElse("ingame").toLowerCase(Locale.ROOT)) {
case "utc" -> getDayForCalendar(Calendar.getInstance(TimeZone.getTimeZone("UTC")));
case "local" -> getDayForCalendar(Calendar.getInstance());
case "ingame" -> day;
default -> throw new LuaException("Unsupported operation");
};
}
/**
* Returns the number of milliseconds since an epoch depending on the locale.
* <p>
* * If called with {@code ingame}, returns the number of milliseconds since the
* world was created. This is the default.
* * If called with {@code utc}, returns the number of milliseconds since 1
* January 1970 in the UTC timezone.
* * If called with {@code local}, returns the number of milliseconds since 1
* January 1970 in the server's local timezone.
*
* @param args The locale to get the milliseconds for. Defaults to {@code ingame} if not set.
* @return The milliseconds since the epoch depending on the selected locale.
* @throws LuaException If an invalid locale is passed.
* @cc.since 1.80pr1
* @cc.usage Get the current time and use {@link #date} to convert it to a table.
* <pre>{@code
* -- Dividing by 1000 converts it from milliseconds to seconds.
* local time = os.epoch("local") / 1000
* local time_table = os.date("*t", time)
* print(textutils.serialize(time_table))
* }</pre>
*/
@LuaFunction
public final long epoch(Optional<String> args) throws LuaException {
switch (args.orElse("ingame").toLowerCase(Locale.ROOT)) {
case "utc": {
// Get utc epoch
var c = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
return getEpochForCalendar(c);
}
case "local": {
// Get local epoch
var c = Calendar.getInstance();
return getEpochForCalendar(c);
}
case "ingame":
// Get in-game epoch
synchronized (alarms) {
return day * 86400000L + (long) (time * 3600000.0);
}
default:
throw new LuaException("Unsupported operation");
}
}
/**
* Returns a date string (or table) using a specified format string and
* optional time to format.
* <p>
* The format string takes the same formats as C's {@code strftime} function
* (http://www.cplusplus.com/reference/ctime/strftime/). In extension, it
* can be prefixed with an exclamation mark ({@code !}) to use UTC time
* instead of the server's local timezone.
* <p>
* If the format is exactly {@code *t} (optionally prefixed with {@code !}), a
* table will be returned instead. This table has fields for the year, month,
* day, hour, minute, second, day of the week, day of the year, and whether
* Daylight Savings Time is in effect. This table can be converted to a UNIX
* timestamp (days since 1 January 1970) with {@link #date}.
*
* @param formatA The format of the string to return. This defaults to {@code %c}, which expands to a string similar to "Sat Dec 24 16:58:00 2011".
* @param timeA The time to convert to a string. This defaults to the current time.
* @return The resulting format string.
* @throws LuaException If an invalid format is passed.
* @cc.since 1.83.0
* @cc.usage Print the current date in a user-friendly string.
* <pre>{@code
* os.date("%A %d %B %Y") -- See the reference above!
* }</pre>
*/
@LuaFunction
public final Object date(Optional<String> formatA, Optional<Long> timeA) throws LuaException {
var format = formatA.orElse("%c");
long time = timeA.orElseGet(() -> Instant.now().getEpochSecond());
var instant = Instant.ofEpochSecond(time);
ZonedDateTime date;
ZoneOffset offset;
if (format.startsWith("!")) {
offset = ZoneOffset.UTC;
date = ZonedDateTime.ofInstant(instant, offset);
format = format.substring(1);
} else {
var id = ZoneId.systemDefault();
offset = id.getRules().getOffset(instant);
date = ZonedDateTime.ofInstant(instant, id);
}
if (format.equals("*t")) return LuaDateTime.toTable(date, offset, instant);
var formatter = new DateTimeFormatterBuilder();
LuaDateTime.format(formatter, format);
// ROOT would be more sensible, but US appears more consistent with the default C locale
// on Linux.
return formatter.toFormatter(Locale.US).format(date);
}
}

View File

@@ -0,0 +1,333 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.filesystem.IMount;
import dan200.computercraft.api.filesystem.IWritableMount;
import dan200.computercraft.api.lua.*;
import dan200.computercraft.api.peripheral.IDynamicPeripheral;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.api.peripheral.IWorkMonitor;
import dan200.computercraft.api.peripheral.NotAttachedException;
import dan200.computercraft.core.asm.LuaMethod;
import dan200.computercraft.core.asm.PeripheralMethod;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.core.metrics.Metrics;
import dan200.computercraft.core.util.LuaUtil;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.*;
/**
* CC's "native" peripheral API. This is wrapped within CraftOS to provide a version which works with modems.
*
* @cc.module peripheral
* @hidden
*/
public class PeripheralAPI implements ILuaAPI, IAPIEnvironment.IPeripheralChangeListener {
private class PeripheralWrapper extends ComputerAccess {
private final String side;
private final IPeripheral peripheral;
private final String type;
private final Set<String> additionalTypes;
private final Map<String, PeripheralMethod> methodMap;
private boolean attached = false;
PeripheralWrapper(IPeripheral peripheral, String side) {
super(environment);
this.side = side;
this.peripheral = peripheral;
type = Objects.requireNonNull(peripheral.getType(), "Peripheral type cannot be null");
additionalTypes = peripheral.getAdditionalTypes();
methodMap = PeripheralAPI.getMethods(peripheral);
}
public IPeripheral getPeripheral() {
return peripheral;
}
public String getType() {
return type;
}
public Set<String> getAdditionalTypes() {
return additionalTypes;
}
public Collection<String> getMethods() {
return methodMap.keySet();
}
public synchronized boolean isAttached() {
return attached;
}
public synchronized void attach() {
attached = true;
peripheral.attach(this);
}
public void detach() {
// Call detach
peripheral.detach(this);
synchronized (this) {
// Unmount everything the detach function forgot to do
unmountAll();
}
attached = false;
}
public MethodResult call(ILuaContext context, String methodName, IArguments arguments) throws LuaException {
PeripheralMethod method;
synchronized (this) {
method = methodMap.get(methodName);
}
if (method == null) throw new LuaException("No such method " + methodName);
environment.observe(Metrics.PERIPHERAL_OPS);
return method.apply(peripheral, context, this, arguments);
}
// IComputerAccess implementation
@Override
public synchronized String mount(@Nonnull String desiredLoc, @Nonnull IMount mount, @Nonnull String driveName) {
if (!attached) throw new NotAttachedException();
return super.mount(desiredLoc, mount, driveName);
}
@Override
public synchronized String mountWritable(@Nonnull String desiredLoc, @Nonnull IWritableMount mount, @Nonnull String driveName) {
if (!attached) throw new NotAttachedException();
return super.mountWritable(desiredLoc, mount, driveName);
}
@Override
public synchronized void unmount(String location) {
if (!attached) throw new NotAttachedException();
super.unmount(location);
}
@Override
public int getID() {
if (!attached) throw new NotAttachedException();
return super.getID();
}
@Override
public void queueEvent(@Nonnull String event, Object... arguments) {
if (!attached) throw new NotAttachedException();
super.queueEvent(event, arguments);
}
@Nonnull
@Override
public String getAttachmentName() {
if (!attached) throw new NotAttachedException();
return side;
}
@Nonnull
@Override
public Map<String, IPeripheral> getAvailablePeripherals() {
if (!attached) throw new NotAttachedException();
Map<String, IPeripheral> peripherals = new HashMap<>();
for (var wrapper : PeripheralAPI.this.peripherals) {
if (wrapper != null && wrapper.isAttached()) {
peripherals.put(wrapper.getAttachmentName(), wrapper.getPeripheral());
}
}
return Collections.unmodifiableMap(peripherals);
}
@Nullable
@Override
public IPeripheral getAvailablePeripheral(@Nonnull String name) {
if (!attached) throw new NotAttachedException();
for (var wrapper : peripherals) {
if (wrapper != null && wrapper.isAttached() && wrapper.getAttachmentName().equals(name)) {
return wrapper.getPeripheral();
}
}
return null;
}
@Nonnull
@Override
public IWorkMonitor getMainThreadMonitor() {
if (!attached) throw new NotAttachedException();
return super.getMainThreadMonitor();
}
}
private final IAPIEnvironment environment;
private final PeripheralWrapper[] peripherals = new PeripheralWrapper[6];
private boolean running;
public PeripheralAPI(IAPIEnvironment environment) {
this.environment = environment;
this.environment.setPeripheralChangeListener(this);
running = false;
}
// IPeripheralChangeListener
@Override
public void onPeripheralChanged(ComputerSide side, IPeripheral newPeripheral) {
synchronized (peripherals) {
var index = side.ordinal();
if (peripherals[index] != null) {
// Queue a detachment
final var wrapper = peripherals[index];
if (wrapper.isAttached()) wrapper.detach();
// Queue a detachment event
environment.queueEvent("peripheral_detach", side.getName());
}
// Assign the new peripheral
peripherals[index] = newPeripheral == null ? null
: new PeripheralWrapper(newPeripheral, side.getName());
if (peripherals[index] != null) {
// Queue an attachment
final var wrapper = peripherals[index];
if (running && !wrapper.isAttached()) wrapper.attach();
// Queue an attachment event
environment.queueEvent("peripheral", side.getName());
}
}
}
@Override
public String[] getNames() {
return new String[]{ "peripheral" };
}
@Override
public void startup() {
synchronized (peripherals) {
running = true;
for (var i = 0; i < 6; i++) {
var wrapper = peripherals[i];
if (wrapper != null && !wrapper.isAttached()) wrapper.attach();
}
}
}
@Override
public void shutdown() {
synchronized (peripherals) {
running = false;
for (var i = 0; i < 6; i++) {
var wrapper = peripherals[i];
if (wrapper != null && wrapper.isAttached()) {
wrapper.detach();
}
}
}
}
@LuaFunction
public final boolean isPresent(String sideName) {
var side = ComputerSide.valueOfInsensitive(sideName);
if (side != null) {
synchronized (peripherals) {
var p = peripherals[side.ordinal()];
if (p != null) return true;
}
}
return false;
}
@LuaFunction
public final Object[] getType(String sideName) {
var side = ComputerSide.valueOfInsensitive(sideName);
if (side == null) return null;
synchronized (peripherals) {
var p = peripherals[side.ordinal()];
return p == null ? null : LuaUtil.consArray(p.getType(), p.getAdditionalTypes());
}
}
@LuaFunction
public final Object[] hasType(String sideName, String type) {
var side = ComputerSide.valueOfInsensitive(sideName);
if (side == null) return null;
synchronized (peripherals) {
var p = peripherals[side.ordinal()];
if (p != null) {
return new Object[]{ p.getType().equals(type) || p.getAdditionalTypes().contains(type) };
}
}
return null;
}
@LuaFunction
public final Object[] getMethods(String sideName) {
var side = ComputerSide.valueOfInsensitive(sideName);
if (side == null) return null;
synchronized (peripherals) {
var p = peripherals[side.ordinal()];
if (p != null) return new Object[]{ p.getMethods() };
}
return null;
}
@LuaFunction
public final MethodResult call(ILuaContext context, IArguments args) throws LuaException {
var side = ComputerSide.valueOfInsensitive(args.getString(0));
var methodName = args.getString(1);
var methodArgs = args.drop(2);
if (side == null) throw new LuaException("No peripheral attached");
PeripheralWrapper p;
synchronized (peripherals) {
p = peripherals[side.ordinal()];
}
if (p == null) throw new LuaException("No peripheral attached");
try {
return p.call(context, methodName, methodArgs).adjustError(1);
} catch (LuaException e) {
// We increase the error level by one in order to shift the error level to where peripheral.call was
// invoked. It would be possible to do it in Lua code, but would add significantly more overhead.
if (e.getLevel() > 0) throw new FastLuaException(e.getMessage(), e.getLevel() + 1);
throw e;
}
}
public static Map<String, PeripheralMethod> getMethods(IPeripheral peripheral) {
var dynamicMethods = peripheral instanceof IDynamicPeripheral
? Objects.requireNonNull(((IDynamicPeripheral) peripheral).getMethodNames(), "Peripheral methods cannot be null")
: LuaMethod.EMPTY_METHODS;
var methods = PeripheralMethod.GENERATOR.getMethods(peripheral.getClass());
Map<String, PeripheralMethod> methodMap = new HashMap<>(methods.size() + dynamicMethods.length);
for (var i = 0; i < dynamicMethods.length; i++) {
methodMap.put(dynamicMethods[i], PeripheralMethod.DYNAMIC.get(i));
}
for (var method : methods) {
methodMap.put(method.getName(), method.getMethod());
}
return methodMap;
}
}

View File

@@ -0,0 +1,205 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.computer.ComputerSide;
/**
* Get and set redstone signals adjacent to this computer.
* <p>
* The {@link RedstoneAPI} library exposes three "types" of redstone control:
* - Binary input/output ({@link #setOutput}/{@link #getInput}): These simply check if a redstone wire has any input or
* output. A signal strength of 1 and 15 are treated the same.
* - Analogue input/output ({@link #setAnalogOutput}/{@link #getAnalogInput}): These work with the actual signal
* strength of the redstone wired, from 0 to 15.
* - Bundled cables ({@link #setBundledOutput}/{@link #getBundledInput}): These interact with "bundled" cables, such
* as those from Project:Red. These allow you to send 16 separate on/off signals. Each channel corresponds to a
* colour, with the first being @{colors.white} and the last @{colors.black}.
* <p>
* Whenever a redstone input changes, a @{event!redstone} event will be fired. This may be used instead of repeativly
* polling.
* <p>
* This module may also be referred to as {@code rs}. For example, one may call {@code rs.getSides()} instead of
* {@link #getSides}.
*
* @cc.usage Toggle the redstone signal above the computer every 0.5 seconds.
*
* <pre>{@code
* while true do
* redstone.setOutput("top", not redstone.getOutput("top"))
* sleep(0.5)
* end
* }</pre>
* @cc.usage Mimic a redstone comparator in [subtraction mode][comparator].
*
* <pre>{@code
* while true do
* local rear = rs.getAnalogueInput("back")
* local sides = math.max(rs.getAnalogueInput("left"), rs.getAnalogueInput("right"))
* rs.setAnalogueOutput("front", math.max(rear - sides, 0))
*
* os.pullEvent("redstone") -- Wait for a change to inputs.
* end
* }</pre>
* <p>
* [comparator]: https://minecraft.gamepedia.com/Redstone_Comparator#Subtract_signal_strength "Redstone Comparator on
* the Minecraft wiki."
* @cc.module redstone
*/
public class RedstoneAPI implements ILuaAPI {
private final IAPIEnvironment environment;
public RedstoneAPI(IAPIEnvironment environment) {
this.environment = environment;
}
@Override
public String[] getNames() {
return new String[]{ "rs", "redstone" };
}
/**
* Returns a table containing the six sides of the computer. Namely, "top", "bottom", "left", "right", "front" and
* "back".
*
* @return A table of valid sides.
* @cc.since 1.2
*/
@LuaFunction
public final String[] getSides() {
return ComputerSide.NAMES;
}
/**
* Turn the redstone signal of a specific side on or off.
*
* @param side The side to set.
* @param on Whether the redstone signal should be on or off. When on, a signal strength of 15 is emitted.
*/
@LuaFunction
public final void setOutput(ComputerSide side, boolean on) {
environment.setOutput(side, on ? 15 : 0);
}
/**
* Get the current redstone output of a specific side.
*
* @param side The side to get.
* @return Whether the redstone output is on or off.
* @see #setOutput
*/
@LuaFunction
public final boolean getOutput(ComputerSide side) {
return environment.getOutput(side) > 0;
}
/**
* Get the current redstone input of a specific side.
*
* @param side The side to get.
* @return Whether the redstone input is on or off.
*/
@LuaFunction
public final boolean getInput(ComputerSide side) {
return environment.getInput(side) > 0;
}
/**
* Set the redstone signal strength for a specific side.
*
* @param side The side to set.
* @param value The signal strength between 0 and 15.
* @throws LuaException If {@code value} is not betwene 0 and 15.
* @cc.since 1.51
*/
@LuaFunction({ "setAnalogOutput", "setAnalogueOutput" })
public final void setAnalogOutput(ComputerSide side, int value) throws LuaException {
if (value < 0 || value > 15) throw new LuaException("Expected number in range 0-15");
environment.setOutput(side, value);
}
/**
* Get the redstone output signal strength for a specific side.
*
* @param side The side to get.
* @return The output signal strength, between 0 and 15.
* @cc.since 1.51
* @see #setAnalogOutput
*/
@LuaFunction({ "getAnalogOutput", "getAnalogueOutput" })
public final int getAnalogOutput(ComputerSide side) {
return environment.getOutput(side);
}
/**
* Get the redstone input signal strength for a specific side.
*
* @param side The side to get.
* @return The input signal strength, between 0 and 15.
* @cc.since 1.51
*/
@LuaFunction({ "getAnalogInput", "getAnalogueInput" })
public final int getAnalogInput(ComputerSide side) {
return environment.getInput(side);
}
/**
* Set the bundled cable output for a specific side.
*
* @param side The side to set.
* @param output The colour bitmask to set.
* @cc.see colors.subtract For removing a colour from the bitmask.
* @cc.see colors.combine For adding a color to the bitmask.
*/
@LuaFunction
public final void setBundledOutput(ComputerSide side, int output) {
environment.setBundledOutput(side, output);
}
/**
* Get the bundled cable output for a specific side.
*
* @param side The side to get.
* @return The bundle cable's output.
*/
@LuaFunction
public final int getBundledOutput(ComputerSide side) {
return environment.getBundledOutput(side);
}
/**
* Get the bundled cable input for a specific side.
*
* @param side The side to get.
* @return The bundle cable's input.
* @see #testBundledInput To determine if a specific colour is set.
*/
@LuaFunction
public final int getBundledInput(ComputerSide side) {
return environment.getBundledInput(side);
}
/**
* Determine if a specific combination of colours are on for the given side.
*
* @param side The side to test.
* @param mask The mask to test.
* @return If the colours are on.
* @cc.usage Check if @{colors.white} and @{colors.black} are on above the computer.
* <pre>{@code
* print(redstone.testBundledInput("top", colors.combine(colors.white, colors.black)))
* }</pre>
* @see #getBundledInput
*/
@LuaFunction
public final boolean testBundledInput(ComputerSide side, int mask) {
var input = environment.getBundledInput(side);
return (input & mask) == mask;
}
}

View File

@@ -0,0 +1,151 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaValues;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Map;
import static dan200.computercraft.api.lua.LuaValues.getNumericType;
/**
* Various helpers for tables.
*/
public final class TableHelper {
private TableHelper() {
throw new IllegalStateException("Cannot instantiate singleton " + getClass().getName());
}
@Nonnull
public static LuaException badKey(@Nonnull String key, @Nonnull String expected, @Nullable Object actual) {
return badKey(key, expected, LuaValues.getType(actual));
}
@Nonnull
public static LuaException badKey(@Nonnull String key, @Nonnull String expected, @Nonnull String actual) {
return new LuaException("bad field '" + key + "' (" + expected + " expected, got " + actual + ")");
}
public static double getNumberField(@Nonnull Map<?, ?> table, @Nonnull String key) throws LuaException {
var value = table.get(key);
if (value instanceof Number) {
return ((Number) value).doubleValue();
} else {
throw badKey(key, "number", value);
}
}
public static int getIntField(@Nonnull Map<?, ?> table, @Nonnull String key) throws LuaException {
var value = table.get(key);
if (value instanceof Number) {
return (int) ((Number) value).longValue();
} else {
throw badKey(key, "number", value);
}
}
public static double getRealField(@Nonnull Map<?, ?> table, @Nonnull String key) throws LuaException {
return checkReal(key, getNumberField(table, key));
}
public static boolean getBooleanField(@Nonnull Map<?, ?> table, @Nonnull String key) throws LuaException {
var value = table.get(key);
if (value instanceof Boolean) {
return (Boolean) value;
} else {
throw badKey(key, "boolean", value);
}
}
@Nonnull
public static String getStringField(@Nonnull Map<?, ?> table, @Nonnull String key) throws LuaException {
var value = table.get(key);
if (value instanceof String) {
return (String) value;
} else {
throw badKey(key, "string", value);
}
}
@SuppressWarnings("unchecked")
@Nonnull
public static Map<Object, Object> getTableField(@Nonnull Map<?, ?> table, @Nonnull String key) throws LuaException {
var value = table.get(key);
if (value instanceof Map) {
return (Map<Object, Object>) value;
} else {
throw badKey(key, "table", value);
}
}
public static double optNumberField(@Nonnull Map<?, ?> table, @Nonnull String key, double def) throws LuaException {
var value = table.get(key);
if (value == null) {
return def;
} else if (value instanceof Number) {
return ((Number) value).doubleValue();
} else {
throw badKey(key, "number", value);
}
}
public static int optIntField(@Nonnull Map<?, ?> table, @Nonnull String key, int def) throws LuaException {
var value = table.get(key);
if (value == null) {
return def;
} else if (value instanceof Number) {
return (int) ((Number) value).longValue();
} else {
throw badKey(key, "number", value);
}
}
public static double optRealField(@Nonnull Map<?, ?> table, @Nonnull String key, double def) throws LuaException {
return checkReal(key, optNumberField(table, key, def));
}
public static boolean optBooleanField(@Nonnull Map<?, ?> table, @Nonnull String key, boolean def) throws LuaException {
var value = table.get(key);
if (value == null) {
return def;
} else if (value instanceof Boolean) {
return (Boolean) value;
} else {
throw badKey(key, "boolean", value);
}
}
public static String optStringField(@Nonnull Map<?, ?> table, @Nonnull String key, String def) throws LuaException {
var value = table.get(key);
if (value == null) {
return def;
} else if (value instanceof String) {
return (String) value;
} else {
throw badKey(key, "string", value);
}
}
@SuppressWarnings("unchecked")
public static Map<Object, Object> optTableField(@Nonnull Map<?, ?> table, @Nonnull String key, Map<Object, Object> def) throws LuaException {
var value = table.get(key);
if (value == null) {
return def;
} else if (value instanceof Map) {
return (Map<Object, Object>) value;
} else {
throw badKey(key, "table", value);
}
}
private static double checkReal(@Nonnull String key, double value) throws LuaException {
if (!Double.isFinite(value)) throw badKey(key, "number", getNumericType(value));
return value;
}
}

View File

@@ -0,0 +1,59 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.core.util.Colour;
import javax.annotation.Nonnull;
/**
* Interact with a computer's terminal or monitors, writing text and drawing
* ASCII graphics.
*
* @cc.module term
*/
public class TermAPI extends TermMethods implements ILuaAPI {
private final Terminal terminal;
public TermAPI(IAPIEnvironment environment) {
terminal = environment.getTerminal();
}
@Override
public String[] getNames() {
return new String[]{ "term" };
}
/**
* Get the default palette value for a colour.
*
* @param colour The colour whose palette should be fetched.
* @return The RGB values.
* @throws LuaException When given an invalid colour.
* @cc.treturn number The red channel, will be between 0 and 1.
* @cc.treturn number The green channel, will be between 0 and 1.
* @cc.treturn number The blue channel, will be between 0 and 1.
* @cc.since 1.81.0
* @see TermMethods#setPaletteColour(IArguments) To change the palette colour.
*/
@LuaFunction({ "nativePaletteColour", "nativePaletteColor" })
public final Object[] nativePaletteColour(int colour) throws LuaException {
var actualColour = 15 - parseColour(colour);
var c = Colour.fromInt(actualColour);
return new Object[]{ c.getR(), c.getG(), c.getB() };
}
@Nonnull
@Override
public Terminal getTerminal() {
return terminal;
}
}

View File

@@ -0,0 +1,359 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.terminal.Palette;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.core.util.StringUtil;
import javax.annotation.Nonnull;
import java.nio.ByteBuffer;
/**
* A base class for all objects which interact with a terminal. Namely the {@link TermAPI} and monitors.
*
* @cc.module term.Redirect
*/
public abstract class TermMethods {
private static int getHighestBit(int group) {
var bit = 0;
while (group > 0) {
group >>= 1;
bit++;
}
return bit;
}
@Nonnull
public abstract Terminal getTerminal() throws LuaException;
/**
* Write {@code text} at the current cursor position, moving the cursor to the end of the text.
* <p>
* Unlike functions like {@code write} and {@code print}, this does not wrap the text - it simply copies the
* text to the current terminal line.
*
* @param arguments The text to write.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.param text The text to write.
*/
@LuaFunction
public final void write(IArguments arguments) throws LuaException {
var text = StringUtil.toString(arguments.get(0));
var terminal = getTerminal();
synchronized (terminal) {
terminal.write(text);
terminal.setCursorPos(terminal.getCursorX() + text.length(), terminal.getCursorY());
}
}
/**
* Move all positions up (or down) by {@code y} pixels.
* <p>
* Every pixel in the terminal will be replaced by the line {@code y} pixels below it. If {@code y} is negative, it
* will copy pixels from above instead.
*
* @param y The number of lines to move up by. This may be a negative number.
* @throws LuaException (hidden) If the terminal cannot be found.
*/
@LuaFunction
public final void scroll(int y) throws LuaException {
getTerminal().scroll(y);
}
/**
* Get the position of the cursor.
*
* @return The cursor's position.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.treturn number The x position of the cursor.
* @cc.treturn number The y position of the cursor.
*/
@LuaFunction
public final Object[] getCursorPos() throws LuaException {
var terminal = getTerminal();
return new Object[]{ terminal.getCursorX() + 1, terminal.getCursorY() + 1 };
}
/**
* Set the position of the cursor. {@link #write(IArguments) terminal writes} will begin from this position.
*
* @param x The new x position of the cursor.
* @param y The new y position of the cursor.
* @throws LuaException (hidden) If the terminal cannot be found.
*/
@LuaFunction
public final void setCursorPos(int x, int y) throws LuaException {
var terminal = getTerminal();
synchronized (terminal) {
terminal.setCursorPos(x - 1, y - 1);
}
}
/**
* Checks if the cursor is currently blinking.
*
* @return If the cursor is blinking.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.since 1.80pr1.9
*/
@LuaFunction
public final boolean getCursorBlink() throws LuaException {
return getTerminal().getCursorBlink();
}
/**
* Sets whether the cursor should be visible (and blinking) at the current {@link #getCursorPos() cursor position}.
*
* @param blink Whether the cursor should blink.
* @throws LuaException (hidden) If the terminal cannot be found.
*/
@LuaFunction
public final void setCursorBlink(boolean blink) throws LuaException {
var terminal = getTerminal();
synchronized (terminal) {
terminal.setCursorBlink(blink);
}
}
/**
* Get the size of the terminal.
*
* @return The terminal's size.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.treturn number The terminal's width.
* @cc.treturn number The terminal's height.
*/
@LuaFunction
public final Object[] getSize() throws LuaException {
var terminal = getTerminal();
return new Object[]{ terminal.getWidth(), terminal.getHeight() };
}
/**
* Clears the terminal, filling it with the {@link #getBackgroundColour() current background colour}.
*
* @throws LuaException (hidden) If the terminal cannot be found.
*/
@LuaFunction
public final void clear() throws LuaException {
getTerminal().clear();
}
/**
* Clears the line the cursor is currently on, filling it with the {@link #getBackgroundColour() current background
* colour}.
*
* @throws LuaException (hidden) If the terminal cannot be found.
*/
@LuaFunction
public final void clearLine() throws LuaException {
getTerminal().clearLine();
}
/**
* Return the colour that new text will be written as.
*
* @return The current text colour.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.see colors For a list of colour constants, returned by this function.
* @cc.since 1.74
*/
@LuaFunction({ "getTextColour", "getTextColor" })
public final int getTextColour() throws LuaException {
return encodeColour(getTerminal().getTextColour());
}
/**
* Set the colour that new text will be written as.
*
* @param colourArg The new text colour.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.see colors For a list of colour constants.
* @cc.since 1.45
* @cc.changed 1.80pr1 Standard computers can now use all 16 colors, being changed to grayscale on screen.
*/
@LuaFunction({ "setTextColour", "setTextColor" })
public final void setTextColour(int colourArg) throws LuaException {
var colour = parseColour(colourArg);
var terminal = getTerminal();
synchronized (terminal) {
terminal.setTextColour(colour);
}
}
/**
* Return the current background colour. This is used when {@link #write writing text} and {@link #clear clearing}
* the terminal.
*
* @return The current background colour.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.see colors For a list of colour constants, returned by this function.
* @cc.since 1.74
*/
@LuaFunction({ "getBackgroundColour", "getBackgroundColor" })
public final int getBackgroundColour() throws LuaException {
return encodeColour(getTerminal().getBackgroundColour());
}
/**
* Set the current background colour. This is used when {@link #write writing text} and {@link #clear clearing} the
* terminal.
*
* @param colourArg The new background colour.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.see colors For a list of colour constants.
* @cc.since 1.45
* @cc.changed 1.80pr1 Standard computers can now use all 16 colors, being changed to grayscale on screen.
*/
@LuaFunction({ "setBackgroundColour", "setBackgroundColor" })
public final void setBackgroundColour(int colourArg) throws LuaException {
var colour = parseColour(colourArg);
var terminal = getTerminal();
synchronized (terminal) {
terminal.setBackgroundColour(colour);
}
}
/**
* Determine if this terminal supports colour.
* <p>
* Terminals which do not support colour will still allow writing coloured text/backgrounds, but it will be
* displayed in greyscale.
*
* @return Whether this terminal supports colour.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.since 1.45
*/
@LuaFunction({ "isColour", "isColor" })
public final boolean getIsColour() throws LuaException {
return getTerminal().isColour();
}
/**
* Writes {@code text} to the terminal with the specific foreground and background characters.
* <p>
* As with {@link #write(IArguments)}, the text will be written at the current cursor location, with the cursor
* moving to the end of the text.
* <p>
* {@code textColour} and {@code backgroundColour} must both be strings the same length as {@code text}. All
* characters represent a single hexadecimal digit, which is converted to one of CC's colours. For instance,
* {@code "a"} corresponds to purple.
*
* @param text The text to write.
* @param textColour The corresponding text colours.
* @param backgroundColour The corresponding background colours.
* @throws LuaException If the three inputs are not the same length.
* @cc.see colors For a list of colour constants, and their hexadecimal values.
* @cc.since 1.74
* @cc.changed 1.80pr1 Standard computers can now use all 16 colors, being changed to grayscale on screen.
* @cc.usage Prints "Hello, world!" in rainbow text.
* <pre>{@code
* term.blit("Hello, world!","01234456789ab","0000000000000")
* }</pre>
*/
@LuaFunction
public final void blit(ByteBuffer text, ByteBuffer textColour, ByteBuffer backgroundColour) throws LuaException {
if (textColour.remaining() != text.remaining() || backgroundColour.remaining() != text.remaining()) {
throw new LuaException("Arguments must be the same length");
}
var terminal = getTerminal();
synchronized (terminal) {
terminal.blit(text, textColour, backgroundColour);
terminal.setCursorPos(terminal.getCursorX() + text.remaining(), terminal.getCursorY());
}
}
/**
* Set the palette for a specific colour.
* <p>
* ComputerCraft's palette system allows you to change how a specific colour should be displayed. For instance, you
* can make @{colors.red} <em>more red</em> by setting its palette to #FF0000. This does now allow you to draw more
* colours - you are still limited to 16 on the screen at one time - but you can change <em>which</em> colours are
* used.
*
* @param args The new palette values.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.tparam [1] number index The colour whose palette should be changed.
* @cc.tparam number colour A 24-bit integer representing the RGB value of the colour. For instance the integer
* `0xFF0000` corresponds to the colour #FF0000.
* @cc.tparam [2] number index The colour whose palette should be changed.
* @cc.tparam number r The intensity of the red channel, between 0 and 1.
* @cc.tparam number g The intensity of the green channel, between 0 and 1.
* @cc.tparam number b The intensity of the blue channel, between 0 and 1.
* @cc.usage Change the @{colors.red|red colour} from the default #CC4C4C to #FF0000.
* <pre>{@code
* term.setPaletteColour(colors.red, 0xFF0000)
* term.setTextColour(colors.red)
* print("Hello, world!")
* }</pre>
* @cc.usage As above, but specifying each colour channel separately.
* <pre>{@code
* term.setPaletteColour(colors.red, 1, 0, 0)
* term.setTextColour(colors.red)
* print("Hello, world!")
* }</pre>
* @cc.see colors.unpackRGB To convert from the 24-bit format to three separate channels.
* @cc.see colors.packRGB To convert from three separate channels to the 24-bit format.
* @cc.since 1.80pr1
*/
@LuaFunction({ "setPaletteColour", "setPaletteColor" })
public final void setPaletteColour(IArguments args) throws LuaException {
var colour = 15 - parseColour(args.getInt(0));
if (args.count() == 2) {
var hex = args.getInt(1);
var rgb = Palette.decodeRGB8(hex);
setColour(getTerminal(), colour, rgb[0], rgb[1], rgb[2]);
} else {
var r = args.getFiniteDouble(1);
var g = args.getFiniteDouble(2);
var b = args.getFiniteDouble(3);
setColour(getTerminal(), colour, r, g, b);
}
}
/**
* Get the current palette for a specific colour.
*
* @param colourArg The colour whose palette should be fetched.
* @return The resulting colour.
* @throws LuaException (hidden) If the terminal cannot be found.
* @cc.treturn number The red channel, will be between 0 and 1.
* @cc.treturn number The green channel, will be between 0 and 1.
* @cc.treturn number The blue channel, will be between 0 and 1.
* @cc.since 1.80pr1
*/
@LuaFunction({ "getPaletteColour", "getPaletteColor" })
public final Object[] getPaletteColour(int colourArg) throws LuaException {
var colour = 15 - parseColour(colourArg);
var terminal = getTerminal();
synchronized (terminal) {
var colourValues = terminal.getPalette().getColour(colour);
return new Object[]{ colourValues[0], colourValues[1], colourValues[2] };
}
}
public static int parseColour(int colour) throws LuaException {
if (colour <= 0) throw new LuaException("Colour out of range");
colour = getHighestBit(colour) - 1;
if (colour < 0 || colour > 15) throw new LuaException("Colour out of range");
return colour;
}
public static int encodeColour(int colour) {
return 1 << colour;
}
public static void setColour(Terminal terminal, int colour, double r, double g, double b) {
terminal.getPalette().setColour(colour, r, g, b);
terminal.setChanged();
}
}

View File

@@ -0,0 +1,83 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.handles;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.NonWritableChannelException;
import java.nio.channels.SeekableByteChannel;
import java.util.Objects;
/**
* A seekable, readable byte channel which is backed by a simple byte array.
*/
public class ArrayByteChannel implements SeekableByteChannel {
private boolean closed = false;
private int position = 0;
private final byte[] backing;
public ArrayByteChannel(byte[] backing) {
this.backing = backing;
}
@Override
public int read(ByteBuffer destination) throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
Objects.requireNonNull(destination, "destination");
if (position >= backing.length) return -1;
var remaining = Math.min(backing.length - position, destination.remaining());
destination.put(backing, position, remaining);
position += remaining;
return remaining;
}
@Override
public int write(ByteBuffer src) throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
throw new NonWritableChannelException();
}
@Override
public long position() throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
return position;
}
@Override
public SeekableByteChannel position(long newPosition) throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
if (newPosition < 0 || newPosition > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Position out of bounds");
}
position = (int) newPosition;
return this;
}
@Override
public long size() throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
return backing.length;
}
@Override
public SeekableByteChannel truncate(long size) throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
throw new NonWritableChannelException();
}
@Override
public boolean isOpen() {
return !closed;
}
@Override
public void close() {
closed = true;
}
}

View File

@@ -0,0 +1,239 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.handles;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.filesystem.TrackingCloseable;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.SeekableByteChannel;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* A file handle opened with {@link dan200.computercraft.core.apis.FSAPI#open(String, String)} with the {@code "rb"}
* mode.
*
* @cc.module fs.BinaryReadHandle
*/
public class BinaryReadableHandle extends HandleGeneric {
private static final int BUFFER_SIZE = 8192;
private final ReadableByteChannel reader;
final SeekableByteChannel seekable;
private final ByteBuffer single = ByteBuffer.allocate(1);
BinaryReadableHandle(ReadableByteChannel reader, SeekableByteChannel seekable, TrackingCloseable closeable) {
super(closeable);
this.reader = reader;
this.seekable = seekable;
}
public static BinaryReadableHandle of(ReadableByteChannel channel, TrackingCloseable closeable) {
var seekable = asSeekable(channel);
return seekable == null ? new BinaryReadableHandle(channel, null, closeable) : new Seekable(seekable, closeable);
}
public static BinaryReadableHandle of(ReadableByteChannel channel) {
return of(channel, new TrackingCloseable.Impl(channel));
}
/**
* Read a number of bytes from this file.
*
* @param countArg The number of bytes to read. When absent, a single byte will be read <em>as a number</em>. This
* may be 0 to determine we are at the end of the file.
* @return The read bytes.
* @throws LuaException When trying to read a negative number of bytes.
* @throws LuaException If the file has been closed.
* @cc.treturn [1] nil If we are at the end of the file.
* @cc.treturn [2] number The value of the byte read. This is returned when the {@code count} is absent.
* @cc.treturn [3] string The bytes read as a string. This is returned when the {@code count} is given.
* @cc.changed 1.80pr1 Now accepts an integer argument to read multiple bytes, returning a string instead of a number.
*/
@LuaFunction
public final Object[] read(Optional<Integer> countArg) throws LuaException {
checkOpen();
try {
if (countArg.isPresent()) {
int count = countArg.get();
if (count < 0) throw new LuaException("Cannot read a negative number of bytes");
if (count == 0 && seekable != null) {
return seekable.position() >= seekable.size() ? null : new Object[]{ "" };
}
if (count <= BUFFER_SIZE) {
var buffer = ByteBuffer.allocate(count);
var read = reader.read(buffer);
if (read < 0) return null;
buffer.flip();
return new Object[]{ buffer };
} else {
// Read the initial set of characters, failing if none are read.
var buffer = ByteBuffer.allocate(BUFFER_SIZE);
var read = reader.read(buffer);
if (read < 0) return null;
// If we failed to read "enough" here, let's just abort
if (read >= count || read < BUFFER_SIZE) {
buffer.flip();
return new Object[]{ buffer };
}
// Build up an array of ByteBuffers. Hopefully this means we can perform less allocation
// than doubling up the buffer each time.
var totalRead = read;
List<ByteBuffer> parts = new ArrayList<>(4);
parts.add(buffer);
while (read >= BUFFER_SIZE && totalRead < count) {
buffer = ByteBuffer.allocate(Math.min(BUFFER_SIZE, count - totalRead));
read = reader.read(buffer);
if (read < 0) break;
totalRead += read;
parts.add(buffer);
}
// Now just copy all the bytes across!
var bytes = new byte[totalRead];
var pos = 0;
for (var part : parts) {
System.arraycopy(part.array(), 0, bytes, pos, part.position());
pos += part.position();
}
return new Object[]{ bytes };
}
} else {
single.clear();
var b = reader.read(single);
return b == -1 ? null : new Object[]{ single.get(0) & 0xFF };
}
} catch (IOException e) {
return null;
}
}
/**
* Read the remainder of the file.
*
* @return The file, or {@code null} if at the end of it.
* @throws LuaException If the file has been closed.
* @cc.treturn string|nil The remaining contents of the file, or {@code nil} if we are at the end.
* @cc.since 1.80pr1
*/
@LuaFunction
public final Object[] readAll() throws LuaException {
checkOpen();
try {
var expected = 32;
if (seekable != null) expected = Math.max(expected, (int) (seekable.size() - seekable.position()));
var stream = new ByteArrayOutputStream(expected);
var buf = ByteBuffer.allocate(8192);
var readAnything = false;
while (true) {
buf.clear();
var r = reader.read(buf);
if (r == -1) break;
readAnything = true;
stream.write(buf.array(), 0, r);
}
return readAnything ? new Object[]{ stream.toByteArray() } : null;
} catch (IOException e) {
return null;
}
}
/**
* Read a line from the file.
*
* @param withTrailingArg Whether to include the newline characters with the returned string. Defaults to {@code false}.
* @return The read string.
* @throws LuaException If the file has been closed.
* @cc.treturn string|nil The read line or {@code nil} if at the end of the file.
* @cc.since 1.80pr1.9
* @cc.changed 1.81.0 `\r` is now stripped.
*/
@LuaFunction
public final Object[] readLine(Optional<Boolean> withTrailingArg) throws LuaException {
checkOpen();
boolean withTrailing = withTrailingArg.orElse(false);
try {
var stream = new ByteArrayOutputStream();
boolean readAnything = false, readRc = false;
while (true) {
single.clear();
var read = reader.read(single);
if (read <= 0) {
// Nothing else to read, and we saw no \n. Return the array. If we saw a \r, then add it
// back.
if (readRc) stream.write('\r');
return readAnything ? new Object[]{ stream.toByteArray() } : null;
}
readAnything = true;
var chr = single.get(0);
if (chr == '\n') {
if (withTrailing) {
if (readRc) stream.write('\r');
stream.write(chr);
}
return new Object[]{ stream.toByteArray() };
} else {
// We want to skip \r\n, but obviously need to include cases where \r is not followed by \n.
// Note, this behaviour is non-standard compliant (strictly speaking we should have no
// special logic for \r), but we preserve compatibility with EncodedReadableHandle and
// previous behaviour of the io library.
if (readRc) stream.write('\r');
readRc = chr == '\r';
if (!readRc) stream.write(chr);
}
}
} catch (IOException e) {
return null;
}
}
public static class Seekable extends BinaryReadableHandle {
Seekable(SeekableByteChannel seekable, TrackingCloseable closeable) {
super(seekable, seekable, closeable);
}
/**
* Seek to a new position within the file, changing where bytes are written to. The new position is an offset
* given by {@code offset}, relative to a start position determined by {@code whence}:
* <p>
* - {@code "set"}: {@code offset} is relative to the beginning of the file.
* - {@code "cur"}: Relative to the current position. This is the default.
* - {@code "end"}: Relative to the end of the file.
* <p>
* In case of success, {@code seek} returns the new file position from the beginning of the file.
*
* @param whence Where the offset is relative to.
* @param offset The offset to seek to.
* @return The new position.
* @throws LuaException If the file has been closed.
* @cc.treturn [1] number The new position.
* @cc.treturn [2] nil If seeking failed.
* @cc.treturn string The reason seeking failed.
* @cc.since 1.80pr1.9
*/
@LuaFunction
public final Object[] seek(Optional<String> whence, Optional<Long> offset) throws LuaException {
checkOpen();
return handleSeek(seekable, whence, offset);
}
}
}

View File

@@ -0,0 +1,123 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.handles;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.api.lua.LuaValues;
import dan200.computercraft.core.filesystem.TrackingCloseable;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.SeekableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.util.Optional;
/**
* A file handle opened by {@link dan200.computercraft.core.apis.FSAPI#open} using the {@code "wb"} or {@code "ab"}
* modes.
*
* @cc.module fs.BinaryWriteHandle
*/
public class BinaryWritableHandle extends HandleGeneric {
private final WritableByteChannel writer;
final SeekableByteChannel seekable;
private final ByteBuffer single = ByteBuffer.allocate(1);
protected BinaryWritableHandle(WritableByteChannel writer, SeekableByteChannel seekable, TrackingCloseable closeable) {
super(closeable);
this.writer = writer;
this.seekable = seekable;
}
public static BinaryWritableHandle of(WritableByteChannel channel, TrackingCloseable closeable) {
var seekable = asSeekable(channel);
return seekable == null ? new BinaryWritableHandle(channel, null, closeable) : new Seekable(seekable, closeable);
}
public static BinaryWritableHandle of(WritableByteChannel channel) {
return of(channel, new TrackingCloseable.Impl(channel));
}
/**
* Write a string or byte to the file.
*
* @param arguments The value to write.
* @throws LuaException If the file has been closed.
* @cc.tparam [1] number The byte to write.
* @cc.tparam [2] string The string to write.
* @cc.changed 1.80pr1 Now accepts a string to write multiple bytes.
*/
@LuaFunction
public final void write(IArguments arguments) throws LuaException {
checkOpen();
try {
var arg = arguments.get(0);
if (arg instanceof Number) {
var number = ((Number) arg).intValue();
single.clear();
single.put((byte) number);
single.flip();
writer.write(single);
} else if (arg instanceof String) {
writer.write(arguments.getBytes(0));
} else {
throw LuaValues.badArgumentOf(0, "string or number", arg);
}
} catch (IOException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Save the current file without closing it.
*
* @throws LuaException If the file has been closed.
*/
@LuaFunction
public final void flush() throws LuaException {
checkOpen();
try {
// Technically this is not needed
if (writer instanceof FileChannel channel) channel.force(false);
} catch (IOException ignored) {
}
}
public static class Seekable extends BinaryWritableHandle {
public Seekable(SeekableByteChannel seekable, TrackingCloseable closeable) {
super(seekable, seekable, closeable);
}
/**
* Seek to a new position within the file, changing where bytes are written to. The new position is an offset
* given by {@code offset}, relative to a start position determined by {@code whence}:
* <p>
* - {@code "set"}: {@code offset} is relative to the beginning of the file.
* - {@code "cur"}: Relative to the current position. This is the default.
* - {@code "end"}: Relative to the end of the file.
* <p>
* In case of success, {@code seek} returns the new file position from the beginning of the file.
*
* @param whence Where the offset is relative to.
* @param offset The offset to seek to.
* @return The new position.
* @throws LuaException If the file has been closed.
* @cc.treturn [1] number The new position.
* @cc.treturn [2] nil If seeking failed.
* @cc.treturn string The reason seeking failed.
* @cc.since 1.80pr1.9
*/
@LuaFunction
public final Object[] seek(Optional<String> whence, Optional<Long> offset) throws LuaException {
checkOpen();
return handleSeek(seekable, whence, offset);
}
}
}

View File

@@ -0,0 +1,88 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.handles;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.NonWritableChannelException;
import java.nio.channels.SeekableByteChannel;
import java.util.Objects;
/**
* A seekable, readable byte channel which is backed by a {@link ByteBuffer}.
*/
public class ByteBufferChannel implements SeekableByteChannel {
private boolean closed = false;
private int position = 0;
private final ByteBuffer backing;
public ByteBufferChannel(ByteBuffer backing) {
this.backing = backing;
}
@Override
public int read(ByteBuffer destination) throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
Objects.requireNonNull(destination, "destination");
if (position >= backing.limit()) return -1;
var remaining = Math.min(backing.limit() - position, destination.remaining());
// TODO: Switch to Java 17 methods on 1.18.x
var slice = backing.slice();
slice.position(position);
slice.limit(position + remaining);
destination.put(slice);
position += remaining;
return remaining;
}
@Override
public int write(ByteBuffer src) throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
throw new NonWritableChannelException();
}
@Override
public long position() throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
return position;
}
@Override
public SeekableByteChannel position(long newPosition) throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
if (newPosition < 0 || newPosition > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Position out of bounds");
}
position = (int) newPosition;
return this;
}
@Override
public long size() throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
return backing.limit();
}
@Override
public SeekableByteChannel truncate(long size) throws ClosedChannelException {
if (closed) throw new ClosedChannelException();
throw new NonWritableChannelException();
}
@Override
public boolean isOpen() {
return !closed;
}
@Override
public void close() {
closed = true;
}
}

View File

@@ -0,0 +1,161 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.handles;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.filesystem.TrackingCloseable;
import javax.annotation.Nonnull;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
/**
* A file handle opened with {@link dan200.computercraft.core.apis.FSAPI#open(String, String)} with the {@code "r"}
* mode.
*
* @cc.module fs.ReadHandle
*/
public class EncodedReadableHandle extends HandleGeneric {
private static final int BUFFER_SIZE = 8192;
private final BufferedReader reader;
public EncodedReadableHandle(@Nonnull BufferedReader reader, @Nonnull TrackingCloseable closable) {
super(closable);
this.reader = reader;
}
public EncodedReadableHandle(@Nonnull BufferedReader reader) {
this(reader, new TrackingCloseable.Impl(reader));
}
/**
* Read a line from the file.
*
* @param withTrailingArg Whether to include the newline characters with the returned string. Defaults to {@code false}.
* @return The read string.
* @throws LuaException If the file has been closed.
* @cc.treturn string|nil The read line or {@code nil} if at the end of the file.
* @cc.changed 1.81.0 Added option to return trailing newline.
*/
@LuaFunction
public final Object[] readLine(Optional<Boolean> withTrailingArg) throws LuaException {
checkOpen();
boolean withTrailing = withTrailingArg.orElse(false);
try {
var line = reader.readLine();
if (line != null) {
// While this is technically inaccurate, it's better than nothing
if (withTrailing) line += "\n";
return new Object[]{ line };
} else {
return null;
}
} catch (IOException e) {
return null;
}
}
/**
* Read the remainder of the file.
*
* @return The file, or {@code null} if at the end of it.
* @throws LuaException If the file has been closed.
* @cc.treturn nil|string The remaining contents of the file, or {@code nil} if we are at the end.
*/
@LuaFunction
public final Object[] readAll() throws LuaException {
checkOpen();
try {
var result = new StringBuilder();
var line = reader.readLine();
while (line != null) {
result.append(line);
line = reader.readLine();
if (line != null) {
result.append("\n");
}
}
return new Object[]{ result.toString() };
} catch (IOException e) {
return null;
}
}
/**
* Read a number of characters from this file.
*
* @param countA The number of characters to read, defaulting to 1.
* @return The read characters.
* @throws LuaException When trying to read a negative number of characters.
* @throws LuaException If the file has been closed.
* @cc.treturn string|nil The read characters, or {@code nil} if at the of the file.
* @cc.since 1.80pr1.4
*/
@LuaFunction
public final Object[] read(Optional<Integer> countA) throws LuaException {
checkOpen();
try {
int count = countA.orElse(1);
if (count < 0) {
// Whilst this may seem absurd to allow reading 0 characters, PUC Lua it so
// it seems best to remain somewhat consistent.
throw new LuaException("Cannot read a negative number of characters");
} else if (count <= BUFFER_SIZE) {
// If we've got a small count, then allocate that and read it.
var chars = new char[count];
var read = reader.read(chars);
return read < 0 ? null : new Object[]{ new String(chars, 0, read) };
} else {
// If we've got a large count, read in bunches of 8192.
var buffer = new char[BUFFER_SIZE];
// Read the initial set of characters, failing if none are read.
var read = reader.read(buffer, 0, Math.min(buffer.length, count));
if (read < 0) return null;
var out = new StringBuilder(read);
var totalRead = read;
out.append(buffer, 0, read);
// Otherwise read until we either reach the limit or we no longer consume
// the full buffer.
while (read >= BUFFER_SIZE && totalRead < count) {
read = reader.read(buffer, 0, Math.min(BUFFER_SIZE, count - totalRead));
if (read < 0) break;
totalRead += read;
out.append(buffer, 0, read);
}
return new Object[]{ out.toString() };
}
} catch (IOException e) {
return null;
}
}
public static BufferedReader openUtf8(ReadableByteChannel channel) {
return open(channel, StandardCharsets.UTF_8);
}
public static BufferedReader open(ReadableByteChannel channel, Charset charset) {
// Create a charset decoder with the same properties as StreamDecoder does for
// InputStreams: namely, replace everything instead of erroring.
var decoder = charset.newDecoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
return new BufferedReader(Channels.newReader(channel, decoder, -1));
}
}

View File

@@ -0,0 +1,99 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.handles;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.filesystem.TrackingCloseable;
import dan200.computercraft.core.util.StringUtil;
import javax.annotation.Nonnull;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.channels.Channels;
import java.nio.channels.WritableByteChannel;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
/**
* A file handle opened by {@link dan200.computercraft.core.apis.FSAPI#open} using the {@code "w"} or {@code "a"} modes.
*
* @cc.module fs.WriteHandle
*/
public class EncodedWritableHandle extends HandleGeneric {
private final BufferedWriter writer;
public EncodedWritableHandle(@Nonnull BufferedWriter writer, @Nonnull TrackingCloseable closable) {
super(closable);
this.writer = writer;
}
/**
* Write a string of characters to the file.
*
* @param args The value to write.
* @throws LuaException If the file has been closed.
* @cc.param value The value to write to the file.
*/
@LuaFunction
public final void write(IArguments args) throws LuaException {
checkOpen();
var text = StringUtil.toString(args.get(0));
try {
writer.write(text, 0, text.length());
} catch (IOException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Write a string of characters to the file, follwing them with a new line character.
*
* @param args The value to write.
* @throws LuaException If the file has been closed.
* @cc.param value The value to write to the file.
*/
@LuaFunction
public final void writeLine(IArguments args) throws LuaException {
checkOpen();
var text = StringUtil.toString(args.get(0));
try {
writer.write(text, 0, text.length());
writer.newLine();
} catch (IOException e) {
throw new LuaException(e.getMessage());
}
}
/**
* Save the current file without closing it.
*
* @throws LuaException If the file has been closed.
*/
@LuaFunction
public final void flush() throws LuaException {
checkOpen();
try {
writer.flush();
} catch (IOException ignored) {
}
}
public static BufferedWriter openUtf8(WritableByteChannel channel) {
return open(channel, StandardCharsets.UTF_8);
}
public static BufferedWriter open(WritableByteChannel channel, Charset charset) {
// Create a charset encoder with the same properties as StreamEncoder does for
// OutputStreams: namely, replace everything instead of erroring.
var encoder = charset.newEncoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
return new BufferedWriter(Channels.newWriter(channel, encoder, -1));
}
}

View File

@@ -0,0 +1,88 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.handles;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.filesystem.TrackingCloseable;
import dan200.computercraft.core.util.IoUtil;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.nio.channels.Channel;
import java.nio.channels.SeekableByteChannel;
import java.util.Optional;
public abstract class HandleGeneric {
private TrackingCloseable closeable;
protected HandleGeneric(@Nonnull TrackingCloseable closeable) {
this.closeable = closeable;
}
protected void checkOpen() throws LuaException {
var closeable = this.closeable;
if (closeable == null || !closeable.isOpen()) throw new LuaException("attempt to use a closed file");
}
protected final void close() {
IoUtil.closeQuietly(closeable);
closeable = null;
}
/**
* Close this file, freeing any resources it uses.
* <p>
* Once a file is closed it may no longer be read or written to.
*
* @throws LuaException If the file has already been closed.
*/
@LuaFunction("close")
public final void doClose() throws LuaException {
checkOpen();
close();
}
/**
* Shared implementation for various file handle types.
*
* @param channel The channel to seek in
* @param whence The seeking mode.
* @param offset The offset to seek to.
* @return The new position of the file, or null if some error occurred.
* @throws LuaException If the arguments were invalid
* @see <a href="https://www.lua.org/manual/5.1/manual.html#pdf-file:seek">{@code file:seek} in the Lua manual.</a>
*/
protected static Object[] handleSeek(SeekableByteChannel channel, Optional<String> whence, Optional<Long> offset) throws LuaException {
long actualOffset = offset.orElse(0L);
try {
switch (whence.orElse("cur")) {
case "set" -> channel.position(actualOffset);
case "cur" -> channel.position(channel.position() + actualOffset);
case "end" -> channel.position(channel.size() + actualOffset);
default -> throw new LuaException("bad argument #1 to 'seek' (invalid option '" + whence + "'");
}
return new Object[]{ channel.position() };
} catch (IllegalArgumentException e) {
return new Object[]{ null, "Position is negative" };
} catch (IOException e) {
return null;
}
}
protected static SeekableByteChannel asSeekable(Channel channel) {
if (!(channel instanceof SeekableByteChannel seekable)) return null;
try {
seekable.position(seekable.position());
return seekable;
} catch (IOException | UnsupportedOperationException e) {
return null;
}
}
}

View File

@@ -0,0 +1,59 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http;
import dan200.computercraft.core.apis.IAPIEnvironment;
import java.net.URI;
import java.util.concurrent.Future;
/**
* Checks a URL using {@link NetworkUtils#getAddress(String, int, boolean)}}
* <p>
* This requires a DNS lookup, and so needs to occur off-thread.
*/
public class CheckUrl extends Resource<CheckUrl> {
private static final String EVENT = "http_check";
private Future<?> future;
private final IAPIEnvironment environment;
private final String address;
private final URI uri;
public CheckUrl(ResourceGroup<CheckUrl> limiter, IAPIEnvironment environment, String address, URI uri) {
super(limiter);
this.environment = environment;
this.address = address;
this.uri = uri;
}
public void run() {
if (isClosed()) return;
future = NetworkUtils.EXECUTOR.submit(this::doRun);
checkClosed();
}
private void doRun() {
if (isClosed()) return;
try {
var ssl = uri.getScheme().equalsIgnoreCase("https");
var netAddress = NetworkUtils.getAddress(uri, ssl);
NetworkUtils.getOptions(uri.getHost(), netAddress);
if (tryClose()) environment.queueEvent(EVENT, address, true);
} catch (HTTPRequestException e) {
if (tryClose()) environment.queueEvent(EVENT, address, false, e.getMessage());
}
}
@Override
protected void dispose() {
super.dispose();
future = closeFuture(future);
}
}

View File

@@ -0,0 +1,22 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http;
import java.io.Serial;
public class HTTPRequestException extends Exception {
@Serial
private static final long serialVersionUID = 7591208619422744652L;
public HTTPRequestException(String s) {
super(s);
}
@Override
public Throwable fillInStackTrace() {
return this;
}
}

View File

@@ -0,0 +1,188 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http;
import dan200.computercraft.core.CoreConfig;
import dan200.computercraft.core.apis.http.options.Action;
import dan200.computercraft.core.apis.http.options.AddressRule;
import dan200.computercraft.core.apis.http.options.Options;
import dan200.computercraft.core.util.ThreadUtils;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ConnectTimeoutException;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.handler.codec.DecoderException;
import io.netty.handler.codec.TooLongFrameException;
import io.netty.handler.codec.http.websocketx.WebSocketHandshakeException;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.timeout.ReadTimeoutException;
import io.netty.handler.traffic.AbstractTrafficShapingHandler;
import io.netty.handler.traffic.GlobalTrafficShapingHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.TrustManagerFactory;
import java.net.InetSocketAddress;
import java.net.URI;
import java.security.KeyStore;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* Just a shared object for executing simple HTTP related tasks.
*/
public final class NetworkUtils {
private static final Logger LOG = LoggerFactory.getLogger(NetworkUtils.class);
public static final ScheduledThreadPoolExecutor EXECUTOR = new ScheduledThreadPoolExecutor(
4,
ThreadUtils.builder("Network")
.setPriority(Thread.MIN_PRIORITY + (Thread.NORM_PRIORITY - Thread.MIN_PRIORITY) / 2)
.build()
);
public static final EventLoopGroup LOOP_GROUP = new NioEventLoopGroup(4, ThreadUtils.builder("Netty")
.setPriority(Thread.MIN_PRIORITY + (Thread.NORM_PRIORITY - Thread.MIN_PRIORITY) / 2)
.build()
);
public static final AbstractTrafficShapingHandler SHAPING_HANDLER = new GlobalTrafficShapingHandler(
EXECUTOR, CoreConfig.httpUploadBandwidth, CoreConfig.httpDownloadBandwidth
);
static {
EXECUTOR.setKeepAliveTime(60, TimeUnit.SECONDS);
}
private NetworkUtils() {
}
private static final Object sslLock = new Object();
private static TrustManagerFactory trustManager;
private static SslContext sslContext;
private static boolean triedSslContext = false;
private static TrustManagerFactory getTrustManager() {
if (trustManager != null) return trustManager;
synchronized (sslLock) {
if (trustManager != null) return trustManager;
TrustManagerFactory tmf = null;
try {
tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore) null);
} catch (Exception e) {
LOG.error("Cannot setup trust manager", e);
}
return trustManager = tmf;
}
}
public static SslContext getSslContext() throws HTTPRequestException {
if (sslContext != null || triedSslContext) return sslContext;
synchronized (sslLock) {
if (sslContext != null || triedSslContext) return sslContext;
try {
return sslContext = SslContextBuilder
.forClient()
.trustManager(getTrustManager())
.build();
} catch (SSLException e) {
LOG.error("Cannot construct SSL context", e);
triedSslContext = true;
sslContext = null;
throw new HTTPRequestException("Cannot create a secure connection");
}
}
}
public static void reloadConfig() {
SHAPING_HANDLER.configure(CoreConfig.httpUploadBandwidth, CoreConfig.httpDownloadBandwidth);
}
public static void reset() {
SHAPING_HANDLER.trafficCounter().resetCumulativeTime();
}
/**
* Create a {@link InetSocketAddress} from a {@link java.net.URI}.
* <p>
* Note, this may require a DNS lookup, and so should not be executed on the main CC thread.
*
* @param uri The URI to fetch.
* @param ssl Whether to connect with SSL. This is used to find the default port if not otherwise specified.
* @return The resolved address.
* @throws HTTPRequestException If the host is not malformed.
*/
public static InetSocketAddress getAddress(URI uri, boolean ssl) throws HTTPRequestException {
return getAddress(uri.getHost(), uri.getPort(), ssl);
}
/**
* Create a {@link InetSocketAddress} from the resolved {@code host} and port.
* <p>
* Note, this may require a DNS lookup, and so should not be executed on the main CC thread.
*
* @param host The host to resolve.
* @param port The port, or -1 if not defined.
* @param ssl Whether to connect with SSL. This is used to find the default port if not otherwise specified.
* @return The resolved address.
* @throws HTTPRequestException If the host is not malformed.
*/
public static InetSocketAddress getAddress(String host, int port, boolean ssl) throws HTTPRequestException {
if (port < 0) port = ssl ? 443 : 80;
var socketAddress = new InetSocketAddress(host, port);
if (socketAddress.isUnresolved()) throw new HTTPRequestException("Unknown host");
return socketAddress;
}
/**
* Get options for a specific domain.
*
* @param host The host to resolve.
* @param address The address, resolved by {@link #getAddress(String, int, boolean)}.
* @return The options for this host.
* @throws HTTPRequestException If the host is not permitted
*/
public static Options getOptions(String host, InetSocketAddress address) throws HTTPRequestException {
var options = AddressRule.apply(CoreConfig.httpRules, host, address);
if (options.action == Action.DENY) throw new HTTPRequestException("Domain not permitted");
return options;
}
/**
* Read a {@link ByteBuf} into a byte array.
*
* @param buffer The buffer to read.
* @return The resulting bytes.
*/
public static byte[] toBytes(ByteBuf buffer) {
var bytes = new byte[buffer.readableBytes()];
buffer.readBytes(bytes);
return bytes;
}
@Nonnull
public static String toFriendlyError(@Nonnull Throwable cause) {
if (cause instanceof WebSocketHandshakeException || cause instanceof HTTPRequestException) {
return cause.getMessage();
} else if (cause instanceof TooLongFrameException) {
return "Message is too large";
} else if (cause instanceof ReadTimeoutException || cause instanceof ConnectTimeoutException) {
return "Timed out";
} else if (cause instanceof SSLHandshakeException || (cause instanceof DecoderException && cause.getCause() instanceof SSLHandshakeException)) {
return "Could not create a secure connection";
} else {
return "Could not connect";
}
}
}

View File

@@ -0,0 +1,133 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http;
import dan200.computercraft.core.util.IoUtil;
import io.netty.channel.ChannelFuture;
import java.io.Closeable;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/**
* A holder for one or more resources, with a lifetime.
*
* @param <T> The type of this resource. Should be the class extending from {@link Resource}.
*/
public abstract class Resource<T extends Resource<T>> implements Closeable {
private final AtomicBoolean closed = new AtomicBoolean(false);
private final ResourceGroup<T> limiter;
protected Resource(ResourceGroup<T> limiter) {
this.limiter = limiter;
}
/**
* Whether this resource is closed.
*
* @return Whether this resource is closed.
*/
public final boolean isClosed() {
return closed.get();
}
/**
* Checks if this has been cancelled. If so, it'll clean up any existing resources and cancel any pending futures.
*
* @return Whether this resource has been closed.
*/
public final boolean checkClosed() {
if (!closed.get()) return false;
dispose();
return true;
}
/**
* Try to close the current resource.
*
* @return Whether this was successfully closed, or {@code false} if it has already been closed.
*/
protected final boolean tryClose() {
if (closed.getAndSet(true)) return false;
dispose();
return true;
}
/**
* Clean up any pending resources
* <p>
* Note, this may be called multiple times, and so should be thread-safe and
* avoid any major side effects.
*/
protected void dispose() {
@SuppressWarnings("unchecked")
var thisT = (T) this;
limiter.release(thisT);
}
/**
* Create a {@link WeakReference} which will close {@code this} when collected.
*
* @param <R> The object we are wrapping in a reference.
* @param object The object to reference to
* @return The weak reference.
*/
protected <R> WeakReference<R> createOwnerReference(R object) {
return new CloseReference<>(this, object);
}
@Override
public final void close() {
tryClose();
}
public final boolean queue(Consumer<T> task) {
@SuppressWarnings("unchecked")
var thisT = (T) this;
return limiter.queue(thisT, () -> task.accept(thisT));
}
protected static <T extends Closeable> T closeCloseable(T closeable) {
IoUtil.closeQuietly(closeable);
return null;
}
protected static ChannelFuture closeChannel(ChannelFuture future) {
if (future != null) {
future.cancel(false);
var channel = future.channel();
if (channel != null && channel.isOpen()) channel.close();
}
return null;
}
protected static <T extends Future<?>> T closeFuture(T future) {
if (future != null) future.cancel(true);
return null;
}
private static final ReferenceQueue<Object> QUEUE = new ReferenceQueue<>();
private static class CloseReference<T> extends WeakReference<T> {
final Resource<?> resource;
CloseReference(Resource<?> resource, T referent) {
super(referent, QUEUE);
this.resource = resource;
}
}
public static void cleanup() {
Reference<?> reference;
while ((reference = QUEUE.poll()) != null) ((CloseReference<?>) reference).resource.close();
}
}

View File

@@ -0,0 +1,76 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.IntSupplier;
import java.util.function.Supplier;
/**
* A collection of {@link Resource}s, with an upper bound on capacity.
*
* @param <T> The type of the resource this group manages.
*/
public class ResourceGroup<T extends Resource<T>> {
public static final int DEFAULT_LIMIT = 512;
public static final IntSupplier DEFAULT = () -> DEFAULT_LIMIT;
private static final IntSupplier ZERO = () -> 0;
final IntSupplier limit;
boolean active = false;
final Set<T> resources = Collections.newSetFromMap(new ConcurrentHashMap<>());
public ResourceGroup(IntSupplier limit) {
this.limit = limit;
}
public ResourceGroup() {
limit = ZERO;
}
public void startup() {
active = true;
}
public synchronized void shutdown() {
active = false;
for (var resource : resources) resource.close();
resources.clear();
Resource.cleanup();
}
public final boolean queue(T resource, Runnable setup) {
return queue(() -> {
setup.run();
return resource;
});
}
public synchronized boolean queue(Supplier<T> resource) {
Resource.cleanup();
if (!active) return false;
var limit = this.limit.getAsInt();
if (limit <= 0 || resources.size() < limit) {
resources.add(resource.get());
return true;
}
return false;
}
public synchronized void release(T resource) {
resources.remove(resource);
}
}

View File

@@ -0,0 +1,55 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http;
import java.util.ArrayDeque;
import java.util.function.IntSupplier;
import java.util.function.Supplier;
/**
* A {@link ResourceGroup} which will queue items when the group at capacity.
*
* @param <T> The type of the resource this queue manages.
*/
public class ResourceQueue<T extends Resource<T>> extends ResourceGroup<T> {
private final ArrayDeque<Supplier<T>> pending = new ArrayDeque<>();
public ResourceQueue(IntSupplier limit) {
super(limit);
}
public ResourceQueue() {
}
@Override
public synchronized void shutdown() {
super.shutdown();
pending.clear();
}
@Override
public synchronized boolean queue(Supplier<T> resource) {
if (!active) return false;
if (super.queue(resource)) return true;
if (pending.size() > DEFAULT_LIMIT) return false;
pending.add(resource);
return true;
}
@Override
public synchronized void release(T resource) {
super.release(resource);
if (!active) return;
var limit = this.limit.getAsInt();
if (limit <= 0 || resources.size() < limit) {
var next = pending.poll();
if (next != null) resources.add(next.get());
}
}
}

View File

@@ -0,0 +1,24 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.options;
import javax.annotation.Nonnull;
import java.util.OptionalInt;
import java.util.OptionalLong;
public enum Action {
ALLOW,
DENY;
private final PartialOptions partial = new PartialOptions(
this, OptionalLong.empty(), OptionalLong.empty(), OptionalInt.empty(), OptionalInt.empty()
);
@Nonnull
public PartialOptions toPartial() {
return partial;
}
}

View File

@@ -0,0 +1,127 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.options;
import com.google.common.net.InetAddresses;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.regex.Pattern;
/**
* A predicate on an address. Matches against a domain and an ip address.
*
* @see AddressRule#apply(Iterable, String, InetSocketAddress) for the actual handling of this rule.
*/
interface AddressPredicate {
Logger LOG = LoggerFactory.getLogger(AddressPredicate.class);
default boolean matches(String domain) {
return false;
}
default boolean matches(InetAddress socketAddress) {
return false;
}
final class HostRange implements AddressPredicate {
private final byte[] min;
private final byte[] max;
HostRange(byte[] min, byte[] max) {
this.min = min;
this.max = max;
}
@Override
public boolean matches(InetAddress address) {
var entry = address.getAddress();
if (entry.length != min.length) return false;
for (var i = 0; i < entry.length; i++) {
var value = 0xFF & entry[i];
if (value < (0xFF & min[i]) || value > (0xFF & max[i])) return false;
}
return true;
}
public static HostRange parse(String addressStr, String prefixSizeStr) {
int prefixSize;
try {
prefixSize = Integer.parseInt(prefixSizeStr);
} catch (NumberFormatException e) {
LOG.error(
"Malformed http whitelist/blacklist entry '{}': Cannot extract size of CIDR mask from '{}'.",
addressStr + '/' + prefixSizeStr, prefixSizeStr
);
return null;
}
InetAddress address;
try {
address = InetAddresses.forString(addressStr);
} catch (IllegalArgumentException e) {
LOG.error(
"Malformed http whitelist/blacklist entry '{}': Cannot extract IP address from '{}'.",
addressStr + '/' + prefixSizeStr, prefixSizeStr
);
return null;
}
// Mask the bytes of the IP address.
byte[] minBytes = address.getAddress(), maxBytes = address.getAddress();
var size = prefixSize;
for (var i = 0; i < minBytes.length; i++) {
if (size <= 0) {
minBytes[i] &= 0;
maxBytes[i] |= 0xFF;
} else if (size < 8) {
minBytes[i] &= 0xFF << (8 - size);
maxBytes[i] |= ~(0xFF << (8 - size));
}
size -= 8;
}
return new HostRange(minBytes, maxBytes);
}
}
final class DomainPattern implements AddressPredicate {
private final Pattern pattern;
DomainPattern(Pattern pattern) {
this.pattern = pattern;
}
@Override
public boolean matches(String domain) {
return pattern.matcher(domain).matches();
}
@Override
public boolean matches(InetAddress socketAddress) {
return pattern.matcher(socketAddress.getHostAddress()).matches();
}
}
final class PrivatePattern implements AddressPredicate {
static final PrivatePattern INSTANCE = new PrivatePattern();
@Override
public boolean matches(InetAddress socketAddress) {
return socketAddress.isAnyLocalAddress()
|| socketAddress.isLoopbackAddress()
|| socketAddress.isLinkLocalAddress()
|| socketAddress.isSiteLocalAddress();
}
}
}

View File

@@ -0,0 +1,88 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.options;
import com.google.common.net.InetAddresses;
import dan200.computercraft.core.apis.http.options.AddressPredicate.DomainPattern;
import dan200.computercraft.core.apis.http.options.AddressPredicate.HostRange;
import dan200.computercraft.core.apis.http.options.AddressPredicate.PrivatePattern;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.OptionalInt;
import java.util.regex.Pattern;
/**
* A pattern which matches an address, and controls whether it is accessible or not.
*/
public final class AddressRule {
public static final long MAX_DOWNLOAD = 16 * 1024 * 1024;
public static final long MAX_UPLOAD = 4 * 1024 * 1024;
public static final int TIMEOUT = 30_000;
public static final int WEBSOCKET_MESSAGE = 128 * 1024;
private final AddressPredicate predicate;
private final OptionalInt port;
private final PartialOptions partial;
private AddressRule(@Nonnull AddressPredicate predicate, OptionalInt port, @Nonnull PartialOptions partial) {
this.predicate = predicate;
this.partial = partial;
this.port = port;
}
@Nullable
public static AddressRule parse(String filter, OptionalInt port, @Nonnull PartialOptions partial) {
var cidr = filter.indexOf('/');
if (cidr >= 0) {
var addressStr = filter.substring(0, cidr);
var prefixSizeStr = filter.substring(cidr + 1);
var range = HostRange.parse(addressStr, prefixSizeStr);
return range == null ? null : new AddressRule(range, port, partial);
} else if (filter.equalsIgnoreCase("$private")) {
return new AddressRule(PrivatePattern.INSTANCE, port, partial);
} else {
var pattern = Pattern.compile("^\\Q" + filter.replaceAll("\\*", "\\\\E.*\\\\Q") + "\\E$", Pattern.CASE_INSENSITIVE);
return new AddressRule(new DomainPattern(pattern), port, partial);
}
}
/**
* Determine whether the given address matches a series of patterns.
*
* @param domain The domain to match
* @param port The port of the address.
* @param address The address to check.
* @param ipv4Address An ipv4 version of the address, if the original was an ipv6 address.
* @return Whether it matches any of these patterns.
*/
private boolean matches(String domain, int port, InetAddress address, Inet4Address ipv4Address) {
if (this.port.isPresent() && this.port.getAsInt() != port) return false;
return predicate.matches(domain)
|| predicate.matches(address)
|| (ipv4Address != null && predicate.matches(ipv4Address));
}
public static Options apply(Iterable<? extends AddressRule> rules, String domain, InetSocketAddress socketAddress) {
var options = PartialOptions.DEFAULT;
var port = socketAddress.getPort();
var address = socketAddress.getAddress();
var ipv4Address = address instanceof Inet6Address inet6 && InetAddresses.is6to4Address(inet6)
? InetAddresses.get6to4IPv4Address(inet6) : null;
for (AddressRule rule : rules) {
if (!rule.matches(domain, port, address, ipv4Address)) continue;
options = options.merge(rule.partial);
}
return options.toOptions();
}
}

View File

@@ -0,0 +1,28 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.options;
import javax.annotation.Nonnull;
/**
* Options about a specific domain.
*/
public final class Options {
@Nonnull
public final Action action;
public final long maxUpload;
public final long maxDownload;
public final int timeout;
public final int websocketMessage;
Options(@Nonnull Action action, long maxUpload, long maxDownload, int timeout, int websocketMessage) {
this.action = action;
this.maxUpload = maxUpload;
this.maxDownload = maxDownload;
this.timeout = timeout;
this.websocketMessage = websocketMessage;
}
}

View File

@@ -0,0 +1,63 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.options;
import javax.annotation.Nullable;
import java.util.OptionalInt;
import java.util.OptionalLong;
public final class PartialOptions {
public static final PartialOptions DEFAULT = new PartialOptions(
null, OptionalLong.empty(), OptionalLong.empty(), OptionalInt.empty(), OptionalInt.empty()
);
private final @Nullable Action action;
private final OptionalLong maxUpload;
private final OptionalLong maxDownload;
private final OptionalInt timeout;
private final OptionalInt websocketMessage;
private @Nullable Options options;
public PartialOptions(@Nullable Action action, OptionalLong maxUpload, OptionalLong maxDownload, OptionalInt timeout, OptionalInt websocketMessage) {
this.action = action;
this.maxUpload = maxUpload;
this.maxDownload = maxDownload;
this.timeout = timeout;
this.websocketMessage = websocketMessage;
}
Options toOptions() {
if (options != null) return options;
return options = new Options(
action == null ? Action.DENY : action,
maxUpload.orElse(AddressRule.MAX_UPLOAD),
maxDownload.orElse(AddressRule.MAX_DOWNLOAD),
timeout.orElse(AddressRule.TIMEOUT),
websocketMessage.orElse(AddressRule.WEBSOCKET_MESSAGE)
);
}
/**
* Perform a left-biased union of two {@link PartialOptions}.
*
* @param other The other partial options to combine with.
* @return The merged options map.
*/
PartialOptions merge(PartialOptions other) {
// Short circuit for DEFAULT. This has no effect on the outcome, but avoids an allocation.
if (this == DEFAULT) return other;
return new PartialOptions(
action == null && other.action != null ? other.action : action,
maxUpload.isPresent() ? maxUpload : other.maxUpload,
maxDownload.isPresent() ? maxDownload : other.maxDownload,
timeout.isPresent() ? timeout : other.timeout,
websocketMessage.isPresent() ? websocketMessage : other.websocketMessage
);
}
}

View File

@@ -0,0 +1,222 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.request;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.apis.http.HTTPRequestException;
import dan200.computercraft.core.apis.http.NetworkUtils;
import dan200.computercraft.core.apis.http.Resource;
import dan200.computercraft.core.apis.http.ResourceGroup;
import dan200.computercraft.core.metrics.Metrics;
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.*;
import io.netty.handler.timeout.ReadTimeoutHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Represents an in-progress HTTP request.
*/
public class HttpRequest extends Resource<HttpRequest> {
private static final Logger LOG = LoggerFactory.getLogger(HttpRequest.class);
private static final String SUCCESS_EVENT = "http_success";
private static final String FAILURE_EVENT = "http_failure";
private static final int MAX_REDIRECTS = 16;
private Future<?> executorFuture;
private ChannelFuture connectFuture;
private HttpRequestHandler currentRequest;
private final IAPIEnvironment environment;
private final String address;
private final ByteBuf postBuffer;
private final HttpHeaders headers;
private final boolean binary;
final AtomicInteger redirects;
public HttpRequest(ResourceGroup<HttpRequest> limiter, IAPIEnvironment environment, String address, String postText, HttpHeaders headers, boolean binary, boolean followRedirects) {
super(limiter);
this.environment = environment;
this.address = address;
postBuffer = postText != null
? Unpooled.wrappedBuffer(postText.getBytes(StandardCharsets.UTF_8))
: Unpooled.buffer(0);
this.headers = headers;
this.binary = binary;
redirects = new AtomicInteger(followRedirects ? MAX_REDIRECTS : 0);
if (postText != null) {
if (!headers.contains(HttpHeaderNames.CONTENT_TYPE)) {
headers.set(HttpHeaderNames.CONTENT_TYPE, "application/x-www-form-urlencoded; charset=utf-8");
}
if (!headers.contains(HttpHeaderNames.CONTENT_LENGTH)) {
headers.set(HttpHeaderNames.CONTENT_LENGTH, postBuffer.readableBytes());
}
}
}
public IAPIEnvironment environment() {
return environment;
}
public static URI checkUri(String address) throws HTTPRequestException {
URI url;
try {
url = new URI(address);
} catch (URISyntaxException e) {
throw new HTTPRequestException("URL malformed");
}
checkUri(url);
return url;
}
public static void checkUri(URI url) throws HTTPRequestException {
// Validate the URL
if (url.getScheme() == null) throw new HTTPRequestException("Must specify http or https");
if (url.getHost() == null) throw new HTTPRequestException("URL malformed");
var scheme = url.getScheme().toLowerCase(Locale.ROOT);
if (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https")) {
throw new HTTPRequestException("Invalid protocol '" + scheme + "'");
}
}
public void request(URI uri, HttpMethod method) {
if (isClosed()) return;
executorFuture = NetworkUtils.EXECUTOR.submit(() -> doRequest(uri, method));
checkClosed();
}
private void doRequest(URI uri, HttpMethod method) {
// If we're cancelled, abort.
if (isClosed()) return;
try {
var ssl = uri.getScheme().equalsIgnoreCase("https");
var socketAddress = NetworkUtils.getAddress(uri, ssl);
var options = NetworkUtils.getOptions(uri.getHost(), socketAddress);
var sslContext = ssl ? NetworkUtils.getSslContext() : null;
// getAddress may have a slight delay, so let's perform another cancellation check.
if (isClosed()) return;
var requestBody = getHeaderSize(headers) + postBuffer.capacity();
if (options.maxUpload != 0 && requestBody > options.maxUpload) {
failure("Request body is too large");
return;
}
// Add request size to the tracker before opening the connection
environment.observe(Metrics.HTTP_REQUESTS);
environment.observe(Metrics.HTTP_UPLOAD, requestBody);
var handler = currentRequest = new HttpRequestHandler(this, uri, method, options);
connectFuture = new Bootstrap()
.group(NetworkUtils.LOOP_GROUP)
.channelFactory(NioSocketChannel::new)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
if (options.timeout > 0) {
ch.config().setConnectTimeoutMillis(options.timeout);
}
var p = ch.pipeline();
p.addLast(NetworkUtils.SHAPING_HANDLER);
if (sslContext != null) {
p.addLast(sslContext.newHandler(ch.alloc(), uri.getHost(), socketAddress.getPort()));
}
if (options.timeout > 0) {
p.addLast(new ReadTimeoutHandler(options.timeout, TimeUnit.MILLISECONDS));
}
p.addLast(
new HttpClientCodec(),
new HttpContentDecompressor(),
handler
);
}
})
.remoteAddress(socketAddress)
.connect()
.addListener(c -> {
if (!c.isSuccess()) failure(NetworkUtils.toFriendlyError(c.cause()));
});
// Do an additional check for cancellation
checkClosed();
} catch (HTTPRequestException e) {
failure(e.getMessage());
} catch (Exception e) {
failure(NetworkUtils.toFriendlyError(e));
LOG.error(Logging.HTTP_ERROR, "Error in HTTP request", e);
}
}
void failure(String message) {
if (tryClose()) environment.queueEvent(FAILURE_EVENT, address, message);
}
void failure(String message, HttpResponseHandle object) {
if (tryClose()) environment.queueEvent(FAILURE_EVENT, address, message, object);
}
void success(HttpResponseHandle object) {
if (tryClose()) environment.queueEvent(SUCCESS_EVENT, address, object);
}
@Override
protected void dispose() {
super.dispose();
executorFuture = closeFuture(executorFuture);
connectFuture = closeChannel(connectFuture);
currentRequest = closeCloseable(currentRequest);
}
public static long getHeaderSize(HttpHeaders headers) {
long size = 0;
for (var header : headers) {
size += header.getKey() == null ? 0 : header.getKey().length();
size += header.getValue() == null ? 0 : header.getValue().length() + 1;
}
return size;
}
public ByteBuf body() {
return postBuffer;
}
public HttpHeaders headers() {
return headers;
}
public boolean isBinary() {
return binary;
}
}

View File

@@ -0,0 +1,228 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.request;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
import dan200.computercraft.core.apis.handles.BinaryReadableHandle;
import dan200.computercraft.core.apis.handles.EncodedReadableHandle;
import dan200.computercraft.core.apis.http.HTTPRequestException;
import dan200.computercraft.core.apis.http.NetworkUtils;
import dan200.computercraft.core.apis.http.options.Options;
import dan200.computercraft.core.metrics.Metrics;
import io.netty.buffer.CompositeByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Closeable;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import static dan200.computercraft.core.apis.http.request.HttpRequest.getHeaderSize;
public final class HttpRequestHandler extends SimpleChannelInboundHandler<HttpObject> implements Closeable {
private static final Logger LOG = LoggerFactory.getLogger(HttpRequestHandler.class);
/**
* Same as {@link io.netty.handler.codec.MessageAggregator}.
*/
private static final int DEFAULT_MAX_COMPOSITE_BUFFER_COMPONENTS = 1024;
private static final byte[] EMPTY_BYTES = new byte[0];
private final HttpRequest request;
private boolean closed = false;
private final URI uri;
private final HttpMethod method;
private final Options options;
private Charset responseCharset;
private final HttpHeaders responseHeaders = new DefaultHttpHeaders();
private HttpResponseStatus responseStatus;
private CompositeByteBuf responseBody;
HttpRequestHandler(HttpRequest request, URI uri, HttpMethod method, Options options) {
this.request = request;
this.uri = uri;
this.method = method;
this.options = options;
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
if (request.checkClosed()) return;
var body = request.body();
body.resetReaderIndex().retain();
var requestUri = uri.getRawPath();
if (uri.getRawQuery() != null) requestUri += "?" + uri.getRawQuery();
FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, requestUri, body);
request.setMethod(method);
request.headers().set(this.request.headers());
// We force some headers to be always applied
if (!request.headers().contains(HttpHeaderNames.ACCEPT_CHARSET)) {
request.headers().set(HttpHeaderNames.ACCEPT_CHARSET, "UTF-8");
}
request.headers().set(HttpHeaderNames.HOST, uri.getPort() < 0 ? uri.getHost() : uri.getHost() + ":" + uri.getPort());
request.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
ctx.channel().writeAndFlush(request);
super.channelActive(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
if (!closed) request.failure("Could not connect");
super.channelInactive(ctx);
}
@Override
public void channelRead0(ChannelHandlerContext ctx, HttpObject message) {
if (closed || request.checkClosed()) return;
if (message instanceof HttpResponse response) {
if (request.redirects.get() > 0) {
var redirect = getRedirect(response.status(), response.headers());
if (redirect != null && !uri.equals(redirect) && request.redirects.getAndDecrement() > 0) {
// If we have a redirect, and don't end up at the same place, then follow it.
// We mark ourselves as disposed first though, to avoid firing events when the channel
// becomes inactive or disposed.
closed = true;
ctx.close();
try {
HttpRequest.checkUri(redirect);
} catch (HTTPRequestException e) {
// If we cannot visit this uri, then fail.
request.failure(e.getMessage());
return;
}
request.request(redirect, response.status().code() == 303 ? HttpMethod.GET : method);
return;
}
}
responseCharset = HttpUtil.getCharset(response, StandardCharsets.UTF_8);
responseStatus = response.status();
responseHeaders.add(response.headers());
}
if (message instanceof HttpContent content) {
if (responseBody == null) {
responseBody = ctx.alloc().compositeBuffer(DEFAULT_MAX_COMPOSITE_BUFFER_COMPONENTS);
}
var partial = content.content();
if (partial.isReadable()) {
// If we've read more than we're allowed to handle, abort as soon as possible.
if (options.maxDownload != 0 && responseBody.readableBytes() + partial.readableBytes() > options.maxDownload) {
closed = true;
ctx.close();
request.failure("Response is too large");
return;
}
responseBody.addComponent(true, partial.retain());
}
if (message instanceof LastHttpContent last) {
responseHeaders.add(last.trailingHeaders());
// Set the content length, if not already given.
if (responseHeaders.contains(HttpHeaderNames.CONTENT_LENGTH)) {
responseHeaders.set(HttpHeaderNames.CONTENT_LENGTH, responseBody.readableBytes());
}
ctx.close();
sendResponse();
}
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
LOG.error(Logging.HTTP_ERROR, "Error handling HTTP response", cause);
request.failure(NetworkUtils.toFriendlyError(cause));
}
private void sendResponse() {
// Read the ByteBuf into a channel.
var body = responseBody;
var bytes = body == null ? EMPTY_BYTES : NetworkUtils.toBytes(body);
// Decode the headers
var status = responseStatus;
Map<String, String> headers = new HashMap<>();
for (var header : responseHeaders) {
var existing = headers.get(header.getKey());
headers.put(header.getKey(), existing == null ? header.getValue() : existing + "," + header.getValue());
}
// Fire off a stats event
request.environment().observe(Metrics.HTTP_DOWNLOAD, getHeaderSize(responseHeaders) + bytes.length);
// Prepare to queue an event
var contents = new ArrayByteChannel(bytes);
var reader = request.isBinary()
? BinaryReadableHandle.of(contents)
: new EncodedReadableHandle(EncodedReadableHandle.open(contents, responseCharset));
var stream = new HttpResponseHandle(reader, status.code(), status.reasonPhrase(), headers);
if (status.code() >= 200 && status.code() < 400) {
request.success(stream);
} else {
request.failure(status.reasonPhrase(), stream);
}
}
/**
* Determine the redirect from this response.
*
* @param status The status of the HTTP response.
* @param headers The headers of the HTTP response.
* @return The URI to redirect to, or {@code null} if no redirect should occur.
*/
private URI getRedirect(HttpResponseStatus status, HttpHeaders headers) {
var code = status.code();
if (code < 300 || code > 307 || code == 304 || code == 306) return null;
var location = headers.get(HttpHeaderNames.LOCATION);
if (location == null) return null;
try {
return uri.resolve(new URI(location));
} catch (IllegalArgumentException | URISyntaxException e) {
return null;
}
}
@Override
public void close() {
closed = true;
if (responseBody != null) {
responseBody.release();
responseBody = null;
}
}
}

View File

@@ -0,0 +1,81 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.request;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.core.apis.HTTPAPI;
import dan200.computercraft.core.apis.handles.BinaryReadableHandle;
import dan200.computercraft.core.apis.handles.EncodedReadableHandle;
import dan200.computercraft.core.apis.handles.HandleGeneric;
import dan200.computercraft.core.asm.ObjectSource;
import javax.annotation.Nonnull;
import java.util.Collections;
import java.util.Map;
/**
* A http response. This provides the same methods as a {@link EncodedReadableHandle file} (or
* {@link BinaryReadableHandle binary file} if the request used binary mode), though provides several request specific
* methods.
*
* @cc.module http.Response
* @see HTTPAPI#request(IArguments) On how to make a http request.
*/
public class HttpResponseHandle implements ObjectSource {
private final Object reader;
private final int responseCode;
private final String responseStatus;
private final Map<String, String> responseHeaders;
public HttpResponseHandle(@Nonnull HandleGeneric reader, int responseCode, String responseStatus, @Nonnull Map<String, String> responseHeaders) {
this.reader = reader;
this.responseCode = responseCode;
this.responseStatus = responseStatus;
this.responseHeaders = responseHeaders;
}
/**
* Returns the response code and response message returned by the server.
*
* @return The response code and message.
* @cc.treturn number The response code (i.e. 200)
* @cc.treturn string The response message (i.e. "OK")
* @cc.changed 1.80pr1.13 Added response message return value.
*/
@LuaFunction
public final Object[] getResponseCode() {
return new Object[]{ responseCode, responseStatus };
}
/**
* Get a table containing the response's headers, in a format similar to that required by {@link HTTPAPI#request}.
* If multiple headers are sent with the same name, they will be combined with a comma.
*
* @return The response's headers.
* @cc.usage Make a request to [example.tweaked.cc](https://example.tweaked.cc), and print the
* returned headers.
* <pre>{@code
* local request = http.get("https://example.tweaked.cc")
* print(textutils.serialize(request.getResponseHeaders()))
* -- => {
* -- [ "Content-Type" ] = "text/plain; charset=utf8",
* -- [ "content-length" ] = 17,
* -- ...
* -- }
* request.close()
* }</pre>
*/
@LuaFunction
public final Map<String, String> getResponseHeaders() {
return responseHeaders;
}
@Override
public Iterable<Object> getExtra() {
return Collections.singletonList(reader);
}
}

View File

@@ -0,0 +1,32 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.websocket;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker13;
import io.netty.handler.codec.http.websocketx.WebSocketVersion;
import java.net.URI;
/**
* A version of {@link WebSocketClientHandshaker13} which doesn't add the {@link HttpHeaderNames#ORIGIN} header to the
* original HTTP request.
*/
public class NoOriginWebSocketHanshakder extends WebSocketClientHandshaker13 {
public NoOriginWebSocketHanshakder(URI webSocketURL, WebSocketVersion version, String subprotocol, boolean allowExtensions, HttpHeaders customHeaders, int maxFramePayloadLength) {
super(webSocketURL, version, subprotocol, allowExtensions, customHeaders, maxFramePayloadLength);
}
@Override
protected FullHttpRequest newHandshakeRequest() {
var request = super.newHandshakeRequest();
var headers = request.headers();
if (!customHeaders.contains(HttpHeaderNames.ORIGIN)) headers.remove(HttpHeaderNames.ORIGIN);
return request;
}
}

View File

@@ -0,0 +1,204 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.websocket;
import com.google.common.base.Strings;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.apis.http.HTTPRequestException;
import dan200.computercraft.core.apis.http.NetworkUtils;
import dan200.computercraft.core.apis.http.Resource;
import dan200.computercraft.core.apis.http.ResourceGroup;
import dan200.computercraft.core.apis.http.options.Options;
import dan200.computercraft.core.util.IoUtil;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker;
import io.netty.handler.codec.http.websocketx.WebSocketVersion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.ref.WeakReference;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.concurrent.Future;
/**
* Provides functionality to verify and connect to a remote websocket.
*/
public class Websocket extends Resource<Websocket> {
private static final Logger LOG = LoggerFactory.getLogger(Websocket.class);
/**
* We declare the maximum size to be 2^30 bytes. While messages can be much longer, we set an arbitrary limit as
* working with larger messages (especially within a Lua VM) is absurd.
*/
public static final int MAX_MESSAGE_SIZE = 1 << 30;
static final String SUCCESS_EVENT = "websocket_success";
static final String FAILURE_EVENT = "websocket_failure";
static final String CLOSE_EVENT = "websocket_closed";
static final String MESSAGE_EVENT = "websocket_message";
private Future<?> executorFuture;
private ChannelFuture connectFuture;
private WeakReference<WebsocketHandle> websocketHandle;
private final IAPIEnvironment environment;
private final URI uri;
private final String address;
private final HttpHeaders headers;
public Websocket(ResourceGroup<Websocket> limiter, IAPIEnvironment environment, URI uri, String address, HttpHeaders headers) {
super(limiter);
this.environment = environment;
this.uri = uri;
this.address = address;
this.headers = headers;
}
public static URI checkUri(String address) throws HTTPRequestException {
URI uri = null;
try {
uri = new URI(address);
} catch (URISyntaxException ignored) {
}
if (uri == null || uri.getHost() == null) {
try {
uri = new URI("ws://" + address);
} catch (URISyntaxException ignored) {
}
}
if (uri == null || uri.getHost() == null) throw new HTTPRequestException("URL malformed");
var scheme = uri.getScheme();
if (scheme == null) {
try {
uri = new URI("ws://" + uri);
} catch (URISyntaxException e) {
throw new HTTPRequestException("URL malformed");
}
} else if (!scheme.equalsIgnoreCase("wss") && !scheme.equalsIgnoreCase("ws")) {
throw new HTTPRequestException("Invalid scheme '" + scheme + "'");
}
return uri;
}
public void connect() {
if (isClosed()) return;
executorFuture = NetworkUtils.EXECUTOR.submit(this::doConnect);
checkClosed();
}
private void doConnect() {
// If we're cancelled, abort.
if (isClosed()) return;
try {
var ssl = uri.getScheme().equalsIgnoreCase("wss");
var socketAddress = NetworkUtils.getAddress(uri, ssl);
var options = NetworkUtils.getOptions(uri.getHost(), socketAddress);
var sslContext = ssl ? NetworkUtils.getSslContext() : null;
// getAddress may have a slight delay, so let's perform another cancellation check.
if (isClosed()) return;
connectFuture = new Bootstrap()
.group(NetworkUtils.LOOP_GROUP)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
var p = ch.pipeline();
p.addLast(NetworkUtils.SHAPING_HANDLER);
if (sslContext != null) {
p.addLast(sslContext.newHandler(ch.alloc(), uri.getHost(), socketAddress.getPort()));
}
var subprotocol = headers.get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL);
WebSocketClientHandshaker handshaker = new NoOriginWebSocketHanshakder(
uri, WebSocketVersion.V13, subprotocol, true, headers,
options.websocketMessage <= 0 ? MAX_MESSAGE_SIZE : options.websocketMessage
);
p.addLast(
new HttpClientCodec(),
new HttpObjectAggregator(8192),
WebsocketCompressionHandler.INSTANCE,
new WebsocketHandler(Websocket.this, handshaker, options)
);
}
})
.remoteAddress(socketAddress)
.connect()
.addListener(c -> {
if (!c.isSuccess()) failure(NetworkUtils.toFriendlyError(c.cause()));
});
// Do an additional check for cancellation
checkClosed();
} catch (HTTPRequestException e) {
failure(e.getMessage());
} catch (Exception e) {
failure(NetworkUtils.toFriendlyError(e));
LOG.error(Logging.HTTP_ERROR, "Error in websocket", e);
}
}
void success(Channel channel, Options options) {
if (isClosed()) return;
var handle = new WebsocketHandle(this, options, channel);
environment().queueEvent(SUCCESS_EVENT, address, handle);
websocketHandle = createOwnerReference(handle);
checkClosed();
}
void failure(String message) {
if (tryClose()) environment.queueEvent(FAILURE_EVENT, address, message);
}
void close(int status, String reason) {
if (tryClose()) {
environment.queueEvent(CLOSE_EVENT, address,
Strings.isNullOrEmpty(reason) ? null : reason,
status < 0 ? null : status);
}
}
@Override
protected void dispose() {
super.dispose();
executorFuture = closeFuture(executorFuture);
connectFuture = closeChannel(connectFuture);
var websocketHandleRef = websocketHandle;
var websocketHandle = websocketHandleRef == null ? null : websocketHandleRef.get();
IoUtil.closeQuietly(websocketHandle);
this.websocketHandle = null;
}
public IAPIEnvironment environment() {
return environment;
}
public String address() {
return address;
}
}

View File

@@ -0,0 +1,36 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.websocket;
import io.netty.channel.ChannelHandler;
import io.netty.handler.codec.compression.ZlibCodecFactory;
import io.netty.handler.codec.http.websocketx.extensions.WebSocketClientExtensionHandler;
import io.netty.handler.codec.http.websocketx.extensions.compression.DeflateFrameClientExtensionHandshaker;
import io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateClientExtensionHandshaker;
import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketClientCompressionHandler;
import static io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE;
/**
* An alternative to {@link WebSocketClientCompressionHandler} which supports the {@code client_no_context_takeover}
* extension. Makes CC <em>slightly</em> more flexible.
*/
@ChannelHandler.Sharable
final class WebsocketCompressionHandler extends WebSocketClientExtensionHandler {
public static final WebsocketCompressionHandler INSTANCE = new WebsocketCompressionHandler();
private WebsocketCompressionHandler() {
super(
new PerMessageDeflateClientExtensionHandshaker(
6, ZlibCodecFactory.isSupportingWindowSizeAndMemLevel(), MAX_WINDOW_SIZE,
true, false
),
new DeflateFrameClientExtensionHandshaker(false),
new DeflateFrameClientExtensionHandshaker(true)
);
}
}

View File

@@ -0,0 +1,147 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.websocket;
import com.google.common.base.Objects;
import dan200.computercraft.api.lua.*;
import dan200.computercraft.core.apis.http.options.Options;
import dan200.computercraft.core.metrics.Metrics;
import dan200.computercraft.core.util.StringUtil;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import javax.annotation.Nonnull;
import java.io.Closeable;
import java.util.Arrays;
import java.util.Optional;
import static dan200.computercraft.api.lua.LuaValues.checkFinite;
import static dan200.computercraft.core.apis.IAPIEnvironment.TIMER_EVENT;
import static dan200.computercraft.core.apis.http.websocket.Websocket.CLOSE_EVENT;
import static dan200.computercraft.core.apis.http.websocket.Websocket.MESSAGE_EVENT;
/**
* A websocket, which can be used to send an receive messages with a web server.
*
* @cc.module http.Websocket
* @see dan200.computercraft.core.apis.HTTPAPI#websocket On how to open a websocket.
*/
public class WebsocketHandle implements Closeable {
private final Websocket websocket;
private final Options options;
private boolean closed = false;
private Channel channel;
public WebsocketHandle(Websocket websocket, Options options, Channel channel) {
this.websocket = websocket;
this.options = options;
this.channel = channel;
}
/**
* Wait for a message from the server.
*
* @param timeout The number of seconds to wait if no message is received.
* @return The result of receiving.
* @throws LuaException If the websocket has been closed.
* @cc.treturn [1] string The received message.
* @cc.treturn boolean If this was a binary message.
* @cc.treturn [2] nil If the websocket was closed while waiting, or if we timed out.
* @cc.changed 1.80pr1.13 Added return value indicating whether the message was binary.
* @cc.changed 1.87.0 Added timeout argument.
*/
@LuaFunction
public final MethodResult receive(Optional<Double> timeout) throws LuaException {
checkOpen();
var timeoutId = timeout.isPresent()
? websocket.environment().startTimer(Math.round(checkFinite(0, timeout.get()) / 0.05))
: -1;
return new ReceiveCallback(timeoutId).pull;
}
/**
* Send a websocket message to the connected server.
*
* @param message The message to send.
* @param binary Whether this message should be treated as a
* @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.
*/
@LuaFunction
public final void send(Object message, Optional<Boolean> binary) throws LuaException {
checkOpen();
var text = StringUtil.toString(message);
if (options.websocketMessage != 0 && text.length() > options.websocketMessage) {
throw new LuaException("Message is too large");
}
websocket.environment().observe(Metrics.WEBSOCKET_OUTGOING, text.length());
var channel = this.channel;
if (channel != null) {
channel.writeAndFlush(binary.orElse(false)
? new BinaryWebSocketFrame(Unpooled.wrappedBuffer(LuaValues.encode(text)))
: new TextWebSocketFrame(text));
}
}
/**
* Close this websocket. This will terminate the connection, meaning messages can no longer be sent or received
* along it.
*/
@LuaFunction("close")
public final void doClose() {
close();
websocket.close();
}
private void checkOpen() throws LuaException {
if (closed) throw new LuaException("attempt to use a closed file");
}
@Override
public void close() {
closed = true;
var channel = this.channel;
if (channel != null) {
channel.close();
this.channel = null;
}
}
private final class ReceiveCallback implements ILuaCallback {
final MethodResult pull = MethodResult.pullEvent(null, this);
private final int timeoutId;
ReceiveCallback(int timeoutId) {
this.timeoutId = timeoutId;
}
@Nonnull
@Override
public MethodResult resume(Object[] event) {
if (event.length >= 3 && Objects.equal(event[0], MESSAGE_EVENT) && Objects.equal(event[1], websocket.address())) {
return MethodResult.of(Arrays.copyOfRange(event, 2, event.length));
} else if (event.length >= 2 && Objects.equal(event[0], CLOSE_EVENT) && Objects.equal(event[1], websocket.address()) && closed) {
// If the socket is closed abort.
return MethodResult.of();
} else if (event.length >= 2 && timeoutId != -1 && Objects.equal(event[0], TIMER_EVENT)
&& event[1] instanceof Number id && id.intValue() == timeoutId) {
// If we received a matching timer event then abort.
return MethodResult.of();
}
return pull;
}
}
}

View File

@@ -0,0 +1,86 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.apis.http.websocket;
import dan200.computercraft.core.apis.http.NetworkUtils;
import dan200.computercraft.core.apis.http.options.Options;
import dan200.computercraft.core.metrics.Metrics;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.websocketx.*;
import io.netty.util.CharsetUtil;
import static dan200.computercraft.core.apis.http.websocket.Websocket.MESSAGE_EVENT;
public class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
private final Websocket websocket;
private final WebSocketClientHandshaker handshaker;
private final Options options;
public WebsocketHandler(Websocket websocket, WebSocketClientHandshaker handshaker, Options options) {
this.handshaker = handshaker;
this.websocket = websocket;
this.options = options;
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
handshaker.handshake(ctx.channel());
super.channelActive(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
websocket.close(-1, "Websocket is inactive");
super.channelInactive(ctx);
}
@Override
public void channelRead0(ChannelHandlerContext ctx, Object msg) {
if (websocket.isClosed()) return;
if (!handshaker.isHandshakeComplete()) {
handshaker.finishHandshake(ctx.channel(), (FullHttpResponse) msg);
websocket.success(ctx.channel(), options);
return;
}
if (msg instanceof FullHttpResponse response) {
throw new IllegalStateException("Unexpected FullHttpResponse (getStatus=" + response.status() + ", content=" + response.content().toString(CharsetUtil.UTF_8) + ')');
}
var frame = (WebSocketFrame) msg;
if (frame instanceof TextWebSocketFrame textFrame) {
var data = textFrame.text();
websocket.environment().observe(Metrics.WEBSOCKET_INCOMING, data.length());
websocket.environment().queueEvent(MESSAGE_EVENT, websocket.address(), data, false);
} else if (frame instanceof BinaryWebSocketFrame) {
var converted = NetworkUtils.toBytes(frame.content());
websocket.environment().observe(Metrics.WEBSOCKET_INCOMING, converted.length);
websocket.environment().queueEvent(MESSAGE_EVENT, websocket.address(), converted, true);
} else if (frame instanceof CloseWebSocketFrame closeFrame) {
websocket.close(closeFrame.statusCode(), closeFrame.reasonText());
} else if (frame instanceof PingWebSocketFrame) {
frame.content().retain();
ctx.channel().writeAndFlush(new PongWebSocketFrame(frame.content()));
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
ctx.close();
var message = NetworkUtils.toFriendlyError(cause);
if (handshaker.isHandshakeComplete()) {
websocket.close(-1, message);
} else {
websocket.failure(message);
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.asm;
import java.security.ProtectionDomain;
final class DeclaringClassLoader extends ClassLoader {
static final DeclaringClassLoader INSTANCE = new DeclaringClassLoader();
private DeclaringClassLoader() {
super(DeclaringClassLoader.class.getClassLoader());
}
Class<?> define(String name, byte[] bytes, ProtectionDomain protectionDomain) throws ClassFormatError {
return defineClass(name, bytes, 0, bytes.length, protectionDomain);
}
}

View File

@@ -0,0 +1,341 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.asm;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.primitives.Primitives;
import com.google.common.reflect.TypeToken;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.api.lua.MethodResult;
import dan200.computercraft.api.peripheral.PeripheralType;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Type;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import static org.objectweb.asm.Opcodes.*;
public final class Generator<T> {
private static final Logger LOG = LoggerFactory.getLogger(Generator.class);
private static final AtomicInteger METHOD_ID = new AtomicInteger();
private static final String METHOD_NAME = "apply";
private static final String[] EXCEPTIONS = new String[]{ Type.getInternalName(LuaException.class) };
private static final String INTERNAL_METHOD_RESULT = Type.getInternalName(MethodResult.class);
private static final String DESC_METHOD_RESULT = Type.getDescriptor(MethodResult.class);
private static final String INTERNAL_ARGUMENTS = Type.getInternalName(IArguments.class);
private static final String DESC_ARGUMENTS = Type.getDescriptor(IArguments.class);
private final Class<T> base;
private final List<Class<?>> context;
private final String[] interfaces;
private final String methodDesc;
private final Function<T, T> wrap;
private final LoadingCache<Class<?>, List<NamedMethod<T>>> classCache = CacheBuilder
.newBuilder()
.build(CacheLoader.from(catching(this::build, Collections.emptyList())));
private final LoadingCache<Method, Optional<T>> methodCache = CacheBuilder
.newBuilder()
.build(CacheLoader.from(catching(this::build, Optional.empty())));
Generator(Class<T> base, List<Class<?>> context, Function<T, T> wrap) {
this.base = base;
this.context = context;
interfaces = new String[]{ Type.getInternalName(base) };
this.wrap = wrap;
var methodDesc = new StringBuilder().append("(Ljava/lang/Object;");
for (var klass : context) methodDesc.append(Type.getDescriptor(klass));
methodDesc.append(DESC_ARGUMENTS).append(")").append(DESC_METHOD_RESULT);
this.methodDesc = methodDesc.toString();
}
@Nonnull
public List<NamedMethod<T>> getMethods(@Nonnull Class<?> klass) {
try {
return classCache.get(klass);
} catch (ExecutionException e) {
LOG.error("Error getting methods for {}.", klass.getName(), e.getCause());
return Collections.emptyList();
}
}
@Nonnull
private List<NamedMethod<T>> build(Class<?> klass) {
ArrayList<NamedMethod<T>> methods = null;
for (var method : klass.getMethods()) {
var annotation = method.getAnnotation(LuaFunction.class);
if (annotation == null) continue;
if (Modifier.isStatic(method.getModifiers())) {
LOG.warn("LuaFunction method {}.{} should be an instance method.", method.getDeclaringClass(), method.getName());
continue;
}
var instance = methodCache.getUnchecked(method).orElse(null);
if (instance == null) continue;
if (methods == null) methods = new ArrayList<>();
addMethod(methods, method, annotation, null, instance);
}
for (var method : GenericMethod.all()) {
if (!method.target.isAssignableFrom(klass)) continue;
var instance = methodCache.getUnchecked(method.method).orElse(null);
if (instance == null) continue;
if (methods == null) methods = new ArrayList<>();
addMethod(methods, method.method, method.annotation, method.peripheralType, instance);
}
if (methods == null) return Collections.emptyList();
methods.trimToSize();
return Collections.unmodifiableList(methods);
}
private void addMethod(List<NamedMethod<T>> methods, Method method, LuaFunction annotation, PeripheralType genericType, T instance) {
var names = annotation.value();
var isSimple = method.getReturnType() != MethodResult.class && !annotation.mainThread();
if (names.length == 0) {
methods.add(new NamedMethod<>(method.getName(), instance, isSimple, genericType));
} else {
for (var name : names) {
methods.add(new NamedMethod<>(name, instance, isSimple, genericType));
}
}
}
@Nonnull
private Optional<T> build(Method method) {
var name = method.getDeclaringClass().getName() + "." + method.getName();
var modifiers = method.getModifiers();
// Instance methods must be final - this prevents them being overridden and potentially exposed twice.
if (!Modifier.isStatic(modifiers) && !Modifier.isFinal(modifiers)) {
LOG.warn("Lua Method {} should be final.", name);
}
if (!Modifier.isPublic(modifiers)) {
LOG.error("Lua Method {} should be a public method.", name);
return Optional.empty();
}
if (!Modifier.isPublic(method.getDeclaringClass().getModifiers())) {
LOG.error("Lua Method {} should be on a public class.", name);
return Optional.empty();
}
LOG.debug("Generating method wrapper for {}.", name);
var exceptions = method.getExceptionTypes();
for (var exception : exceptions) {
if (exception != LuaException.class) {
LOG.error("Lua Method {} cannot throw {}.", name, exception.getName());
return Optional.empty();
}
}
var annotation = method.getAnnotation(LuaFunction.class);
if (annotation.unsafe() && annotation.mainThread()) {
LOG.error("Lua Method {} cannot use unsafe and mainThread", name);
return Optional.empty();
}
// We have some rather ugly handling of static methods in both here and the main generate function. Static methods
// only come from generic sources, so this should be safe.
var target = Modifier.isStatic(modifiers) ? method.getParameterTypes()[0] : method.getDeclaringClass();
try {
var className = method.getDeclaringClass().getName() + "$cc$" + method.getName() + METHOD_ID.getAndIncrement();
var bytes = generate(className, target, method, annotation.unsafe());
if (bytes == null) return Optional.empty();
var klass = DeclaringClassLoader.INSTANCE.define(className, bytes, method.getDeclaringClass().getProtectionDomain());
var instance = klass.asSubclass(base).getDeclaredConstructor().newInstance();
return Optional.of(annotation.mainThread() ? wrap.apply(instance) : instance);
} catch (ReflectiveOperationException | ClassFormatError | RuntimeException e) {
LOG.error("Error generating wrapper for {}.", name, e);
return Optional.empty();
}
}
@Nullable
private byte[] generate(String className, Class<?> target, Method method, boolean unsafe) {
var internalName = className.replace(".", "/");
// Construct a public final class which extends Object and implements MethodInstance.Delegate
var cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
cw.visit(V1_8, ACC_PUBLIC | ACC_FINAL, internalName, null, "java/lang/Object", interfaces);
cw.visitSource("CC generated method", null);
{ // Constructor just invokes super.
var mw = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
mw.visitCode();
mw.visitVarInsn(ALOAD, 0);
mw.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
mw.visitInsn(RETURN);
mw.visitMaxs(0, 0);
mw.visitEnd();
}
{
var mw = cw.visitMethod(ACC_PUBLIC, METHOD_NAME, methodDesc, null, EXCEPTIONS);
mw.visitCode();
// If we're an instance method, load the this parameter.
if (!Modifier.isStatic(method.getModifiers())) {
mw.visitVarInsn(ALOAD, 1);
mw.visitTypeInsn(CHECKCAST, Type.getInternalName(target));
}
var argIndex = 0;
for (var genericArg : method.getGenericParameterTypes()) {
var loadedArg = loadArg(mw, target, method, unsafe, genericArg, argIndex);
if (loadedArg == null) return null;
if (loadedArg) argIndex++;
}
mw.visitMethodInsn(
Modifier.isStatic(method.getModifiers()) ? INVOKESTATIC : INVOKEVIRTUAL,
Type.getInternalName(method.getDeclaringClass()), method.getName(),
Type.getMethodDescriptor(method), false
);
// We allow a reasonable amount of flexibility on the return value's type. Alongside the obvious MethodResult,
// we convert basic types into an immediate result.
var ret = method.getReturnType();
if (ret != MethodResult.class) {
if (ret == void.class) {
mw.visitMethodInsn(INVOKESTATIC, INTERNAL_METHOD_RESULT, "of", "()" + DESC_METHOD_RESULT, false);
} else if (ret.isPrimitive()) {
var boxed = Primitives.wrap(ret);
mw.visitMethodInsn(INVOKESTATIC, Type.getInternalName(boxed), "valueOf", "(" + Type.getDescriptor(ret) + ")" + Type.getDescriptor(boxed), false);
mw.visitMethodInsn(INVOKESTATIC, INTERNAL_METHOD_RESULT, "of", "(Ljava/lang/Object;)" + DESC_METHOD_RESULT, false);
} else if (ret == Object[].class) {
mw.visitMethodInsn(INVOKESTATIC, INTERNAL_METHOD_RESULT, "of", "([Ljava/lang/Object;)" + DESC_METHOD_RESULT, false);
} else {
mw.visitMethodInsn(INVOKESTATIC, INTERNAL_METHOD_RESULT, "of", "(Ljava/lang/Object;)" + DESC_METHOD_RESULT, false);
}
}
mw.visitInsn(ARETURN);
mw.visitMaxs(0, 0);
mw.visitEnd();
}
cw.visitEnd();
return cw.toByteArray();
}
private Boolean loadArg(MethodVisitor mw, Class<?> target, Method method, boolean unsafe, java.lang.reflect.Type genericArg, int argIndex) {
if (genericArg == target) {
mw.visitVarInsn(ALOAD, 1);
mw.visitTypeInsn(CHECKCAST, Type.getInternalName(target));
return false;
}
var arg = Reflect.getRawType(method, genericArg, true);
if (arg == null) return null;
if (arg == IArguments.class) {
mw.visitVarInsn(ALOAD, 2 + context.size());
return false;
}
var idx = context.indexOf(arg);
if (idx >= 0) {
mw.visitVarInsn(ALOAD, 2 + idx);
return false;
}
if (arg == Optional.class) {
var klass = Reflect.getRawType(method, TypeToken.of(genericArg).resolveType(Reflect.OPTIONAL_IN).getType(), false);
if (klass == null) return null;
if (Enum.class.isAssignableFrom(klass) && klass != Enum.class) {
mw.visitVarInsn(ALOAD, 2 + context.size());
Reflect.loadInt(mw, argIndex);
mw.visitLdcInsn(Type.getType(klass));
mw.visitMethodInsn(INVOKEINTERFACE, INTERNAL_ARGUMENTS, "optEnum", "(ILjava/lang/Class;)Ljava/util/Optional;", true);
return true;
}
var name = Reflect.getLuaName(Primitives.unwrap(klass), unsafe);
if (name != null) {
mw.visitVarInsn(ALOAD, 2 + context.size());
Reflect.loadInt(mw, argIndex);
mw.visitMethodInsn(INVOKEINTERFACE, INTERNAL_ARGUMENTS, "opt" + name, "(I)Ljava/util/Optional;", true);
return true;
}
}
if (Enum.class.isAssignableFrom(arg) && arg != Enum.class) {
mw.visitVarInsn(ALOAD, 2 + context.size());
Reflect.loadInt(mw, argIndex);
mw.visitLdcInsn(Type.getType(arg));
mw.visitMethodInsn(INVOKEINTERFACE, INTERNAL_ARGUMENTS, "getEnum", "(ILjava/lang/Class;)Ljava/lang/Enum;", true);
mw.visitTypeInsn(CHECKCAST, Type.getInternalName(arg));
return true;
}
var name = arg == Object.class ? "" : Reflect.getLuaName(arg, unsafe);
if (name != null) {
if (Reflect.getRawType(method, genericArg, false) == null) return null;
mw.visitVarInsn(ALOAD, 2 + context.size());
Reflect.loadInt(mw, argIndex);
mw.visitMethodInsn(INVOKEINTERFACE, INTERNAL_ARGUMENTS, "get" + name, "(I)" + Type.getDescriptor(arg), true);
return true;
}
LOG.error("Unknown parameter type {} for method {}.{}.",
arg.getName(), method.getDeclaringClass().getName(), method.getName());
return null;
}
@SuppressWarnings("Guava")
private static <T, U> com.google.common.base.Function<T, U> catching(Function<T, U> function, U def) {
return x -> {
try {
return function.apply(x);
} catch (Exception | LinkageError e) {
// LinkageError due to possible codegen bugs and NoClassDefFoundError. The latter occurs when fetching
// methods on a class which references non-existent (i.e. client-only) types.
LOG.error("Error generating @LuaFunctions", e);
return def;
}
};
}
}

View File

@@ -0,0 +1,92 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.asm;
import dan200.computercraft.api.lua.GenericSource;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.api.peripheral.GenericPeripheral;
import dan200.computercraft.api.peripheral.PeripheralType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
/**
* A generic method is a method belonging to a {@link GenericSource} with a known target.
*/
public class GenericMethod {
private static final Logger LOG = LoggerFactory.getLogger(GenericMethod.class);
final Method method;
final LuaFunction annotation;
final Class<?> target;
final PeripheralType peripheralType;
private static final List<GenericSource> sources = new ArrayList<>();
private static List<GenericMethod> cache;
GenericMethod(Method method, LuaFunction annotation, Class<?> target, PeripheralType peripheralType) {
this.method = method;
this.annotation = annotation;
this.target = target;
this.peripheralType = peripheralType;
}
/**
* Find all public static methods annotated with {@link LuaFunction} which belong to a {@link GenericSource}.
*
* @return All available generic methods.
*/
static List<GenericMethod> all() {
if (cache != null) return cache;
return cache = sources.stream().flatMap(GenericMethod::getMethods).toList();
}
public static synchronized void register(@Nonnull GenericSource source) {
Objects.requireNonNull(source, "Source cannot be null");
if (cache != null) {
LOG.warn("Registering a generic source {} after cache has been built. This source will be ignored.", cache);
}
sources.add(source);
}
private static Stream<GenericMethod> getMethods(GenericSource source) {
Class<?> klass = source.getClass();
var type = source instanceof GenericPeripheral generic ? generic.getType() : null;
return Arrays.stream(klass.getDeclaredMethods())
.map(method -> {
var annotation = method.getAnnotation(LuaFunction.class);
if (annotation == null) return null;
if (!Modifier.isStatic(method.getModifiers())) {
LOG.error("GenericSource method {}.{} should be static.", method.getDeclaringClass(), method.getName());
return null;
}
var types = method.getGenericParameterTypes();
if (types.length == 0) {
LOG.error("GenericSource method {}.{} has no parameters.", method.getDeclaringClass(), method.getName());
return null;
}
var target = Reflect.getRawType(method, types[0], false);
if (target == null) return null;
return new GenericMethod(method, annotation, target, type);
})
.filter(Objects::nonNull);
}
}

View File

@@ -0,0 +1,35 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.asm;
import java.util.Arrays;
import java.util.function.IntFunction;
public final class IntCache<T> {
private final IntFunction<T> factory;
private volatile Object[] cache = new Object[16];
IntCache(IntFunction<T> factory) {
this.factory = factory;
}
@SuppressWarnings("unchecked")
public T get(int index) {
if (index < 0) throw new IllegalArgumentException("index < 0");
if (index < cache.length) {
var current = (T) cache[index];
if (current != null) return current;
}
synchronized (this) {
if (index >= cache.length) cache = Arrays.copyOf(cache, Math.max(cache.length * 2, index + 1));
var current = (T) cache[index];
if (current == null) cache[index] = current = factory.apply(index);
return current;
}
}
}

View File

@@ -0,0 +1,26 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.asm;
import dan200.computercraft.api.lua.*;
import javax.annotation.Nonnull;
import java.util.Collections;
public interface LuaMethod {
Generator<LuaMethod> GENERATOR = new Generator<>(LuaMethod.class, Collections.singletonList(ILuaContext.class),
m -> (target, context, args) -> context.executeMainThreadTask(() -> ResultHelpers.checkNormalResult(m.apply(target, context, args)))
);
IntCache<LuaMethod> DYNAMIC = new IntCache<>(
method -> (instance, context, args) -> ((IDynamicLuaObject) instance).callMethod(context, method, args)
);
String[] EMPTY_METHODS = new String[0];
@Nonnull
MethodResult apply(@Nonnull Object target, @Nonnull ILuaContext context, @Nonnull IArguments args) throws LuaException;
}

View File

@@ -0,0 +1,45 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.asm;
import dan200.computercraft.api.peripheral.PeripheralType;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
public final class NamedMethod<T> {
private final String name;
private final T method;
private final boolean nonYielding;
private final PeripheralType genericType;
NamedMethod(String name, T method, boolean nonYielding, PeripheralType genericType) {
this.name = name;
this.method = method;
this.nonYielding = nonYielding;
this.genericType = genericType;
}
@Nonnull
public String getName() {
return name;
}
@Nonnull
public T getMethod() {
return method;
}
public boolean nonYielding() {
return nonYielding;
}
@Nullable
public PeripheralType getGenericType() {
return genericType;
}
}

View File

@@ -0,0 +1,28 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.asm;
import java.util.function.BiConsumer;
/**
* A Lua object which exposes additional methods.
* <p>
* This can be used to merge multiple objects together into one. Ideally this'd be part of the API, but I'm not entirely
* happy with the interface - something I'd like to think about first.
*/
public interface ObjectSource {
Iterable<Object> getExtra();
static <T> void allMethods(Generator<T> generator, Object object, BiConsumer<Object, NamedMethod<T>> accept) {
for (var method : generator.getMethods(object.getClass())) accept.accept(object, method);
if (object instanceof ObjectSource source) {
for (var extra : source.getExtra()) {
for (var method : generator.getMethods(extra.getClass())) accept.accept(extra, method);
}
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.asm;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.ILuaContext;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.MethodResult;
import dan200.computercraft.api.peripheral.IComputerAccess;
import dan200.computercraft.api.peripheral.IDynamicPeripheral;
import javax.annotation.Nonnull;
import java.util.Arrays;
public interface PeripheralMethod {
Generator<PeripheralMethod> GENERATOR = new Generator<>(PeripheralMethod.class, Arrays.asList(ILuaContext.class, IComputerAccess.class),
m -> (target, context, computer, args) -> context.executeMainThreadTask(() -> ResultHelpers.checkNormalResult(m.apply(target, context, computer, args)))
);
IntCache<PeripheralMethod> DYNAMIC = new IntCache<>(
method -> (instance, context, computer, args) -> ((IDynamicPeripheral) instance).callMethod(computer, context, method, args)
);
@Nonnull
MethodResult apply(@Nonnull Object target, @Nonnull ILuaContext context, @Nonnull IComputerAccess computer, @Nonnull IArguments args) throws LuaException;
}

View File

@@ -0,0 +1,81 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.asm;
import dan200.computercraft.api.lua.LuaTable;
import org.objectweb.asm.MethodVisitor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.lang.reflect.*;
import java.nio.ByteBuffer;
import java.util.Map;
import java.util.Optional;
import static org.objectweb.asm.Opcodes.ICONST_0;
final class Reflect {
private static final Logger LOG = LoggerFactory.getLogger(Reflect.class);
static final java.lang.reflect.Type OPTIONAL_IN = Optional.class.getTypeParameters()[0];
private Reflect() {
}
@Nullable
static String getLuaName(Class<?> klass, boolean unsafe) {
if (klass.isPrimitive()) {
if (klass == int.class) return "Int";
if (klass == boolean.class) return "Boolean";
if (klass == double.class) return "Double";
if (klass == long.class) return "Long";
} else {
if (klass == Map.class) return "Table";
if (klass == String.class) return "String";
if (klass == ByteBuffer.class) return "Bytes";
if (klass == LuaTable.class && unsafe) return "TableUnsafe";
}
return null;
}
@Nullable
static Class<?> getRawType(Method method, Type root, boolean allowParameter) {
var underlying = root;
while (true) {
if (underlying instanceof Class<?> klass) return klass;
if (underlying instanceof ParameterizedType type) {
if (!allowParameter) {
for (var arg : type.getActualTypeArguments()) {
if (arg instanceof WildcardType) continue;
if (arg instanceof TypeVariable<?> var && var.getName().startsWith("capture#")) {
continue;
}
LOG.error("Method {}.{} has generic type {} with non-wildcard argument {}.", method.getDeclaringClass(), method.getName(), root, arg);
return null;
}
}
// Continue to extract from this child
underlying = type.getRawType();
continue;
}
LOG.error("Method {}.{} has unknown generic type {}.", method.getDeclaringClass(), method.getName(), root);
return null;
}
}
static void loadInt(MethodVisitor visitor, int value) {
if (value >= -1 && value <= 5) {
visitor.visitInsn(ICONST_0 + value);
} else {
visitor.visitLdcInsn(value);
}
}
}

View File

@@ -0,0 +1,23 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.asm;
import dan200.computercraft.api.lua.MethodResult;
final class ResultHelpers {
private ResultHelpers() {
}
static Object[] checkNormalResult(MethodResult result) {
if (result.getCallback() != null) {
// Due to how tasks are implemented, we can't currently return a MethodResult. This is an
// entirely artificial limitation - we can remove it if it ever becomes an issue.
throw new IllegalStateException("Must return MethodResult.of from mainThread function.");
}
return result.getResult();
}
}

View File

@@ -0,0 +1,46 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import dan200.computercraft.api.lua.ILuaAPI;
/**
* A wrapper for {@link ILuaAPI}s which cleans up after a {@link ComputerSystem} when the computer is shutdown.
*/
final class ApiWrapper implements ILuaAPI {
private final ILuaAPI delegate;
private final ComputerSystem system;
ApiWrapper(ILuaAPI delegate, ComputerSystem system) {
this.delegate = delegate;
this.system = system;
}
@Override
public String[] getNames() {
return delegate.getNames();
}
@Override
public void startup() {
delegate.startup();
}
@Override
public void update() {
delegate.update();
}
@Override
public void shutdown() {
delegate.shutdown();
system.unmountAll();
}
public ILuaAPI getDelegate() {
return delegate;
}
}

View File

@@ -0,0 +1,197 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import com.google.common.base.Objects;
import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.api.lua.ILuaContext;
import dan200.computercraft.api.lua.ILuaTask;
import dan200.computercraft.api.peripheral.IWorkMonitor;
import dan200.computercraft.core.ComputerContext;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.computer.mainthread.MainThreadScheduler;
import dan200.computercraft.core.filesystem.FileSystem;
import dan200.computercraft.core.terminal.Terminal;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* Represents a computer which may exist in-world or elsewhere.
* <p>
* Note, this class has several (read: far, far too many) responsibilities, so can get a little unwieldy at times.
*
* <ul>
* <li>Updates the {@link Environment}.</li>
* <li>Keeps track of whether the computer is on and blinking.</li>
* <li>Monitors whether the computer's visible state (redstone, on/off/blinking) has changed.</li>
* <li>Passes commands and events to the {@link ComputerExecutor}.</li>
* <li>Passes main thread tasks to the {@link MainThreadScheduler.Executor}.</li>
* </ul>
*/
public class Computer {
private static final int START_DELAY = 50;
// Various properties of the computer
private final int id;
private String label = null;
// Read-only fields about the computer
private final GlobalEnvironment globalEnvironment;
private final Terminal terminal;
private final ComputerExecutor executor;
private final MainThreadScheduler.Executor serverExecutor;
/**
* An internal counter for {@link ILuaTask} ids.
*
* @see ILuaContext#issueMainThreadTask(ILuaTask)
* @see #getUniqueTaskId()
*/
private final AtomicLong lastTaskId = new AtomicLong();
// Additional state about the computer and its environment.
private boolean blinking = false;
private final Environment internalEnvironment;
private final AtomicBoolean externalOutputChanged = new AtomicBoolean();
private boolean startRequested;
private int ticksSinceStart = -1;
public Computer(ComputerContext context, ComputerEnvironment environment, Terminal terminal, int id) {
if (id < 0) throw new IllegalStateException("Id has not been assigned");
this.id = id;
globalEnvironment = context.globalEnvironment();
this.terminal = terminal;
internalEnvironment = new Environment(this, environment);
executor = new ComputerExecutor(this, environment, context);
serverExecutor = context.mainThreadScheduler().createExecutor(environment.getMetrics());
}
GlobalEnvironment getGlobalEnvironment() {
return globalEnvironment;
}
FileSystem getFileSystem() {
return executor.getFileSystem();
}
Terminal getTerminal() {
return terminal;
}
public Environment getEnvironment() {
return internalEnvironment;
}
public IAPIEnvironment getAPIEnvironment() {
return internalEnvironment;
}
public boolean isOn() {
return executor.isOn();
}
public void turnOn() {
startRequested = true;
}
public void shutdown() {
executor.queueStop(false, false);
}
public void reboot() {
executor.queueStop(true, false);
}
public void unload() {
executor.queueStop(false, true);
}
public void queueEvent(String event, Object[] args) {
executor.queueEvent(event, args);
}
/**
* Queue a task to be run on the main thread, using {@link MainThreadScheduler}.
*
* @param runnable The task to run
* @return If the task was successfully queued (namely, whether there is space on it).
*/
public boolean queueMainThread(Runnable runnable) {
return serverExecutor.enqueue(runnable);
}
public IWorkMonitor getMainThreadMonitor() {
return serverExecutor;
}
public int getID() {
return id;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
if (!Objects.equal(label, this.label)) {
this.label = label;
externalOutputChanged.set(true);
}
}
public void tick() {
// We keep track of the number of ticks since the last start, only
if (ticksSinceStart >= 0 && ticksSinceStart <= START_DELAY) ticksSinceStart++;
if (startRequested && (ticksSinceStart < 0 || ticksSinceStart > START_DELAY)) {
startRequested = false;
if (!executor.isOn()) {
ticksSinceStart = 0;
executor.queueStart();
}
}
executor.tick();
// Update the environment's internal state.
internalEnvironment.tick();
// Propagate the environment's output to the world.
if (internalEnvironment.updateOutput()) externalOutputChanged.set(true);
// Set output changed if the terminal has changed from blinking to not
var blinking = terminal.getCursorBlink() &&
terminal.getCursorX() >= 0 && terminal.getCursorX() < terminal.getWidth() &&
terminal.getCursorY() >= 0 && terminal.getCursorY() < terminal.getHeight();
if (blinking != this.blinking) {
this.blinking = blinking;
externalOutputChanged.set(true);
}
}
void markChanged() {
externalOutputChanged.set(true);
}
public boolean pollAndResetChanged() {
return externalOutputChanged.getAndSet(false);
}
public boolean isBlinking() {
return isOn() && blinking;
}
public void addApi(ILuaAPI api) {
executor.addApi(api);
}
long getUniqueTaskId() {
return lastTaskId.incrementAndGet();
}
}

View File

@@ -0,0 +1,45 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import dan200.computercraft.api.filesystem.IWritableMount;
import dan200.computercraft.core.filesystem.FileMount;
import dan200.computercraft.core.metrics.MetricsObserver;
import javax.annotation.Nullable;
public interface ComputerEnvironment {
/**
* Get the current in-game day.
*
* @return The current day.
*/
int getDay();
/**
* Get the current in-game time of day.
*
* @return The current time.
*/
double getTimeOfDay();
/**
* Get the {@link MetricsObserver} for this computer. This should be constant for the duration of this
* {@link ComputerEnvironment}.
*
* @return This computer's {@link MetricsObserver}.
*/
MetricsObserver getMetrics();
/**
* Construct the mount for this computer's user-writable data.
*
* @return The constructed mount or {@code null} if the mount could not be created.
* @see FileMount
*/
@Nullable
IWritableMount createRootMount();
}

View File

@@ -0,0 +1,605 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import dan200.computercraft.api.filesystem.IMount;
import dan200.computercraft.api.filesystem.IWritableMount;
import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.core.ComputerContext;
import dan200.computercraft.core.CoreConfig;
import dan200.computercraft.core.apis.*;
import dan200.computercraft.core.filesystem.FileSystem;
import dan200.computercraft.core.filesystem.FileSystemException;
import dan200.computercraft.core.lua.ILuaMachine;
import dan200.computercraft.core.lua.MachineEnvironment;
import dan200.computercraft.core.metrics.Metrics;
import dan200.computercraft.core.metrics.MetricsObserver;
import dan200.computercraft.core.util.Colour;
import dan200.computercraft.core.util.IoUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.InputStream;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
/**
* The main task queue and executor for a single computer. This handles turning on and off a computer, as well as
* running events.
* <p>
* When the computer is instructed to turn on or off, or handle an event, we queue a task and register this to be
* executed on the {@link ComputerThread}. Note, as we may be starting many events in a single tick, the external
* cannot lock on anything which may be held for a long time.
* <p>
* The executor is effectively composed of two separate queues. Firstly, we have a "single element" queue
* {@link #command} which determines which state the computer should transition too. This is set by
* {@link #queueStart()} and {@link #queueStop(boolean, boolean)}.
* <p>
* When a computer is on, we simply push any events onto to the {@link #eventQueue}.
* <p>
* Both queues are run from the {@link #work()} method, which tries to execute a command if one exists, or resumes the
* machine with an event otherwise.
* <p>
* One final responsibility for the executor is calling {@link ILuaAPI#update()} every tick, via the {@link #tick()}
* method. This should only be called when the computer is actually on ({@link #isOn}).
*/
final class ComputerExecutor {
private static final Logger LOG = LoggerFactory.getLogger(ComputerExecutor.class);
private static final int QUEUE_LIMIT = 256;
private final Computer computer;
private final ComputerEnvironment computerEnvironment;
private final MetricsObserver metrics;
private final List<ILuaAPI> apis = new ArrayList<>();
private final ComputerThread scheduler;
final TimeoutState timeout;
private FileSystem fileSystem;
private ILuaMachine machine;
/**
* Whether the computer is currently on. This is set to false when a shutdown starts, or when turning on completes
* (but just before the Lua machine is started).
*
* @see #isOnLock
*/
private volatile boolean isOn = false;
/**
* The lock to acquire when you need to modify the "on state" of a computer.
* <p>
* We hold this lock when running any command, and attempt to hold it when updating APIs. This ensures you don't
* update APIs while also starting/stopping them.
*
* @see #isOn
* @see #tick()
* @see #turnOn()
* @see #shutdown()
*/
private final ReentrantLock isOnLock = new ReentrantLock();
/**
* A lock used for any changes to {@link #eventQueue}, {@link #command} or {@link #onComputerQueue}. This will be
* used on the main thread, so locks should be kept as brief as possible.
*/
private final Object queueLock = new Object();
/**
* Determines if this executor is present within {@link ComputerThread}.
*
* @see #queueLock
* @see #enqueue()
* @see #afterWork()
*/
volatile boolean onComputerQueue = false;
/**
* The amount of time this computer has used on a theoretical machine which shares work evenly amongst computers.
*
* @see ComputerThread
*/
long virtualRuntime = 0;
/**
* The last time at which we updated {@link #virtualRuntime}.
*
* @see ComputerThread
*/
long vRuntimeStart;
/**
* The command that {@link #work()} should execute on the computer thread.
* <p>
* One sets the command with {@link #queueStart()} and {@link #queueStop(boolean, boolean)}. Neither of these will
* queue a new event if there is an existing one in the queue.
* <p>
* Note, if command is not {@code null}, then some command is scheduled to be executed. Otherwise it is not
* currently in the queue (or is currently being executed).
*/
private volatile StateCommand command;
/**
* The queue of events which should be executed when this computer is on.
* <p>
* Note, this should be empty if this computer is off - it is cleared on shutdown and when turning on again.
*/
private final Queue<Event> eventQueue = new ArrayDeque<>(4);
/**
* Whether we interrupted an event and so should resume it instead of executing another task.
*
* @see #work()
* @see #resumeMachine(String, Object[])
*/
private boolean interruptedEvent = false;
/**
* Whether this executor has been closed, and will no longer accept any incoming commands or events.
*
* @see #queueStop(boolean, boolean)
*/
private boolean closed;
private IWritableMount rootMount;
/**
* The thread the executor is running on. This is non-null when performing work. We use this to ensure we're only
* doing one bit of work at one time.
*
* @see ComputerThread
*/
final AtomicReference<Thread> executingThread = new AtomicReference<>();
private final ILuaMachine.Factory luaFactory;
ComputerExecutor(Computer computer, ComputerEnvironment computerEnvironment, ComputerContext context) {
this.computer = computer;
this.computerEnvironment = computerEnvironment;
metrics = computerEnvironment.getMetrics();
luaFactory = context.luaFactory();
scheduler = context.computerScheduler();
timeout = new TimeoutState(scheduler);
var environment = computer.getEnvironment();
// Add all default APIs to the loaded list.
apis.add(new TermAPI(environment));
apis.add(new RedstoneAPI(environment));
apis.add(new FSAPI(environment));
apis.add(new PeripheralAPI(environment));
apis.add(new OSAPI(environment));
if (CoreConfig.httpEnabled) apis.add(new HTTPAPI(environment));
// Load in the externally registered APIs.
for (var factory : ApiFactories.getAll()) {
var system = new ComputerSystem(environment);
var api = factory.create(system);
if (api != null) apis.add(new ApiWrapper(api, system));
}
}
boolean isOn() {
return isOn;
}
FileSystem getFileSystem() {
return fileSystem;
}
Computer getComputer() {
return computer;
}
void addApi(ILuaAPI api) {
apis.add(api);
}
/**
* Schedule this computer to be started if not already on.
*/
void queueStart() {
synchronized (queueLock) {
// We should only schedule a start if we're not currently on and there's turn on.
if (closed || isOn || command != null) return;
command = StateCommand.TURN_ON;
enqueue();
}
}
/**
* Schedule this computer to be stopped if not already on.
*
* @param reboot Reboot the computer after stopping
* @param close Close the computer after stopping.
* @see #closed
*/
void queueStop(boolean reboot, boolean close) {
synchronized (queueLock) {
if (closed) return;
closed = close;
var newCommand = reboot ? StateCommand.REBOOT : StateCommand.SHUTDOWN;
// We should only schedule a stop if we're currently on and there's no shutdown pending.
if (!isOn || command != null) {
// If we're closing, set the command just in case.
if (close) command = newCommand;
return;
}
command = newCommand;
enqueue();
}
}
/**
* Abort this whole computer due to a timeout. This will immediately destroy the Lua machine,
* and then schedule a shutdown.
*/
void abort() {
immediateFail(StateCommand.ABORT);
}
/**
* Abort this whole computer due to an internal error. This will immediately destroy the Lua machine,
* and then schedule a shutdown.
*/
void fastFail() {
immediateFail(StateCommand.ERROR);
}
private void immediateFail(StateCommand command) {
var machine = this.machine;
if (machine != null) machine.close();
synchronized (queueLock) {
if (closed) return;
this.command = command;
if (isOn) enqueue();
}
}
/**
* Queue an event if the computer is on.
*
* @param event The event's name
* @param args The event's arguments
*/
void queueEvent(@Nonnull String event, @Nullable Object[] args) {
// Events should be skipped if we're not on.
if (!isOn) return;
synchronized (queueLock) {
// And if we've got some command in the pipeline, then don't queue events - they'll
// probably be disposed of anyway.
// We also limit the number of events which can be queued.
if (closed || command != null || eventQueue.size() >= QUEUE_LIMIT) return;
eventQueue.offer(new Event(event, args));
enqueue();
}
}
/**
* Add this executor to the {@link ComputerThread} if not already there.
*/
private void enqueue() {
synchronized (queueLock) {
if (!onComputerQueue) scheduler.queue(this);
}
}
/**
* Update the internals of the executor.
*/
void tick() {
if (isOn && isOnLock.tryLock()) {
// This horrific structure means we don't try to update APIs while the state is being changed
// (and so they may be running startup/shutdown).
// We use tryLock here, as it has minimal delay, and it doesn't matter if we miss an advance at the
// beginning or end of a computer's lifetime.
try {
if (isOn) {
// Advance our APIs.
for (var api : apis) api.update();
}
} finally {
isOnLock.unlock();
}
}
}
private IMount getRomMount() {
return computer.getGlobalEnvironment().createResourceMount("computercraft", "lua/rom");
}
private IWritableMount getRootMount() {
if (rootMount == null) rootMount = computerEnvironment.createRootMount();
return rootMount;
}
private FileSystem createFileSystem() {
FileSystem filesystem = null;
try {
filesystem = new FileSystem("hdd", getRootMount());
var romMount = getRomMount();
if (romMount == null) {
displayFailure("Cannot mount ROM", null);
return null;
}
filesystem.mount("rom", "rom", romMount);
return filesystem;
} catch (FileSystemException e) {
if (filesystem != null) filesystem.close();
LOG.error("Cannot mount computer filesystem", e);
displayFailure("Cannot mount computer system", null);
return null;
}
}
private ILuaMachine createLuaMachine() {
// Load the bios resource
InputStream biosStream = null;
try {
biosStream = computer.getGlobalEnvironment().createResourceFile("computercraft", "lua/bios.lua");
} catch (Exception ignored) {
}
if (biosStream == null) {
displayFailure("Error loading bios.lua", null);
return null;
}
// Create the lua machine
var machine = luaFactory.create(new MachineEnvironment(
new LuaContext(computer), metrics, timeout, computer.getGlobalEnvironment().getHostString()
));
// Add the APIs. We unwrap them (yes, this is horrible) to get access to the underlying object.
for (var api : apis) machine.addAPI(api instanceof ApiWrapper wrapper ? wrapper.getDelegate() : api);
// Start the machine running the bios resource
var result = machine.loadBios(biosStream);
IoUtil.closeQuietly(biosStream);
if (result.isError()) {
machine.close();
displayFailure("Error loading bios.lua", result.getMessage());
return null;
}
return machine;
}
private void turnOn() throws InterruptedException {
isOnLock.lockInterruptibly();
try {
// Reset the terminal and event queue
computer.getTerminal().reset();
interruptedEvent = false;
synchronized (queueLock) {
eventQueue.clear();
}
// Init filesystem
if ((fileSystem = createFileSystem()) == null) {
shutdown();
return;
}
// Init APIs
computer.getEnvironment().reset();
for (var api : apis) api.startup();
// Init lua
if ((machine = createLuaMachine()) == null) {
shutdown();
return;
}
// Initialisation has finished, so let's mark ourselves as on.
isOn = true;
computer.markChanged();
} finally {
isOnLock.unlock();
}
// Now actually start the computer, now that everything is set up.
resumeMachine(null, null);
}
private void shutdown() throws InterruptedException {
isOnLock.lockInterruptibly();
try {
isOn = false;
interruptedEvent = false;
synchronized (queueLock) {
eventQueue.clear();
}
// Shutdown Lua machine
if (machine != null) {
machine.close();
machine = null;
}
// Shutdown our APIs
for (var api : apis) api.shutdown();
computer.getEnvironment().reset();
// Unload filesystem
if (fileSystem != null) {
fileSystem.close();
fileSystem = null;
}
computer.getEnvironment().resetOutput();
computer.markChanged();
} finally {
isOnLock.unlock();
}
}
/**
* Called before calling {@link #work()}, setting up any important state.
*/
void beforeWork() {
vRuntimeStart = System.nanoTime();
timeout.startTimer();
}
/**
* Called after executing {@link #work()}.
*
* @return If we have more work to do.
*/
boolean afterWork() {
if (interruptedEvent) {
timeout.pauseTimer();
} else {
timeout.stopTimer();
}
metrics.observe(Metrics.COMPUTER_TASKS, timeout.nanoCurrent());
if (interruptedEvent) return true;
synchronized (queueLock) {
if (eventQueue.isEmpty() && command == null) return onComputerQueue = false;
return true;
}
}
/**
* The main worker function, called by {@link ComputerThread}.
* <p>
* This either executes a {@link StateCommand} or attempts to run an event
*
* @throws InterruptedException If various locks could not be acquired.
* @see #command
* @see #eventQueue
*/
void work() throws InterruptedException {
if (interruptedEvent && !closed) {
interruptedEvent = false;
if (machine != null) {
resumeMachine(null, null);
return;
}
}
StateCommand command;
Event event = null;
synchronized (queueLock) {
command = this.command;
this.command = null;
// If we've no command, pull something from the event queue instead.
if (command == null) {
if (!isOn) {
// We're not on and had no command, but we had work queued. This should never happen, so clear
// the event queue just in case.
eventQueue.clear();
return;
}
event = eventQueue.poll();
}
}
if (command != null) {
switch (command) {
case TURN_ON -> {
if (isOn) return;
turnOn();
}
case SHUTDOWN -> {
if (!isOn) return;
computer.getTerminal().reset();
shutdown();
}
case REBOOT -> {
if (!isOn) return;
computer.getTerminal().reset();
shutdown();
computer.turnOn();
}
case ABORT -> {
if (!isOn) return;
displayFailure("Error running computer", TimeoutState.ABORT_MESSAGE);
shutdown();
}
case ERROR -> {
if (!isOn) return;
displayFailure("Error running computer", "An internal error occurred, see logs.");
shutdown();
}
}
} else if (event != null) {
resumeMachine(event.name, event.args);
}
}
void printState(StringBuilder out) {
out.append("Enqueued command: ").append(command).append('\n');
out.append("Enqueued events: ").append(eventQueue.size()).append('\n');
var machine = this.machine;
if (machine != null) machine.printExecutionState(out);
}
private void displayFailure(String message, String extra) {
var terminal = computer.getTerminal();
terminal.reset();
// Display our primary error message
if (terminal.isColour()) terminal.setTextColour(15 - Colour.RED.ordinal());
terminal.write(message);
if (extra != null) {
// Display any additional information. This generally comes from the Lua Machine, such as compilation or
// runtime errors.
terminal.setCursorPos(0, terminal.getCursorY() + 1);
terminal.write(extra);
}
// And display our generic "CC may be installed incorrectly" message.
terminal.setCursorPos(0, terminal.getCursorY() + 1);
if (terminal.isColour()) terminal.setTextColour(15 - Colour.WHITE.ordinal());
terminal.write("ComputerCraft may be installed incorrectly");
}
private void resumeMachine(String event, Object[] args) throws InterruptedException {
var result = machine.handleEvent(event, args);
interruptedEvent = result.isPause();
if (!result.isError()) return;
displayFailure("Error running computer", result.getMessage());
shutdown();
}
private enum StateCommand {
TURN_ON,
SHUTDOWN,
REBOOT,
ABORT,
ERROR,
}
private record Event(String name, Object[] args) {
}
}

View File

@@ -0,0 +1,51 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* A side on a computer. This is relative to the direction the computer is facing.
*/
public enum ComputerSide {
BOTTOM("bottom"),
TOP("top"),
BACK("back"),
FRONT("front"),
RIGHT("right"),
LEFT("left");
public static final String[] NAMES = new String[]{ "bottom", "top", "back", "front", "right", "left" };
public static final int COUNT = 6;
private static final ComputerSide[] VALUES = values();
private final String name;
ComputerSide(String name) {
this.name = name;
}
@Nonnull
public static ComputerSide valueOf(int side) {
return VALUES[side];
}
@Nullable
public static ComputerSide valueOfInsensitive(@Nonnull String name) {
for (var side : VALUES) {
if (side.name.equalsIgnoreCase(name)) return side;
}
return null;
}
public String getName() {
return name;
}
}

View File

@@ -0,0 +1,67 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import dan200.computercraft.api.filesystem.IFileSystem;
import dan200.computercraft.api.lua.IComputerSystem;
import dan200.computercraft.api.lua.ILuaAPIFactory;
import dan200.computercraft.api.peripheral.IComputerAccess;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.core.apis.ComputerAccess;
import dan200.computercraft.core.apis.IAPIEnvironment;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Collections;
import java.util.Map;
/**
* Implementation of {@link IComputerAccess}/{@link IComputerSystem} for usage by externally registered APIs.
*
* @see dan200.computercraft.api.ComputerCraftAPI#registerAPIFactory(ILuaAPIFactory)
* @see ILuaAPIFactory
* @see ApiWrapper
*/
public class ComputerSystem extends ComputerAccess implements IComputerSystem {
private final IAPIEnvironment environment;
ComputerSystem(IAPIEnvironment environment) {
super(environment);
this.environment = environment;
}
@Nonnull
@Override
public String getAttachmentName() {
return "computer";
}
@Nullable
@Override
public IFileSystem getFileSystem() {
var fs = environment.getFileSystem();
return fs == null ? null : fs.getMountWrapper();
}
@Nullable
@Override
public String getLabel() {
return environment.getLabel();
}
@Nonnull
@Override
public Map<String, IPeripheral> getAvailablePeripherals() {
// TODO: Should this return peripherals on the current computer?
return Collections.emptyMap();
}
@Nullable
@Override
public IPeripheral getAvailablePeripheral(@Nonnull String name) {
return null;
}
}

View File

@@ -0,0 +1,663 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import com.google.common.annotations.VisibleForTesting;
import com.google.errorprone.annotations.concurrent.GuardedBy;
import dan200.computercraft.core.ComputerContext;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.util.ThreadUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Objects;
import java.util.TreeSet;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.LockSupport;
import java.util.concurrent.locks.ReentrantLock;
/**
* Runs all scheduled tasks for computers in a {@link ComputerContext}.
* <p>
* This acts as an over-complicated {@link ThreadPoolExecutor}: It creates several {@link Worker} threads which pull
* tasks from a shared queue, executing them. It also creates a single {@link Monitor} thread, which updates computer
* timeouts, killing workers if they have not been terminated by {@link TimeoutState#isSoftAborted()}.
* <p>
* Computers are executed using a priority system, with those who have spent less time executing having a higher
* priority than those hogging the thread. This, combined with {@link TimeoutState#isPaused()} means we can reduce the
* risk of badly behaved computers stalling execution for everyone else.
* <p>
* This is done using an implementation of Linux's Completely Fair Scheduler. When a computer executes, we compute what
* share of execution time it has used (time executed/number of tasks). We then pick the computer who has the least
* "virtual execution time" (aka {@link ComputerExecutor#virtualRuntime}).
* <p>
* When adding a computer to the queue, we make sure its "virtual runtime" is at least as big as the smallest runtime.
* This means that adding computers which have slept a lot do not then have massive priority over everyone else. See
* {@link #queue(ComputerExecutor)} for how this is implemented.
* <p>
* In reality, it's unlikely that more than a few computers are waiting to execute at once, so this will not have much
* effect unless you have a computer hogging execution time. However, it is pretty effective in those situations.
*
* @see TimeoutState For how hard timeouts are handled.
* @see ComputerExecutor For how computers actually do execution.
*/
public final class ComputerThread {
private static final Logger LOG = LoggerFactory.getLogger(ComputerThread.class);
private static final ThreadFactory monitorFactory = ThreadUtils.factory("Computer-Monitor");
private static final ThreadFactory workerFactory = ThreadUtils.factory("Computer-Worker");
/**
* How often the computer thread monitor should run.
*
* @see Monitor
*/
private static final long MONITOR_WAKEUP = TimeUnit.MILLISECONDS.toNanos(100);
/**
* The target latency between executing two tasks on a single machine.
* <p>
* An average tick takes 50ms, and so we ideally need to have handled a couple of events within that window in order
* to have a perceived low latency.
*/
private static final long DEFAULT_LATENCY = TimeUnit.MILLISECONDS.toNanos(50);
/**
* The minimum value that {@link #DEFAULT_LATENCY} can have when scaled.
* <p>
* From statistics gathered on SwitchCraft, almost all machines will execute under 15ms, 75% under 1.5ms, with the
* mean being about 3ms. Most computers shouldn't be too impacted with having such a short period to execute in.
*/
private static final long DEFAULT_MIN_PERIOD = TimeUnit.MILLISECONDS.toNanos(5);
/**
* The maximum number of tasks before we have to start scaling latency linearly.
*/
private static final long LATENCY_MAX_TASKS = DEFAULT_LATENCY / DEFAULT_MIN_PERIOD;
/**
* Time difference between reporting crashed threads.
*
* @see Worker#reportTimeout(ComputerExecutor, long)
*/
private static final long REPORT_DEBOUNCE = TimeUnit.SECONDS.toNanos(1);
/**
* Lock used for modifications to the array of current threads.
*/
private final ReentrantLock threadLock = new ReentrantLock();
private static final int RUNNING = 0;
private static final int STOPPING = 1;
private static final int CLOSED = 2;
/**
* Whether the computer thread system is currently running.
*/
private final AtomicInteger state = new AtomicInteger(RUNNING);
/**
* The current task manager.
*/
private @Nullable Thread monitor;
/**
* The array of current workers, and their owning threads.
*/
@GuardedBy("threadLock")
private final Worker[] workers;
/**
* The number of workers in {@link #workers}.
*/
@GuardedBy("threadLock")
private int workerCount = 0;
private final Condition shutdown = threadLock.newCondition();
private final long latency;
private final long minPeriod;
private final ReentrantLock computerLock = new ReentrantLock();
private final Condition workerWakeup = computerLock.newCondition();
private final Condition monitorWakeup = computerLock.newCondition();
private final AtomicInteger idleWorkers = new AtomicInteger(0);
/**
* Active queues to execute.
*/
private final TreeSet<ComputerExecutor> computerQueue = new TreeSet<>((a, b) -> {
if (a == b) return 0; // Should never happen, but let's be consistent here
long at = a.virtualRuntime, bt = b.virtualRuntime;
if (at == bt) return Integer.compare(a.hashCode(), b.hashCode());
return at < bt ? -1 : 1;
});
/**
* The minimum {@link ComputerExecutor#virtualRuntime} time on the tree.
*/
private long minimumVirtualRuntime = 0;
public ComputerThread(int threadCount) {
workers = new Worker[threadCount];
// latency and minPeriod are scaled by 1 + floor(log2(threads)). We can afford to execute tasks for
// longer when executing on more than one thread.
var factor = 64 - Long.numberOfLeadingZeros(workers.length);
latency = DEFAULT_LATENCY * factor;
minPeriod = DEFAULT_MIN_PERIOD * factor;
}
@GuardedBy("threadLock")
private void addWorker(int index) {
LOG.trace("Spawning new worker {}.", index);
(workers[index] = new Worker(index)).owner.start();
workerCount++;
}
/**
* Ensure sufficient workers are running.
*/
@GuardedBy("computerLock")
private void ensureRunning() {
// Don't even enter the lock if we've a monitor and don't need to/can't spawn an additional worker.
// We'll be holding the computer lock at this point, so there's no problems with idleWorkers being wrong.
if (monitor != null && (idleWorkers.get() > 0 || workerCount == workers.length)) return;
threadLock.lock();
try {
LOG.trace("Possibly spawning a worker or monitor.");
if (monitor == null || !monitor.isAlive()) (monitor = monitorFactory.newThread(new Monitor())).start();
if (idleWorkers.get() == 0 || workerCount < workers.length) {
for (var i = 0; i < workers.length; i++) {
if (workers[i] == null) {
addWorker(i);
break;
}
}
}
} finally {
threadLock.unlock();
}
}
private void advanceState(int newState) {
while (true) {
var current = state.get();
if (current >= newState || state.compareAndSet(current, newState)) break;
}
}
/**
* Attempt to stop the computer thread. This interrupts each worker, and clears the task queue.
*
* @param timeout The maximum time to wait.
* @param unit The unit {@code timeout} is in.
* @return Whether the thread was successfully shut down.
* @throws InterruptedException If interrupted while waiting.
*/
public boolean stop(long timeout, TimeUnit unit) throws InterruptedException {
advanceState(STOPPING);
// Encourage any currently running runners to terminate.
threadLock.lock();
try {
for (@Nullable var worker : workers) {
if (worker == null) continue;
var executor = worker.currentExecutor.get();
if (executor != null) executor.timeout.hardAbort();
}
} finally {
threadLock.unlock();
}
// Wake all workers
computerLock.lock();
try {
workerWakeup.signalAll();
} finally {
computerLock.unlock();
}
// Wait for all workers to signal they have finished.
var timeoutNs = unit.toNanos(timeout);
threadLock.lock();
try {
while (workerCount > 0) {
if (timeoutNs <= 0) return false;
timeoutNs = shutdown.awaitNanos(timeoutNs);
}
} finally {
threadLock.unlock();
}
advanceState(CLOSED);
// Signal the monitor to finish, but don't wait for it to stop.
computerLock.lock();
try {
monitorWakeup.signal();
} finally {
computerLock.unlock();
}
return true;
}
/**
* Mark a computer as having work, enqueuing it on the thread.
* <p>
* You must be holding {@link ComputerExecutor}'s {@code queueLock} when calling this method - it should only
* be called from {@code enqueue}.
*
* @param executor The computer to execute work on.
*/
void queue(ComputerExecutor executor) {
computerLock.lock();
try {
if (state.get() != RUNNING) throw new IllegalStateException("ComputerThread is no longer running");
// Ensure we've got a worker running.
ensureRunning();
if (executor.onComputerQueue) throw new IllegalStateException("Cannot queue already queued executor");
executor.onComputerQueue = true;
updateRuntimes(null);
// We're not currently on the queue, so update its current execution time to
// ensure its at least as high as the minimum.
var newRuntime = minimumVirtualRuntime;
if (executor.virtualRuntime == 0) {
// Slow down new computers a little bit.
newRuntime += scaledPeriod();
} else {
// Give a small boost to computers which have slept a little.
newRuntime -= latency / 2;
}
executor.virtualRuntime = Math.max(newRuntime, executor.virtualRuntime);
var wasBusy = isBusy();
// Add to the queue, and signal the workers.
computerQueue.add(executor);
workerWakeup.signal();
// If we've transitioned into a busy state, notify the monitor. This will cause it to sleep for scaledPeriod
// instead of the longer wakeup duration.
if (!wasBusy && isBusy()) monitorWakeup.signal();
} finally {
computerLock.unlock();
}
}
/**
* Update the {@link ComputerExecutor#virtualRuntime}s of all running tasks, and then update the
* {@link #minimumVirtualRuntime} based on the current tasks.
* <p>
* This is called before queueing tasks, to ensure that {@link #minimumVirtualRuntime} is up-to-date.
*
* @param current The machine which we updating runtimes from.
*/
private void updateRuntimes(@Nullable ComputerExecutor current) {
var minRuntime = Long.MAX_VALUE;
// If we've a task on the queue, use that as our base time.
if (!computerQueue.isEmpty()) minRuntime = computerQueue.first().virtualRuntime;
// Update all the currently executing tasks
var now = System.nanoTime();
var tasks = 1 + computerQueue.size();
for (@Nullable var runner : workers) {
if (runner == null) continue;
var executor = runner.currentExecutor.get();
if (executor == null) continue;
// We do two things here: first we update the task's virtual runtime based on when we
// last checked, and then we check the minimum.
minRuntime = Math.min(minRuntime, executor.virtualRuntime += (now - executor.vRuntimeStart) / tasks);
executor.vRuntimeStart = now;
}
// And update the most recently executed one (if set).
if (current != null) {
minRuntime = Math.min(minRuntime, current.virtualRuntime += (now - current.vRuntimeStart) / tasks);
}
if (minRuntime > minimumVirtualRuntime && minRuntime < Long.MAX_VALUE) {
minimumVirtualRuntime = minRuntime;
}
}
/**
* Ensure the "currently working" state of the executor is reset, the timings are updated, and then requeue the
* executor if needed.
*
* @param runner The runner this task was on.
* @param executor The executor to requeue
*/
private void afterWork(Worker runner, ComputerExecutor executor) {
// Clear the executor's thread.
var currentThread = executor.executingThread.getAndSet(null);
if (currentThread != runner.owner) {
LOG.error(
"Expected computer #{} to be running on {}, but already running on {}. This is a SERIOUS bug, please report with your debug.log.",
executor.getComputer().getID(),
runner.owner.getName(),
currentThread == null ? "nothing" : currentThread.getName()
);
}
computerLock.lock();
try {
updateRuntimes(executor);
// If we've no more tasks, just return.
if (!executor.afterWork() || state.get() != RUNNING) return;
// Otherwise, add to the queue, and signal any waiting workers.
computerQueue.add(executor);
workerWakeup.signal();
} finally {
computerLock.unlock();
}
}
/**
* The scaled period for a single task.
*
* @return The scaled period for the task
* @see #DEFAULT_LATENCY
* @see #DEFAULT_MIN_PERIOD
* @see #LATENCY_MAX_TASKS
*/
long scaledPeriod() {
// FIXME: We access this on other threads (in TimeoutState), so their reads won't be consistent. This isn't
// "criticial" behaviour, so not clear if it matters too much.
// +1 to include the current task
var count = 1 + computerQueue.size();
return count < LATENCY_MAX_TASKS ? latency / count : minPeriod;
}
/**
* Determine if the thread has computers queued up.
*
* @return If we have work queued up.
*/
@VisibleForTesting
public boolean hasPendingWork() {
// FIXME: See comment in scaledPeriod. Again, we access this in multiple threads but not clear if it matters!
return !computerQueue.isEmpty();
}
/**
* Check if we have more work queued than we have capacity for. Effectively a more fine-grained version of
* {@link #hasPendingWork()}.
*
* @return If the computer threads are busy.
*/
@GuardedBy("computerLock")
private boolean isBusy() {
return computerQueue.size() > idleWorkers.get();
}
private void workerFinished(Worker worker) {
// We should only shut down a worker once! This should only happen if we fail to abort a worker and then the
// worker finishes normally.
if (!worker.running.getAndSet(false)) return;
LOG.trace("Worker {} finished.", worker.index);
var executor = worker.currentExecutor.getAndSet(null);
if (executor != null) executor.afterWork();
threadLock.lock();
try {
workerCount--;
if (workers[worker.index] != worker) {
LOG.error("Worker {} closed, but new runner has been spawned.", worker.index);
} else if (state.get() == RUNNING || (state.get() == STOPPING && hasPendingWork())) {
addWorker(worker.index);
workerCount++;
} else {
workers[worker.index] = null;
}
} finally {
threadLock.unlock();
}
}
/**
* Observes all currently active {@link Worker}s and terminates their tasks once they have exceeded the hard
* abort limit.
*
* @see TimeoutState
*/
private final class Monitor implements Runnable {
@Override
public void run() {
LOG.trace("Monitor starting.");
try {
runImpl();
} finally {
LOG.trace("Monitor shutting down. Current state is {}.", state.get());
}
}
private void runImpl() {
while (state.get() < CLOSED) {
computerLock.lock();
try {
// If we've got more work than we have capacity for it, then we'll need to pause a task soon, so
// sleep for a single pause duration. Otherwise we only need to wake up to set the soft/hard abort
// flags, which are far less granular.
monitorWakeup.awaitNanos(isBusy() ? scaledPeriod() : MONITOR_WAKEUP);
} catch (InterruptedException e) {
LOG.error("Monitor thread interrupted. Computers may behave very badly!", e);
break;
} finally {
computerLock.unlock();
}
checkRunners();
}
}
private void checkRunners() {
for (@Nullable var runner : workers) {
if (runner == null) continue;
// If the worker has no work, skip
var executor = runner.currentExecutor.get();
if (executor == null) continue;
// Refresh the timeout state. Will set the pause/soft timeout flags as appropriate.
executor.timeout.refresh();
// If we're still within normal execution times (TIMEOUT) or soft abort (ABORT_TIMEOUT),
// then we can let the Lua machine do its work.
var afterStart = executor.timeout.nanoCumulative();
var afterHardAbort = afterStart - TimeoutState.TIMEOUT - TimeoutState.ABORT_TIMEOUT;
if (afterHardAbort < 0) continue;
// Set the hard abort flag.
executor.timeout.hardAbort();
executor.abort();
if (afterHardAbort >= TimeoutState.ABORT_TIMEOUT * 2) {
// If we've hard aborted and interrupted, and we're still not dead, then mark the worker
// as dead, finish off the task, and spawn a new runner.
runner.reportTimeout(executor, afterStart);
runner.owner.interrupt();
workerFinished(runner);
} else if (afterHardAbort >= TimeoutState.ABORT_TIMEOUT) {
// If we've hard aborted but we're still not dead, dump the stack trace and interrupt
// the task.
runner.reportTimeout(executor, afterStart);
runner.owner.interrupt();
}
}
}
}
/**
* Pulls tasks from the {@link #computerQueue} queue and runs them.
* <p>
* This is responsible for running the {@link ComputerExecutor#work()}, {@link ComputerExecutor#beforeWork()} and
* {@link ComputerExecutor#afterWork()} functions. Everything else is either handled by the executor, timeout
* state or monitor.
*/
private final class Worker implements Runnable {
/**
* The index into the {@link #workers} array.
*/
final int index;
/**
* The thread this runner runs on.
*/
final @Nonnull Thread owner;
/**
* Whether this runner is currently executing. This may be set to false when this worker terminates, or when
* we try to abandon a worker in the monitor
*
* @see #workerFinished(Worker)
*/
final AtomicBoolean running = new AtomicBoolean(true);
/**
* The computer we're currently running.
*/
final AtomicReference<ComputerExecutor> currentExecutor = new AtomicReference<>(null);
/**
* The last time we reported a stack trace, used to avoid spamming the logs.
*/
AtomicLong lastReport = new AtomicLong(Long.MIN_VALUE);
Worker(int index) {
this.index = index;
owner = workerFactory.newThread(this);
}
@Override
public void run() {
try {
runImpl();
} finally {
workerFinished(this);
}
}
private void runImpl() {
tasks:
while (running.get()) {
// Wait for an active queue to execute
ComputerExecutor executor;
computerLock.lock();
try {
idleWorkers.getAndIncrement();
while ((executor = computerQueue.pollFirst()) == null) {
if (state.get() >= STOPPING) return;
// We should never interrupt() the worker, so this should be fine.
workerWakeup.awaitUninterruptibly();
}
} finally {
idleWorkers.getAndDecrement();
computerLock.unlock();
}
// If we're trying to executing some task on this computer while someone else is doing work, something
// is seriously wrong.
while (!executor.executingThread.compareAndSet(null, owner)) {
var existing = executor.executingThread.get();
if (existing != null) {
LOG.error(
"Trying to run computer #{} on thread {}, but already running on {}. This is a SERIOUS bug, please report with your debug.log.",
executor.getComputer().getID(), owner.getName(), existing.getName()
);
continue tasks;
}
}
// If we're stopping, the only thing this executor should be doing is shutting down.
if (state.get() >= STOPPING) executor.queueStop(false, true);
// Reset the timers
executor.beforeWork();
// And then set the current executor. It's important to do it afterwards, as otherwise we introduce
// race conditions with the monitor.
currentExecutor.set(executor);
// Execute the task
try {
executor.work();
} catch (Exception | LinkageError | VirtualMachineError e) {
LOG.error("Error running task on computer #" + executor.getComputer().getID(), e);
// Tear down the computer immediately. There's no guarantee it's well-behaved from now on.
executor.fastFail();
} finally {
var thisExecutor = currentExecutor.getAndSet(null);
if (thisExecutor != null) afterWork(this, executor);
}
}
}
private void reportTimeout(ComputerExecutor executor, long time) {
if (!LOG.isErrorEnabled(Logging.COMPUTER_ERROR)) return;
// Attempt to debounce stack trace reporting, limiting ourselves to one every second. There's no need to be
// ultra-precise in our atomics, as long as one of them wins!
var now = System.nanoTime();
var then = lastReport.get();
if (then != Long.MIN_VALUE && now - then - REPORT_DEBOUNCE <= 0) return;
if (!lastReport.compareAndSet(then, now)) return;
var owner = Objects.requireNonNull(this.owner);
var builder = new StringBuilder()
.append("Terminating computer #").append(executor.getComputer().getID())
.append(" due to timeout (running for ").append(time * 1e-9)
.append(" seconds). This is NOT a bug, but may mean a computer is misbehaving.\n")
.append("Thread ")
.append(owner.getName())
.append(" is currently ")
.append(owner.getState())
.append('\n');
var blocking = LockSupport.getBlocker(owner);
if (blocking != null) builder.append(" on ").append(blocking).append('\n');
for (var element : owner.getStackTrace()) {
builder.append(" at ").append(element).append('\n');
}
executor.printState(builder);
LOG.warn(builder.toString());
}
}
}

View File

@@ -0,0 +1,334 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.api.peripheral.IWorkMonitor;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.filesystem.FileSystem;
import dan200.computercraft.core.metrics.Metric;
import dan200.computercraft.core.metrics.MetricsObserver;
import dan200.computercraft.core.terminal.Terminal;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import javax.annotation.Nonnull;
import java.util.Arrays;
import java.util.Iterator;
/**
* Represents the "environment" that a {@link Computer} exists in.
* <p>
* This handles storing and updating of peripherals and redstone.
*
* <h1>Redstone</h1>
* We holds three kinds of arrays for redstone, in normal and bundled versions:
* <ul>
* <li>{@link #internalOutput} is the redstone output which the computer has currently set. This is read on both
* threads, and written on the computer thread.</li>
* <li>{@link #externalOutput} is the redstone output currently propagated to the world. This is only read and written
* on the main thread.</li>
* <li>{@link #input} is the redstone input from external sources. This is read on both threads, and written on the main
* thread.</li>
* </ul>
*
* <h1>Peripheral</h1>
* We also keep track of peripherals. These are read on both threads, and only written on the main thread.
*/
public final class Environment implements IAPIEnvironment {
private final Computer computer;
private final ComputerEnvironment environment;
private final MetricsObserver metrics;
private boolean internalOutputChanged = false;
private final int[] internalOutput = new int[ComputerSide.COUNT];
private final int[] internalBundledOutput = new int[ComputerSide.COUNT];
private final int[] externalOutput = new int[ComputerSide.COUNT];
private final int[] externalBundledOutput = new int[ComputerSide.COUNT];
private boolean inputChanged = false;
private final int[] input = new int[ComputerSide.COUNT];
private final int[] bundledInput = new int[ComputerSide.COUNT];
private final IPeripheral[] peripherals = new IPeripheral[ComputerSide.COUNT];
private IPeripheralChangeListener peripheralListener = null;
private final Int2ObjectMap<Timer> timers = new Int2ObjectOpenHashMap<>();
private int nextTimerToken = 0;
Environment(Computer computer, ComputerEnvironment environment) {
this.computer = computer;
this.environment = environment;
metrics = environment.getMetrics();
}
@Override
public int getComputerID() {
return computer.getID();
}
@Nonnull
@Override
public ComputerEnvironment getComputerEnvironment() {
return environment;
}
@Nonnull
@Override
public GlobalEnvironment getGlobalEnvironment() {
return computer.getGlobalEnvironment();
}
@Nonnull
@Override
public IWorkMonitor getMainThreadMonitor() {
return computer.getMainThreadMonitor();
}
@Nonnull
@Override
public Terminal getTerminal() {
return computer.getTerminal();
}
@Override
public FileSystem getFileSystem() {
return computer.getFileSystem();
}
@Override
public void shutdown() {
computer.shutdown();
}
@Override
public void reboot() {
computer.reboot();
}
@Override
public void queueEvent(String event, Object... args) {
computer.queueEvent(event, args);
}
@Override
public int getInput(ComputerSide side) {
return input[side.ordinal()];
}
@Override
public int getBundledInput(ComputerSide side) {
return bundledInput[side.ordinal()];
}
@Override
public void setOutput(ComputerSide side, int output) {
var index = side.ordinal();
synchronized (internalOutput) {
if (internalOutput[index] != output) {
internalOutput[index] = output;
internalOutputChanged = true;
}
}
}
@Override
public int getOutput(ComputerSide side) {
synchronized (internalOutput) {
return computer.isOn() ? internalOutput[side.ordinal()] : 0;
}
}
@Override
public void setBundledOutput(ComputerSide side, int output) {
var index = side.ordinal();
synchronized (internalOutput) {
if (internalBundledOutput[index] != output) {
internalBundledOutput[index] = output;
internalOutputChanged = true;
}
}
}
@Override
public int getBundledOutput(ComputerSide side) {
synchronized (internalOutput) {
return computer.isOn() ? internalBundledOutput[side.ordinal()] : 0;
}
}
public int getExternalRedstoneOutput(ComputerSide side) {
return computer.isOn() ? externalOutput[side.ordinal()] : 0;
}
public int getExternalBundledRedstoneOutput(ComputerSide side) {
return computer.isOn() ? externalBundledOutput[side.ordinal()] : 0;
}
public void setRedstoneInput(ComputerSide side, int level) {
var index = side.ordinal();
if (input[index] != level) {
input[index] = level;
inputChanged = true;
}
}
public void setBundledRedstoneInput(ComputerSide side, int combination) {
var index = side.ordinal();
if (bundledInput[index] != combination) {
bundledInput[index] = combination;
inputChanged = true;
}
}
/**
* Called when the computer starts up or shuts down, to reset any internal state.
*
* @see ILuaAPI#startup()
* @see ILuaAPI#shutdown()
*/
void reset() {
synchronized (timers) {
timers.clear();
}
}
/**
* Called on the main thread to update the internal state of the computer.
*/
void tick() {
if (inputChanged) {
inputChanged = false;
queueEvent("redstone");
}
synchronized (timers) {
// Countdown all of our active timers
Iterator<Int2ObjectMap.Entry<Timer>> it = timers.int2ObjectEntrySet().iterator();
while (it.hasNext()) {
var entry = it.next();
var timer = entry.getValue();
timer.ticksLeft--;
if (timer.ticksLeft <= 0) {
// Queue the "timer" event
queueEvent(TIMER_EVENT, entry.getIntKey());
it.remove();
}
}
}
}
/**
* Called on the main thread to propagate the internal outputs to the external ones.
*
* @return If the outputs have changed.
*/
boolean updateOutput() {
// Mark output as changed if the internal redstone has changed
synchronized (internalOutput) {
if (!internalOutputChanged) return false;
var changed = false;
for (var i = 0; i < ComputerSide.COUNT; i++) {
if (externalOutput[i] != internalOutput[i]) {
externalOutput[i] = internalOutput[i];
changed = true;
}
if (externalBundledOutput[i] != internalBundledOutput[i]) {
externalBundledOutput[i] = internalBundledOutput[i];
changed = true;
}
}
internalOutputChanged = false;
return changed;
}
}
void resetOutput() {
// Reset redstone output
synchronized (internalOutput) {
Arrays.fill(internalOutput, 0);
Arrays.fill(internalBundledOutput, 0);
internalOutputChanged = true;
}
}
@Override
public IPeripheral getPeripheral(ComputerSide side) {
synchronized (peripherals) {
return peripherals[side.ordinal()];
}
}
public void setPeripheral(ComputerSide side, IPeripheral peripheral) {
synchronized (peripherals) {
var index = side.ordinal();
var existing = peripherals[index];
if ((existing == null && peripheral != null) ||
(existing != null && peripheral == null) ||
(existing != null && !existing.equals(peripheral))) {
peripherals[index] = peripheral;
if (peripheralListener != null) peripheralListener.onPeripheralChanged(side, peripheral);
}
}
}
@Override
public void setPeripheralChangeListener(IPeripheralChangeListener listener) {
synchronized (peripherals) {
peripheralListener = listener;
}
}
@Override
public String getLabel() {
return computer.getLabel();
}
@Override
public void setLabel(String label) {
computer.setLabel(label);
}
@Override
public int startTimer(long ticks) {
synchronized (timers) {
timers.put(nextTimerToken, new Timer(ticks));
return nextTimerToken++;
}
}
@Override
public void cancelTimer(int id) {
synchronized (timers) {
timers.remove(id);
}
}
@Override
public void observe(@Nonnull Metric.Event event, long change) {
metrics.observe(event, change);
}
@Override
public void observe(@Nonnull Metric.Counter counter) {
metrics.observe(counter);
}
private static class Timer {
long ticksLeft;
Timer(long ticksLeft) {
this.ticksLeft = ticksLeft;
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import dan200.computercraft.api.filesystem.IMount;
import javax.annotation.Nullable;
import java.io.InputStream;
/**
* The global environment in which computers reside.
*/
public interface GlobalEnvironment {
/**
* Get a "host" string describing the program hosting CC. It should be of the form {@literal ComputerCraft
* $CC_VERSION ($HOST)}, where {@literal $HOST} is a user-defined string such as {@literal Minecraft 1.19}.
*
* @return The host string.
*/
String getHostString();
/**
* Get the HTTP user-agent to use for requests. This should be similar to {@link #getHostString()} , but in the form
* of a HTTP User-Agent.
*
* @return The HTTP
*/
String getUserAgent();
/**
* Create a mount from mod-provided resources.
*
* @param domain The domain (i.e. mod id) providing resources.
* @param subPath The path to these resources under the domain.
* @return The created mount or {@code null} if it could not be created.
*/
@Nullable
IMount createResourceMount(String domain, String subPath);
/**
* Open a single mod-provided file.
*
* @param domain The domain (i.e. mod id) providing resources.
* @param subPath The path to these files under the domain.
* @return The opened file or {@code null} if it could not be opened.
*/
@Nullable
InputStream createResourceFile(String domain, String subPath);
}

View File

@@ -0,0 +1,56 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import dan200.computercraft.api.lua.ILuaContext;
import dan200.computercraft.api.lua.ILuaTask;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.core.Logging;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
class LuaContext implements ILuaContext {
private static final Logger LOG = LoggerFactory.getLogger(LuaContext.class);
private final Computer computer;
LuaContext(Computer computer) {
this.computer = computer;
}
@Override
public long issueMainThreadTask(@Nonnull final ILuaTask task) throws LuaException {
// Issue command
final var taskID = computer.getUniqueTaskId();
final Runnable iTask = () -> {
try {
var results = task.execute();
if (results != null) {
var eventArguments = new Object[results.length + 2];
eventArguments[0] = taskID;
eventArguments[1] = true;
System.arraycopy(results, 0, eventArguments, 2, results.length);
computer.queueEvent("task_complete", eventArguments);
} else {
computer.queueEvent("task_complete", new Object[]{ taskID, true });
}
} catch (LuaException e) {
computer.queueEvent("task_complete", new Object[]{ taskID, false, e.getMessage() });
} catch (Exception t) {
LOG.error(Logging.JAVA_ERROR, "Error running task", t);
computer.queueEvent("task_complete", new Object[]{
taskID, false, "Java Exception Thrown: " + t,
});
}
};
if (computer.queueMainThread(iTask)) {
return taskID;
} else {
throw new LuaException("Task limit exceeded");
}
}
}

View File

@@ -0,0 +1,166 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer;
import dan200.computercraft.core.lua.ILuaMachine;
import dan200.computercraft.core.lua.MachineResult;
import java.util.concurrent.TimeUnit;
/**
* Used to measure how long a computer has executed for, and thus the relevant timeout states.
* <p>
* Timeouts are mostly used for execution of Lua code: we should ideally never have a state where constructing the APIs
* or machines themselves takes more than a fraction of a second.
* <p>
* When a computer runs, it is allowed to run for 7 seconds ({@link #TIMEOUT}). After that point, the "soft abort" flag
* is set ({@link #isSoftAborted()}). Here, the Lua machine will attempt to abort the program in some safe manner
* (namely, throwing a "Too long without yielding" error).
* <p>
* Now, if a computer still does not stop after that period, they're behaving really badly. 1.5 seconds after a soft
* abort ({@link #ABORT_TIMEOUT}), we trigger a hard abort (note, this is done from the computer thread manager). This
* will destroy the entire Lua runtime and shut the computer down.
* <p>
* The Lua runtime is also allowed to pause execution if there are other computers contesting for work. All computers
* are allowed to run for {@link ComputerThread#scaledPeriod()} nanoseconds (see {@link #currentDeadline}). After that
* period, if any computers are waiting to be executed then we'll set the paused flag to true ({@link #isPaused()}.
*
* @see ComputerThread
* @see ILuaMachine
* @see MachineResult#isPause()
*/
public final class TimeoutState {
/**
* The total time a task is allowed to run before aborting in nanoseconds.
*/
static final long TIMEOUT = TimeUnit.MILLISECONDS.toNanos(7000);
/**
* The time the task is allowed to run after each abort in nanoseconds.
*/
static final long ABORT_TIMEOUT = TimeUnit.MILLISECONDS.toNanos(1500);
/**
* The error message to display when we trigger an abort.
*/
public static final String ABORT_MESSAGE = "Too long without yielding";
private final ComputerThread scheduler;
private boolean paused;
private boolean softAbort;
private volatile boolean hardAbort;
/**
* When the cumulative time would have started had the whole event been processed in one go.
*/
private long cumulativeStart;
/**
* How much cumulative time has elapsed. This is effectively {@code cumulativeStart - currentStart}.
*/
private long cumulativeElapsed;
/**
* When this execution round started.
*/
private long currentStart;
/**
* When this execution round should look potentially be paused.
*/
private long currentDeadline;
public TimeoutState(ComputerThread scheduler) {
this.scheduler = scheduler;
}
long nanoCumulative() {
return System.nanoTime() - cumulativeStart;
}
long nanoCurrent() {
return System.nanoTime() - currentStart;
}
/**
* Recompute the {@link #isSoftAborted()} and {@link #isPaused()} flags.
*/
public synchronized void refresh() {
// Important: The weird arithmetic here is important, as nanoTime may return negative values, and so we
// need to handle overflow.
var now = System.nanoTime();
if (!paused) paused = currentDeadline - now <= 0 && scheduler.hasPendingWork(); // now >= currentDeadline
if (!softAbort) softAbort = now - cumulativeStart - TIMEOUT >= 0; // now - cumulativeStart >= TIMEOUT
}
/**
* Whether we should pause execution of this machine.
* <p>
* This is determined by whether we've consumed our time slice, and if there are other computers waiting to perform
* work.
*
* @return Whether we should pause execution.
*/
public boolean isPaused() {
return paused;
}
/**
* If the machine should be passively aborted.
*
* @return {@code true} if we should throw a timeout error.
*/
public boolean isSoftAborted() {
return softAbort;
}
/**
* Determine if the machine should be forcibly aborted.
*
* @return {@code true} if the machine should be forcibly shut down.
*/
public boolean isHardAborted() {
return hardAbort;
}
/**
* If the machine should be forcibly aborted.
*/
void hardAbort() {
softAbort = hardAbort = true;
}
/**
* Start the current and cumulative timers again.
*/
void startTimer() {
var now = System.nanoTime();
currentStart = now;
currentDeadline = now + scheduler.scaledPeriod();
// Compute the "nominal start time".
cumulativeStart = now - cumulativeElapsed;
}
/**
* Pauses the cumulative time, to be resumed by {@link #startTimer()}.
*
* @see #nanoCumulative()
*/
synchronized void pauseTimer() {
// We set the cumulative time to difference between current time and "nominal start time".
cumulativeElapsed = System.nanoTime() - cumulativeStart;
paused = false;
}
/**
* Resets the cumulative time and resets the abort flags.
*/
synchronized void stopTimer() {
cumulativeElapsed = 0;
paused = softAbort = hardAbort = false;
}
}

View File

@@ -0,0 +1,159 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer.mainthread;
import dan200.computercraft.core.CoreConfig;
import dan200.computercraft.core.metrics.MetricsObserver;
import java.util.HashSet;
import java.util.TreeSet;
/**
* Runs tasks on the main (server) thread, ticks {@link MainThreadExecutor}s, and limits how much time is used this
* tick.
* <p>
* Similar to {@link MainThreadExecutor}, the {@link MainThread} can be in one of three states: cool, hot and cooling.
* However, the implementation here is a little different:
* <p>
* {@link MainThread} starts cool, and runs as many tasks as it can in the current {@link #budget}ns. Any external tasks
* (those run by tile entities, etc...) will also consume the budget
* <p>
* Next tick, we add {@link CoreConfig#maxMainGlobalTime} to our budget (clamp it to that value too). If we're still
* over budget, then we should not execute <em>any</em> work (either as part of {@link MainThread} or externally).
*/
public final class MainThread implements MainThreadScheduler {
/**
* The queue of {@link MainThreadExecutor}s with tasks to perform.
*/
private final TreeSet<MainThreadExecutor> executors = new TreeSet<>((a, b) -> {
if (a == b) return 0; // Should never happen, but let's be consistent here
long at = a.virtualTime, bt = b.virtualTime;
if (at == bt) return Integer.compare(a.hashCode(), b.hashCode());
return at < bt ? -1 : 1;
});
/**
* The set of executors which went over budget in a previous tick, and are waiting for their time to run down.
*
* @see MainThreadExecutor#tickCooling()
* @see #cooling(MainThreadExecutor)
*/
private final HashSet<MainThreadExecutor> cooling = new HashSet<>();
/**
* The current tick number. This is used by {@link MainThreadExecutor} to determine when to reset its own time
* counter.
*
* @see #currentTick()
*/
private int currentTick;
/**
* The remaining budgeted time for this tick. This may be negative, in the case that we've gone over budget.
*/
private long budget;
/**
* Whether we should be executing any work this tick.
* <p>
* This is true iff {@code MAX_TICK_TIME - currentTime} was true <em>at the beginning of the tick</em>.
*/
private boolean canExecute = true;
private long minimumTime = 0;
public MainThread() {
}
void queue(MainThreadExecutor executor) {
synchronized (executors) {
if (executor.onQueue) throw new IllegalStateException("Cannot queue already queued executor");
executor.onQueue = true;
executor.updateTime();
// We're not currently on the queue, so update its current execution time to
// ensure it's at least as high as the minimum.
var newRuntime = minimumTime;
// Slow down new computers a little bit.
if (executor.virtualTime == 0) newRuntime += CoreConfig.maxMainComputerTime;
executor.virtualTime = Math.max(newRuntime, executor.virtualTime);
executors.add(executor);
}
}
void cooling(MainThreadExecutor executor) {
cooling.add(executor);
}
void consumeTime(long time) {
budget -= time;
}
boolean canExecute() {
return canExecute;
}
int currentTick() {
return currentTick;
}
public void tick() {
// Move onto the next tick and cool down the global executor. We're allowed to execute if we have _any_ time
// allocated for this tick. This means we'll stick much closer to doing MAX_TICK_TIME work every tick.
//
// Of course, we'll go over the MAX_TICK_TIME most of the time, but eventually that overrun will accumulate
// and we'll skip a whole tick - bringing the average back down again.
currentTick++;
budget = Math.min(budget + CoreConfig.maxMainGlobalTime, CoreConfig.maxMainGlobalTime);
canExecute = budget > 0;
// Cool down any warm computers.
cooling.removeIf(MainThreadExecutor::tickCooling);
if (!canExecute) return;
// Run until we meet the deadline.
var start = System.nanoTime();
var deadline = start + budget;
while (true) {
MainThreadExecutor executor;
synchronized (executors) {
executor = executors.pollFirst();
}
if (executor == null) break;
var taskStart = System.nanoTime();
executor.execute();
var taskStop = System.nanoTime();
synchronized (executors) {
if (executor.afterExecute(taskStop - taskStart)) executors.add(executor);
// Compute the new minimum time (including the next task on the queue too). Note that this may also include
// time spent in external tasks.
var newMinimum = executor.virtualTime;
if (!executors.isEmpty()) {
var next = executors.first();
if (next.virtualTime < newMinimum) newMinimum = next.virtualTime;
}
minimumTime = Math.max(minimumTime, newMinimum);
}
if (taskStop >= deadline) break;
}
consumeTime(System.nanoTime() - start);
}
@Override
public Executor createExecutor(MetricsObserver metrics) {
return new MainThreadExecutor(metrics, this);
}
}

View File

@@ -0,0 +1,233 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer.mainthread;
import dan200.computercraft.api.peripheral.IWorkMonitor;
import dan200.computercraft.core.CoreConfig;
import dan200.computercraft.core.computer.Computer;
import dan200.computercraft.core.metrics.Metrics;
import dan200.computercraft.core.metrics.MetricsObserver;
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.TimeUnit;
/**
* Keeps track of tasks that a {@link Computer} should run on the main thread and how long that has been spent executing
* them.
* <p>
* This provides rate-limiting mechanism for tasks enqueued with {@link Computer#queueMainThread(Runnable)}, but also
* those run elsewhere (such as during the turtle's tick). In order to handle this, the executor goes through three
* stages:
* <p>
* When {@link State#COOL}, the computer is allocated {@link CoreConfig#maxMainComputerTime}ns to execute any work
* this tick. At the beginning of the tick, we execute as many {@link MainThread} tasks as possible, until our
* time-frame or the global time frame has expired.
* <p>
* Then, when other objects (such as block entities or entities) are ticked, we update how much time we've used via
* {@link IWorkMonitor#trackWork(long, TimeUnit)}.
* <p>
* Now, if anywhere during this period, we use more than our allocated time slice, the executor is marked as
* {@link State#HOT}. This means it will no longer be able to execute {@link MainThread} tasks (though will still
* execute tile entity tasks, in order to prevent the main thread from exhausting work every tick).
* <p>
* At the beginning of the next tick, we increment the budget e by {@link CoreConfig#maxMainComputerTime} and any
* {@link State#HOT} executors are marked as {@link State#COOLING}. They will remain cooling until their budget is fully
* replenished (is equal to {@link CoreConfig#maxMainComputerTime}). Note, this is different to {@link MainThread},
* which allows running when it has any budget left. When cooling, <em>no</em> tasks are executed - be they on the tile
* entity or main thread.
* <p>
* This mechanism means that, on average, computers will use at most {@link CoreConfig#maxMainComputerTime}ns per
* second, but one task source will not prevent others from executing.
*
* @see MainThread
* @see IWorkMonitor
* @see Computer#getMainThreadMonitor()
* @see Computer#queueMainThread(Runnable)
*/
final class MainThreadExecutor implements MainThreadScheduler.Executor {
/**
* The maximum number of {@link MainThread} tasks allowed on the queue.
*/
private static final int MAX_TASKS = 5000;
private final MetricsObserver metrics;
/**
* A lock used for any changes to {@link #tasks}, or {@link #onQueue}. This will be
* used on the main thread, so locks should be kept as brief as possible.
*/
private final Object queueLock = new Object();
/**
* The queue of tasks which should be executed.
*
* @see #queueLock
*/
private final Queue<Runnable> tasks = new ArrayDeque<>(4);
/**
* Determines if this executor is currently present on the queue.
* <p>
* This should be true iff {@link #tasks} is non-empty.
*
* @see #queueLock
* @see #enqueue(Runnable)
* @see #afterExecute(long)
*/
volatile boolean onQueue;
/**
* The remaining budgeted time for this tick. This may be negative, in the case that we've gone over budget.
*
* @see #tickCooling()
* @see #consumeTime(long)
*/
private long budget = 0;
/**
* The last tick that {@link #budget} was updated.
*
* @see #tickCooling()
* @see #consumeTime(long)
*/
private int currentTick = -1;
/**
* The current state of this executor.
*
* @see #canWork()
*/
private State state = State.COOL;
private long pendingTime;
long virtualTime;
private final MainThread scheduler;
MainThreadExecutor(MetricsObserver metrics, MainThread scheduler) {
this.metrics = metrics;
this.scheduler = scheduler;
}
/**
* Push a task onto this executor's queue, pushing it onto the {@link MainThread} if needed.
*
* @param runnable The task to run on the main thread.
* @return Whether this task was enqueued (namely, was there space).
*/
@Override
public boolean enqueue(Runnable runnable) {
synchronized (queueLock) {
if (tasks.size() >= MAX_TASKS || !tasks.offer(runnable)) return false;
if (!onQueue && state == State.COOL) scheduler.queue(this);
return true;
}
}
void execute() {
if (state != State.COOL) return;
Runnable task;
synchronized (queueLock) {
task = tasks.poll();
}
if (task != null) task.run();
}
/**
* Update the time taken to run an {@link #enqueue(Runnable)} task.
*
* @param time The time some task took to run.
* @return Whether this should be added back to the queue.
*/
boolean afterExecute(long time) {
consumeTime(time);
synchronized (queueLock) {
virtualTime += time;
updateTime();
if (state != State.COOL || tasks.isEmpty()) return onQueue = false;
return true;
}
}
/**
* Whether we should execute "external" tasks (ones not part of {@link #tasks}).
*
* @return Whether we can execute external tasks.
*/
@Override
public boolean canWork() {
return state != State.COOLING && scheduler.canExecute();
}
@Override
public boolean shouldWork() {
return state == State.COOL && scheduler.canExecute();
}
@Override
public void trackWork(long time, TimeUnit unit) {
var nanoTime = unit.toNanos(time);
synchronized (queueLock) {
pendingTime += nanoTime;
}
consumeTime(nanoTime);
scheduler.consumeTime(nanoTime);
}
private void consumeTime(long time) {
metrics.observe(Metrics.SERVER_TASKS, time);
// Reset the budget if moving onto a new tick. We know this is safe, as this will only have happened if
// #tickCooling() isn't called, and so we didn't overrun the previous tick.
if (currentTick != scheduler.currentTick()) {
currentTick = scheduler.currentTick();
budget = CoreConfig.maxMainComputerTime;
}
budget -= time;
// If we've gone over our limit, mark us as having to cool down.
if (budget < 0 && state == State.COOL) {
state = State.HOT;
scheduler.cooling(this);
}
}
/**
* Move this executor forward one tick, replenishing the budget by {@link CoreConfig#maxMainComputerTime}.
*
* @return Whether this executor has cooled down, and so is safe to run again.
*/
boolean tickCooling() {
state = State.COOLING;
currentTick = scheduler.currentTick();
budget = Math.min(budget + CoreConfig.maxMainComputerTime, CoreConfig.maxMainComputerTime);
if (budget < CoreConfig.maxMainComputerTime) return false;
state = State.COOL;
synchronized (queueLock) {
if (!tasks.isEmpty() && !onQueue) scheduler.queue(this);
}
return true;
}
void updateTime() {
virtualTime += pendingTime;
pendingTime = 0;
}
private enum State {
COOL,
HOT,
COOLING,
}
}

View File

@@ -0,0 +1,41 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer.mainthread;
import dan200.computercraft.api.peripheral.IWorkMonitor;
import dan200.computercraft.core.metrics.MetricsObserver;
import java.util.OptionalLong;
/**
* A {@link MainThreadScheduler} is responsible for running work on the main thread, for instance the server thread in
* Minecraft.
*
* @see MainThread is the default implementation
*/
public interface MainThreadScheduler {
/**
* Create an executor for a computer. This should only be called once for a single computer.
*
* @param observer A sink for metrics, used to monitor task timings.
* @return The executor for this computer.
*/
Executor createExecutor(MetricsObserver observer);
/**
* An {@link Executor} is responsible for managing scheduled tasks for a single computer.
*/
interface Executor extends IWorkMonitor {
/**
* Schedule a task to be run on the main thread. This can be called from any thread.
*
* @param task The task to schedule.
* @return The task ID if the task could be scheduled, or {@link OptionalLong#empty()} if the task failed to
* be scheduled.
*/
boolean enqueue(Runnable task);
}
}

View File

@@ -0,0 +1,44 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.computer.mainthread;
import dan200.computercraft.core.metrics.MetricsObserver;
import javax.annotation.Nonnull;
import java.util.concurrent.TimeUnit;
/**
* A {@link MainThreadScheduler} which fails when a computer tries to enqueue work.
* <p>
* This is useful for emulators, where we'll never make any main thread calls.
*/
public class NoWorkMainThreadScheduler implements MainThreadScheduler {
@Override
public Executor createExecutor(MetricsObserver observer) {
return new ExecutorImpl();
}
private static class ExecutorImpl implements Executor {
@Override
public boolean enqueue(Runnable task) {
throw new IllegalStateException("Cannot schedule tasks");
}
@Override
public boolean canWork() {
return false;
}
@Override
public boolean shouldWork() {
return false;
}
@Override
public void trackWork(long time, @Nonnull TimeUnit unit) {
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import java.io.Closeable;
import java.io.IOException;
import java.nio.channels.Channel;
/**
* Wraps some closeable object such as a buffered writer, and the underlying stream.
* <p>
* When flushing a buffer before closing, some implementations will not close the buffer if an exception is thrown
* this causes us to release the channel, but not actually close it. This wrapper will attempt to close the wrapper (and
* so hopefully flush the channel), and then close the underlying channel.
*
* @param <T> The type of the closeable object to write.
*/
class ChannelWrapper<T extends Closeable> implements Closeable {
private final T wrapper;
private final Channel channel;
ChannelWrapper(T wrapper, Channel channel) {
this.wrapper = wrapper;
this.channel = channel;
}
@Override
public void close() throws IOException {
try {
wrapper.close();
} finally {
channel.close();
}
}
T get() {
return wrapper;
}
}

View File

@@ -0,0 +1,117 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.IMount;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class ComboMount implements IMount {
private final IMount[] parts;
public ComboMount(IMount[] parts) {
this.parts = parts;
}
// IMount implementation
@Override
public boolean exists(@Nonnull String path) throws IOException {
for (var i = parts.length - 1; i >= 0; --i) {
var part = parts[i];
if (part.exists(path)) {
return true;
}
}
return false;
}
@Override
public boolean isDirectory(@Nonnull String path) throws IOException {
for (var i = parts.length - 1; i >= 0; --i) {
var part = parts[i];
if (part.isDirectory(path)) {
return true;
}
}
return false;
}
@Override
public void list(@Nonnull String path, @Nonnull List<String> contents) throws IOException {
// Combine the lists from all the mounts
List<String> foundFiles = null;
var foundDirs = 0;
for (var i = parts.length - 1; i >= 0; --i) {
var part = parts[i];
if (part.exists(path) && part.isDirectory(path)) {
if (foundFiles == null) {
foundFiles = new ArrayList<>();
}
part.list(path, foundFiles);
foundDirs++;
}
}
if (foundDirs == 1) {
// We found one directory, so we know it already doesn't contain duplicates
contents.addAll(foundFiles);
} else if (foundDirs > 1) {
// We found multiple directories, so filter for duplicates
Set<String> seen = new HashSet<>();
for (var file : foundFiles) {
if (seen.add(file)) {
contents.add(file);
}
}
} else {
throw new FileOperationException(path, "Not a directory");
}
}
@Override
public long getSize(@Nonnull String path) throws IOException {
for (var i = parts.length - 1; i >= 0; --i) {
var part = parts[i];
if (part.exists(path)) {
return part.getSize(path);
}
}
throw new FileOperationException(path, "No such file");
}
@Nonnull
@Override
public ReadableByteChannel openForRead(@Nonnull String path) throws IOException {
for (var i = parts.length - 1; i >= 0; --i) {
var part = parts[i];
if (part.exists(path) && !part.isDirectory(path)) {
return part.openForRead(path);
}
}
throw new FileOperationException(path, "No such file");
}
@Nonnull
@Override
public BasicFileAttributes getAttributes(@Nonnull String path) throws IOException {
for (var i = parts.length - 1; i >= 0; --i) {
var part = parts[i];
if (part.exists(path) && !part.isDirectory(path)) {
return part.getAttributes(path);
}
}
throw new FileOperationException(path, "No such file");
}
}

View File

@@ -0,0 +1,41 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.IMount;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.nio.channels.ReadableByteChannel;
import java.util.List;
public class EmptyMount implements IMount {
@Override
public boolean exists(@Nonnull String path) {
return path.isEmpty();
}
@Override
public boolean isDirectory(@Nonnull String path) {
return path.isEmpty();
}
@Override
public void list(@Nonnull String path, @Nonnull List<String> contents) {
}
@Override
public long getSize(@Nonnull String path) {
return 0;
}
@Nonnull
@Override
public ReadableByteChannel openForRead(@Nonnull String path) throws IOException {
throw new FileOperationException(path, "No such file");
}
}

View File

@@ -0,0 +1,352 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import com.google.common.collect.Sets;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.IWritableMount;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Collections;
import java.util.List;
import java.util.OptionalLong;
import java.util.Set;
public class FileMount implements IWritableMount {
private static final Logger LOG = LoggerFactory.getLogger(FileMount.class);
private static final int MINIMUM_FILE_SIZE = 500;
private static final Set<OpenOption> READ_OPTIONS = Collections.singleton(StandardOpenOption.READ);
private static final Set<OpenOption> WRITE_OPTIONS = Sets.newHashSet(StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
private static final Set<OpenOption> APPEND_OPTIONS = Sets.newHashSet(StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
private class WritableCountingChannel implements WritableByteChannel {
private final WritableByteChannel inner;
long ignoredBytesLeft;
WritableCountingChannel(WritableByteChannel inner, long bytesToIgnore) {
this.inner = inner;
ignoredBytesLeft = bytesToIgnore;
}
@Override
public int write(@Nonnull ByteBuffer b) throws IOException {
count(b.remaining());
return inner.write(b);
}
void count(long n) throws IOException {
ignoredBytesLeft -= n;
if (ignoredBytesLeft < 0) {
var newBytes = -ignoredBytesLeft;
ignoredBytesLeft = 0;
var bytesLeft = capacity - usedSpace;
if (newBytes > bytesLeft) throw new IOException("Out of space");
usedSpace += newBytes;
}
}
@Override
public boolean isOpen() {
return inner.isOpen();
}
@Override
public void close() throws IOException {
inner.close();
}
}
private class SeekableCountingChannel extends WritableCountingChannel implements SeekableByteChannel {
private final SeekableByteChannel inner;
SeekableCountingChannel(SeekableByteChannel inner, long bytesToIgnore) {
super(inner, bytesToIgnore);
this.inner = inner;
}
@Override
public SeekableByteChannel position(long newPosition) throws IOException {
if (!isOpen()) throw new ClosedChannelException();
if (newPosition < 0) {
throw new IllegalArgumentException("Cannot seek before the beginning of the stream");
}
var delta = newPosition - inner.position();
if (delta < 0) {
ignoredBytesLeft -= delta;
} else {
count(delta);
}
return inner.position(newPosition);
}
@Override
public SeekableByteChannel truncate(long size) throws IOException {
throw new IOException("Not yet implemented");
}
@Override
public int read(ByteBuffer dst) throws ClosedChannelException {
if (!inner.isOpen()) throw new ClosedChannelException();
throw new NonReadableChannelException();
}
@Override
public long position() throws IOException {
return inner.position();
}
@Override
public long size() throws IOException {
return inner.size();
}
}
private final File rootPath;
private final long capacity;
private long usedSpace;
public FileMount(File rootPath, long capacity) {
this.rootPath = rootPath;
this.capacity = capacity + MINIMUM_FILE_SIZE;
usedSpace = created() ? measureUsedSpace(this.rootPath) : MINIMUM_FILE_SIZE;
}
// IMount implementation
@Override
public boolean exists(@Nonnull String path) {
if (!created()) return path.isEmpty();
var file = getRealPath(path);
return file.exists();
}
@Override
public boolean isDirectory(@Nonnull String path) {
if (!created()) return path.isEmpty();
var file = getRealPath(path);
return file.exists() && file.isDirectory();
}
@Override
public void list(@Nonnull String path, @Nonnull List<String> contents) throws IOException {
if (!created()) {
if (!path.isEmpty()) throw new FileOperationException(path, "Not a directory");
return;
}
var file = getRealPath(path);
if (!file.exists() || !file.isDirectory()) throw new FileOperationException(path, "Not a directory");
var paths = file.list();
for (var subPath : paths) {
if (new File(file, subPath).exists()) contents.add(subPath);
}
}
@Override
public long getSize(@Nonnull String path) throws IOException {
if (!created()) {
if (path.isEmpty()) return 0;
} else {
var file = getRealPath(path);
if (file.exists()) return file.isDirectory() ? 0 : file.length();
}
throw new FileOperationException(path, "No such file");
}
@Nonnull
@Override
public ReadableByteChannel openForRead(@Nonnull String path) throws IOException {
if (created()) {
var file = getRealPath(path);
if (file.exists() && !file.isDirectory()) return FileChannel.open(file.toPath(), READ_OPTIONS);
}
throw new FileOperationException(path, "No such file");
}
@Nonnull
@Override
public BasicFileAttributes getAttributes(@Nonnull String path) throws IOException {
if (created()) {
var file = getRealPath(path);
if (file.exists()) return Files.readAttributes(file.toPath(), BasicFileAttributes.class);
}
throw new FileOperationException(path, "No such file");
}
// IWritableMount implementation
@Override
public void makeDirectory(@Nonnull String path) throws IOException {
create();
var file = getRealPath(path);
if (file.exists()) {
if (!file.isDirectory()) throw new FileOperationException(path, "File exists");
return;
}
var dirsToCreate = 1;
var parent = file.getParentFile();
while (!parent.exists()) {
++dirsToCreate;
parent = parent.getParentFile();
}
if (getRemainingSpace() < dirsToCreate * MINIMUM_FILE_SIZE) {
throw new FileOperationException(path, "Out of space");
}
if (file.mkdirs()) {
usedSpace += dirsToCreate * MINIMUM_FILE_SIZE;
} else {
throw new FileOperationException(path, "Access denied");
}
}
@Override
public void delete(@Nonnull String path) throws IOException {
if (path.isEmpty()) throw new FileOperationException(path, "Access denied");
if (created()) {
var file = getRealPath(path);
if (file.exists()) deleteRecursively(file);
}
}
private void deleteRecursively(File file) throws IOException {
// Empty directories first
if (file.isDirectory()) {
var children = file.list();
for (var aChildren : children) {
deleteRecursively(new File(file, aChildren));
}
}
// Then delete
var fileSize = file.isDirectory() ? 0 : file.length();
var success = file.delete();
if (success) {
usedSpace -= Math.max(MINIMUM_FILE_SIZE, fileSize);
} else {
throw new IOException("Access denied");
}
}
@Nonnull
@Override
public WritableByteChannel openForWrite(@Nonnull String path) throws IOException {
create();
var file = getRealPath(path);
if (file.exists() && file.isDirectory()) throw new FileOperationException(path, "Cannot write to directory");
if (file.exists()) {
usedSpace -= Math.max(file.length(), MINIMUM_FILE_SIZE);
} else if (getRemainingSpace() < MINIMUM_FILE_SIZE) {
throw new FileOperationException(path, "Out of space");
}
usedSpace += MINIMUM_FILE_SIZE;
return new SeekableCountingChannel(Files.newByteChannel(file.toPath(), WRITE_OPTIONS), MINIMUM_FILE_SIZE);
}
@Nonnull
@Override
public WritableByteChannel openForAppend(@Nonnull String path) throws IOException {
if (!created()) {
throw new FileOperationException(path, "No such file");
}
var file = getRealPath(path);
if (!file.exists()) throw new FileOperationException(path, "No such file");
if (file.isDirectory()) throw new FileOperationException(path, "Cannot write to directory");
// Allowing seeking when appending is not recommended, so we use a separate channel.
return new WritableCountingChannel(
Files.newByteChannel(file.toPath(), APPEND_OPTIONS),
Math.max(MINIMUM_FILE_SIZE - file.length(), 0)
);
}
@Override
public long getRemainingSpace() {
return Math.max(capacity - usedSpace, 0);
}
@Nonnull
@Override
public OptionalLong getCapacity() {
return OptionalLong.of(capacity - MINIMUM_FILE_SIZE);
}
private File getRealPath(String path) {
return new File(rootPath, path);
}
private boolean created() {
return rootPath.exists();
}
private void create() throws IOException {
if (!rootPath.exists()) {
var success = rootPath.mkdirs();
if (!success) {
throw new IOException("Access denied");
}
}
}
private static class Visitor extends SimpleFileVisitor<Path> {
long size;
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
size += MINIMUM_FILE_SIZE;
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
size += Math.max(attrs.size(), MINIMUM_FILE_SIZE);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
LOG.error("Error computing file size for {}", file, exc);
return FileVisitResult.CONTINUE;
}
}
private static long measureUsedSpace(File file) {
if (!file.exists()) return 0;
try {
var visitor = new Visitor();
Files.walkFileTree(file.toPath(), visitor);
return visitor.size;
} catch (IOException e) {
LOG.error("Error computing file size for {}", file, e);
return 0;
}
}
}

View File

@@ -0,0 +1,506 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import com.google.common.io.ByteStreams;
import dan200.computercraft.api.filesystem.IFileSystem;
import dan200.computercraft.api.filesystem.IMount;
import dan200.computercraft.api.filesystem.IWritableMount;
import dan200.computercraft.core.CoreConfig;
import dan200.computercraft.core.util.IoUtil;
import javax.annotation.Nonnull;
import java.io.Closeable;
import java.io.IOException;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.nio.channels.Channel;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.file.AccessDeniedException;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.function.Function;
import java.util.regex.Pattern;
public class FileSystem {
/**
* Maximum depth that {@link #copyRecursive(String, MountWrapper, String, MountWrapper, int)} will descend into.
* <p>
* This is a pretty arbitrary value, though hopefully it is large enough that it'll never be normally hit. This
* exists to prevent it overflowing if it ever gets into an infinite loop.
*/
private static final int MAX_COPY_DEPTH = 128;
private final FileSystemWrapperMount wrapper = new FileSystemWrapperMount(this);
private final Map<String, MountWrapper> mounts = new HashMap<>();
private final HashMap<WeakReference<FileSystemWrapper<?>>, ChannelWrapper<?>> openFiles = new HashMap<>();
private final ReferenceQueue<FileSystemWrapper<?>> openFileQueue = new ReferenceQueue<>();
public FileSystem(String rootLabel, IMount rootMount) throws FileSystemException {
mount(rootLabel, "", rootMount);
}
public FileSystem(String rootLabel, IWritableMount rootMount) throws FileSystemException {
mountWritable(rootLabel, "", rootMount);
}
public void close() {
// Close all dangling open files
synchronized (openFiles) {
for (Closeable file : openFiles.values()) IoUtil.closeQuietly(file);
openFiles.clear();
while (openFileQueue.poll() != null) ;
}
}
public synchronized void mount(String label, String location, IMount mount) throws FileSystemException {
if (mount == null) throw new NullPointerException();
location = sanitizePath(location);
if (location.contains("..")) throw new FileSystemException("Cannot mount below the root");
mount(new MountWrapper(label, location, mount));
}
public synchronized void mountWritable(String label, String location, IWritableMount mount) throws FileSystemException {
if (mount == null) {
throw new NullPointerException();
}
location = sanitizePath(location);
if (location.contains("..")) {
throw new FileSystemException("Cannot mount below the root");
}
mount(new MountWrapper(label, location, mount));
}
private synchronized void mount(MountWrapper wrapper) {
var location = wrapper.getLocation();
mounts.remove(location);
mounts.put(location, wrapper);
}
public synchronized void unmount(String path) {
var mount = mounts.remove(sanitizePath(path));
if (mount == null) return;
cleanup();
// Close any files which belong to this mount - don't want people writing to a disk after it's been ejected!
// There's no point storing a Mount -> Wrapper[] map, as openFiles is small and unmount isn't called very
// often.
synchronized (openFiles) {
for (var iterator = openFiles.keySet().iterator(); iterator.hasNext(); ) {
var reference = iterator.next();
var wrapper = reference.get();
if (wrapper == null) continue;
if (wrapper.mount == mount) {
wrapper.closeExternally();
iterator.remove();
}
}
}
}
public String combine(String path, String childPath) {
path = sanitizePath(path, true);
childPath = sanitizePath(childPath, true);
if (path.isEmpty()) {
return childPath;
} else if (childPath.isEmpty()) {
return path;
} else {
return sanitizePath(path + '/' + childPath, true);
}
}
public static String getDirectory(String path) {
path = sanitizePath(path, true);
if (path.isEmpty()) {
return "..";
}
var lastSlash = path.lastIndexOf('/');
if (lastSlash >= 0) {
return path.substring(0, lastSlash);
} else {
return "";
}
}
public static String getName(String path) {
path = sanitizePath(path, true);
if (path.isEmpty()) return "root";
var lastSlash = path.lastIndexOf('/');
return lastSlash >= 0 ? path.substring(lastSlash + 1) : path;
}
public synchronized long getSize(String path) throws FileSystemException {
return getMount(sanitizePath(path)).getSize(sanitizePath(path));
}
public synchronized BasicFileAttributes getAttributes(String path) throws FileSystemException {
return getMount(sanitizePath(path)).getAttributes(sanitizePath(path));
}
public synchronized String[] list(String path) throws FileSystemException {
path = sanitizePath(path);
var mount = getMount(path);
// Gets a list of the files in the mount
List<String> list = new ArrayList<>();
mount.list(path, list);
// Add any mounts that are mounted at this location
for (var otherMount : mounts.values()) {
if (getDirectory(otherMount.getLocation()).equals(path)) {
list.add(getName(otherMount.getLocation()));
}
}
// Return list
var array = new String[list.size()];
list.toArray(array);
Arrays.sort(array);
return array;
}
private void findIn(String dir, List<String> matches, Pattern wildPattern) throws FileSystemException {
var list = list(dir);
for (var entry : list) {
var entryPath = dir.isEmpty() ? entry : dir + "/" + entry;
if (wildPattern.matcher(entryPath).matches()) {
matches.add(entryPath);
}
if (isDir(entryPath)) {
findIn(entryPath, matches, wildPattern);
}
}
}
public synchronized String[] find(String wildPath) throws FileSystemException {
// Match all the files on the system
wildPath = sanitizePath(wildPath, true);
// If we don't have a wildcard at all just check the file exists
var starIndex = wildPath.indexOf('*');
if (starIndex == -1) {
return exists(wildPath) ? new String[]{ wildPath } : new String[0];
}
// Find the all non-wildcarded directories. For instance foo/bar/baz* -> foo/bar
var prevDir = wildPath.substring(0, starIndex).lastIndexOf('/');
var startDir = prevDir == -1 ? "" : wildPath.substring(0, prevDir);
// If this isn't a directory then just abort
if (!isDir(startDir)) return new String[0];
// Scan as normal, starting from this directory
var wildPattern = Pattern.compile("^\\Q" + wildPath.replaceAll("\\*", "\\\\E[^\\\\/]*\\\\Q") + "\\E$");
List<String> matches = new ArrayList<>();
findIn(startDir, matches, wildPattern);
// Return matches
var array = new String[matches.size()];
matches.toArray(array);
return array;
}
public synchronized boolean exists(String path) throws FileSystemException {
path = sanitizePath(path);
var mount = getMount(path);
return mount.exists(path);
}
public synchronized boolean isDir(String path) throws FileSystemException {
path = sanitizePath(path);
var mount = getMount(path);
return mount.isDirectory(path);
}
public synchronized boolean isReadOnly(String path) throws FileSystemException {
path = sanitizePath(path);
var mount = getMount(path);
return mount.isReadOnly(path);
}
public synchronized String getMountLabel(String path) throws FileSystemException {
path = sanitizePath(path);
var mount = getMount(path);
return mount.getLabel();
}
public synchronized void makeDir(String path) throws FileSystemException {
path = sanitizePath(path);
var mount = getMount(path);
mount.makeDirectory(path);
}
public synchronized void delete(String path) throws FileSystemException {
path = sanitizePath(path);
var mount = getMount(path);
mount.delete(path);
}
public synchronized void move(String sourcePath, String destPath) throws FileSystemException {
sourcePath = sanitizePath(sourcePath);
destPath = sanitizePath(destPath);
if (isReadOnly(sourcePath) || isReadOnly(destPath)) {
throw new FileSystemException("Access denied");
}
if (!exists(sourcePath)) {
throw new FileSystemException("No such file");
}
if (exists(destPath)) {
throw new FileSystemException("File exists");
}
if (contains(sourcePath, destPath)) {
throw new FileSystemException("Can't move a directory inside itself");
}
copy(sourcePath, destPath);
delete(sourcePath);
}
public synchronized void copy(String sourcePath, String destPath) throws FileSystemException {
sourcePath = sanitizePath(sourcePath);
destPath = sanitizePath(destPath);
if (isReadOnly(destPath)) {
throw new FileSystemException("/" + destPath + ": Access denied");
}
if (!exists(sourcePath)) {
throw new FileSystemException("/" + sourcePath + ": No such file");
}
if (exists(destPath)) {
throw new FileSystemException("/" + destPath + ": File exists");
}
if (contains(sourcePath, destPath)) {
throw new FileSystemException("/" + sourcePath + ": Can't copy a directory inside itself");
}
copyRecursive(sourcePath, getMount(sourcePath), destPath, getMount(destPath), 0);
}
private synchronized void copyRecursive(String sourcePath, MountWrapper sourceMount, String destinationPath, MountWrapper destinationMount, int depth) throws FileSystemException {
if (!sourceMount.exists(sourcePath)) return;
if (depth >= MAX_COPY_DEPTH) throw new FileSystemException("Too many directories to copy");
if (sourceMount.isDirectory(sourcePath)) {
// Copy a directory:
// Make the new directory
destinationMount.makeDirectory(destinationPath);
// Copy the source contents into it
List<String> sourceChildren = new ArrayList<>();
sourceMount.list(sourcePath, sourceChildren);
for (var child : sourceChildren) {
copyRecursive(
combine(sourcePath, child), sourceMount,
combine(destinationPath, child), destinationMount,
depth + 1
);
}
} else {
// Copy a file:
try (var source = sourceMount.openForRead(sourcePath);
var destination = destinationMount.openForWrite(destinationPath)) {
// Copy bytes as fast as we can
ByteStreams.copy(source, destination);
} catch (AccessDeniedException e) {
throw new FileSystemException("Access denied");
} catch (IOException e) {
throw new FileSystemException(e.getMessage());
}
}
}
private void cleanup() {
synchronized (openFiles) {
Reference<?> ref;
while ((ref = openFileQueue.poll()) != null) {
IoUtil.closeQuietly(openFiles.remove(ref));
}
}
}
private synchronized <T extends Closeable> FileSystemWrapper<T> openFile(@Nonnull MountWrapper mount, @Nonnull Channel channel, @Nonnull T file) throws FileSystemException {
synchronized (openFiles) {
if (CoreConfig.maximumFilesOpen > 0 &&
openFiles.size() >= CoreConfig.maximumFilesOpen) {
IoUtil.closeQuietly(file);
IoUtil.closeQuietly(channel);
throw new FileSystemException("Too many files already open");
}
var channelWrapper = new ChannelWrapper<T>(file, channel);
var fsWrapper = new FileSystemWrapper<T>(this, mount, channelWrapper, openFileQueue);
openFiles.put(fsWrapper.self, channelWrapper);
return fsWrapper;
}
}
void removeFile(FileSystemWrapper<?> handle) {
synchronized (openFiles) {
openFiles.remove(handle.self);
}
}
public synchronized <T extends Closeable> FileSystemWrapper<T> openForRead(String path, Function<ReadableByteChannel, T> open) throws FileSystemException {
cleanup();
path = sanitizePath(path);
var mount = getMount(path);
var channel = mount.openForRead(path);
return channel != null ? openFile(mount, channel, open.apply(channel)) : null;
}
public synchronized <T extends Closeable> FileSystemWrapper<T> openForWrite(String path, boolean append, Function<WritableByteChannel, T> open) throws FileSystemException {
cleanup();
path = sanitizePath(path);
var mount = getMount(path);
var channel = append ? mount.openForAppend(path) : mount.openForWrite(path);
return channel != null ? openFile(mount, channel, open.apply(channel)) : null;
}
public synchronized long getFreeSpace(String path) throws FileSystemException {
path = sanitizePath(path);
var mount = getMount(path);
return mount.getFreeSpace();
}
@Nonnull
public synchronized OptionalLong getCapacity(String path) throws FileSystemException {
path = sanitizePath(path);
var mount = getMount(path);
return mount.getCapacity();
}
private synchronized MountWrapper getMount(String path) throws FileSystemException {
// Return the deepest mount that contains a given path
var it = mounts.values().iterator();
MountWrapper match = null;
var matchLength = 999;
while (it.hasNext()) {
var mount = it.next();
if (contains(mount.getLocation(), path)) {
var len = toLocal(path, mount.getLocation()).length();
if (match == null || len < matchLength) {
match = mount;
matchLength = len;
}
}
}
if (match == null) {
throw new FileSystemException("/" + path + ": Invalid Path");
}
return match;
}
public IFileSystem getMountWrapper() {
return wrapper;
}
private static String sanitizePath(String path) {
return sanitizePath(path, false);
}
private static final Pattern threeDotsPattern = Pattern.compile("^\\.{3,}$");
public static String sanitizePath(String path, boolean allowWildcards) {
// Allow windowsy slashes
path = path.replace('\\', '/');
// Clean the path or illegal characters.
final var specialChars = new char[]{
'"', ':', '<', '>', '?', '|', // Sorted by ascii value (important)
};
var cleanName = new StringBuilder();
for (var i = 0; i < path.length(); i++) {
var c = path.charAt(i);
if (c >= 32 && Arrays.binarySearch(specialChars, c) < 0 && (allowWildcards || c != '*')) {
cleanName.append(c);
}
}
path = cleanName.toString();
// Collapse the string into its component parts, removing ..'s
var parts = path.split("/");
var outputParts = new Stack<String>();
for (var part : parts) {
if (part.isEmpty() || part.equals(".") || threeDotsPattern.matcher(part).matches()) {
// . is redundant
// ... and more are treated as .
continue;
}
if (part.equals("..")) {
// .. can cancel out the last folder entered
if (!outputParts.empty()) {
var top = outputParts.peek();
if (!top.equals("..")) {
outputParts.pop();
} else {
outputParts.push("..");
}
} else {
outputParts.push("..");
}
} else if (part.length() >= 255) {
// If part length > 255 and it is the last part
outputParts.push(part.substring(0, 255));
} else {
// Anything else we add to the stack
outputParts.push(part);
}
}
// Recombine the output parts into a new string
var result = new StringBuilder();
var it = outputParts.iterator();
while (it.hasNext()) {
var part = it.next();
result.append(part);
if (it.hasNext()) {
result.append('/');
}
}
return result.toString();
}
public static boolean contains(String pathA, String pathB) {
pathA = sanitizePath(pathA).toLowerCase(Locale.ROOT);
pathB = sanitizePath(pathB).toLowerCase(Locale.ROOT);
if (pathB.equals("..")) {
return false;
} else if (pathB.startsWith("../")) {
return false;
} else if (pathB.equals(pathA)) {
return true;
} else if (pathA.isEmpty()) {
return true;
} else {
return pathB.startsWith(pathA + "/");
}
}
public static String toLocal(String path, String location) {
path = sanitizePath(path);
location = sanitizePath(location);
assert contains(location, path);
var local = path.substring(location.length());
if (local.startsWith("/")) {
return local.substring(1);
} else {
return local;
}
}
}

View File

@@ -0,0 +1,17 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import java.io.Serial;
public class FileSystemException extends Exception {
@Serial
private static final long serialVersionUID = -2500631644868104029L;
FileSystemException(String s) {
super(s);
}
}

View File

@@ -0,0 +1,64 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import dan200.computercraft.core.util.IoUtil;
import javax.annotation.Nonnull;
import java.io.Closeable;
import java.io.IOException;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
/**
* An alternative closeable implementation that will free up resources in the filesystem.
* <p>
* The {@link FileSystem} maps weak references of this to its underlying object. If the wrapper has been disposed of
* (say, the Lua object referencing it has gone), then the wrapped object will be closed by the filesystem.
* <p>
* Closing this will stop the filesystem tracking it, reducing the current descriptor count.
* <p>
* In an ideal world, we'd just wrap the closeable. However, as we do some {@code instanceof} checks
* on the stream, it's not really possible as it'd require numerous instances.
*
* @param <T> The type of writer or channel to wrap.
*/
public class FileSystemWrapper<T extends Closeable> implements TrackingCloseable {
private final FileSystem fileSystem;
final MountWrapper mount;
private final ChannelWrapper<T> closeable;
final WeakReference<FileSystemWrapper<?>> self;
private boolean isOpen = true;
FileSystemWrapper(FileSystem fileSystem, MountWrapper mount, ChannelWrapper<T> closeable, ReferenceQueue<FileSystemWrapper<?>> queue) {
this.fileSystem = fileSystem;
this.mount = mount;
this.closeable = closeable;
self = new WeakReference<>(this, queue);
}
@Override
public void close() throws IOException {
isOpen = false;
fileSystem.removeFile(this);
closeable.close();
}
void closeExternally() {
isOpen = false;
IoUtil.closeQuietly(closeable);
}
@Override
public boolean isOpen() {
return isOpen;
}
@Nonnull
public T get() {
return closeable.get();
}
}

View File

@@ -0,0 +1,141 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import dan200.computercraft.api.filesystem.IFileSystem;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
public class FileSystemWrapperMount implements IFileSystem {
private final FileSystem filesystem;
public FileSystemWrapperMount(FileSystem filesystem) {
this.filesystem = filesystem;
}
@Override
public void makeDirectory(@Nonnull String path) throws IOException {
try {
filesystem.makeDir(path);
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Override
public void delete(@Nonnull String path) throws IOException {
try {
filesystem.delete(path);
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Nonnull
@Override
public ReadableByteChannel openForRead(@Nonnull String path) throws IOException {
try {
// FIXME: Think of a better way of implementing this, so closing this will close on the computer.
return filesystem.openForRead(path, Function.identity()).get();
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Nonnull
@Override
public WritableByteChannel openForWrite(@Nonnull String path) throws IOException {
try {
return filesystem.openForWrite(path, false, Function.identity()).get();
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Nonnull
@Override
public WritableByteChannel openForAppend(@Nonnull String path) throws IOException {
try {
return filesystem.openForWrite(path, true, Function.identity()).get();
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Override
public long getRemainingSpace() throws IOException {
try {
return filesystem.getFreeSpace("/");
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Override
public boolean exists(@Nonnull String path) throws IOException {
try {
return filesystem.exists(path);
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Override
public boolean isDirectory(@Nonnull String path) throws IOException {
try {
return filesystem.isDir(path);
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Override
public void list(@Nonnull String path, @Nonnull List<String> contents) throws IOException {
try {
Collections.addAll(contents, filesystem.list(path));
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Override
public long getSize(@Nonnull String path) throws IOException {
try {
return filesystem.getSize(path);
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Override
public String combine(String path, String child) {
return filesystem.combine(path, child);
}
@Override
public void copy(String from, String to) throws IOException {
try {
filesystem.copy(from, to);
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
@Override
public void move(String from, String to) throws IOException {
try {
filesystem.move(from, to);
} catch (FileSystemException e) {
throw new IOException(e.getMessage());
}
}
}

View File

@@ -0,0 +1,299 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.io.ByteStreams;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.IMount;
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
import dan200.computercraft.core.util.IoUtil;
import javax.annotation.Nonnull;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
public class JarMount implements IMount {
/**
* Only cache files smaller than 1MiB.
*/
private static final int MAX_CACHED_SIZE = 1 << 20;
/**
* Limit the entire cache to 64MiB.
*/
private static final int MAX_CACHE_SIZE = 64 << 20;
/**
* We maintain a cache of the contents of all files in the mount. This allows us to allow
* seeking within ROM files, and reduces the amount we need to access disk for computer startup.
*/
private static final Cache<FileEntry, byte[]> CONTENTS_CACHE = CacheBuilder.newBuilder()
.concurrencyLevel(4)
.expireAfterAccess(60, TimeUnit.SECONDS)
.maximumWeight(MAX_CACHE_SIZE)
.weakKeys()
.<FileEntry, byte[]>weigher((k, v) -> v.length)
.build();
/**
* We have a {@link ReferenceQueue} of all mounts, a long with their corresponding {@link ZipFile}. If
* the mount has been destroyed, we clean up after it.
*/
private static final ReferenceQueue<JarMount> MOUNT_QUEUE = new ReferenceQueue<>();
private final ZipFile zip;
private final FileEntry root;
public JarMount(File jarFile, String subPath) throws IOException {
// Cleanup any old mounts. It's unlikely that there will be any, but it's best to be safe.
cleanup();
if (!jarFile.exists() || jarFile.isDirectory()) throw new FileNotFoundException("Cannot find " + jarFile);
// Open the zip file
try {
zip = new ZipFile(jarFile);
} catch (IOException e) {
throw new IOException("Error loading zip file", e);
}
// Ensure the root entry exists.
if (zip.getEntry(subPath) == null) {
zip.close();
throw new FileNotFoundException("Zip does not contain path");
}
// We now create a weak reference to this mount. This is automatically added to the appropriate queue.
new MountReference(this);
// Read in all the entries
root = new FileEntry();
var zipEntries = zip.entries();
while (zipEntries.hasMoreElements()) {
var entry = zipEntries.nextElement();
var entryPath = entry.getName();
if (!entryPath.startsWith(subPath)) continue;
var localPath = FileSystem.toLocal(entryPath, subPath);
create(entry, localPath);
}
}
private FileEntry get(String path) {
var lastEntry = root;
var lastIndex = 0;
while (lastEntry != null && lastIndex < path.length()) {
var nextIndex = path.indexOf('/', lastIndex);
if (nextIndex < 0) nextIndex = path.length();
lastEntry = lastEntry.children == null ? null : lastEntry.children.get(path.substring(lastIndex, nextIndex));
lastIndex = nextIndex + 1;
}
return lastEntry;
}
private void create(ZipEntry entry, String localPath) {
var lastEntry = root;
var lastIndex = 0;
while (lastIndex < localPath.length()) {
var nextIndex = localPath.indexOf('/', lastIndex);
if (nextIndex < 0) nextIndex = localPath.length();
var part = localPath.substring(lastIndex, nextIndex);
if (lastEntry.children == null) lastEntry.children = new HashMap<>(0);
var nextEntry = lastEntry.children.get(part);
if (nextEntry == null || !nextEntry.isDirectory()) {
lastEntry.children.put(part, nextEntry = new FileEntry());
}
lastEntry = nextEntry;
lastIndex = nextIndex + 1;
}
lastEntry.setup(entry);
}
@Override
public boolean exists(@Nonnull String path) {
return get(path) != null;
}
@Override
public boolean isDirectory(@Nonnull String path) {
var file = get(path);
return file != null && file.isDirectory();
}
@Override
public void list(@Nonnull String path, @Nonnull List<String> contents) throws IOException {
var file = get(path);
if (file == null || !file.isDirectory()) throw new FileOperationException(path, "Not a directory");
file.list(contents);
}
@Override
public long getSize(@Nonnull String path) throws IOException {
var file = get(path);
if (file != null) return file.size;
throw new FileOperationException(path, "No such file");
}
@Nonnull
@Override
public ReadableByteChannel openForRead(@Nonnull String path) throws IOException {
var file = get(path);
if (file != null && !file.isDirectory()) {
var contents = CONTENTS_CACHE.getIfPresent(file);
if (contents != null) return new ArrayByteChannel(contents);
try {
var entry = zip.getEntry(file.path);
if (entry != null) {
try (var stream = zip.getInputStream(entry)) {
if (stream.available() > MAX_CACHED_SIZE) return Channels.newChannel(stream);
contents = ByteStreams.toByteArray(stream);
CONTENTS_CACHE.put(file, contents);
return new ArrayByteChannel(contents);
}
}
} catch (IOException e) {
// Treat errors as non-existence of file
}
}
throw new FileOperationException(path, "No such file");
}
@Nonnull
@Override
public BasicFileAttributes getAttributes(@Nonnull String path) throws IOException {
var file = get(path);
if (file != null) {
var entry = zip.getEntry(file.path);
if (entry != null) return new ZipEntryAttributes(entry);
}
throw new FileOperationException(path, "No such file");
}
private static class FileEntry {
String path;
long size;
Map<String, FileEntry> children;
void setup(ZipEntry entry) {
path = entry.getName();
size = entry.getSize();
if (children == null && entry.isDirectory()) children = new HashMap<>(0);
}
boolean isDirectory() {
return children != null;
}
void list(List<String> contents) {
if (children != null) contents.addAll(children.keySet());
}
}
private static class MountReference extends WeakReference<JarMount> {
final ZipFile file;
MountReference(JarMount file) {
super(file, MOUNT_QUEUE);
this.file = file.zip;
}
}
private static void cleanup() {
Reference<? extends JarMount> next;
while ((next = MOUNT_QUEUE.poll()) != null) IoUtil.closeQuietly(((MountReference) next).file);
}
private static class ZipEntryAttributes implements BasicFileAttributes {
private final ZipEntry entry;
ZipEntryAttributes(ZipEntry entry) {
this.entry = entry;
}
@Override
public FileTime lastModifiedTime() {
return orEpoch(entry.getLastModifiedTime());
}
@Override
public FileTime lastAccessTime() {
return orEpoch(entry.getLastAccessTime());
}
@Override
public FileTime creationTime() {
var time = entry.getCreationTime();
return time == null ? lastModifiedTime() : time;
}
@Override
public boolean isRegularFile() {
return !entry.isDirectory();
}
@Override
public boolean isDirectory() {
return entry.isDirectory();
}
@Override
public boolean isSymbolicLink() {
return false;
}
@Override
public boolean isOther() {
return false;
}
@Override
public long size() {
return entry.getSize();
}
@Override
public Object fileKey() {
return null;
}
private static final FileTime EPOCH = FileTime.from(Instant.EPOCH);
private static FileTime orEpoch(FileTime time) {
return time == null ? EPOCH : time;
}
}
}

View File

@@ -0,0 +1,239 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import dan200.computercraft.api.filesystem.FileOperationException;
import dan200.computercraft.api.filesystem.IMount;
import dan200.computercraft.api.filesystem.IWritableMount;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.file.AccessDeniedException;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.OptionalLong;
class MountWrapper {
private final String label;
private final String location;
private final IMount mount;
private final IWritableMount writableMount;
MountWrapper(String label, String location, IMount mount) {
this.label = label;
this.location = location;
this.mount = mount;
writableMount = null;
}
MountWrapper(String label, String location, IWritableMount mount) {
this.label = label;
this.location = location;
this.mount = mount;
writableMount = mount;
}
public String getLabel() {
return label;
}
public String getLocation() {
return location;
}
public long getFreeSpace() {
if (writableMount == null) return 0;
try {
return writableMount.getRemainingSpace();
} catch (IOException e) {
return 0;
}
}
public OptionalLong getCapacity() {
return writableMount == null ? OptionalLong.empty() : writableMount.getCapacity();
}
public boolean isReadOnly(String path) {
return writableMount == null;
}
public boolean exists(String path) throws FileSystemException {
path = toLocal(path);
try {
return mount.exists(path);
} catch (IOException e) {
throw localExceptionOf(path, e);
}
}
public boolean isDirectory(String path) throws FileSystemException {
path = toLocal(path);
try {
return mount.exists(path) && mount.isDirectory(path);
} catch (IOException e) {
throw localExceptionOf(path, e);
}
}
public void list(String path, List<String> contents) throws FileSystemException {
path = toLocal(path);
try {
if (!mount.exists(path) || !mount.isDirectory(path)) {
throw localExceptionOf(path, "Not a directory");
}
mount.list(path, contents);
} catch (IOException e) {
throw localExceptionOf(path, e);
}
}
public long getSize(String path) throws FileSystemException {
path = toLocal(path);
try {
if (!mount.exists(path)) throw localExceptionOf(path, "No such file");
return mount.isDirectory(path) ? 0 : mount.getSize(path);
} catch (IOException e) {
throw localExceptionOf(path, e);
}
}
@Nonnull
public BasicFileAttributes getAttributes(String path) throws FileSystemException {
path = toLocal(path);
try {
if (!mount.exists(path)) throw localExceptionOf(path, "No such file");
return mount.getAttributes(path);
} catch (IOException e) {
throw localExceptionOf(path, e);
}
}
public ReadableByteChannel openForRead(String path) throws FileSystemException {
path = toLocal(path);
try {
if (mount.exists(path) && !mount.isDirectory(path)) {
return mount.openForRead(path);
} else {
throw localExceptionOf(path, "No such file");
}
} catch (IOException e) {
throw localExceptionOf(path, e);
}
}
public void makeDirectory(String path) throws FileSystemException {
if (writableMount == null) throw exceptionOf(path, "Access denied");
path = toLocal(path);
try {
if (mount.exists(path)) {
if (!mount.isDirectory(path)) throw localExceptionOf(path, "File exists");
} else {
writableMount.makeDirectory(path);
}
} catch (IOException e) {
throw localExceptionOf(path, e);
}
}
public void delete(String path) throws FileSystemException {
if (writableMount == null) throw exceptionOf(path, "Access denied");
path = toLocal(path);
try {
if (mount.exists(path)) {
writableMount.delete(path);
}
} catch (AccessDeniedException e) {
throw new FileSystemException("Access denied");
} catch (IOException e) {
throw localExceptionOf(path, e);
}
}
public WritableByteChannel openForWrite(String path) throws FileSystemException {
if (writableMount == null) throw exceptionOf(path, "Access denied");
path = toLocal(path);
try {
if (mount.exists(path) && mount.isDirectory(path)) {
throw localExceptionOf(path, "Cannot write to directory");
} else {
if (!path.isEmpty()) {
var dir = FileSystem.getDirectory(path);
if (!dir.isEmpty() && !mount.exists(path)) {
writableMount.makeDirectory(dir);
}
}
return writableMount.openForWrite(path);
}
} catch (AccessDeniedException e) {
throw new FileSystemException("Access denied");
} catch (IOException e) {
throw localExceptionOf(path, e);
}
}
public WritableByteChannel openForAppend(String path) throws FileSystemException {
if (writableMount == null) throw exceptionOf(path, "Access denied");
path = toLocal(path);
try {
if (!mount.exists(path)) {
if (!path.isEmpty()) {
var dir = FileSystem.getDirectory(path);
if (!dir.isEmpty() && !mount.exists(path)) {
writableMount.makeDirectory(dir);
}
}
return writableMount.openForWrite(path);
} else if (mount.isDirectory(path)) {
throw localExceptionOf(path, "Cannot write to directory");
} else {
return writableMount.openForAppend(path);
}
} catch (AccessDeniedException e) {
throw new FileSystemException("Access denied");
} catch (IOException e) {
throw localExceptionOf(path, e);
}
}
private String toLocal(String path) {
return FileSystem.toLocal(path, location);
}
private FileSystemException localExceptionOf(@Nullable String localPath, @Nonnull IOException e) {
if (!location.isEmpty() && e instanceof FileOperationException ex) {
if (ex.getFilename() != null) return localExceptionOf(ex.getFilename(), ex.getMessage());
}
if (e instanceof java.nio.file.FileSystemException ex) {
// This error will contain the absolute path, leaking information about where MC is installed. We drop that,
// just taking the reason. We assume that the error refers to the input path.
var message = ex.getReason().trim();
return localPath == null ? new FileSystemException(message) : localExceptionOf(localPath, message);
}
return new FileSystemException(e.getMessage());
}
private FileSystemException localExceptionOf(String path, String message) {
if (!location.isEmpty()) path = path.isEmpty() ? location : location + "/" + path;
return exceptionOf(path, message);
}
private static FileSystemException exceptionOf(String path, String message) {
return new FileSystemException("/" + path + ": " + message);
}
}

View File

@@ -0,0 +1,60 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import dan200.computercraft.api.filesystem.IMount;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.nio.channels.ReadableByteChannel;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
public class SubMount implements IMount {
private final IMount parent;
private final String subPath;
public SubMount(IMount parent, String subPath) {
this.parent = parent;
this.subPath = subPath;
}
@Override
public boolean exists(@Nonnull String path) throws IOException {
return parent.exists(getFullPath(path));
}
@Override
public boolean isDirectory(@Nonnull String path) throws IOException {
return parent.isDirectory(getFullPath(path));
}
@Override
public void list(@Nonnull String path, @Nonnull List<String> contents) throws IOException {
parent.list(getFullPath(path), contents);
}
@Override
public long getSize(@Nonnull String path) throws IOException {
return parent.getSize(getFullPath(path));
}
@Nonnull
@Override
public ReadableByteChannel openForRead(@Nonnull String path) throws IOException {
return parent.openForRead(getFullPath(path));
}
@Nonnull
@Override
public BasicFileAttributes getAttributes(@Nonnull String path) throws IOException {
return parent.getAttributes(getFullPath(path));
}
private String getFullPath(String path) {
return path.isEmpty() ? subPath : subPath + "/" + path;
}
}

View File

@@ -0,0 +1,39 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.filesystem;
import java.io.Closeable;
import java.io.IOException;
/**
* A {@link Closeable} which knows when it has been closed.
* <p>
* This is a quick (though racey) way of providing more friendly (and more similar to Lua)
* error messages to the user.
*/
public interface TrackingCloseable extends Closeable {
boolean isOpen();
class Impl implements TrackingCloseable {
private final Closeable object;
private boolean isOpen = true;
public Impl(Closeable object) {
this.object = object;
}
@Override
public boolean isOpen() {
return isOpen;
}
@Override
public void close() throws IOException {
isOpen = false;
object.close();
}
}
}

View File

@@ -0,0 +1,65 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.lua;
import dan200.computercraft.api.lua.ILuaContext;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.MethodResult;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.asm.LuaMethod;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.squiddev.cobalt.LuaError;
import org.squiddev.cobalt.LuaState;
import org.squiddev.cobalt.Varargs;
import org.squiddev.cobalt.function.VarArgFunction;
/**
* An "optimised" version of {@link ResultInterpreterFunction} which is guaranteed to never yield.
* <p>
* As we never yield, we do not need to push a function to the stack, which removes a small amount of overhead.
*/
class BasicFunction extends VarArgFunction {
private static final Logger LOG = LoggerFactory.getLogger(BasicFunction.class);
private final CobaltLuaMachine machine;
private final LuaMethod method;
private final Object instance;
private final ILuaContext context;
private final String name;
BasicFunction(CobaltLuaMachine machine, LuaMethod method, Object instance, ILuaContext context, String name) {
this.machine = machine;
this.method = method;
this.instance = instance;
this.context = context;
this.name = name;
}
@Override
public Varargs invoke(LuaState luaState, Varargs args) throws LuaError {
var arguments = VarargArguments.of(args);
MethodResult results;
try {
results = method.apply(instance, context, arguments);
} catch (LuaException e) {
throw wrap(e);
} catch (Throwable t) {
LOG.error(Logging.JAVA_ERROR, "Error calling {} on {}", name, instance, t);
throw new LuaError("Java Exception Thrown: " + t, 0);
} finally {
arguments.close();
}
if (results.getCallback() != null) {
throw new IllegalStateException("Cannot have a yielding non-yielding function");
}
return machine.toValues(results.getResult());
}
public static LuaError wrap(LuaException exception) {
return exception.hasLevel() ? new LuaError(exception.getMessage()) : new LuaError(exception.getMessage(), exception.getLevel());
}
}

View File

@@ -0,0 +1,457 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.lua;
import dan200.computercraft.api.lua.IDynamicLuaObject;
import dan200.computercraft.api.lua.ILuaAPI;
import dan200.computercraft.api.lua.ILuaContext;
import dan200.computercraft.api.lua.ILuaFunction;
import dan200.computercraft.core.CoreConfig;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.asm.LuaMethod;
import dan200.computercraft.core.asm.ObjectSource;
import dan200.computercraft.core.computer.TimeoutState;
import dan200.computercraft.core.metrics.Metrics;
import dan200.computercraft.core.util.ThreadUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.squiddev.cobalt.*;
import org.squiddev.cobalt.compiler.CompileException;
import org.squiddev.cobalt.compiler.LoadState;
import org.squiddev.cobalt.debug.DebugFrame;
import org.squiddev.cobalt.debug.DebugHandler;
import org.squiddev.cobalt.debug.DebugState;
import org.squiddev.cobalt.lib.*;
import org.squiddev.cobalt.lib.platform.VoidResourceManipulator;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.InputStream;
import java.io.Serial;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import static org.squiddev.cobalt.ValueFactory.valueOf;
import static org.squiddev.cobalt.ValueFactory.varargsOf;
import static org.squiddev.cobalt.debug.DebugFrame.FLAG_HOOKED;
import static org.squiddev.cobalt.debug.DebugFrame.FLAG_HOOKYIELD;
public class CobaltLuaMachine implements ILuaMachine {
private static final Logger LOG = LoggerFactory.getLogger(CobaltLuaMachine.class);
private static final ThreadPoolExecutor COROUTINES = new ThreadPoolExecutor(
0, Integer.MAX_VALUE,
5L, TimeUnit.MINUTES,
new SynchronousQueue<>(),
ThreadUtils.factory("Coroutine")
);
private static final LuaMethod FUNCTION_METHOD = (target, context, args) -> ((ILuaFunction) target).call(args);
private final TimeoutState timeout;
private final TimeoutDebugHandler debug;
private final ILuaContext context;
private LuaState state;
private LuaTable globals;
private LuaThread mainRoutine = null;
private String eventFilter = null;
public CobaltLuaMachine(MachineEnvironment environment) {
timeout = environment.timeout();
context = environment.context();
debug = new TimeoutDebugHandler();
// Create an environment to run in
var metrics = environment.metrics();
var state = this.state = LuaState.builder()
.resourceManipulator(new VoidResourceManipulator())
.debug(debug)
.coroutineExecutor(command -> {
metrics.observe(Metrics.COROUTINES_CREATED);
COROUTINES.execute(() -> {
try {
command.run();
} finally {
metrics.observe(Metrics.COROUTINES_DISPOSED);
}
});
})
.build();
globals = new LuaTable();
state.setupThread(globals);
// Add basic libraries
globals.load(state, new BaseLib());
globals.load(state, new TableLib());
globals.load(state, new StringLib());
globals.load(state, new MathLib());
globals.load(state, new CoroutineLib());
globals.load(state, new Bit32Lib());
globals.load(state, new Utf8Lib());
globals.load(state, new DebugLib());
// Remove globals we don't want to expose
globals.rawset("collectgarbage", Constants.NIL);
globals.rawset("dofile", Constants.NIL);
globals.rawset("loadfile", Constants.NIL);
globals.rawset("print", Constants.NIL);
// Add version globals
globals.rawset("_VERSION", valueOf("Lua 5.1"));
globals.rawset("_HOST", valueOf(environment.hostString()));
globals.rawset("_CC_DEFAULT_SETTINGS", valueOf(CoreConfig.defaultComputerSettings));
if (CoreConfig.disableLua51Features) {
globals.rawset("_CC_DISABLE_LUA51_FEATURES", Constants.TRUE);
}
}
@Override
public void addAPI(@Nonnull ILuaAPI api) {
// Add the methods of an API to the global table
var table = wrapLuaObject(api);
if (table == null) {
LOG.warn("API {} does not provide any methods", api);
table = new LuaTable();
}
var names = api.getNames();
for (var name : names) globals.rawset(name, table);
}
@Override
public MachineResult loadBios(@Nonnull InputStream bios) {
// Begin executing a file (ie, the bios)
if (mainRoutine != null) return MachineResult.OK;
try {
var value = LoadState.load(state, bios, "@bios.lua", globals);
mainRoutine = new LuaThread(state, value, globals);
return MachineResult.OK;
} catch (CompileException e) {
close();
return MachineResult.error(e);
} catch (Exception e) {
LOG.warn("Could not load bios.lua", e);
close();
return MachineResult.GENERIC_ERROR;
}
}
@Override
public MachineResult handleEvent(String eventName, Object[] arguments) {
if (mainRoutine == null) return MachineResult.OK;
if (eventFilter != null && eventName != null && !eventName.equals(eventFilter) && !eventName.equals("terminate")) {
return MachineResult.OK;
}
// If the soft abort has been cleared then we can reset our flag.
timeout.refresh();
if (!timeout.isSoftAborted()) debug.thrownSoftAbort = false;
try {
Varargs resumeArgs = Constants.NONE;
if (eventName != null) {
resumeArgs = varargsOf(valueOf(eventName), toValues(arguments));
}
// Resume the current thread, or the main one when first starting off.
var thread = state.getCurrentThread();
if (thread == null || thread == state.getMainThread()) thread = mainRoutine;
var results = LuaThread.run(thread, resumeArgs);
if (timeout.isHardAborted()) throw HardAbortError.INSTANCE;
if (results == null) return MachineResult.PAUSE;
var filter = results.first();
eventFilter = filter.isString() ? filter.toString() : null;
if (mainRoutine.getStatus().equals("dead")) {
close();
return MachineResult.GENERIC_ERROR;
} else {
return MachineResult.OK;
}
} catch (HardAbortError | InterruptedException e) {
close();
return MachineResult.TIMEOUT;
} catch (LuaError e) {
close();
LOG.warn("Top level coroutine errored", e);
return MachineResult.error(e);
}
}
@Override
public void printExecutionState(StringBuilder out) {
var state = this.state;
if (state == null) {
out.append("CobaltLuaMachine is terminated\n");
} else {
state.printExecutionState(out);
}
}
@Override
public void close() {
var state = this.state;
if (state == null) return;
state.abandon();
mainRoutine = null;
this.state = null;
globals = null;
}
@Nullable
private LuaTable wrapLuaObject(Object object) {
var dynamicMethods = object instanceof IDynamicLuaObject dynamic
? Objects.requireNonNull(dynamic.getMethodNames(), "Methods cannot be null")
: LuaMethod.EMPTY_METHODS;
var table = new LuaTable();
for (var i = 0; i < dynamicMethods.length; i++) {
var method = dynamicMethods[i];
table.rawset(method, new ResultInterpreterFunction(this, LuaMethod.DYNAMIC.get(i), object, context, method));
}
ObjectSource.allMethods(LuaMethod.GENERATOR, object, (instance, method) ->
table.rawset(method.getName(), method.nonYielding()
? new BasicFunction(this, method.getMethod(), instance, context, method.getName())
: new ResultInterpreterFunction(this, method.getMethod(), instance, context, method.getName())));
try {
if (table.keyCount() == 0) return null;
} catch (LuaError ignored) {
}
return table;
}
@Nonnull
private LuaValue toValue(@Nullable Object object, @Nullable Map<Object, LuaValue> values) {
if (object == null) return Constants.NIL;
if (object instanceof Number num) return valueOf(num.doubleValue());
if (object instanceof Boolean bool) return valueOf(bool);
if (object instanceof String str) return valueOf(str);
if (object instanceof byte[] b) {
return valueOf(Arrays.copyOf(b, b.length));
}
if (object instanceof ByteBuffer b) {
var bytes = new byte[b.remaining()];
b.get(bytes);
return valueOf(bytes);
}
if (values == null) values = new IdentityHashMap<>(1);
var result = values.get(object);
if (result != null) return result;
if (object instanceof ILuaFunction) {
return new ResultInterpreterFunction(this, FUNCTION_METHOD, object, context, object.toString());
}
if (object instanceof IDynamicLuaObject) {
LuaValue wrapped = wrapLuaObject(object);
if (wrapped == null) wrapped = new LuaTable();
values.put(object, wrapped);
return wrapped;
}
if (object instanceof Map<?, ?> map) {
var table = new LuaTable();
values.put(object, table);
for (Map.Entry<?, ?> pair : map.entrySet()) {
var key = toValue(pair.getKey(), values);
var value = toValue(pair.getValue(), values);
if (!key.isNil() && !value.isNil()) table.rawset(key, value);
}
return table;
}
if (object instanceof Collection<?> objects) {
var table = new LuaTable(objects.size(), 0);
values.put(object, table);
var i = 0;
for (Object child : objects) table.rawset(++i, toValue(child, values));
return table;
}
if (object instanceof Object[] objects) {
var table = new LuaTable(objects.length, 0);
values.put(object, table);
for (var i = 0; i < objects.length; i++) table.rawset(i + 1, toValue(objects[i], values));
return table;
}
var wrapped = wrapLuaObject(object);
if (wrapped != null) {
values.put(object, wrapped);
return wrapped;
}
LOG.warn(Logging.JAVA_ERROR, "Received unknown type '{}', returning nil.", object.getClass().getName());
return Constants.NIL;
}
Varargs toValues(Object[] objects) {
if (objects == null || objects.length == 0) return Constants.NONE;
if (objects.length == 1) return toValue(objects[0], null);
Map<Object, LuaValue> result = new IdentityHashMap<>(0);
var values = new LuaValue[objects.length];
for (var i = 0; i < values.length; i++) {
var object = objects[i];
values[i] = toValue(object, result);
}
return varargsOf(values);
}
static Object toObject(LuaValue value, Map<LuaValue, Object> objects) {
switch (value.type()) {
case Constants.TNIL:
case Constants.TNONE:
return null;
case Constants.TINT:
case Constants.TNUMBER:
return value.toDouble();
case Constants.TBOOLEAN:
return value.toBoolean();
case Constants.TSTRING:
return value.toString();
case Constants.TTABLE: {
// Table:
// Start remembering stuff
if (objects == null) {
objects = new IdentityHashMap<>(1);
} else {
var existing = objects.get(value);
if (existing != null) return existing;
}
Map<Object, Object> table = new HashMap<>();
objects.put(value, table);
var luaTable = (LuaTable) value;
// Convert all keys
var k = Constants.NIL;
while (true) {
Varargs keyValue;
try {
keyValue = luaTable.next(k);
} catch (LuaError luaError) {
break;
}
k = keyValue.first();
if (k.isNil()) break;
var v = keyValue.arg(2);
var keyObject = toObject(k, objects);
var valueObject = toObject(v, objects);
if (keyObject != null && valueObject != null) {
table.put(keyObject, valueObject);
}
}
return table;
}
default:
return null;
}
}
static Object[] toObjects(Varargs values) {
var count = values.count();
var objects = new Object[count];
for (var i = 0; i < count; i++) objects[i] = toObject(values.arg(i + 1), null);
return objects;
}
/**
* A {@link DebugHandler} which observes the {@link TimeoutState} and responds accordingly.
*/
private class TimeoutDebugHandler extends DebugHandler {
private final TimeoutState timeout;
private int count = 0;
boolean thrownSoftAbort;
private boolean isPaused;
private int oldFlags;
private boolean oldInHook;
TimeoutDebugHandler() {
timeout = CobaltLuaMachine.this.timeout;
}
@Override
public void onInstruction(DebugState ds, DebugFrame di, int pc) throws LuaError, UnwindThrowable {
di.pc = pc;
if (isPaused) resetPaused(ds, di);
// We check our current pause/abort state every 128 instructions.
if ((count = (count + 1) & 127) == 0) {
if (timeout.isHardAborted() || state == null) throw HardAbortError.INSTANCE;
if (timeout.isPaused()) handlePause(ds, di);
if (timeout.isSoftAborted()) handleSoftAbort();
}
super.onInstruction(ds, di, pc);
}
@Override
public void poll() throws LuaError {
var state = CobaltLuaMachine.this.state;
if (timeout.isHardAborted() || state == null) throw HardAbortError.INSTANCE;
if (timeout.isPaused()) LuaThread.suspendBlocking(state);
if (timeout.isSoftAborted()) handleSoftAbort();
}
private void resetPaused(DebugState ds, DebugFrame di) {
// Restore the previous paused state
isPaused = false;
ds.inhook = oldInHook;
di.flags = oldFlags;
}
private void handleSoftAbort() throws LuaError {
// If we already thrown our soft abort error then don't do it again.
if (thrownSoftAbort) return;
thrownSoftAbort = true;
throw new LuaError(TimeoutState.ABORT_MESSAGE);
}
private void handlePause(DebugState ds, DebugFrame di) throws LuaError, UnwindThrowable {
// Preserve the current state
isPaused = true;
oldInHook = ds.inhook;
oldFlags = di.flags;
// Suspend the state. This will probably throw, but we need to handle the case where it won't.
di.flags |= FLAG_HOOKYIELD | FLAG_HOOKED;
LuaThread.suspend(ds.getLuaState());
resetPaused(ds, di);
}
}
private static final class HardAbortError extends Error {
@Serial
private static final long serialVersionUID = 7954092008586367501L;
static final HardAbortError INSTANCE = new HardAbortError();
private HardAbortError() {
super("Hard Abort", null, true, false);
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.lua;
import dan200.computercraft.api.lua.IDynamicLuaObject;
import dan200.computercraft.api.lua.ILuaAPI;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.InputStream;
/**
* Represents a machine which will execute Lua code. Technically this API is flexible enough to support many languages,
* but you'd need a way to provide alternative ROMs, BIOSes, etc...
* <p>
* There should only be one concrete implementation at any one time, which is currently {@link CobaltLuaMachine}. If
* external mod authors are interested in registering their own machines, we can look into how we can provide some
* mechanism for registering these.
* <p>
* This should provide implementations of {@link dan200.computercraft.api.lua.ILuaContext}, and the ability to convert
* {@link IDynamicLuaObject}s into something the VM understands, as well as handling method calls.
*/
public interface ILuaMachine {
/**
* Inject an API into the global environment of this machine. This should construct an object, as it would for any
* {@link IDynamicLuaObject} and set it to all names in {@link ILuaAPI#getNames()}.
* <p>
* Called before {@link #loadBios(InputStream)}.
*
* @param api The API to register.
*/
void addAPI(@Nonnull ILuaAPI api);
/**
* Create a function from the provided program, and set it up to run when {@link #handleEvent(String, Object[])} is
* called.
* <p>
* This should destroy the machine if it failed to load the bios.
*
* @param bios The stream containing the boot program.
* @return The result of loading this machine. Will either be OK, or the error message when loading the bios.
*/
MachineResult loadBios(@Nonnull InputStream bios);
/**
* Resume the machine, either starting or resuming the coroutine.
* <p>
* This should destroy the machine if it failed to execute successfully.
*
* @param eventName The name of the event. This is {@code null} when first starting the machine. Note, this may
* do nothing if it does not match the event filter.
* @param arguments The arguments for this event.
* @return The result of loading this machine. Will either be OK, or the error message that occurred when
* executing.
*/
MachineResult handleEvent(@Nullable String eventName, @Nullable Object[] arguments);
/**
* Print some information about the internal execution state.
* <p>
* This function is purely intended for debugging, its output should not be relied on in any way.
*
* @param out The buffer to write to.
*/
void printExecutionState(StringBuilder out);
/**
* Close the Lua machine, aborting any running functions and deleting the internal state.
*/
void close();
interface Factory {
ILuaMachine create(MachineEnvironment environment);
}
}

View File

@@ -0,0 +1,30 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.lua;
import dan200.computercraft.api.lua.ILuaContext;
import dan200.computercraft.core.computer.GlobalEnvironment;
import dan200.computercraft.core.computer.TimeoutState;
import dan200.computercraft.core.metrics.Metrics;
import dan200.computercraft.core.metrics.MetricsObserver;
/**
* Arguments used to construct a {@link ILuaMachine}.
*
* @param context The Lua context to execute main-thread tasks with.
* @param metrics A sink to submit metrics to. You do not need to submit task timings here, it should only be for additional
* metrics such as {@link Metrics#COROUTINES_CREATED}
* @param timeout The current timeout state. This should be used by the machine to interrupt its execution.
* @param hostString A {@linkplain GlobalEnvironment#getHostString() host string} to identify the current environment.
* @see ILuaMachine.Factory
*/
public record MachineEnvironment(
ILuaContext context,
MetricsObserver metrics,
TimeoutState timeout,
String hostString
) {
}

View File

@@ -0,0 +1,73 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.lua;
import dan200.computercraft.core.computer.TimeoutState;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.InputStream;
/**
* The result of executing an action on a machine.
* <p>
* Errors should halt the machine and display the error to the user.
*
* @see ILuaMachine#loadBios(InputStream)
* @see ILuaMachine#handleEvent(String, Object[])
*/
public final class MachineResult {
/**
* A successful complete execution.
*/
public static final MachineResult OK = new MachineResult(false, false, null);
/**
* A successful paused execution.
*/
public static final MachineResult PAUSE = new MachineResult(false, true, null);
/**
* An execution which timed out.
*/
public static final MachineResult TIMEOUT = new MachineResult(true, false, TimeoutState.ABORT_MESSAGE);
/**
* An error with no user-friendly error message.
*/
public static final MachineResult GENERIC_ERROR = new MachineResult(true, false, null);
private final boolean error;
private final boolean pause;
private final String message;
private MachineResult(boolean error, boolean pause, String message) {
this.pause = pause;
this.message = message;
this.error = error;
}
public static MachineResult error(@Nonnull String error) {
return new MachineResult(true, false, error);
}
public static MachineResult error(@Nonnull Exception error) {
return new MachineResult(true, false, error.getMessage());
}
public boolean isError() {
return error;
}
public boolean isPause() {
return pause;
}
@Nullable
public String getMessage() {
return message;
}
}

View File

@@ -0,0 +1,106 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.lua;
import dan200.computercraft.api.lua.ILuaCallback;
import dan200.computercraft.api.lua.ILuaContext;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.MethodResult;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.asm.LuaMethod;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.squiddev.cobalt.*;
import org.squiddev.cobalt.debug.DebugFrame;
import org.squiddev.cobalt.function.ResumableVarArgFunction;
import javax.annotation.Nonnull;
/**
* Calls a {@link LuaMethod}, and interprets the resulting {@link MethodResult}, either returning the result or yielding
* and resuming the supplied continuation.
*/
class ResultInterpreterFunction extends ResumableVarArgFunction<ResultInterpreterFunction.Container> {
private static final Logger LOG = LoggerFactory.getLogger(ResultInterpreterFunction.class);
@Nonnull
static class Container {
ILuaCallback callback;
final int errorAdjust;
Container(ILuaCallback callback, int errorAdjust) {
this.callback = callback;
this.errorAdjust = errorAdjust;
}
}
private final CobaltLuaMachine machine;
private final LuaMethod method;
private final Object instance;
private final ILuaContext context;
private final String name;
ResultInterpreterFunction(CobaltLuaMachine machine, LuaMethod method, Object instance, ILuaContext context, String name) {
this.machine = machine;
this.method = method;
this.instance = instance;
this.context = context;
this.name = name;
}
@Override
protected Varargs invoke(LuaState state, DebugFrame debugFrame, Varargs args) throws LuaError, UnwindThrowable {
var arguments = VarargArguments.of(args);
MethodResult results;
try {
results = method.apply(instance, context, arguments);
} catch (LuaException e) {
throw wrap(e, 0);
} catch (Throwable t) {
LOG.error(Logging.JAVA_ERROR, "Error calling {} on {}", name, instance, t);
throw new LuaError("Java Exception Thrown: " + t, 0);
} finally {
arguments.close();
}
var callback = results.getCallback();
var ret = machine.toValues(results.getResult());
if (callback == null) return ret;
debugFrame.state = new Container(callback, results.getErrorAdjust());
return LuaThread.yield(state, ret);
}
@Override
protected Varargs resumeThis(LuaState state, Container container, Varargs args) throws LuaError, UnwindThrowable {
MethodResult results;
var arguments = CobaltLuaMachine.toObjects(args);
try {
results = container.callback.resume(arguments);
} catch (LuaException e) {
throw wrap(e, container.errorAdjust);
} catch (Throwable t) {
LOG.error(Logging.JAVA_ERROR, "Error calling {} on {}", name, container.callback, t);
throw new LuaError("Java Exception Thrown: " + t, 0);
}
var ret = machine.toValues(results.getResult());
var callback = results.getCallback();
if (callback == null) return ret;
container.callback = callback;
return LuaThread.yield(state, ret);
}
public static LuaError wrap(LuaException exception, int adjust) {
if (!exception.hasLevel() && adjust == 0) return new LuaError(exception.getMessage());
var level = exception.getLevel();
return new LuaError(exception.getMessage(), level <= 0 ? level : level + adjust + 1);
}
}

View File

@@ -0,0 +1,119 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.lua;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaValues;
import org.squiddev.cobalt.*;
import javax.annotation.Nonnull;
import java.util.*;
import static dan200.computercraft.api.lua.LuaValues.badTableItem;
import static dan200.computercraft.api.lua.LuaValues.getNumericType;
class TableImpl implements dan200.computercraft.api.lua.LuaTable<Object, Object> {
private final VarargArguments arguments;
private final LuaTable table;
private Map<Object, Object> backingMap;
TableImpl(VarargArguments arguments, LuaTable table) {
this.arguments = arguments;
this.table = table;
}
@Override
public int size() {
checkValid();
try {
return table.keyCount();
} catch (LuaError e) {
throw new IllegalStateException(e);
}
}
@Override
public int length() {
return table.length();
}
@Override
public long getLong(int index) throws LuaException {
var value = table.rawget(index);
if (!(value instanceof LuaNumber)) throw LuaValues.badTableItem(index, "number", value.typeName());
if (value instanceof LuaInteger) return value.toInteger();
var number = value.toDouble();
if (!Double.isFinite(number)) throw badTableItem(index, "number", getNumericType(number));
return (long) number;
}
@Override
public boolean isEmpty() {
checkValid();
try {
return table.next(Constants.NIL).first().isNil();
} catch (LuaError e) {
throw new IllegalStateException(e);
}
}
@Nonnull
private LuaValue getImpl(Object o) {
checkValid();
if (o instanceof String) return table.rawget((String) o);
if (o instanceof Integer) return table.rawget((Integer) o);
return Constants.NIL;
}
@Override
public boolean containsKey(Object o) {
return !getImpl(o).isNil();
}
@Override
public Object get(Object o) {
return CobaltLuaMachine.toObject(getImpl(o), null);
}
@Nonnull
private Map<Object, Object> getBackingMap() {
checkValid();
if (backingMap != null) return backingMap;
return backingMap = Collections.unmodifiableMap(
Objects.requireNonNull((Map<?, ?>) CobaltLuaMachine.toObject(table, null))
);
}
@Override
public boolean containsValue(Object o) {
return getBackingMap().containsKey(o);
}
@Nonnull
@Override
public Set<Object> keySet() {
return getBackingMap().keySet();
}
@Nonnull
@Override
public Collection<Object> values() {
return getBackingMap().values();
}
@Nonnull
@Override
public Set<Entry<Object, Object>> entrySet() {
return getBackingMap().entrySet();
}
private void checkValid() {
if (arguments.closed) {
throw new IllegalStateException("Cannot use LuaTable after IArguments has been released");
}
}
}

View File

@@ -0,0 +1,123 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.lua;
import dan200.computercraft.api.lua.IArguments;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaValues;
import org.squiddev.cobalt.*;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.nio.ByteBuffer;
import java.util.Optional;
final class VarargArguments implements IArguments {
private static final VarargArguments EMPTY = new VarargArguments(Constants.NONE);
boolean closed;
private final Varargs varargs;
private Object[] cache;
private VarargArguments(Varargs varargs) {
this.varargs = varargs;
}
static VarargArguments of(Varargs values) {
return values == Constants.NONE ? EMPTY : new VarargArguments(values);
}
@Override
public int count() {
return varargs.count();
}
@Nullable
@Override
public Object get(int index) {
if (index < 0 || index >= varargs.count()) return null;
var cache = this.cache;
if (cache == null) {
cache = this.cache = new Object[varargs.count()];
} else {
var existing = cache[index];
if (existing != null) return existing;
}
return cache[index] = CobaltLuaMachine.toObject(varargs.arg(index + 1), null);
}
@Override
public IArguments drop(int count) {
if (count < 0) throw new IllegalStateException("count cannot be negative");
if (count == 0) return this;
return new VarargArguments(varargs.subargs(count + 1));
}
@Override
public double getDouble(int index) throws LuaException {
var value = varargs.arg(index + 1);
if (!(value instanceof LuaNumber)) throw LuaValues.badArgument(index, "number", value.typeName());
return value.toDouble();
}
@Override
public long getLong(int index) throws LuaException {
var value = varargs.arg(index + 1);
if (!(value instanceof LuaNumber)) throw LuaValues.badArgument(index, "number", value.typeName());
return value instanceof LuaInteger ? value.toInteger() : (long) LuaValues.checkFinite(index, value.toDouble());
}
@Nonnull
@Override
public ByteBuffer getBytes(int index) throws LuaException {
var value = varargs.arg(index + 1);
if (!(value instanceof LuaBaseString)) throw LuaValues.badArgument(index, "string", value.typeName());
var str = ((LuaBaseString) value).strvalue();
return ByteBuffer.wrap(str.bytes, str.offset, str.length).asReadOnlyBuffer();
}
@Override
public Optional<ByteBuffer> optBytes(int index) throws LuaException {
var value = varargs.arg(index + 1);
if (value.isNil()) return Optional.empty();
if (!(value instanceof LuaBaseString)) throw LuaValues.badArgument(index, "string", value.typeName());
var str = ((LuaBaseString) value).strvalue();
return Optional.of(ByteBuffer.wrap(str.bytes, str.offset, str.length).asReadOnlyBuffer());
}
@Nonnull
@Override
public dan200.computercraft.api.lua.LuaTable<?, ?> getTableUnsafe(int index) throws LuaException {
if (closed) {
throw new IllegalStateException("Cannot use getTableUnsafe after IArguments has been closed.");
}
var value = varargs.arg(index + 1);
if (!(value instanceof LuaTable)) throw LuaValues.badArgument(index, "table", value.typeName());
return new TableImpl(this, (LuaTable) value);
}
@Nonnull
@Override
public Optional<dan200.computercraft.api.lua.LuaTable<?, ?>> optTableUnsafe(int index) throws LuaException {
if (closed) {
throw new IllegalStateException("Cannot use optTableUnsafe after IArguments has been closed.");
}
var value = varargs.arg(index + 1);
if (value.isNil()) return Optional.empty();
if (!(value instanceof LuaTable)) throw LuaValues.badArgument(index, "table", value.typeName());
return Optional.of(new TableImpl(this, (LuaTable) value));
}
public void close() {
closed = true;
}
}

View File

@@ -0,0 +1,126 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.metrics;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.LongFunction;
/**
* A metric is some event which is emitted by a computer and observed by a {@link MetricsObserver}.
* <p>
* It comes in two forms: a simple {@link Metric.Counter} counts how many times an event has occurred (for instance,
* how many HTTP requests have there been) while a {@link Metric.Event} has a discrete value for each event (for
* instance, the number of bytes downloaded in this HTTP request). The values tied to a {@link Metric.Event}s can be
* accumulated, and thus used to derive averages, rates and be sorted into histogram buckets.
*/
public abstract class Metric {
private static final Map<String, Metric> allMetrics = new ConcurrentHashMap<>();
private static final AtomicInteger nextId = new AtomicInteger();
private final int id;
private final String name;
private final String unit;
private final LongFunction<String> format;
private Metric(String name, String unit, LongFunction<String> format) {
if (allMetrics.containsKey(name)) throw new IllegalStateException("Duplicate key " + name);
id = nextId.getAndIncrement();
this.name = name;
this.unit = unit;
this.format = format;
allMetrics.put(name, this);
}
/**
* The unique ID for this metric.
* <p>
* Each metric is assigned a consecutive metric ID starting from 0. This allows you to store metrics in an array,
* rather than a map.
*
* @return The metric's ID.
*/
public int id() {
return id;
}
/**
* The unique name for this metric.
*
* @return The metric's name.
*/
public String name() {
return name;
}
/**
* The unit used for this metric. This should be a lowercase "identifier-like" such as {@literal ms}. If no unit is
* relevant, it can be empty.
*
* @return The metric's unit.
*/
public String unit() {
return unit;
}
/**
* Format a value according to the metric's formatting rules. Implementations may choose to append units to the
* returned value where relevant.
*
* @param value The value to format.
* @return The formatted value.
*/
public String format(long value) {
return format.apply(value);
}
@Override
public String toString() {
return getClass().getName() + ":" + name();
}
/**
* Get a map of all metrics.
*
* @return A map of all metrics.
*/
public static Map<String, Metric> metrics() {
return Collections.unmodifiableMap(allMetrics);
}
public static final class Counter extends Metric {
public Counter(String id) {
super(id, "", Metric::formatDefault);
}
}
public static final class Event extends Metric {
public Event(String id, String unit, LongFunction<String> format) {
super(id, unit, format);
}
}
public static String formatTime(long value) {
return String.format("%.1fms", value * 1e-6);
}
public static String formatDefault(long value) {
return String.format("%d", value);
}
private static final int KILOBYTE_SIZE = 1024;
private static final String SI_PREFIXES = "KMGT";
public static String formatBytes(long bytes) {
if (bytes < KILOBYTE_SIZE) return String.format("%d B", bytes);
var exp = (int) (Math.log((double) bytes) / Math.log(KILOBYTE_SIZE));
if (exp > SI_PREFIXES.length()) exp = SI_PREFIXES.length();
return String.format("%.1f %siB", bytes / Math.pow(KILOBYTE_SIZE, exp), SI_PREFIXES.charAt(exp - 1));
}
}

View File

@@ -0,0 +1,38 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.metrics;
/**
* Built-in metrics that CC produces.
*/
public final class Metrics {
private Metrics() {
}
public static final Metric.Event COMPUTER_TASKS = new Metric.Event("computer_tasks", "ms", Metric::formatTime);
public static final Metric.Event SERVER_TASKS = new Metric.Event("server_tasks", "ms", Metric::formatTime);
public static final Metric.Counter PERIPHERAL_OPS = new Metric.Counter("peripheral");
public static final Metric.Counter FS_OPS = new Metric.Counter("fs");
public static final Metric.Counter HTTP_REQUESTS = new Metric.Counter("http_requests");
public static final Metric.Event HTTP_UPLOAD = new Metric.Event("http_upload", "bytes", Metric::formatBytes);
public static final Metric.Event HTTP_DOWNLOAD = new Metric.Event("http_download", "bytes", Metric::formatBytes);
public static final Metric.Event WEBSOCKET_INCOMING = new Metric.Event("websocket_incoming", "bytes", Metric::formatBytes);
public static final Metric.Event WEBSOCKET_OUTGOING = new Metric.Event("websocket_outgoing", "bytes", Metric::formatBytes);
public static final Metric.Counter COROUTINES_CREATED = new Metric.Counter("coroutines_created");
public static final Metric.Counter COROUTINES_DISPOSED = new Metric.Counter("coroutines_dead");
public static final Metric.Counter TURTLE_OPS = new Metric.Counter("turtle_ops");
/**
* Ensures metrics are registered.
*/
public static void init() {
}
}

View File

@@ -0,0 +1,35 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.metrics;
import dan200.computercraft.core.computer.ComputerEnvironment;
/**
* A metrics observer is used to report metrics for a single computer.
* <p>
* Various components (such as the computer scheduler or http API) will report statistics about their behaviour to the
* observer. The observer may choose to consume these metrics, aggregating them and presenting them to the user in some
* manner.
*
* @see ComputerEnvironment#getMetrics()
* @see Metrics Built-in metrics which will be reported.
*/
public interface MetricsObserver {
/**
* Increment a counter by 1.
*
* @param counter The counter to observe.
*/
void observe(Metric.Counter counter);
/**
* Observe a single instance of an event.
*
* @param event The event to observe.
* @param value The value corresponding to this event.
*/
void observe(Metric.Event event, long value);
}

View File

@@ -0,0 +1,91 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.terminal;
import dan200.computercraft.core.util.Colour;
import javax.annotation.Nonnull;
public class Palette {
public static final int PALETTE_SIZE = 16;
private final boolean colour;
private final double[][] colours = new double[PALETTE_SIZE][3];
private final byte[][] byteColours = new byte[PALETTE_SIZE][4];
public static final Palette DEFAULT = new Palette(true);
public Palette(boolean colour) {
this.colour = colour;
resetColours();
for (var i = 0; i < PALETTE_SIZE; i++) byteColours[i][3] = (byte) 255;
}
public void setColour(int i, double r, double g, double b) {
if (i < 0 || i >= PALETTE_SIZE) return;
colours[i][0] = r;
colours[i][1] = g;
colours[i][2] = b;
if (colour) {
byteColours[i][0] = (byte) (int) (r * 255);
byteColours[i][1] = (byte) (int) (g * 255);
byteColours[i][2] = (byte) (int) (b * 255);
} else {
var grey = (byte) (int) ((r + g + b) / 3 * 255);
byteColours[i][0] = byteColours[i][1] = byteColours[i][2] = grey;
}
}
public void setColour(int i, Colour colour) {
setColour(i, colour.getR(), colour.getG(), colour.getB());
}
public double[] getColour(int i) {
return i >= 0 && i < PALETTE_SIZE ? colours[i] : null;
}
/**
* Get the colour as a set of RGB values suitable for rendering. Colours are automatically converted to greyscale
* when using a black and white palette.
* <p>
* This returns a byte array, suitable for being used directly by our terminal vertex format.
*
* @param i The colour index.
* @return The number as a tuple of bytes.
*/
@Nonnull
public byte[] getRenderColours(int i) {
return byteColours[i];
}
public void resetColour(int i) {
if (i >= 0 && i < PALETTE_SIZE) setColour(i, Colour.VALUES[i]);
}
public void resetColours() {
for (var i = 0; i < Colour.VALUES.length; i++) {
resetColour(i);
}
}
public static int encodeRGB8(double[] rgb) {
var r = (int) (rgb[0] * 255) & 0xFF;
var g = (int) (rgb[1] * 255) & 0xFF;
var b = (int) (rgb[2] * 255) & 0xFF;
return (r << 16) | (g << 8) | b;
}
public static double[] decodeRGB8(int rgb) {
return new double[]{
((rgb >> 16) & 0xFF) / 255.0f,
((rgb >> 8) & 0xFF) / 255.0f,
(rgb & 0xFF) / 255.0f,
};
}
}

View File

@@ -0,0 +1,274 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.terminal;
import dan200.computercraft.core.util.Colour;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.nio.ByteBuffer;
public class Terminal {
protected static final String BASE_16 = "0123456789abcdef";
protected int width;
protected int height;
protected final boolean colour;
protected int cursorX = 0;
protected int cursorY = 0;
protected boolean cursorBlink = false;
protected int cursorColour = 0;
protected int cursorBackgroundColour = 15;
protected TextBuffer[] text;
protected TextBuffer[] textColour;
protected TextBuffer[] backgroundColour;
protected final Palette palette;
private final @Nullable Runnable onChanged;
public Terminal(int width, int height, boolean colour) {
this(width, height, colour, null);
}
public Terminal(int width, int height, boolean colour, Runnable changedCallback) {
this.width = width;
this.height = height;
this.colour = colour;
palette = new Palette(colour);
onChanged = changedCallback;
text = new TextBuffer[height];
textColour = new TextBuffer[height];
backgroundColour = new TextBuffer[height];
for (var i = 0; i < this.height; i++) {
text[i] = new TextBuffer(' ', this.width);
textColour[i] = new TextBuffer(BASE_16.charAt(cursorColour), this.width);
backgroundColour[i] = new TextBuffer(BASE_16.charAt(cursorBackgroundColour), this.width);
}
}
public synchronized void reset() {
cursorColour = 0;
cursorBackgroundColour = 15;
cursorX = 0;
cursorY = 0;
cursorBlink = false;
clear();
setChanged();
palette.resetColours();
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public boolean isColour() {
return colour;
}
public synchronized void resize(int width, int height) {
if (width == this.width && height == this.height) {
return;
}
var oldHeight = this.height;
var oldWidth = this.width;
var oldText = text;
var oldTextColour = textColour;
var oldBackgroundColour = backgroundColour;
this.width = width;
this.height = height;
text = new TextBuffer[height];
textColour = new TextBuffer[height];
backgroundColour = new TextBuffer[height];
for (var i = 0; i < this.height; i++) {
if (i >= oldHeight) {
text[i] = new TextBuffer(' ', this.width);
textColour[i] = new TextBuffer(BASE_16.charAt(cursorColour), this.width);
backgroundColour[i] = new TextBuffer(BASE_16.charAt(cursorBackgroundColour), this.width);
} else if (this.width == oldWidth) {
text[i] = oldText[i];
textColour[i] = oldTextColour[i];
backgroundColour[i] = oldBackgroundColour[i];
} else {
text[i] = new TextBuffer(' ', this.width);
textColour[i] = new TextBuffer(BASE_16.charAt(cursorColour), this.width);
backgroundColour[i] = new TextBuffer(BASE_16.charAt(cursorBackgroundColour), this.width);
text[i].write(oldText[i]);
textColour[i].write(oldTextColour[i]);
backgroundColour[i].write(oldBackgroundColour[i]);
}
}
setChanged();
}
public void setCursorPos(int x, int y) {
if (cursorX != x || cursorY != y) {
cursorX = x;
cursorY = y;
setChanged();
}
}
public void setCursorBlink(boolean blink) {
if (cursorBlink != blink) {
cursorBlink = blink;
setChanged();
}
}
public void setTextColour(int colour) {
if (cursorColour != colour) {
cursorColour = colour;
setChanged();
}
}
public void setBackgroundColour(int colour) {
if (cursorBackgroundColour != colour) {
cursorBackgroundColour = colour;
setChanged();
}
}
public int getCursorX() {
return cursorX;
}
public int getCursorY() {
return cursorY;
}
public boolean getCursorBlink() {
return cursorBlink;
}
public int getTextColour() {
return cursorColour;
}
public int getBackgroundColour() {
return cursorBackgroundColour;
}
@Nonnull
public Palette getPalette() {
return palette;
}
public synchronized void blit(ByteBuffer text, ByteBuffer textColour, ByteBuffer backgroundColour) {
var x = cursorX;
var y = cursorY;
if (y >= 0 && y < height) {
this.text[y].write(text, x);
this.textColour[y].write(textColour, x);
this.backgroundColour[y].write(backgroundColour, x);
setChanged();
}
}
public synchronized void write(String text) {
var x = cursorX;
var y = cursorY;
if (y >= 0 && y < height) {
this.text[y].write(text, x);
textColour[y].fill(BASE_16.charAt(cursorColour), x, x + text.length());
backgroundColour[y].fill(BASE_16.charAt(cursorBackgroundColour), x, x + text.length());
setChanged();
}
}
public synchronized void scroll(int yDiff) {
if (yDiff != 0) {
var newText = new TextBuffer[height];
var newTextColour = new TextBuffer[height];
var newBackgroundColour = new TextBuffer[height];
for (var y = 0; y < height; y++) {
var oldY = y + yDiff;
if (oldY >= 0 && oldY < height) {
newText[y] = text[oldY];
newTextColour[y] = textColour[oldY];
newBackgroundColour[y] = backgroundColour[oldY];
} else {
newText[y] = new TextBuffer(' ', width);
newTextColour[y] = new TextBuffer(BASE_16.charAt(cursorColour), width);
newBackgroundColour[y] = new TextBuffer(BASE_16.charAt(cursorBackgroundColour), width);
}
}
text = newText;
textColour = newTextColour;
backgroundColour = newBackgroundColour;
setChanged();
}
}
public synchronized void clear() {
for (var y = 0; y < height; y++) {
text[y].fill(' ');
textColour[y].fill(BASE_16.charAt(cursorColour));
backgroundColour[y].fill(BASE_16.charAt(cursorBackgroundColour));
}
setChanged();
}
public synchronized void clearLine() {
var y = cursorY;
if (y >= 0 && y < height) {
text[y].fill(' ');
textColour[y].fill(BASE_16.charAt(cursorColour));
backgroundColour[y].fill(BASE_16.charAt(cursorBackgroundColour));
setChanged();
}
}
public synchronized TextBuffer getLine(int y) {
if (y >= 0 && y < height) {
return text[y];
}
return null;
}
public synchronized void setLine(int y, String text, String textColour, String backgroundColour) {
this.text[y].write(text);
this.textColour[y].write(textColour);
this.backgroundColour[y].write(backgroundColour);
setChanged();
}
public synchronized TextBuffer getTextColourLine(int y) {
if (y >= 0 && y < height) {
return textColour[y];
}
return null;
}
public synchronized TextBuffer getBackgroundColourLine(int y) {
if (y >= 0 && y < height) {
return backgroundColour[y];
}
return null;
}
public final void setChanged() {
if (onChanged != null) onChanged.run();
}
public static int getColour(char c, Colour def) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return 15 - def.ordinal();
}
}

View File

@@ -0,0 +1,86 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.terminal;
import java.nio.ByteBuffer;
public class TextBuffer {
private final char[] text;
public TextBuffer(char c, int length) {
text = new char[length];
fill(c);
}
public TextBuffer(String text) {
this.text = text.toCharArray();
}
public int length() {
return text.length;
}
public void write(String text) {
write(text, 0);
}
public void write(String text, int start) {
var pos = start;
start = Math.max(start, 0);
var end = Math.min(start + text.length(), pos + text.length());
end = Math.min(end, this.text.length);
for (var i = start; i < end; i++) {
this.text[i] = text.charAt(i - pos);
}
}
public void write(ByteBuffer text, int start) {
var pos = start;
var bufferPos = text.position();
start = Math.max(start, 0);
var length = text.remaining();
var end = Math.min(start + length, pos + length);
end = Math.min(end, this.text.length);
for (var i = start; i < end; i++) {
this.text[i] = (char) (text.get(bufferPos + i - pos) & 0xFF);
}
}
public void write(TextBuffer text) {
var end = Math.min(text.length(), this.text.length);
for (var i = 0; i < end; i++) {
this.text[i] = text.charAt(i);
}
}
public void fill(char c) {
fill(c, 0, text.length);
}
public void fill(char c, int start, int end) {
start = Math.max(start, 0);
end = Math.min(end, text.length);
for (var i = start; i < end; i++) {
text[i] = c;
}
}
public char charAt(int i) {
return text[i];
}
public void setChar(int i, char c) {
if (i >= 0 && i < text.length) {
text[i] = c;
}
}
@Override
public String toString() {
return new String(text);
}
}

View File

@@ -0,0 +1,73 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.util;
public enum Colour {
BLACK(0x111111),
RED(0xcc4c4c),
GREEN(0x57A64E),
BROWN(0x7f664c),
BLUE(0x3366cc),
PURPLE(0xb266e5),
CYAN(0x4c99b2),
LIGHT_GREY(0x999999),
GREY(0x4c4c4c),
PINK(0xf2b2cc),
LIME(0x7fcc19),
YELLOW(0xdede6c),
LIGHT_BLUE(0x99b2f2),
MAGENTA(0xe57fd8),
ORANGE(0xf2b233),
WHITE(0xf0f0f0);
public static final Colour[] VALUES = values();
public static Colour fromInt(int colour) {
return Colour.VALUES[colour];
}
public static Colour fromHex(int colour) {
for (var entry : VALUES) {
if (entry.getHex() == colour) return entry;
}
return null;
}
private final int hex;
private final float red, green, blue;
Colour(int hex) {
this.hex = hex;
red = ((hex >> 16) & 0xFF) / 255.0f;
green = ((hex >> 8) & 0xFF) / 255.0f;
blue = (hex & 0xFF) / 255.0f;
}
public Colour getNext() {
return VALUES[(ordinal() + 1) % 16];
}
public Colour getPrevious() {
return VALUES[(ordinal() + 15) % 16];
}
public int getHex() {
return hex;
}
public float getR() {
return red;
}
public float getG() {
return green;
}
public float getB() {
return blue;
}
}

View File

@@ -0,0 +1,22 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.util;
import javax.annotation.Nullable;
import java.io.Closeable;
import java.io.IOException;
public final class IoUtil {
private IoUtil() {
}
public static void closeQuietly(@Nullable Closeable closeable) {
try {
if (closeable != null) closeable.close();
} catch (IOException ignored) {
}
}
}

View File

@@ -0,0 +1,21 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.util;
import java.util.Collection;
public class LuaUtil {
public static Object[] consArray(Object value, Collection<?> rest) {
if (rest.isEmpty()) return new Object[]{ value };
// I'm not proud of this code.
var out = new Object[rest.size() + 1];
out[0] = value;
var i = 1;
for (Object additionalType : rest) out[i++] = additionalType;
return out;
}
}

View File

@@ -0,0 +1,34 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.util;
import javax.annotation.Nullable;
public final class StringUtil {
private StringUtil() {
}
public static String normaliseLabel(String label) {
if (label == null) return null;
var length = Math.min(32, label.length());
var builder = new StringBuilder(length);
for (var i = 0; i < length; i++) {
var c = label.charAt(i);
if ((c >= ' ' && c <= '~') || (c >= 161 && c <= 172) || (c >= 174 && c <= 255)) {
builder.append(c);
} else {
builder.append('?');
}
}
return builder.toString();
}
public static String toString(@Nullable Object value) {
return value == null ? "" : value.toString();
}
}

View File

@@ -0,0 +1,75 @@
/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.core.util;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.ThreadFactory;
/**
* Provides some utilities to create thread groups.
*/
public final class ThreadUtils {
private static final Logger LOG = LoggerFactory.getLogger(ThreadUtils.class);
private static final ThreadGroup baseGroup = new ThreadGroup("ComputerCraft");
private ThreadUtils() {
}
/**
* Get the base thread group, that all off-thread ComputerCraft activities are run on.
*
* @return The ComputerCraft group.
*/
public static ThreadGroup group() {
return baseGroup;
}
/**
* Construct a group under ComputerCraft's shared group.
*
* @param name The group's name. This will be prefixed with "ComputerCraft-".
* @return The constructed thread group.
*/
public static ThreadGroup group(String name) {
return new ThreadGroup(baseGroup, baseGroup.getName() + "-" + name);
}
/**
* Create a new {@link ThreadFactoryBuilder}, which constructs threads under a group of the given {@code name}.
* <p>
* Each thread will be of the format {@code ComputerCraft-<name>-<number>}, and belong to a group
* called {@code ComputerCraft-<name>} (which in turn will be a child group of the main {@code ComputerCraft} group.
*
* @param name The name for the thread group and child threads.
* @return The constructed thread factory builder, which may be extended with other properties.
* @see #factory(String)
*/
public static ThreadFactoryBuilder builder(String name) {
var group = group(name);
return new ThreadFactoryBuilder()
.setDaemon(true)
.setNameFormat(group.getName().replace("%", "%%") + "-%d")
.setUncaughtExceptionHandler((t, e) -> LOG.error("Exception in thread " + t.getName(), e))
.setThreadFactory(x -> new Thread(group, x));
}
/**
* Create a new {@link ThreadFactory}, which constructs threads under a group of the given {@code name}.
* <p>
* Each thread will be of the format {@code ComputerCraft-<name>-<number>}, and belong to a group
* called {@code ComputerCraft-<name>} (which in turn will be a child group of the main {@code ComputerCraft} group.
*
* @param name The name for the thread group and child threads.
* @return The constructed thread factory.
* @see #builder(String)
*/
public static ThreadFactory factory(String name) {
return builder(name).build();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,361 @@
--[[- Constants and functions for colour values, suitable for working with
@{term} and @{redstone}.
This is useful in conjunction with @{redstone.setBundledOutput|Bundled Cables}
from mods like Project Red, and @{term.setTextColour|colors on Advanced
Computers and Advanced Monitors}.
For the non-American English version just replace @{colors} with @{colours}.
This alternative API is exactly the same, except the colours use British English
(e.g. @{colors.gray} is spelt @{colours.grey}).
On basic terminals (such as the Computer and Monitor), all the colors are
converted to grayscale. This means you can still use all 16 colors on the
screen, but they will appear as the nearest tint of gray. You can check if a
terminal supports color by using the function @{term.isColor}.
Grayscale colors are calculated by taking the average of the three components,
i.e. `(red + green + blue) / 3`.
<table>
<thead>
<tr><th colspan="8" align="center">Default Colors</th></tr>
<tr>
<th rowspan="2" align="center">Color</th>
<th colspan="3" align="center">Value</th>
<th colspan="4" align="center">Default Palette Color</th>
</tr>
<tr>
<th>Dec</th><th>Hex</th><th>Paint/Blit</th>
<th>Preview</th><th>Hex</th><th>RGB</th><th>Grayscale</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>colors.white</code></td>
<td align="right">1</td><td align="right">0x1</td><td align="right">0</td>
<td style="background:#F0F0F0"></td><td>#F0F0F0</td><td>240, 240, 240</td>
<td style="background:#F0F0F0"></td>
</tr>
<tr>
<td><code>colors.orange</code></td>
<td align="right">2</td><td align="right">0x2</td><td align="right">1</td>
<td style="background:#F2B233"></td><td>#F2B233</td><td>242, 178, 51</td>
<td style="background:#9D9D9D"></td>
</tr>
<tr>
<td><code>colors.magenta</code></td>
<td align="right">4</td><td align="right">0x4</td><td align="right">2</td>
<td style="background:#E57FD8"></td><td>#E57FD8</td><td>229, 127, 216</td>
<td style="background:#BEBEBE"></td>
</tr>
<tr>
<td><code>colors.lightBlue</code></td>
<td align="right">8</td><td align="right">0x8</td><td align="right">3</td>
<td style="background:#99B2F2"></td><td>#99B2F2</td><td>153, 178, 242</td>
<td style="background:#BFBFBF"></td>
</tr>
<tr>
<td><code>colors.yellow</code></td>
<td align="right">16</td><td align="right">0x10</td><td align="right">4</td>
<td style="background:#DEDE6C"></td><td>#DEDE6C</td><td>222, 222, 108</td>
<td style="background:#B8B8B8"></td>
</tr>
<tr>
<td><code>colors.lime</code></td>
<td align="right">32</td><td align="right">0x20</td><td align="right">5</td>
<td style="background:#7FCC19"></td><td>#7FCC19</td><td>127, 204, 25</td>
<td style="background:#767676"></td>
</tr>
<tr>
<td><code>colors.pink</code></td>
<td align="right">64</td><td align="right">0x40</td><td align="right">6</td>
<td style="background:#F2B2CC"></td><td>#F2B2CC</td><td>242, 178, 204</td>
<td style="background:#D0D0D0"></td>
</tr>
<tr>
<td><code>colors.gray</code></td>
<td align="right">128</td><td align="right">0x80</td><td align="right">7</td>
<td style="background:#4C4C4C"></td><td>#4C4C4C</td><td>76, 76, 76</td>
<td style="background:#4C4C4C"></td>
</tr>
<tr>
<td><code>colors.lightGray</code></td>
<td align="right">256</td><td align="right">0x100</td><td align="right">8</td>
<td style="background:#999999"></td><td>#999999</td><td>153, 153, 153</td>
<td style="background:#999999"></td>
</tr>
<tr>
<td><code>colors.cyan</code></td>
<td align="right">512</td><td align="right">0x200</td><td align="right">9</td>
<td style="background:#4C99B2"></td><td>#4C99B2</td><td>76, 153, 178</td>
<td style="background:#878787"></td>
</tr>
<tr>
<td><code>colors.purple</code></td>
<td align="right">1024</td><td align="right">0x400</td><td align="right">a</td>
<td style="background:#B266E5"></td><td>#B266E5</td><td>178, 102, 229</td>
<td style="background:#A9A9A9"></td>
</tr>
<tr>
<td><code>colors.blue</code></td>
<td align="right">2048</td><td align="right">0x800</td><td align="right">b</td>
<td style="background:#3366CC"></td><td>#3366CC</td><td>51, 102, 204</td>
<td style="background:#777777"></td>
</tr>
<tr>
<td><code>colors.brown</code></td>
<td align="right">4096</td><td align="right">0x1000</td><td align="right">c</td>
<td style="background:#7F664C"></td><td>#7F664C</td><td>127, 102, 76</td>
<td style="background:#656565"></td>
</tr>
<tr>
<td><code>colors.green</code></td>
<td align="right">8192</td><td align="right">0x2000</td><td align="right">d</td>
<td style="background:#57A64E"></td><td>#57A64E</td><td>87, 166, 78</td>
<td style="background:#6E6E6E"></td>
</tr>
<tr>
<td><code>colors.red</code></td>
<td align="right">16384</td><td align="right">0x4000</td><td align="right">e</td>
<td style="background:#CC4C4C"></td><td>#CC4C4C</td><td>204, 76, 76</td>
<td style="background:#767676"></td>
</tr>
<tr>
<td><code>colors.black</code></td>
<td align="right">32768</td><td align="right">0x8000</td><td align="right">f</td>
<td style="background:#111111"></td><td>#111111</td><td>17, 17, 17</td>
<td style="background:#111111"></td>
</tr>
</tbody>
</table>
@see colours
@module colors
]]
local expect = dofile("rom/modules/main/cc/expect.lua").expect
--- White: Written as `0` in paint files and @{term.blit}, has a default
-- terminal colour of #F0F0F0.
white = 0x1
--- Orange: Written as `1` in paint files and @{term.blit}, has a
-- default terminal colour of #F2B233.
orange = 0x2
--- Magenta: Written as `2` in paint files and @{term.blit}, has a
-- default terminal colour of #E57FD8.
magenta = 0x4
--- Light blue: Written as `3` in paint files and @{term.blit}, has a
-- default terminal colour of #99B2F2.
lightBlue = 0x8
--- Yellow: Written as `4` in paint files and @{term.blit}, has a
-- default terminal colour of #DEDE6C.
yellow = 0x10
--- Lime: Written as `5` in paint files and @{term.blit}, has a default
-- terminal colour of #7FCC19.
lime = 0x20
--- Pink: Written as `6` in paint files and @{term.blit}, has a default
-- terminal colour of #F2B2CC.
pink = 0x40
--- Gray: Written as `7` in paint files and @{term.blit}, has a default
-- terminal colour of #4C4C4C.
gray = 0x80
--- Light gray: Written as `8` in paint files and @{term.blit}, has a
-- default terminal colour of #999999.
lightGray = 0x100
--- Cyan: Written as `9` in paint files and @{term.blit}, has a default
-- terminal colour of #4C99B2.
cyan = 0x200
--- Purple: Written as `a` in paint files and @{term.blit}, has a
-- default terminal colour of #B266E5.
purple = 0x400
--- Blue: Written as `b` in paint files and @{term.blit}, has a default
-- terminal colour of #3366CC.
blue = 0x800
--- Brown: Written as `c` in paint files and @{term.blit}, has a default
-- terminal colour of #7F664C.
brown = 0x1000
--- Green: Written as `d` in paint files and @{term.blit}, has a default
-- terminal colour of #57A64E.
green = 0x2000
--- Red: Written as `e` in paint files and @{term.blit}, has a default
-- terminal colour of #CC4C4C.
red = 0x4000
--- Black: Written as `f` in paint files and @{term.blit}, has a default
-- terminal colour of #111111.
black = 0x8000
--- Combines a set of colors (or sets of colors) into a larger set. Useful for
-- Bundled Cables.
--
-- @tparam number ... The colors to combine.
-- @treturn number The union of the color sets given in `...`
-- @since 1.2
-- @usage
-- ```lua
-- colors.combine(colors.white, colors.magenta, colours.lightBlue)
-- -- => 13
-- ```
function combine(...)
local r = 0
for i = 1, select('#', ...) do
local c = select(i, ...)
expect(i, c, "number")
r = bit32.bor(r, c)
end
return r
end
--- Removes one or more colors (or sets of colors) from an initial set. Useful
-- for Bundled Cables.
--
-- Each parameter beyond the first may be a single color or may be a set of
-- colors (in the latter case, all colors in the set are removed from the
-- original set).
--
-- @tparam number colors The color from which to subtract.
-- @tparam number ... The colors to subtract.
-- @treturn number The resulting color.
-- @since 1.2
-- @usage
-- ```lua
-- colours.subtract(colours.lime, colours.orange, colours.white)
-- -- => 32
-- ```
function subtract(colors, ...)
expect(1, colors, "number")
local r = colors
for i = 1, select('#', ...) do
local c = select(i, ...)
expect(i + 1, c, "number")
r = bit32.band(r, bit32.bnot(c))
end
return r
end
--- Tests whether `color` is contained within `colors`. Useful for Bundled
-- Cables.
--
-- @tparam number colors A color, or color set
-- @tparam number color A color or set of colors that `colors` should contain.
-- @treturn boolean If `colors` contains all colors within `color`.
-- @since 1.2
-- @usage
-- ```lua
-- colors.test(colors.combine(colors.white, colors.magenta, colours.lightBlue), colors.lightBlue)
-- -- => true
-- ```
function test(colors, color)
expect(1, colors, "number")
expect(2, color, "number")
return bit32.band(colors, color) == color
end
--- Combine a three-colour RGB value into one hexadecimal representation.
--
-- @tparam number r The red channel, should be between 0 and 1.
-- @tparam number g The red channel, should be between 0 and 1.
-- @tparam number b The blue channel, should be between 0 and 1.
-- @treturn number The combined hexadecimal colour.
-- @usage
-- ```lua
-- colors.packRGB(0.7, 0.2, 0.6)
-- -- => 0xb23399
-- ```
-- @since 1.81.0
function packRGB(r, g, b)
expect(1, r, "number")
expect(2, g, "number")
expect(3, b, "number")
return
bit32.band(r * 255, 0xFF) * 2 ^ 16 +
bit32.band(g * 255, 0xFF) * 2 ^ 8 +
bit32.band(b * 255, 0xFF)
end
--- Separate a hexadecimal RGB colour into its three constituent channels.
--
-- @tparam number rgb The combined hexadecimal colour.
-- @treturn number The red channel, will be between 0 and 1.
-- @treturn number The red channel, will be between 0 and 1.
-- @treturn number The blue channel, will be between 0 and 1.
-- @usage
-- ```lua
-- colors.unpackRGB(0xb23399)
-- -- => 0.7, 0.2, 0.6
-- ```
-- @see colors.packRGB
-- @since 1.81.0
function unpackRGB(rgb)
expect(1, rgb, "number")
return
bit32.band(bit32.rshift(rgb, 16), 0xFF) / 255,
bit32.band(bit32.rshift(rgb, 8), 0xFF) / 255,
bit32.band(rgb, 0xFF) / 255
end
--- Either calls @{colors.packRGB} or @{colors.unpackRGB}, depending on how many
-- arguments it receives.
--
-- @tparam[1] number r The red channel, as an argument to @{colors.packRGB}.
-- @tparam[1] number g The green channel, as an argument to @{colors.packRGB}.
-- @tparam[1] number b The blue channel, as an argument to @{colors.packRGB}.
-- @tparam[2] number rgb The combined hexadecimal color, as an argument to @{colors.unpackRGB}.
-- @treturn[1] number The combined hexadecimal colour, as returned by @{colors.packRGB}.
-- @treturn[2] number The red channel, as returned by @{colors.unpackRGB}
-- @treturn[2] number The green channel, as returned by @{colors.unpackRGB}
-- @treturn[2] number The blue channel, as returned by @{colors.unpackRGB}
-- @deprecated Use @{packRGB} or @{unpackRGB} directly.
-- @usage
-- ```lua
-- colors.rgb8(0xb23399)
-- -- => 0.7, 0.2, 0.6
-- ```
-- @usage
-- ```lua
-- colors.rgb8(0.7, 0.2, 0.6)
-- -- => 0xb23399
-- ```
-- @since 1.80pr1
-- @changed 1.81.0 Deprecated in favor of colors.(un)packRGB.
function rgb8(r, g, b)
if g == nil and b == nil then
return unpackRGB(r)
else
return packRGB(r, g, b)
end
end
-- Colour to hex lookup table for toBlit
local color_hex_lookup = {}
for i = 0, 15 do
color_hex_lookup[2 ^ i] = string.format("%x", i)
end
--- Converts the given color to a paint/blit hex character (0-9a-f).
--
-- This is equivalent to converting floor(log_2(color)) to hexadecimal.
--
-- @tparam number color The color to convert.
-- @treturn string The blit hex code of the color.
-- @since 1.94.0
function toBlit(color)
expect(1, color, "number")
return color_hex_lookup[color] or
string.format("%x", math.floor(math.log(color) / math.log(2)))
end

Some files were not shown because too many files have changed in this diff Show More