1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2026-06-05 20:32:07 +00:00

Build a web-based emulator for the documentation site (#1597)

Historically we've used copy-cat to provide a web-based emulator for
running example code on our documentation site. However, copy-cat is
often out-of-date with CC:T, which means example snippets fail when you
try to run them!

This commit vendors in copy-cat (or rather an updated version of it)
into CC:T itself, allowing us to ensure the emulator is always in sync
with the mod.

While the ARCHITECTURE.md documentation goes into a little bit more
detail here, the general implementation is as follows

 - In project/src/main we implement the core of the emulator. This
   includes a basic reimplementation of some of CC's classes to work on
   the web (mostly the HTTP API and ComputerThread), and some additional
   code to expose the computers to Javascript.

 - This is all then compiled to Javascript using [TeaVM][1] (we actually
   use a [personal fork of it][2] as there's a couple of changes I've
   not upstreamed yet).

 - The Javascript side then pulls in the these compiled classes (and
   the CC ROM) and hooks them up to [cc-web-term][3] to display the
   actual computer.

 - As we're no longer pulling in copy-cat, we can simplify our bundling
   system a little - we now just compile to ESM modules directly.

[1]: https://github.com/konsoletyper/teavm
[2]: https://github.com/SquidDev/teavm/tree/squid-patches
[3]: https://github.com/squiddev-cc/cc-web-term
This commit is contained in:
Jonathan Coates
2023-10-03 09:19:19 +01:00
committed by GitHub
parent 0a31de43c2
commit c0643fadca
55 changed files with 4762 additions and 217 deletions
@@ -0,0 +1,209 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web;
import cc.tweaked.web.js.ComputerDisplay;
import cc.tweaked.web.js.ComputerHandle;
import cc.tweaked.web.js.JavascriptConv;
import cc.tweaked.web.peripheral.SpeakerPeripheral;
import cc.tweaked.web.peripheral.TickablePeripheral;
import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.core.ComputerContext;
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
import dan200.computercraft.core.apis.transfer.TransferredFile;
import dan200.computercraft.core.apis.transfer.TransferredFiles;
import dan200.computercraft.core.computer.Computer;
import dan200.computercraft.core.computer.ComputerEnvironment;
import dan200.computercraft.core.computer.ComputerSide;
import dan200.computercraft.core.filesystem.MemoryMount;
import dan200.computercraft.core.metrics.Metric;
import dan200.computercraft.core.metrics.MetricsObserver;
import dan200.computercraft.core.terminal.Terminal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.teavm.jso.JSObject;
import org.teavm.jso.core.JSString;
import org.teavm.jso.typedarrays.ArrayBuffer;
import javax.annotation.Nullable;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/**
* Manages the core lifecycle of an emulated {@link Computer}.
* <p>
* This is exposed to Javascript via the {@link ComputerHandle} interface.
*/
class EmulatedComputer implements ComputerEnvironment, ComputerHandle, MetricsObserver {
private static final Logger LOG = LoggerFactory.getLogger(EmulatedComputer.class);
private static final ComputerSide[] SIDES = ComputerSide.values();
private boolean terminalChanged = false;
private final Terminal terminal = new Terminal(51, 19, true, () -> terminalChanged = true);
private final Computer computer;
private final ComputerDisplay computerAccess;
private boolean disposed = false;
private final MemoryMount mount = new MemoryMount();
EmulatedComputer(ComputerContext context, ComputerDisplay computerAccess) {
this.computerAccess = computerAccess;
this.computer = new Computer(context, this, terminal, 0);
if (!disposed) computer.turnOn();
}
/**
* Tick this computer.
*
* @return If this computer has been disposed of.
*/
public boolean tick() {
if (disposed && computer.isOn()) computer.unload();
try {
computer.tick();
} catch (RuntimeException e) {
LOG.error("Error when ticking computer", e);
}
if (computer.pollAndResetChanged()) {
computerAccess.setState(computer.getLabel(), computer.isOn());
}
for (var side : SIDES) {
var peripheral = computer.getEnvironment().getPeripheral(side);
if (peripheral instanceof TickablePeripheral toTick) toTick.tick();
}
if (terminalChanged) {
terminalChanged = false;
computerAccess.updateTerminal(
terminal.getWidth(), terminal.getHeight(),
terminal.getCursorX(), terminal.getCursorY(),
terminal.getCursorBlink(), terminal.getTextColour()
);
for (var i = 0; i < terminal.getHeight(); i++) {
computerAccess.setTerminalLine(i,
terminal.getLine(i).toString(),
terminal.getTextColourLine(i).toString(),
terminal.getBackgroundColourLine(i).toString()
);
}
var palette = terminal.getPalette();
for (var i = 0; i < 16; i++) {
var colours = palette.getColour(i);
computerAccess.setPaletteColour(15 - i, colours[0], colours[1], colours[2]);
}
computerAccess.flushTerminal();
}
return disposed && !computer.isOn();
}
@Override
public int getDay() {
return (int) ((Main.getTicks() + 6000) / 24000) + 1;
}
@Override
public double getTimeOfDay() {
return ((Main.getTicks() + 6000) % 24000) / 1000.0;
}
@Nullable
@Override
public WritableMount createRootMount() {
return mount;
}
@Override
public MetricsObserver getMetrics() {
return this;
}
@Override
public void observe(Metric.Counter counter) {
}
@Override
public void observe(Metric.Event event, long value) {
}
@Override
public void event(String event, @Nullable JSObject[] args) {
computer.queueEvent(event, JavascriptConv.toJava(args));
}
@Override
public void shutdown() {
computer.shutdown();
}
@Override
public void turnOn() {
computer.turnOn();
}
@Override
public void reboot() {
computer.reboot();
}
@Override
public void dispose() {
disposed = true;
}
@Override
public void transferFiles(FileContents[] files) {
computer.queueEvent(TransferredFiles.EVENT, new Object[]{
new TransferredFiles(
Arrays.stream(files)
.map(x -> new TransferredFile(x.getName(), new ArrayByteChannel(bytesOfBuffer(x.getContents()))))
.toList(),
() -> {
}),
});
}
@Override
public void setPeripheral(String sideName, @Nullable String kind) {
var side = ComputerSide.valueOfInsensitive(sideName);
if (side == null) throw new IllegalArgumentException("Unknown sideName");
IPeripheral peripheral;
if (kind == null) {
peripheral = null;
} else if (kind.equals("speaker")) {
peripheral = new SpeakerPeripheral();
} else {
throw new IllegalArgumentException("Unknown peripheral kind");
}
computer.getEnvironment().setPeripheral(side, peripheral);
}
@Override
public void addFile(String path, JSObject contents) {
byte[] bytes;
if (JavascriptConv.isArrayBuffer(contents)) {
bytes = bytesOfBuffer(contents.cast());
} else {
JSString string = contents.cast();
bytes = string.stringValue().getBytes(StandardCharsets.UTF_8);
}
mount.addFile(path, bytes);
}
private byte[] bytesOfBuffer(ArrayBuffer buffer) {
var oldBytes = JavascriptConv.asByteArray(buffer);
return Arrays.copyOf(oldBytes, oldBytes.length);
}
}
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web;
import cc.tweaked.web.js.Callbacks;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.core.computer.GlobalEnvironment;
import javax.annotation.Nullable;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
/**
* The {@link GlobalEnvironment} for all {@linkplain EmulatedComputer emulated computers}. This reads resources and
* version information from {@linkplain Callbacks the resources module}.
*/
final class EmulatorEnvironment implements GlobalEnvironment {
public static final EmulatorEnvironment INSTANCE = new EmulatorEnvironment();
private final String version = Callbacks.getModVersion();
private @Nullable ResourceMount romMount;
private @Nullable byte[] bios;
private EmulatorEnvironment() {
}
@Override
public String getHostString() {
return "ComputerCraft " + version + " (tweaked.cc)";
}
@Override
public String getUserAgent() {
return "computercraft/" + version;
}
@Override
public Mount createResourceMount(String domain, String subPath) {
if (domain.equals("computercraft") && subPath.equals("lua/rom")) {
return romMount != null ? romMount : (romMount = new ResourceMount());
} else {
throw new IllegalArgumentException("Unknown domain or subpath");
}
}
@Override
public InputStream createResourceFile(String domain, String subPath) {
if (domain.equals("computercraft") && subPath.equals("lua/bios.lua")) {
var biosContents = bios != null ? bios : (bios = Callbacks.getResource("bios.lua"));
return new ByteArrayInputStream(biosContents);
} else {
throw new IllegalArgumentException("Unknown domain or subpath");
}
}
}
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web;
import cc.tweaked.web.js.Callbacks;
import dan200.computercraft.core.ComputerContext;
import org.teavm.jso.browser.Window;
import java.util.ArrayList;
import java.util.List;
/**
* The main entrypoint to the emulator.
*/
public class Main {
public static final String CORS_PROXY = "https://copy-cat-cors.vercel.app/?{}";
private static long ticks;
public static void main(String[] args) {
var context = ComputerContext.builder(EmulatorEnvironment.INSTANCE).build();
List<EmulatedComputer> computers = new ArrayList<>();
Callbacks.setup(access -> {
var wrapper = new EmulatedComputer(context, access);
computers.add(wrapper);
return wrapper;
});
Window.setInterval(() -> {
ticks++;
var iterator = computers.iterator();
while (iterator.hasNext()) {
if (iterator.next().tick()) iterator.remove();
}
}, 50);
}
public static long getTicks() {
return ticks;
}
}
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web;
import cc.tweaked.web.js.Callbacks;
import dan200.computercraft.core.apis.handles.ArrayByteChannel;
import dan200.computercraft.core.filesystem.AbstractInMemoryMount;
import javax.annotation.Nullable;
import java.nio.channels.SeekableByteChannel;
/**
* Mounts in files from JavaScript-supplied resources.
*
* @see Callbacks#listResources()
* @see Callbacks#getResource(String)
*/
final class ResourceMount extends AbstractInMemoryMount<ResourceMount.FileEntry> {
private static final String PREFIX = "rom/";
ResourceMount() {
root = new FileEntry("");
for (var file : Callbacks.listResources()) {
if (file.startsWith(PREFIX)) getOrCreateChild(root, file.substring(PREFIX.length()), FileEntry::new);
}
}
@Override
protected long getSize(String path, FileEntry file) {
return file.isDirectory() ? 0 : getContents(file).length;
}
@Override
protected SeekableByteChannel openForRead(String path, FileEntry file) {
return new ArrayByteChannel(getContents(file));
}
private byte[] getContents(FileEntry file) {
return file.contents != null ? file.contents : (file.contents = Callbacks.getResource(PREFIX + file.path));
}
protected static final class FileEntry extends AbstractInMemoryMount.FileEntry<FileEntry> {
private final String path;
private @Nullable byte[] contents;
FileEntry(String path) {
this.path = path;
}
}
}
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.js;
import org.teavm.jso.JSBody;
import org.teavm.jso.JSByRef;
import org.teavm.jso.JSFunctor;
import org.teavm.jso.JSObject;
import org.teavm.jso.browser.TimerHandler;
/**
* Invoke functions in the {@code $javaCallbacks} object. This global is set up by the Javascript code before the
* Java code is started.
* <p>
* This module is a bit of a hack - we should be able to do most of this with module imports/exports. However, handling
* those within TeaVM is a bit awkward, so this ends up being much easier.
*/
public class Callbacks {
@JSFunctor
@FunctionalInterface
public interface AddComputer extends JSObject {
ComputerHandle addComputer(ComputerDisplay computer);
}
/**
* Export the {@link AddComputer} function to the Javascript code.
*
* @param addComputer The function to add a computer.
*/
@JSBody(params = "setup", script = "return $javaCallbacks.setup(setup);")
public static native void setup(AddComputer addComputer);
/**
* Get the version of CC: Tweaked.
*
* @return The mod's version.
*/
@JSBody(script = "return $javaCallbacks.modVersion;")
public static native String getModVersion();
/**
* List all resources available in the ROM.
*
* @return All available resources.
*/
@JSBody(script = "return $javaCallbacks.listResources();")
public static native String[] listResources();
/**
* Load a resource from the ROM.
*
* @param resource The path to the resource to load.
* @return The loaded resource.
*/
@JSByRef
@JSBody(params = "name", script = "return $javaCallbacks.getResource(name);")
public static native byte[] getResource(String resource);
/**
* Call {@code setImmediate} (or rather a polyfill) to run an asynchronous task.
* <p>
* While it would be nicer to use something built-in like {@code queueMicrotask}, our computer execution definitely
* doesn't count as a microtask, and doing so will stall the UI thread.
*
* @param task The task to run.
*/
@JSBody(params = "task", script = "return setImmediate(task);")
public static native void setImmediate(TimerHandler task);
}
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.js;
import org.teavm.jso.JSObject;
import javax.annotation.Nullable;
/**
* The Javascript-side terminal which displays this computer.
*
* @see Callbacks.AddComputer#addComputer(ComputerDisplay)
*/
public interface ComputerDisplay extends JSObject {
/**
* Set this computer's current state.
*
* @param label This computer's label
* @param on If this computer is on right now
*/
void setState(@Nullable String label, boolean on);
/**
* Update the terminal's properties.
*
* @param width The terminal width
* @param height The terminal height
* @param x The X cursor
* @param y The Y cursor
* @param blink Whether the cursor is blinking
* @param cursorColour The cursor's colour
*/
void updateTerminal(int width, int height, int x, int y, boolean blink, int cursorColour);
/**
* Set a line on the terminal.
*
* @param line The line index to set
* @param text The line's text
* @param fore The line's foreground
* @param back The line's background
*/
void setTerminalLine(int line, String text, String fore, String back);
/**
* Set the palette colour for a specific index.
*
* @param colour The colour index to set
* @param r The red value, between 0 and 1
* @param g The green value, between 0 and 1
* @param b The blue value, between 0 and 1
*/
void setPaletteColour(int colour, double r, double g, double b);
/**
* Mark the terminal as having changed. Should be called after all other terminal methods.
*/
void flushTerminal();
}
@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.js;
import org.teavm.jso.JSObject;
import org.teavm.jso.JSProperty;
import org.teavm.jso.core.JSString;
import org.teavm.jso.typedarrays.ArrayBuffer;
import javax.annotation.Nullable;
/**
* A Javascript-facing interface for controlling computers.
*/
public interface ComputerHandle extends JSObject {
/**
* Queue an event on the computer.
*
* @param event The name of the event.
* @param args The arguments for this event.
*/
void event(String event, @Nullable JSObject[] args);
/**
* Shut the computer down.
*/
void shutdown();
/**
* Turn the computer on.
*/
void turnOn();
/**
* Reboot the computer.
*/
void reboot();
/**
* Dispose of this computer, marking it as no longer running.
*/
void dispose();
/**
* Transfer some files to this computer.
*
* @param files A list of files and their contents.
*/
void transferFiles(FileContents[] files);
/**
* Set a peripheral on a particular side.
*
* @param side The side to set the peripheral on.
* @param kind The kind of peripheral. For now, can only be "speaker".
*/
void setPeripheral(String side, @Nullable String kind);
/**
* Add a file to this computer's filesystem.
*
* @param path The path of the file.
* @param contents The contents of the file, either a {@link JSString} or {@link ArrayBuffer}.
*/
void addFile(String path, JSObject contents);
/**
* A file to transfer to the computer.
*/
interface FileContents extends JSObject {
@JSProperty
String getName();
@JSProperty
ArrayBuffer getContents();
}
}
@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.js;
import org.teavm.jso.JSBody;
import org.teavm.jso.JSObject;
/**
* Wraps Javascript's {@code console} class.
*/
public final class Console {
private Console() {
}
@JSBody(params = "x", script = "console.log(x);")
public static native void log(String message);
@JSBody(params = "x", script = "console.warn(x);")
public static native void warn(String message);
@JSBody(params = "x", script = "console.error(x);")
public static native void error(String message);
@JSBody(params = "x", script = "console.error(x);")
public static native void error(JSObject object);
}
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.js;
import org.jetbrains.annotations.Contract;
import org.teavm.jso.JSBody;
import org.teavm.jso.JSByRef;
import org.teavm.jso.JSObject;
import org.teavm.jso.core.JSBoolean;
import org.teavm.jso.core.JSNumber;
import org.teavm.jso.core.JSObjects;
import org.teavm.jso.core.JSString;
import org.teavm.jso.typedarrays.ArrayBuffer;
import org.teavm.jso.typedarrays.Int8Array;
import javax.annotation.Nullable;
/**
* Utility methods for converting between Java and Javascript representations.
*/
public class JavascriptConv {
/**
* Convert an array of Javascript values to an equivalent array of Java values.
*
* @param value The value to convert.
* @return The converted value.
*/
@Contract("null -> null; !null -> !null")
public static @Nullable Object[] toJava(@Nullable JSObject[] value) {
if (value == null) return null;
var out = new Object[value.length];
for (var i = 0; i < value.length; i++) out[i] = toJava(value[i]);
return out;
}
/**
* Convert a primitive Javascript value to a boxed Java object.
*
* @param value The value to convert.
* @return The converted value.
*/
public static @Nullable Object toJava(@Nullable JSObject value) {
if (value == null) return null;
return switch (JSObjects.typeOf(value)) {
case "string" -> ((JSString) value).stringValue();
case "number" -> ((JSNumber) value).doubleValue();
case "boolean" -> ((JSBoolean) value).booleanValue();
default -> null;
};
}
/**
* Check if an arbitrary object is a {@link ArrayBuffer}.
*
* @param object The object ot check
* @return Whether this is an {@link ArrayBuffer}.
*/
@JSBody(params = "data", script = "return data instanceof ArrayBuffer;")
public static native boolean isArrayBuffer(JSObject object);
/**
* Wrap a JS {@link Int8Array} into a {@code byte[]}.
*
* @param view The array to wrap.
* @return The wrapped array.
*/
@JSByRef
@JSBody(params = "x", script = "return x;")
public static native byte[] asByteArray(Int8Array view);
/**
* Wrap a JS {@link ArrayBuffer} into a {@code byte[]}.
*
* @param view The array to wrap.
* @return The wrapped array.
*/
public static byte[] asByteArray(ArrayBuffer view) {
return asByteArray(Int8Array.create(view));
}
}
@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
@DefaultQualifier(value = NonNull.class, locations = {
TypeUseLocation.RETURN,
TypeUseLocation.PARAMETER,
TypeUseLocation.FIELD,
})
package cc.tweaked.web;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.framework.qual.DefaultQualifier;
import org.checkerframework.framework.qual.TypeUseLocation;
@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.peripheral;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaTable;
import org.teavm.jso.webaudio.AudioBuffer;
import org.teavm.jso.webaudio.AudioContext;
import javax.annotation.Nullable;
import java.util.Optional;
import static cc.tweaked.web.peripheral.SpeakerPeripheral.SAMPLE_RATE;
final class AudioState {
/**
* The minimum size of the client's audio buffer. Once we have less than this on the client, we should send another
* batch of audio.
*/
private static final double CLIENT_BUFFER = 0.5;
private final AudioContext audioContext;
private @Nullable AudioBuffer nextBuffer;
private double nextTime;
AudioState(AudioContext audioContext) {
this.audioContext = audioContext;
nextTime = audioContext.getCurrentTime();
}
boolean pushBuffer(LuaTable<?, ?> table, int size, Optional<Double> volume) throws LuaException {
if (nextBuffer != null) return false;
var buffer = nextBuffer = audioContext.createBuffer(1, size, SAMPLE_RATE);
var contents = buffer.getChannelData(0);
for (var i = 0; i < size; i++) contents.set(i, table.getInt(i + 1) / 128.0f);
// So we really should go via DFPWM here, but I do not have enough faith in our performance to do this properly.
if (shouldSendPending()) playNext();
return true;
}
boolean isPlaying() {
return nextTime >= audioContext.getCurrentTime();
}
boolean shouldSendPending() {
return nextBuffer != null && audioContext.getCurrentTime() >= nextTime - CLIENT_BUFFER;
}
void playNext() {
if (nextBuffer == null) throw new NullPointerException("Buffer is null");
var source = audioContext.createBufferSource();
source.setBuffer(nextBuffer);
source.connect(audioContext.getDestination());
source.start(nextTime);
nextTime += nextBuffer.getDuration();
nextBuffer = null;
}
}
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.peripheral;
import dan200.computercraft.api.lua.LuaException;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.api.lua.LuaTable;
import dan200.computercraft.api.peripheral.IComputerAccess;
import dan200.computercraft.api.peripheral.IPeripheral;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.teavm.jso.webaudio.AudioContext;
import javax.annotation.Nullable;
import java.util.Optional;
import static dan200.computercraft.api.lua.LuaValues.checkFinite;
/**
* A minimal speaker peripheral, which implements {@code playAudio} and nothing else.
*/
public class SpeakerPeripheral implements TickablePeripheral {
public static final int SAMPLE_RATE = 48000;
private static @MonotonicNonNull AudioContext audioContext;
private @Nullable IComputerAccess computer;
private @Nullable AudioState state;
@Override
public String getType() {
return "speaker";
}
@Override
public void attach(IComputerAccess computer) {
this.computer = computer;
}
@Override
public void detach(IComputerAccess computer) {
this.computer = null;
}
@Override
public void tick() {
if (state != null && state.shouldSendPending()) {
state.playNext();
if (computer != null) computer.queueEvent("speaker_audio_empty", computer.getAttachmentName());
}
}
@LuaFunction
public final boolean playNote(String instrumentA, Optional<Double> volumeA, Optional<Double> pitchA) throws LuaException {
throw new LuaException("Cannot play notes outside of Minecraft");
}
@LuaFunction
public final boolean playSound(String name, Optional<Double> volumeA, Optional<Double> pitchA) throws LuaException {
throw new LuaException("Cannot play sounds outside of Minecraft");
}
@LuaFunction(unsafe = true)
public final boolean playAudio(LuaTable<?, ?> audio, Optional<Double> volume) throws LuaException {
checkFinite(1, volume.orElse(0.0));
var length = audio.length();
if (length <= 0) throw new LuaException("Cannot play empty audio");
if (length > 128 * 1024) throw new LuaException("Audio data is too large");
if (audioContext == null) audioContext = AudioContext.create();
if (state == null || !state.isPlaying()) state = new AudioState(audioContext);
return state.pushBuffer(audio, length, volume);
}
@LuaFunction
public final void stop() {
// TODO: Not sure how to do this.
}
@Override
public boolean equals(@Nullable IPeripheral other) {
return other instanceof SpeakerPeripheral;
}
}
@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.peripheral;
import dan200.computercraft.api.peripheral.IPeripheral;
/**
* A peripheral which will be updated every time the computer ticks.
*/
public interface TickablePeripheral extends IPeripheral {
void tick();
}
@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.stub;
import java.io.IOException;
import java.nio.channels.SeekableByteChannel;
import java.nio.channels.WritableByteChannel;
/**
* A stub for {@link java.nio.channels.FileChannel}. This is never constructed, only used in {@code instanceof} checks.
*/
public abstract class FileChannel implements SeekableByteChannel {
private FileChannel() {
}
@Override
public abstract FileChannel position(long newPosition) throws IOException;
public abstract void force(boolean metadata) throws IOException;
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;
}
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.stub;
/**
* A no-op stub for {@link java.util.concurrent.locks.ReentrantLock}.
*/
public class ReentrantLock {
public boolean tryLock() {
return true;
}
public void unlock() {
}
public void lockInterruptibly() throws InterruptedException {
}
}
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core;
import dan200.computercraft.core.apis.http.options.AddressRule;
/**
* Replaces {@link CoreConfig} with a slightly cut-down version.
* <p>
* This is mostly required to avoid pulling in {@link AddressRule}.
*/
public final class TCoreConfig {
private TCoreConfig() {
}
public static int maximumFilesOpen = 128;
public static boolean disableLua51Features = false;
public static String defaultComputerSettings = "";
public static boolean httpEnabled = true;
public static boolean httpWebsocketEnabled = true;
public static int httpMaxRequests = 16;
public static int httpMaxWebsockets = 4;
}
@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.apis.http;
import dan200.computercraft.core.apis.IAPIEnvironment;
import java.net.URI;
/**
* Replaces {@link CheckUrl} with an implementation which unconditionally returns true.
*/
public class TCheckUrl extends Resource<TCheckUrl> {
private static final String EVENT = "http_check";
private final IAPIEnvironment environment;
private final String address;
public TCheckUrl(ResourceGroup<TCheckUrl> limiter, IAPIEnvironment environment, String address, URI uri) {
super(limiter);
this.environment = environment;
this.address = address;
}
public void run() {
if (isClosed()) return;
environment.queueEvent(EVENT, address, true);
}
}
@@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.apis.http.request;
import cc.tweaked.web.Main;
import cc.tweaked.web.js.JavascriptConv;
import dan200.computercraft.core.Logging;
import dan200.computercraft.core.apis.IAPIEnvironment;
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.handles.HandleGeneric;
import dan200.computercraft.core.apis.http.HTTPRequestException;
import dan200.computercraft.core.apis.http.Resource;
import dan200.computercraft.core.apis.http.ResourceGroup;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpMethod;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.teavm.jso.ajax.XMLHttpRequest;
import org.teavm.jso.typedarrays.ArrayBuffer;
import javax.annotation.Nullable;
import java.io.BufferedReader;
import java.io.StringReader;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.channels.SeekableByteChannel;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
/**
* Replaces {@link HttpRequest} with a version which uses AJAX/{@link XMLHttpRequest}.
*/
public class THttpRequest extends Resource<THttpRequest> {
private static final Logger LOG = LoggerFactory.getLogger(THttpRequest.class);
private static final String SUCCESS_EVENT = "http_success";
private static final String FAILURE_EVENT = "http_failure";
private final IAPIEnvironment environment;
private final String address;
private final @Nullable String postBuffer;
private final HttpHeaders headers;
private final boolean binary;
public THttpRequest(
ResourceGroup<THttpRequest> limiter, IAPIEnvironment environment, String address, @Nullable String postText,
HttpHeaders headers, boolean binary, boolean followRedirects, int timeout
) {
super(limiter);
this.environment = environment;
this.address = address;
postBuffer = postText;
this.headers = headers;
this.binary = binary;
if (postText != null) {
if (!headers.contains(HttpHeaderNames.CONTENT_TYPE)) {
headers.set(HttpHeaderNames.CONTENT_TYPE, "application/x-www-form-urlencoded; charset=utf-8");
}
}
}
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;
try {
var request = XMLHttpRequest.create();
request.setOnReadyStateChange(() -> onResponseStateChange(request));
request.setResponseType(binary ? "arraybuffer" : "text");
var address = uri.toASCIIString();
request.open(method.toString(), Main.CORS_PROXY.isEmpty() ? address : Main.CORS_PROXY.replace("{}", address));
for (var iterator = headers.iteratorAsString(); iterator.hasNext(); ) {
var header = iterator.next();
request.setRequestHeader(header.getKey(), header.getValue());
}
request.send(postBuffer);
checkClosed();
} catch (Exception e) {
failure("Could not connect");
LOG.error(Logging.HTTP_ERROR, "Error in HTTP request", e);
}
}
private void onResponseStateChange(XMLHttpRequest request) {
if (request.getReadyState() != XMLHttpRequest.DONE) return;
if (request.getStatus() == 0) {
this.failure("Could not connect");
return;
}
HandleGeneric reader;
if (binary) {
ArrayBuffer buffer = request.getResponse().cast();
SeekableByteChannel contents = new ArrayByteChannel(JavascriptConv.asByteArray(buffer));
reader = BinaryReadableHandle.of(contents);
} else {
reader = new EncodedReadableHandle(new BufferedReader(new StringReader(request.getResponseText())));
}
Map<String, String> responseHeaders = new HashMap<>();
for (var header : request.getAllResponseHeaders().split("\r\n")) {
var index = header.indexOf(':');
if (index < 0) continue;
// Normalise the header (so "content-type" becomes "Content-Type")
var upcase = true;
var headerBuilder = new StringBuilder(index);
for (var i = 0; i < index; i++) {
var c = header.charAt(i);
headerBuilder.append(upcase ? Character.toUpperCase(c) : c);
upcase = c == '-';
}
responseHeaders.put(headerBuilder.toString(), header.substring(index + 1).trim());
}
var stream = new HttpResponseHandle(reader, request.getStatus(), request.getStatusText(), responseHeaders);
if (request.getStatus() >= 200 && request.getStatus() < 400) {
if (tryClose()) environment.queueEvent(SUCCESS_EVENT, address, stream);
} else {
if (tryClose()) environment.queueEvent(FAILURE_EVENT, address, request.getStatusText(), stream);
}
}
void failure(String message) {
if (tryClose()) environment.queueEvent(FAILURE_EVENT, address, message);
}
}
@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.apis.http.websocket;
import cc.tweaked.web.js.Console;
import cc.tweaked.web.js.JavascriptConv;
import com.google.common.base.Strings;
import dan200.computercraft.core.apis.IAPIEnvironment;
import dan200.computercraft.core.apis.http.Resource;
import dan200.computercraft.core.apis.http.ResourceGroup;
import dan200.computercraft.core.apis.http.options.Action;
import dan200.computercraft.core.apis.http.options.Options;
import io.netty.handler.codec.http.HttpHeaders;
import org.teavm.jso.typedarrays.Int8Array;
import org.teavm.jso.websocket.WebSocket;
import javax.annotation.Nullable;
import java.net.URI;
import java.nio.ByteBuffer;
/**
* Replaces {@link Websocket} with a version which uses Javascript's built-in {@link WebSocket} client.
*/
public class TWebsocket extends Resource<TWebsocket> implements WebsocketClient {
private final IAPIEnvironment environment;
private final URI uri;
private final String address;
private @Nullable WebSocket websocket;
public TWebsocket(ResourceGroup<TWebsocket> limiter, IAPIEnvironment environment, URI uri, String address, HttpHeaders headers, int timeout) {
super(limiter);
this.environment = environment;
this.uri = uri;
this.address = address;
}
public void connect() {
if (isClosed()) return;
var client = this.websocket = WebSocket.create(uri.toASCIIString());
client.setBinaryType("arraybuffer");
client.onOpen(e -> success(Action.ALLOW.toPartial().toOptions()));
client.onError(e -> {
Console.error(e);
failure("Could not connect");
});
client.onMessage(e -> {
if (isClosed()) return;
if (JavascriptConv.isArrayBuffer(e.getData())) {
var array = Int8Array.create(e.getDataAsArray());
var contents = new byte[array.getLength()];
for (var i = 0; i < contents.length; i++) contents[i] = array.get(i);
environment.queueEvent("websocket_message", address, contents, true);
} else {
environment.queueEvent("websocket_message", address, e.getDataAsString(), false);
}
});
client.onClose(e -> close(e.getCode(), e.getReason()));
}
@Override
public void sendText(String message) {
if (websocket == null) return;
websocket.send(message);
}
@Override
public void sendBinary(ByteBuffer message) {
if (websocket == null) return;
var array = Int8Array.create(message.remaining());
for (var i = 0; i < array.getLength(); i++) array.set(i, message.get(i));
websocket.send(array);
}
@Override
protected void dispose() {
super.dispose();
if (websocket != null) {
websocket.close();
websocket = null;
}
}
private void success(Options options) {
if (isClosed()) return;
var handle = new WebsocketHandle(environment, address, this, options);
environment.queueEvent(SUCCESS_EVENT, address, handle);
createOwnerReference(handle);
checkClosed();
}
void failure(String message) {
if (tryClose()) environment.queueEvent(FAILURE_EVENT, address, message);
}
void close(int status, String reason) {
if (!tryClose()) return;
environment.queueEvent(CLOSE_EVENT, address, Strings.isNullOrEmpty(reason) ? null : reason, status < 0 ? null : status);
}
}
@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.asm;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import dan200.computercraft.api.lua.ILuaContext;
import dan200.computercraft.api.lua.LuaFunction;
import dan200.computercraft.api.lua.MethodResult;
import dan200.computercraft.api.peripheral.PeripheralType;
import dan200.computercraft.core.methods.LuaMethod;
import dan200.computercraft.core.methods.NamedMethod;
import org.teavm.metaprogramming.*;
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.concurrent.ExecutionException;
import java.util.function.Consumer;
/**
* Compile-time generation of {@link LuaMethod} methods.
*
* @see TLuaMethodSupplier
* @see StaticGenerator
*/
@CompileTime
public class MethodReflection {
@Meta
public static native boolean getMethods(Class<?> type, Consumer<NamedMethod<LuaMethod>> make);
private static void getMethods(ReflectClass<?> klass, Value<Consumer<NamedMethod<LuaMethod>>> make) {
var result = getMethodsImpl(klass, make);
// Using "unsupportedCase" here causes us to skip generating any code and just return null. While null isn't
// a boolean, it's still false-y and thus has the same effect in the generated JS!
if (!result) Metaprogramming.unsupportedCase();
Metaprogramming.exit(() -> result);
}
private static boolean getMethodsImpl(ReflectClass<?> klass, Value<Consumer<NamedMethod<LuaMethod>>> make) {
if (!klass.getName().startsWith("dan200.computercraft.") && !klass.getName().startsWith("cc.tweaked.web.peripheral")) {
return false;
}
if (klass.getName().contains("lambda")) return false;
Class<?> actualClass;
try {
actualClass = Metaprogramming.getClassLoader().loadClass(klass.getName());
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
var methods = Internal.getMethods(actualClass);
for (var method : methods) {
var name = method.name();
var nonYielding = method.nonYielding();
var actualField = method.method().getField("INSTANCE");
Metaprogramming.emit(() -> make.get().accept(new NamedMethod<>(name, (LuaMethod) actualField.get(null), nonYielding, null)));
}
return !methods.isEmpty();
}
private static final class Internal {
private static final LoadingCache<Class<?>, List<NamedMethod<ReflectClass<LuaMethod>>>> CLASS_CACHE = CacheBuilder
.newBuilder()
.build(CacheLoader.from(Internal::getMethodsImpl));
private static final StaticGenerator<LuaMethod> GENERATOR = new StaticGenerator<>(
LuaMethod.class, Collections.singletonList(ILuaContext.class), Internal::createClass
);
static List<NamedMethod<ReflectClass<LuaMethod>>> getMethods(Class<?> klass) {
try {
return CLASS_CACHE.get(klass);
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
}
private static ReflectClass<?> createClass(byte[] bytes) {
/*
StaticGenerator is not declared to be @CompileTime, to ensure it loads in the same module/classloader as
other files in this package. This means it can't call Metaprogramming.createClass directly, as that's
only available to @CompileTime classes.
We need to use an explicit call (rather than a MethodReference), as TeaVM doesn't correctly rewrite the
latter.
*/
return Metaprogramming.createClass(bytes);
}
private static List<NamedMethod<ReflectClass<LuaMethod>>> getMethodsImpl(Class<?> klass) {
ArrayList<NamedMethod<ReflectClass<LuaMethod>>> methods = null;
// Find all methods on the current class
for (var method : klass.getMethods()) {
var annotation = method.getAnnotation(LuaFunction.class);
if (annotation == null) continue;
if (Modifier.isStatic(method.getModifiers())) {
System.err.printf("LuaFunction method %s.%s should be an instance method.\n", method.getDeclaringClass(), method.getName());
continue;
}
var instance = GENERATOR.getMethod(method).orElse(null);
if (instance == null) continue;
if (methods == null) methods = new ArrayList<>();
addMethod(methods, method, annotation, null, instance);
}
if (methods == null) return List.of();
methods.trimToSize();
return Collections.unmodifiableList(methods);
}
private static void addMethod(List<NamedMethod<ReflectClass<LuaMethod>>> methods, Method method, LuaFunction annotation, @Nullable PeripheralType genericType, ReflectClass<LuaMethod> 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));
}
}
}
}
}
@@ -0,0 +1,313 @@
// SPDX-FileCopyrightText: 2020 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
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.*;
import dan200.computercraft.core.methods.LuaMethod;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Type;
import org.teavm.metaprogramming.ReflectClass;
import javax.annotation.Nullable;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import static org.objectweb.asm.Opcodes.*;
/**
* The underlying generator for {@link LuaFunction}-annotated methods.
* <p>
* The constructor {@link StaticGenerator#StaticGenerator(Class, List, Function)} takes in the type of interface to generate (i.e.
* {@link LuaMethod}) and the context arguments for this function (in the case of {@link LuaMethod}, this will just be
* {@link ILuaContext}).
* <p>
* The generated class then implements this interface - the {@code apply} method calls the appropriate methods on
* {@link IArguments} to extract the arguments, and then calls the original method.
*
* @param <T> The type of the interface the generated classes implement.
*/
public final class StaticGenerator<T> {
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 static final String INTERNAL_COERCED = Type.getInternalName(Coerced.class);
private final Class<T> base;
private final List<Class<?>> context;
private final String[] interfaces;
private final String methodDesc;
private final String classPrefix;
private final Function<byte[], ReflectClass<?>> createClass;
private final LoadingCache<Method, Optional<ReflectClass<T>>> methodCache = CacheBuilder
.newBuilder()
.build(CacheLoader.from(catching(this::build, Optional.empty())));
public StaticGenerator(Class<T> base, List<Class<?>> context, Function<byte[], ReflectClass<?>> createClass) {
this.base = base;
this.context = context;
this.createClass = createClass;
interfaces = new String[]{Type.getInternalName(base)};
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();
classPrefix = StaticGenerator.class.getPackageName() + "." + base.getSimpleName() + "$";
}
public Optional<ReflectClass<T>> getMethod(Method method) {
return methodCache.getUnchecked(method);
}
private Optional<ReflectClass<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)) {
System.err.printf("Lua Method %s should be final.\n", name);
}
if (!Modifier.isPublic(modifiers)) {
System.err.printf("Lua Method %s should be a public method.\n", name);
return Optional.empty();
}
if (!Modifier.isPublic(method.getDeclaringClass().getModifiers())) {
System.err.printf("Lua Method %s should be on a public class.\n", name);
return Optional.empty();
}
var exceptions = method.getExceptionTypes();
for (var exception : exceptions) {
if (exception != LuaException.class) {
System.err.printf("Lua Method %s cannot throw %s.\n", name, exception.getName());
return Optional.empty();
}
}
var annotation = method.getAnnotation(LuaFunction.class);
if (annotation.unsafe() && annotation.mainThread()) {
System.err.printf("Lua Method %s cannot use unsafe and mainThread.\n", 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 bytes = generate(classPrefix + method.getDeclaringClass().getSimpleName() + "$" + method.getName(), target, method, annotation.unsafe());
if (bytes == null) return Optional.empty();
return Optional.of(createClass.apply(bytes).asSubclass(base));
} catch (ClassFormatError | RuntimeException e) {
System.err.printf("Error generating %s\n", name);
e.printStackTrace();
return Optional.empty();
}
}
@Nullable
private byte[] generate(String className, Class<?> target, Method targetMethod, 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(V17, ACC_PUBLIC | ACC_FINAL, internalName, null, "java/lang/Object", interfaces);
cw.visitSource("CC generated method", null);
cw.visitField(ACC_PUBLIC | ACC_STATIC | ACC_FINAL, "INSTANCE", "L" + internalName + ";", null, null).visitEnd();
{ // 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();
}
// Static initialiser sets the INSTANCE field.
{
var mw = cw.visitMethod(ACC_STATIC, "<clinit>", "()V", null, null);
mw.visitCode();
mw.visitTypeInsn(NEW, internalName);
mw.visitInsn(DUP);
mw.visitMethodInsn(INVOKESPECIAL, internalName, "<init>", "()V", false);
mw.visitFieldInsn(PUTSTATIC, internalName, "INSTANCE", "L" + internalName + ";");
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 target as the first argument.
if (!Modifier.isStatic(targetMethod.getModifiers())) {
mw.visitVarInsn(ALOAD, 1);
mw.visitTypeInsn(CHECKCAST, Type.getInternalName(target));
}
var argIndex = 0;
for (var genericArg : targetMethod.getGenericParameterTypes()) {
var loadedArg = loadArg(mw, target, targetMethod, unsafe, genericArg, argIndex);
if (loadedArg == null) return null;
if (loadedArg) argIndex++;
}
mw.visitMethodInsn(
Modifier.isStatic(targetMethod.getModifiers()) ? INVOKESTATIC : INVOKEVIRTUAL,
Type.getInternalName(targetMethod.getDeclaringClass()), targetMethod.getName(),
Type.getMethodDescriptor(targetMethod), 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 = targetMethod.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();
}
@Nullable
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 == Coerced.class) {
var klass = Reflect.getRawType(method, TypeToken.of(genericArg).resolveType(Reflect.COERCED_IN).getType(), false);
if (klass == null) return null;
if (klass == String.class) {
mw.visitTypeInsn(NEW, INTERNAL_COERCED);
mw.visitInsn(DUP);
mw.visitVarInsn(ALOAD, 2 + context.size());
Reflect.loadInt(mw, argIndex);
mw.visitMethodInsn(INVOKEINTERFACE, INTERNAL_ARGUMENTS, "getStringCoerced", "(I)Ljava/lang/String;", true);
mw.visitMethodInsn(INVOKESPECIAL, INTERNAL_COERCED, "<init>", "(Ljava/lang/Object;)V", false);
return true;
}
}
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;
}
System.err.printf("Unknown parameter type %s for method %s.%s.\n",
arg.getName(), method.getDeclaringClass().getName(), method.getName());
return null;
}
@SuppressWarnings("Guava")
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.
e.printStackTrace();
return def;
}
};
}
}
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.asm;
import dan200.computercraft.core.methods.LuaMethod;
import dan200.computercraft.core.methods.MethodSupplier;
import dan200.computercraft.core.methods.ObjectSource;
import java.util.List;
/**
* Replaces {@link LuaMethodSupplier} with a version which uses {@link MethodReflection} to fabricate the classes.
*/
public final class TLuaMethodSupplier implements MethodSupplier<LuaMethod> {
static final TLuaMethodSupplier INSTANCE = new TLuaMethodSupplier();
private TLuaMethodSupplier() {
}
@Override
public boolean forEachSelfMethod(Object object, UntargetedConsumer<LuaMethod> consumer) {
return MethodReflection.getMethods(object.getClass(), method -> consumer.accept(method.name(), method.method(), method));
}
@Override
public boolean forEachMethod(Object object, TargetedConsumer<LuaMethod> consumer) {
var hasMethods = MethodReflection.getMethods(object.getClass(), method -> consumer.accept(object, method.name(), method.method(), method));
if (object instanceof ObjectSource source) {
for (var extra : source.getExtra()) {
hasMethods |= MethodReflection.getMethods(extra.getClass(), method -> consumer.accept(extra, method.name(), method.method(), method));
}
}
return hasMethods;
}
public static MethodSupplier<LuaMethod> create(List<GenericMethod> genericMethods) {
return INSTANCE;
}
}
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.asm;
import dan200.computercraft.api.peripheral.IComputerAccess;
import dan200.computercraft.core.methods.LuaMethod;
import dan200.computercraft.core.methods.MethodSupplier;
import dan200.computercraft.core.methods.PeripheralMethod;
import java.util.List;
/**
* Replaces {@link PeripheralMethodSupplier} with a version which lifts {@link LuaMethod}s to {@link PeripheralMethod}.
* As none of our peripherals need {@link IComputerAccess}, this is entirely safe.
*/
public final class TPeripheralMethodSupplier implements MethodSupplier<PeripheralMethod> {
static final TPeripheralMethodSupplier INSTANCE = new TPeripheralMethodSupplier();
private TPeripheralMethodSupplier() {
}
@Override
public boolean forEachSelfMethod(Object object, UntargetedConsumer<PeripheralMethod> consumer) {
return TLuaMethodSupplier.INSTANCE.forEachSelfMethod(object, (name, method, info) -> consumer.accept(name, cast(method), null));
}
@Override
public boolean forEachMethod(Object object, TargetedConsumer<PeripheralMethod> consumer) {
return TLuaMethodSupplier.INSTANCE.forEachMethod(object, (target, name, method, info) -> consumer.accept(target, name, cast(method), null));
}
private static PeripheralMethod cast(LuaMethod method) {
return (target, context, computer, args) -> method.apply(target, context, args);
}
public static MethodSupplier<PeripheralMethod> create(List<GenericMethod> genericMethods) {
return INSTANCE;
}
}
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package dan200.computercraft.core.computer;
import cc.tweaked.web.js.Callbacks;
import org.teavm.jso.browser.TimerHandler;
import java.util.ArrayDeque;
import java.util.concurrent.TimeUnit;
/**
* A reimplementation of {@link ComputerThread} which, well, avoids any threading!
* <p>
* This instead just exucutes work as soon as possible via {@link Callbacks#setImmediate(TimerHandler)}. Timeouts are
* instead handled via polling, see {@link cc.tweaked.web.builder.PatchCobalt}.
*/
public class TComputerThread {
private static final ArrayDeque<ComputerExecutor> executors = new ArrayDeque<>();
private final TimerHandler callback = this::workOnce;
public TComputerThread(int threads) {
}
public void queue(ComputerExecutor executor) {
if (executor.onComputerQueue) throw new IllegalStateException("Cannot queue already queued executor");
executor.onComputerQueue = true;
if (executors.isEmpty()) Callbacks.setImmediate(callback);
executors.add(executor);
}
private void workOnce() {
var executor = executors.poll();
if (executor == null) throw new IllegalStateException("Working, but executor is null");
if (!executor.onComputerQueue) throw new IllegalArgumentException("Working but not on queue");
executor.beforeWork();
try {
executor.work();
} catch (Exception e) {
e.printStackTrace();
}
if (executor.afterWork()) executors.push(executor);
if (!executors.isEmpty()) Callbacks.setImmediate(callback);
}
public boolean hasPendingWork() {
return true;
}
public long scaledPeriod() {
return 50 * 1_000_000L;
}
public boolean stop(long timeout, TimeUnit unit) {
return true;
}
}
@@ -0,0 +1,154 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package io.netty.handler.codec.http;
import java.util.*;
/**
* A replacement for {@link DefaultHttpHeaders}.
* <p>
* The default implementation does additional conversion for dates, which ends up pulling in a lot of code we can't
* compile.
*/
public class TDefaultHttpHeaders extends HttpHeaders {
private final Map<String, String> map = new HashMap<>();
@Override
public String get(String name) {
return map.get(normalise(name));
}
@Override
public List<String> getAll(String name) {
var value = get(name);
return value == null ? List.of() : List.of(value);
}
@Override
public List<Map.Entry<String, String>> entries() {
return List.copyOf(map.entrySet());
}
@Override
public boolean contains(String name) {
return get(name) != null;
}
@Override
@Deprecated
public Iterator<Map.Entry<String, String>> iterator() {
return map.entrySet().iterator();
}
@Override
@SuppressWarnings("unchecked")
public Iterator<Map.Entry<CharSequence, CharSequence>> iteratorCharSequence() {
return (Iterator<Map.Entry<CharSequence, CharSequence>>) (Iterator<?>) map.entrySet().iterator();
}
@Override
public boolean isEmpty() {
return map.isEmpty();
}
@Override
public int size() {
return map.size();
}
@Override
public Set<String> names() {
return map.keySet();
}
@Override
public HttpHeaders add(String name, Object value) {
return set(name, value);
}
@Override
public HttpHeaders set(String name, Object value) {
map.put(normalise(name), (String) value);
return this;
}
@Override
public HttpHeaders remove(String name) {
map.remove(normalise(name));
return this;
}
@Override
public HttpHeaders clear() {
map.clear();
return this;
}
//region Uncalled/unsupported methods
@Override
public Integer getInt(CharSequence name) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(CharSequence name, int defaultValue) {
throw new UnsupportedOperationException();
}
@Override
public Short getShort(CharSequence name) {
throw new UnsupportedOperationException();
}
@Override
public short getShort(CharSequence name, short defaultValue) {
throw new UnsupportedOperationException();
}
@Override
public Long getTimeMillis(CharSequence name) {
throw new UnsupportedOperationException();
}
@Override
public long getTimeMillis(CharSequence name, long defaultValue) {
throw new UnsupportedOperationException();
}
@Override
public HttpHeaders add(String name, Iterable<?> values) {
throw new UnsupportedOperationException();
}
@Override
public HttpHeaders addInt(CharSequence name, int value) {
throw new UnsupportedOperationException();
}
@Override
public HttpHeaders addShort(CharSequence name, short value) {
throw new UnsupportedOperationException();
}
@Override
public HttpHeaders set(String name, Iterable<?> values) {
throw new UnsupportedOperationException();
}
@Override
public HttpHeaders setInt(CharSequence name, int value) {
throw new UnsupportedOperationException();
}
@Override
public HttpHeaders setShort(CharSequence name, short value) {
throw new UnsupportedOperationException();
}
//endregion
private static String normalise(String string) {
return string.toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package io.netty.util;
import org.teavm.interop.NoSideEffects;
/**
* A replacement for {@link AsciiString} which just wraps a normal string.
* <p>
* {@link AsciiString} relies heavily on Netty's low-level (and often unsafe!) code, which doesn't run on Javascript.
*/
public final class TAsciiString implements CharSequence {
private final String value;
private TAsciiString(String value) {
this.value = value;
}
@NoSideEffects
public static TAsciiString cached(String value) {
return new TAsciiString(value);
}
@Override
public int length() {
return value.length();
}
@Override
public char charAt(int index) {
return value.charAt(index);
}
@Override
public CharSequence subSequence(int start, int end) {
return value.subSequence(start, end);
}
@Override
public String toString() {
return value;
}
@Override
public boolean equals(Object o) {
return this == o || (o instanceof TAsciiString other && value.equals(other.value));
}
@Override
public int hashCode() {
return value.hashCode();
}
}
@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package org.slf4j;
import cc.tweaked.web.js.Console;
import org.slf4j.event.Level;
import org.slf4j.helpers.AbstractLogger;
import java.io.Serial;
import java.util.Arrays;
/**
* A replacement for SLF4J's {@link LoggerFactory}, which skips service loading, returning a logger which prints to the
* JS console.
*/
public final class TLoggerFactory {
private static final Logger INSTANCE = new LoggerImpl();
private TLoggerFactory() {
}
public static Logger getLogger(Class<?> klass) {
return INSTANCE;
}
private static final class LoggerImpl extends AbstractLogger {
@Serial
private static final long serialVersionUID = 3442920913507872371L;
@Override
protected String getFullyQualifiedCallerName() {
return "logger";
}
@Override
protected void handleNormalizedLoggingCall(Level level, Marker marker, String msg, Object[] arguments, Throwable throwable) {
if (arguments != null) msg += " " + Arrays.toString(arguments);
switch (level) {
case TRACE, DEBUG, INFO -> Console.log(msg);
case WARN -> Console.warn(msg);
case ERROR -> Console.error(msg);
}
if (throwable != null) throwable.printStackTrace();
}
@Override
public boolean isTraceEnabled() {
return true;
}
@Override
public boolean isTraceEnabled(Marker marker) {
return true;
}
@Override
public boolean isDebugEnabled() {
return true;
}
@Override
public boolean isDebugEnabled(Marker marker) {
return true;
}
@Override
public boolean isInfoEnabled() {
return true;
}
@Override
public boolean isInfoEnabled(Marker marker) {
return true;
}
@Override
public boolean isWarnEnabled() {
return true;
}
@Override
public boolean isWarnEnabled(Marker marker) {
return true;
}
@Override
public boolean isErrorEnabled() {
return true;
}
@Override
public boolean isErrorEnabled(Marker marker) {
return true;
}
}
}
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package org.slf4j;
import java.io.Serial;
import java.util.Collections;
import java.util.Iterator;
/**
* A replacement for SLF4J's {@link MarkerFactory}, which skips service loading and always uses a constant
* {@link Marker}.
*/
public final class TMarkerFactory {
private static final Marker INSTANCE = new MarkerImpl();
private TMarkerFactory() {
}
public static Marker getMarker(String name) {
return INSTANCE;
}
private static final class MarkerImpl implements Marker {
@Serial
private static final long serialVersionUID = 6353565105632304410L;
@Override
public String getName() {
return "unnamed";
}
@Override
public void add(Marker reference) {
}
@Override
public boolean remove(Marker reference) {
return false;
}
@Override
@Deprecated
public boolean hasChildren() {
return false;
}
@Override
public boolean hasReferences() {
return false;
}
@Override
public Iterator<Marker> iterator() {
return Collections.emptyIterator();
}
@Override
public boolean contains(Marker other) {
return false;
}
@Override
public boolean contains(String name) {
return false;
}
}
}