Build the web-emulator as part of the doc site

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

This copies copy-cat into the main project, allowing us to ensure the
two are always in sync.
This commit is contained in:
Jonathan Coates 2023-09-30 13:14:54 +01:00
parent 96b6947ef2
commit 5016ef594f
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
55 changed files with 4756 additions and 210 deletions

View File

@ -66,6 +66,7 @@ repositories {
includeGroup("me.shedaniel.cloth")
includeGroup("me.shedaniel")
includeGroup("mezz.jei")
includeGroup("org.teavm")
includeModule("com.terraformersmc", "modmenu")
includeModule("me.lucko", "fabric-permissions-api")
}
@ -99,7 +100,10 @@ sourceSets.all {
check("FutureReturnValueIgnored", CheckSeverity.OFF) // Too many false positives with Netty
check("NullAway", CheckSeverity.ERROR)
option("NullAway:AnnotatedPackages", listOf("dan200.computercraft", "net.fabricmc.fabric.api").joinToString(","))
option(
"NullAway:AnnotatedPackages",
listOf("dan200.computercraft", "cc.tweaked", "net.fabricmc.fabric.api").joinToString(","),
)
option("NullAway:ExcludedFieldAnnotations", listOf("org.spongepowered.asm.mixin.Shadow").joinToString(","))
option("NullAway:CastToNonNullMethod", "dan200.computercraft.core.util.Nullability.assertNonNull")
option("NullAway:CheckOptionalEmptiness")

View File

@ -5,6 +5,7 @@
package cc.tweaked.gradle
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.api.file.FileSystemLocation
import org.gradle.api.file.FileSystemLocationProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
@ -126,5 +127,5 @@ inline fun <R> use(block: (CloseScope) -> R): R {
/** Proxy method to avoid overload ambiguity. */
fun <T> Property<T>.setProvider(provider: Provider<out T>) = set(provider)
/** Short-cut method to get the absolute path of a [FileSystemLocationProperty]. */
fun FileSystemLocationProperty<*>.getAbsolutePath(): String = get().asFile.absolutePath
/** Short-cut method to get the absolute path of a [FileSystemLocation] provider. */
fun Provider<out FileSystemLocation>.getAbsolutePath(): String = get().asFile.absolutePath

View File

@ -16,4 +16,10 @@ SPDX-License-Identifier: MPL-2.0
<!-- The commands API is documented in Lua. -->
<suppress checks="SummaryJavadocCheck" files=".*[\\/]CommandAPI.java" />
<!-- Allow putting files in other packages if they look like our TeaVM stubs. -->
<suppress checks="PackageName" files=".*[\\/]T[A-Za-z]+.java" />
<!-- Allow underscores in our test classes. -->
<suppress checks="MethodName" files=".*Contract.java" />
</suppressions>

View File

@ -29,7 +29,7 @@ ## Example
local size = file.seek("end")
file.seek("set", 0)
print(file.getName() .. " " .. file.getSize())
print(file.getName() .. " " .. size)
end
```

View File

@ -30,7 +30,7 @@ kotlin = "1.8.10"
kotlin-coroutines = "1.6.4"
netty = "4.1.82.Final"
nightConfig = "3.6.7"
slf4j = "1.7.36"
slf4j = "2.0.1"
# Minecraft mods
emi = "1.0.8+1.20.1"
@ -60,7 +60,7 @@ fabric-loom = "1.3.7"
forgeGradle = "6.0.8"
githubRelease = "2.4.1"
ideaExt = "1.1.7"
illuaminate = "0.1.0-40-g975cbc3"
illuaminate = "0.1.0-44-g9ee0055"
librarian = "1.+"
minotaur = "2.+"
mixinGradle = "0.7.+"
@ -69,10 +69,12 @@ spotless = "6.21.0"
taskTree = "2.1.1"
vanillaGradle = "0.2.1-SNAPSHOT"
vineflower = "1.11.0"
teavm = "0.9.0-SQUID.1"
[libraries]
# Normal dependencies
asm = { module = "org.ow2.asm:asm", version.ref = "asm" }
asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm" }
autoService = { module = "com.google.auto.service:auto-service", version.ref = "autoService" }
checkerFramework = { module = "org.checkerframework:checker-qual", version.ref = "checkerFramework" }
cobalt = { module = "org.squiddev:Cobalt", version.ref = "cobalt" }
@ -138,6 +140,14 @@ librarian = { module = "org.parchmentmc:librarian", version.ref = "librarian" }
minotaur = { module = "com.modrinth.minotaur:Minotaur", version.ref = "minotaur" }
nullAway = { module = "com.uber.nullaway:nullaway", version.ref = "nullAway" }
spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" }
teavm-classlib = { module = "org.teavm:teavm-classlib", version.ref = "teavm" }
teavm-jso = { module = "org.teavm:teavm-jso", version.ref = "teavm" }
teavm-jso-apis = { module = "org.teavm:teavm-jso-apis", version.ref = "teavm" }
teavm-jso-impl = { module = "org.teavm:teavm-jso-impl", version.ref = "teavm" }
teavm-metaprogramming-api = { module = "org.teavm:teavm-metaprogramming-api", version.ref = "teavm" }
teavm-metaprogramming-impl = { module = "org.teavm:teavm-metaprogramming-impl", version.ref = "teavm" }
teavm-platform = { module = "org.teavm:teavm-platform", version.ref = "teavm" }
teavm-tooling = { module = "org.teavm:teavm-tooling", version.ref = "teavm" }
vanillaGradle = { module = "org.spongepowered:vanillagradle", version.ref = "vanillaGradle" }
vineflower = { module = "io.github.juuxel:loom-vineflower", version.ref = "vineflower" }
@ -151,6 +161,7 @@ mixinGradle = { id = "org.spongepowered.mixin", version.ref = "mixinGradle" }
taskTree = { id = "com.dorongold.task-tree", version.ref = "taskTree" }
[bundles]
annotations = ["jsr305", "checkerFramework", "jetbrainsAnnotations"]
kotlin = ["kotlin-stdlib", "kotlin-coroutines"]
# Minecraft
@ -164,3 +175,7 @@ externalMods-fabric-runtime = ["jei-fabric", "modmenu"]
# Testing
test = ["junit-jupiter-api", "junit-jupiter-params", "hamcrest", "jqwik-api"]
testRuntime = ["junit-jupiter-engine", "jqwik-engine"]
# Build tools
teavm-api = [ "teavm-jso", "teavm-jso-apis", "teavm-platform", "teavm-classlib", "teavm-metaprogramming-api" ]
teavm-tooling = [ "teavm-tooling", "teavm-metaprogramming-impl", "teavm-jso-impl" ]

View File

@ -23,7 +23,7 @@
(url https://tweaked.cc/)
(source-link https://github.com/cc-tweaked/CC-Tweaked/blob/${commit}/${path}#L${line})
(styles /projects/web/src/frontend/styles.css)
(styles /projects/web/build/rollup/index.css)
(scripts /projects/web/build/rollup/index.js)
(head doc/head.html))

1598
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,11 +6,14 @@
"license": "BSD-3-Clause",
"type": "module",
"dependencies": {
"@squid-dev/cc-web-term": "^2.0.0",
"preact": "^10.5.5",
"setimmediate": "^1.0.5",
"tslib": "^2.0.3"
},
"devDependencies": {
"@rollup/plugin-terser": "^0.4.0",
"@rollup/plugin-node-resolve": "^15.2.1",
"@rollup/plugin-terser": "^0.4.3",
"@rollup/plugin-typescript": "^11.0.0",
"@rollup/plugin-url": "^8.0.1",
"@types/glob": "^8.1.0",
@ -19,8 +22,8 @@
"rehype": "^13.0.0",
"rehype-highlight": "^7.0.0",
"rehype-react": "^8.0.0",
"requirejs": "^2.3.6",
"rollup": "^3.19.1",
"rollup-plugin-postcss": "^4.0.2",
"tsx": "^3.12.10",
"typescript": "^5.2.2"
}

View File

@ -18,9 +18,7 @@ val docApi by configurations.registering {
}
dependencies {
compileOnlyApi(libs.jsr305)
compileOnlyApi(libs.checkerFramework)
compileOnlyApi(libs.jetbrainsAnnotations)
compileOnlyApi(libs.bundles.annotations)
"docApi"(project(":common-api"))
}

View File

@ -20,7 +20,7 @@
* This is used by {@link ComputerContext} to construct {@linkplain ComputerContext#peripheralMethods() the context-wide
* method supplier}. It should not be used directly.
*/
public class PeripheralMethodSupplier {
public final class PeripheralMethodSupplier {
private static final Generator<PeripheralMethod> GENERATOR = new Generator<>(PeripheralMethod.class, List.of(ILuaContext.class, IComputerAccess.class),
m -> (target, context, computer, args) -> {
var escArgs = args.escapes();
@ -31,6 +31,9 @@ public class PeripheralMethodSupplier {
method -> (instance, context, computer, args) -> ((IDynamicPeripheral) instance).callMethod(computer, context, method, args)
);
private PeripheralMethodSupplier() {
}
public static MethodSupplier<PeripheralMethod> create(List<GenericMethod> genericMethods) {
return new MethodSupplierImpl<>(genericMethods, GENERATOR, DYNAMIC, x -> x instanceof IDynamicPeripheral dynamic
? Objects.requireNonNull(dynamic.getMethodNames(), "Dynamic methods cannot be null")

View File

@ -0,0 +1,42 @@
<!--
SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
SPDX-License-Identifier: MPL-2.0
-->
# Architecture
As mentioned in the main architecture guide, the web subproject is responsible for building CC: Tweaked's documentation
website. This is surprisingly more complex than one might initially assume, hence the need for this document at all!
## Web-based emulator
Most of the complexity comes from the web-based emulator we embed in our documentation. This uses [TeaVM] to compile
CC: Tweaked's core to Javascript, and then call out to it in the main site.
The code for this is split into three separate components:
- `src/main`: This holds the emulator itself: this is a basic Java project which depends on CC:T's core, and exposes an
interface for Javascript code.
Some of our code (or dependencies) cannot be compiled to Javascript, for instance most of our HTTP implementation. In
theses cases we provide a replacement class. These classes start with `T` (for instance `THttpRequest`), which are
specially handled in the next step.
- `src/builder`: This module consumes the above code and compiles everything to Javascript using TeaVM. There's a
couple of places where we need to patch the bytecode before compiling it, so this also includes a basic ASM
rewriting system.
- `src/frontend`: This consumes the interface exposed by the main emulator, and actually embeds the emulator in the
website.
## Static content
Rendering the static portion of the website is fortunately much simpler.
- Doc generation: This is mostly handled in various Gradle files. The Forge Gradle script uses [cct-javadoc] to convert
Javadoc on our peripherals and APIs to LDoc/[illuaminate] compatible documentation. This is then fed into illuaminate
which spits out HTML.
- `src/htmlTransform`: We do a small amount of post-processing on the HTML in order. This project does syntax
highlighting of non-Lua code blocks, and replaces special `<mc-recipe>` tags with a rendered view of a given
Minecraft recipe.
[cct-javadoc]: https://github.com/cc-tweaked/cct-javadoc
[illuaminate]: https://github.com/Squiddev/illuaminate

View File

@ -5,11 +5,13 @@
import cc.tweaked.gradle.getAbsolutePath
plugins {
`lifecycle-base`
id("cc-tweaked.java-convention")
id("cc-tweaked.node")
id("cc-tweaked.illuaminate")
}
val modVersion: String by extra
node {
projectRoot.set(rootProject.projectDir)
}
@ -18,12 +20,55 @@ illuaminate {
version.set(libs.versions.illuaminate)
}
sourceSets.register("builder")
dependencies {
implementation(project(":core"))
implementation(libs.bundles.teavm.api)
implementation(libs.asm)
implementation(libs.guava)
implementation(libs.netty.http)
implementation(libs.slf4j)
"builderCompileOnly"(libs.bundles.annotations)
"builderImplementation"(libs.bundles.teavm.tooling)
"builderImplementation"(libs.asm)
"builderImplementation"(libs.asm.commons)
}
val compileTeaVM by tasks.registering(JavaExec::class) {
group = LifecycleBasePlugin.BUILD_GROUP
description = "Generate our classes and resources files"
val output = layout.buildDirectory.dir("teaVM")
inputs.property("version", "modVersion")
inputs.files(sourceSets.main.get().runtimeClasspath).withPropertyName("inputClasspath")
outputs.dir(output).withPropertyName("output")
classpath = sourceSets["builder"].runtimeClasspath
jvmArguments.addAll(
provider {
val main = sourceSets.main.get()
listOf(
"-Dcct.input=${main.output.classesDirs.asPath}",
"-Dcct.version=$modVersion",
"-Dcct.classpath=${main.runtimeClasspath.asPath}",
"-Dcct.output=${output.getAbsolutePath()}",
)
},
)
mainClass.set("cc.tweaked.web.builder.Builder")
javaLauncher.set(project.javaToolchains.launcherFor { languageVersion.set(java.toolchain.languageVersion) })
}
val rollup by tasks.registering(cc.tweaked.gradle.NpxExecToDir::class) {
group = LifecycleBasePlugin.BUILD_GROUP
description = "Bundles JS into rollup"
// Sources
inputs.files(fileTree("src/frontend")).withPropertyName("sources")
inputs.files(compileTeaVM)
// Config files
inputs.file("tsconfig.json").withPropertyName("Typescript config")
inputs.file("rollup.config.js").withPropertyName("Rollup config")
@ -44,9 +89,8 @@ val illuaminateDocs by tasks.registering(cc.tweaked.gradle.IlluaminateExecToDir:
inputs.files(rootProject.fileTree("doc")).withPropertyName("docs")
inputs.files(project(":core").fileTree("src/main/resources/data/computercraft/lua")).withPropertyName("lua rom")
inputs.files(project(":forge").tasks.named("luaJavadoc"))
// Additional assets
// Assets
inputs.files(rollup)
inputs.file("src/frontend/styles.css").withPropertyName("styles")
// Output directory. Also defined in illuaminate.sexp.
output.set(layout.buildDirectory.dir("illuaminate"))
@ -72,7 +116,8 @@ val htmlTransform by tasks.registering(cc.tweaked.gradle.NpxExecToDir::class) {
argumentProviders.add {
listOf(
"tsx", sources.dir.resolve("index.tsx").absolutePath,
"tsx",
sources.dir.resolve("index.tsx").absolutePath,
illuaminateDocs.get().output.getAbsolutePath(),
sources.dir.resolve("export/index.json").absolutePath,
output.getAbsolutePath(),

View File

@ -2,15 +2,17 @@
//
// SPDX-License-Identifier: MPL-2.0
import { readFileSync } from "fs";
import path from "path";
import terser from "@rollup/plugin-terser";
import resolve from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import url from "@rollup/plugin-url";
import postcss from "rollup-plugin-postcss";
const input = "src/frontend";
const requirejs = readFileSync("../../node_modules/requirejs/require.js");
const minify = true;
/** @type import("rollup").RollupOptions */
export default {
@ -19,17 +21,7 @@ export default {
// Also defined in build.gradle.kts
dir: "build/rollup/",
// We bundle requirejs (and config) into the header. It's rather gross
// but also works reasonably well.
// Also suffix a ?v=${date} onto the end in the event we need to require a specific copy-cat version.
banner: `
${requirejs}
require.config({
paths: { copycat: "https://copy-cat.squiddev.cc" },
urlArgs: function(id) { return id == "copycat/embed" ? "?v=20211221" : ""; }
});
`,
format: "amd",
format: "esm",
generatedCode: {
preset: "es2015",
constBindings: true,
@ -39,15 +31,22 @@ export default {
}
},
context: "window",
external: ["copycat/embed"],
plugins: [
typescript(),
resolve({ browser: true }),
url({
include: "**/*.dfpwm",
include: ["**/*.dfpwm", "**/*.worker.js", "**/*.png"],
fileName: "[name]-[hash][extname]",
publicPath: "/",
limit: 0,
}),
postcss({
namedExports: true,
minimize: minify,
extract: true,
}),
{
@ -59,8 +58,14 @@ export default {
? `export default ${JSON.stringify(code)};\n`
: null;
},
async resolveId(source) {
if (source === "cct/classes") return path.resolve("build/teaVM/classes.js");
if (source === "cct/resources") return path.resolve("build/teaVM/resources.js");
return null;
},
},
terser(),
minify && terser(),
],
};

View File

@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.builder;
import org.teavm.common.JsonUtil;
import org.teavm.tooling.ConsoleTeaVMToolLog;
import org.teavm.tooling.TeaVMProblemRenderer;
import org.teavm.tooling.TeaVMTargetType;
import org.teavm.tooling.TeaVMTool;
import org.teavm.vm.TeaVMOptimizationLevel;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.stream.Stream;
/**
* The main entrypoint to our Javascript builder.
* <p>
* This generates both our classes and resources JS files.
*/
public class Builder {
public static void main(String[] args) throws Exception {
try (var scope = new CloseScope()) {
var input = getPath(scope, "cct.input");
var classpath = getPath(scope, "cct.classpath");
var output = getFile("cct.output");
var version = System.getProperty("cct.version");
buildClasses(input, classpath, output);
buildResources(version, input, classpath, output);
} catch (UncheckedIOException e) {
throw e.getCause();
}
}
private static void buildClasses(List<Path> input, List<Path> classpath, Path output) throws Exception {
var remapper = new TransformingClassLoader(classpath);
// Remap several classes to our stubs. Really we should add all of these to TeaVM, but our current
// implementations are a bit of a hack.
remapper.remapClass("java/nio/channels/FileChannel", "cc/tweaked/web/stub/FileChannel");
remapper.remapClass("java/util/concurrent/locks/ReentrantLock", "cc/tweaked/web/stub/ReentrantLock");
// Add some additional transformers.
remapper.addTransformer(PatchCobalt::patch);
// Scans the main input folders for classes starting with "T", and uses them as an overlay, replacing the
// original class with this redefinition.
for (var file : input) {
traverseClasses(file, (fullName, path) -> {
var lastPart = fullName.lastIndexOf('/');
var className = fullName.substring(lastPart + 1);
if (className.startsWith("T") && Character.isUpperCase(className.charAt(1))) {
var originalName = fullName.substring(0, lastPart + 1) + className.substring(1);
System.out.printf("Replacing %s with %s\n", originalName, fullName);
remapper.remapClass(fullName, originalName, path);
}
});
}
// Then finally start the compiler!
var tool = new TeaVMTool();
tool.setTargetType(TeaVMTargetType.JAVASCRIPT);
tool.setTargetDirectory(output.toFile());
tool.setClassLoader(remapper);
tool.setMainClass("cc.tweaked.web.Main");
tool.setOptimizationLevel(TeaVMOptimizationLevel.ADVANCED);
tool.setObfuscated(true);
tool.generate();
TeaVMProblemRenderer.describeProblems(tool.getDependencyInfo().getCallGraph(), tool.getProblemProvider(), new ConsoleTeaVMToolLog(false));
if (!tool.getProblemProvider().getSevereProblems().isEmpty()) System.exit(1);
}
private static void buildResources(String version, List<Path> input, List<Path> classpath, Path output) throws IOException {
try (var out = Files.newBufferedWriter(output.resolve("resources.js"))) {
out.write("export const version = \"");
JsonUtil.writeEscapedString(out, version);
out.write("\";\n");
out.write("export const resources = {\n");
Stream.of(input, classpath).flatMap(Collection::stream).forEach(root -> {
var start = root.resolve("data/computercraft/lua");
if (!Files.exists(start)) return;
try (var walker = Files.find(start, Integer.MAX_VALUE, (p, a) -> a.isRegularFile())) {
walker.forEach(x -> {
try {
out.write(" \"");
JsonUtil.writeEscapedString(out, start.relativize(x).toString());
out.write("\": \"");
JsonUtil.writeEscapedString(out, Files.readString(x, StandardCharsets.UTF_8));
out.write("\",\n");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
out.write("};\n");
}
}
private static void traverseClasses(Path root, BiConsumer<String, Path> child) throws IOException {
try (var walker = Files.walk(root)) {
walker.forEach(entry -> {
if (Files.isDirectory(entry)) return;
var name = root.relativize(entry).toString();
if (!name.endsWith(".class")) return;
var className = name.substring(0, name.length() - 6).replace(File.separatorChar, '/');
child.accept(className, entry);
});
}
}
private static List<Path> getPath(CloseScope scope, String name) {
return Arrays.stream(System.getProperty(name).split(File.pathSeparator))
.map(Path::of)
.filter(Files::exists)
.map(file -> {
try {
return Files.isDirectory(file) ? file : scope.add(FileSystems.newFileSystem(file)).getPath("/");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
})
.toList();
}
private static Path getFile(String name) {
return Path.of(System.getProperty(name));
}
}

View File

@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2022 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.builder;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.Deque;
/**
* A {@link Closeable} implementation which can be used to combine other {@link Closeable} instances.
* <p>
* This is mostly identical to the version in {@link dan200.computercraft.test.core.CloseScope}. Really they should be
* merged, but not sure where/how to do that!
*/
public final class CloseScope implements Closeable {
private final Deque<Closeable> toClose = new ArrayDeque<>();
public <T extends Closeable> T add(T value) {
toClose.addLast(value);
return value;
}
@Override
public void close() throws IOException {
Throwable error = null;
AutoCloseable next;
while ((next = toClose.pollLast()) != null) {
try {
next.close();
} catch (Throwable e) {
if (error == null) {
error = e;
} else {
error.addSuppressed(e);
}
}
}
if (error != null) CloseScope.<IOException>throwUnchecked0(error);
}
@SuppressWarnings("unchecked")
private static <T extends Throwable> void throwUnchecked0(Throwable t) throws T {
throw (T) t;
}
}

View File

@ -0,0 +1,133 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.builder;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import javax.annotation.Nullable;
import static org.objectweb.asm.Opcodes.*;
/**
* Patch Cobalt's {@code LuaState} and CC's {@code CobaltLuaMachine} to self-interrupt.
* <p>
* In normal CC:T, computers are paused/interrupted asynchronously from the {@code ComputerThread}. However, as
* Javascript doesn't (easily) support multi-threaded code, we must find another option.
* <p>
* Instead, we patch {@code LuaState.isInterrupted()} to periodically return true (every 1024 instructions), and then
* patch the interruption callback to refresh the timeout state. This means that the machine runs in very small time
* slices which, while quite slow, ensures we never block the UI thread for very long.
*/
public final class PatchCobalt {
private static final String LUA_STATE = "org/squiddev/cobalt/LuaState";
private static final String COBALT_MACHINE = "dan200/computercraft/core/lua/CobaltLuaMachine";
private PatchCobalt() {
}
public static ClassVisitor patch(String name, ClassVisitor visitor) {
return switch (name) {
case LUA_STATE -> patchLuaState(visitor);
case COBALT_MACHINE -> patchCobaltMachine(visitor);
default -> visitor;
};
}
/**
* Patch Cobalt's {@code LuaState.isInterrupted()} to periodically return true.
*
* @param cv The original class visitor.
* @return The transforming class visitor.
*/
private static ClassVisitor patchLuaState(ClassVisitor cv) {
return new ClassVisitor(Opcodes.ASM9, cv) {
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
super.visitField(ACC_PRIVATE, "$count", "I", null, null).visitEnd();
}
@Override
public @Nullable MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
var mv = super.visitMethod(access, name, descriptor, signature, exceptions);
if (mv == null) return null;
if (name.equals("isInterrupted")) {
mv.visitCode();
// int x = $count + 1;
mv.visitVarInsn(ALOAD, 0);
mv.visitFieldInsn(GETFIELD, LUA_STATE, "$count", "I");
mv.visitInsn(ICONST_1);
mv.visitInsn(IADD);
mv.visitLdcInsn(1023);
mv.visitInsn(IAND);
mv.visitVarInsn(ISTORE, 1);
// $count = x;
mv.visitVarInsn(ALOAD, 0);
mv.visitVarInsn(ILOAD, 1);
mv.visitFieldInsn(PUTFIELD, LUA_STATE, "$count", "I");
// return x == 0;
var label = new Label();
mv.visitVarInsn(ILOAD, 1);
mv.visitJumpInsn(IFNE, label);
mv.visitInsn(ICONST_1);
mv.visitInsn(IRETURN);
mv.visitLabel(label);
mv.visitInsn(ICONST_0);
mv.visitInsn(IRETURN);
mv.visitMaxs(2, 2);
mv.visitEnd();
return null;
}
return mv;
}
};
}
/**
* Patch {@code CobaltLuaMachine} to call {@code TimeoutState.refresh()} at the head of the function.
*
* @param cv The original class visitor.
* @return The wrapped visitor.
*/
private static ClassVisitor patchCobaltMachine(ClassVisitor cv) {
return new ClassVisitor(ASM9, cv) {
private boolean visited = false;
@Override
public @Nullable MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
var mv = super.visitMethod(access, name, descriptor, signature, exceptions);
if (mv == null) return null;
if (name.startsWith("lambda$") && descriptor.equals("()Lorg/squiddev/cobalt/interrupt/InterruptAction;")) {
visited = true;
return new MethodVisitor(api, mv) {
@Override
public void visitCode() {
super.visitCode();
mv.visitVarInsn(ALOAD, 0);
mv.visitFieldInsn(GETFIELD, COBALT_MACHINE, "timeout", "Ldan200/computercraft/core/computer/TimeoutState;");
mv.visitMethodInsn(INVOKEVIRTUAL, "dan200/computercraft/core/computer/TimeoutState", "refresh", "()V", false);
}
};
}
return mv;
}
@Override
public void visitEnd() {
if (!visited) throw new IllegalStateException("Did not inject .refresh() into CobaltLuaMachine");
super.visitEnd();
}
};
}
}

View File

@ -0,0 +1,164 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.web.builder;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.commons.ClassRemapper;
import org.objectweb.asm.commons.Remapper;
import javax.annotation.Nullable;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.function.BiFunction;
import java.util.stream.Stream;
/**
* A class loader which can {@linkplain #addTransformer(BiFunction) transform} and {@linkplain #remapClass(String, String)
* remap/rename} classes.
* <p>
* When loading classes, this behaves much like {@link java.net.URLClassLoader}, loading files from a list of paths.
* However, when the TeaVM compiler requests the class bytes for compilation, we run the class through a list of
* transformers first, patching the classes to function in a Javascript environment.
*/
public class TransformingClassLoader extends ClassLoader {
private final Map<String, String> remappedClasses = new HashMap<>();
private final Map<String, Path> remappedResources = new HashMap<>();
private final List<BiFunction<String, ClassVisitor, ClassVisitor>> transformers = new ArrayList<>();
private final Remapper remapper = new Remapper() {
@Override
public String map(String internalName) {
return remappedClasses.getOrDefault(internalName, internalName);
}
};
private final List<Path> classpath;
// Cache of the last transformed file - TeaVM tends to call getResourceAsStream multiple times.
private @Nullable TransformedClass lastFile;
public TransformingClassLoader(List<Path> classpath) {
this.classpath = classpath;
addTransformer((name, cv) -> new ClassRemapper(cv, remapper));
}
public void addTransformer(BiFunction<String, ClassVisitor, ClassVisitor> transform) {
transformers.add(transform);
}
public void remapClass(String from, String to, Path fromLocation) {
remappedClasses.put(from, to);
remappedResources.put(to + ".class", fromLocation);
}
public void remapClass(String from, String to) {
remappedClasses.put(from, to);
}
private @Nullable Path findUnmappedFile(String name) {
return classpath.stream().map(x -> x.resolve(name)).filter(Files::exists).findFirst().orElse(null);
}
private @Nullable Path findFile(String name) {
var path = remappedResources.get(name);
return path != null ? path : findUnmappedFile(name);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// findClass is only called at compile time, and so we load the original class, not the remapped one.
// Yes, this is super cursed.
var path = findUnmappedFile(name.replace('.', '/') + ".class");
if (path == null) throw new ClassNotFoundException();
byte[] bytes;
try {
bytes = Files.readAllBytes(path);
} catch (IOException e) {
throw new ClassNotFoundException("Failed reading " + name, e);
}
return defineClass(name, bytes, 0, bytes.length);
}
@Override
public @Nullable InputStream getResourceAsStream(String name) {
if (!name.endsWith(".class")) return super.getResourceAsStream(name);
var lastFile = this.lastFile;
if (lastFile != null && lastFile.name().equals(name)) return new ByteArrayInputStream(lastFile.contents());
var path = findFile(name);
if (path == null) return null;
ClassReader reader;
try (var stream = Files.newInputStream(path)) {
reader = new ClassReader(stream);
} catch (IOException e) {
throw new UncheckedIOException("Failed reading " + name, e);
}
var writer = new ClassWriter(reader, 0);
var className = reader.getClassName();
ClassVisitor sink = writer;
for (var transformer : transformers) sink = transformer.apply(className, sink);
reader.accept(sink, 0);
var bytes = writer.toByteArray();
this.lastFile = new TransformedClass(name, bytes);
return new ByteArrayInputStream(bytes);
}
@Override
protected @Nullable URL findResource(String name) {
var path = findFile(name);
return path == null ? null : toURL(path);
}
@Override
protected Enumeration<URL> findResources(String name) {
var path = remappedResources.get(name);
return new IteratorEnumeration<>(
(path == null ? classpath.stream().map(x -> x.resolve(name)) : Stream.of(path))
.filter(Files::exists)
.map(TransformingClassLoader::toURL)
.iterator()
);
}
@SuppressWarnings("JdkObsolete")
private record IteratorEnumeration<T>(Iterator<T> iterator) implements Enumeration<T> {
@Override
public boolean hasMoreElements() {
return iterator.hasNext();
}
@Override
public T nextElement() {
return iterator.next();
}
}
private static URL toURL(Path path) {
try {
return path.toUri().toURL();
} catch (MalformedURLException e) {
throw new IllegalStateException("Cannot convert " + path + " to a URL", e);
}
}
private record TransformedClass(String name, byte[] contents) {
}
}

View File

@ -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.builder;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.framework.qual.DefaultQualifier;
import org.checkerframework.framework.qual.TypeUseLocation;

View File

@ -0,0 +1,141 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
import { type ComputerActionable, type KeyCode, type LuaValue, Semaphore, TerminalData, lwjgl3Code } from "@squid-dev/cc-web-term";
import { type ComputerDisplay, type ComputerHandle, type PeripheralKind, type Side, start } from "./java";
const colours = "0123456789abcdef";
/**
* A reference to an emulated computer.
*
* This acts as a bridge between the Java-side computer and our Javascript code,
* including the terminal renderer and the main computer component.
*/
class EmulatedComputer implements ComputerDisplay, ComputerActionable {
public readonly terminal: TerminalData = new TerminalData();
public readonly semaphore: Semaphore = new Semaphore();
private readonly stateChanged: (label: string | null, on: boolean) => void;
private label: string | null = null;
private computer?: ComputerHandle;
private callbacks: Array<(cb: ComputerHandle) => void> = [];
private removed: boolean = false;
public constructor(stateChange: (label: string | null, on: boolean) => void) {
this.stateChanged = stateChange;
this.label = null;
}
public getLabel(): string | null {
return this.label;
}
public setState(label: string | null, on: boolean): void {
if (this.label !== label) this.label = label;
this.stateChanged(label, on);
}
public updateTerminal(
width: number, height: number,
x: number, y: number, blink: boolean, cursorColour: number,
): void {
this.terminal.resize(width, height);
this.terminal.cursorX = x;
this.terminal.cursorY = y;
this.terminal.cursorBlink = blink;
this.terminal.currentFore = colours.charAt(cursorColour);
}
public setTerminalLine(line: number, text: string, fore: string, back: string): void {
this.terminal.text[line] = text;
this.terminal.fore[line] = fore;
this.terminal.back[line] = back;
}
public setPaletteColour(colour: number, r: number, g: number, b: number): void {
this.terminal.palette[colours.charAt(colour)] =
`rgb(${(r * 0xFF) & 0xFF},${(g * 0xFF) & 0xFF},${(b * 0xFF) & 0xFF})`;
}
public flushTerminal(): void {
this.semaphore.signal();
}
public start(): void {
start(this)
.then(computer => {
this.computer = computer;
if (this.removed) computer.dispose();
for (const callback of this.callbacks) callback(computer);
})
.catch(e => {
const width = this.terminal.sizeX;
const fg = "0".repeat(width);
const bg = "e".repeat(width);
const message = `${e}`.replace(/(?![^\n]{1,51}$)([^\n]{1,51})\s/, "$1\n").split("\n");
for (let y = 0; y < this.terminal.sizeY; y++) {
const text = message[y] ?? "";
this.terminal.text[y] = text.length > width ? text.substring(0, width) : text + " ".repeat(width - text.length);
this.terminal.fore[y] = fg;
this.terminal.back[y] = bg;
}
});
}
public queueEvent(event: string, args: Array<LuaValue>): void {
if (this.computer !== undefined) this.computer.event(event, args);
}
public keyDown(key: KeyCode, repeat: boolean): void {
const code = lwjgl3Code(key);
if (code !== undefined) this.queueEvent("key", [code, repeat]);
}
public keyUp(key: KeyCode): void {
const code = lwjgl3Code(key);
if (code !== undefined) this.queueEvent("key_up", [code]);
}
public turnOn(): void {
this.computer?.turnOn();
}
public shutdown(): void {
this.computer?.shutdown();
}
public reboot(): void {
this.computer?.reboot();
}
public dispose(): void {
this.removed = true;
this.computer?.dispose();
}
public transferFiles(files: Array<{ name: string, contents: ArrayBuffer }>): void {
this.computer?.transferFiles(files);
}
public setPeripheral(side: Side, kind: PeripheralKind | null): void {
if (this.computer) {
this.computer.setPeripheral(side, kind);
} else {
this.callbacks.push(handler => handler.setPeripheral(side, kind));
}
}
public addFile(path: string, contents: string | ArrayBuffer): void {
if (this.computer) {
this.computer.addFile(path, contents);
} else {
this.callbacks.push(handler => handler.addFile(path, contents));
}
}
}
export default EmulatedComputer;

View File

@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
import { type FunctionalComponent, h } from "preact";
import type { PeripheralKind, Side } from "./java";
import { useEffect, useMemo, useState } from "preact/hooks";
import { Terminal } from "@squid-dev/cc-web-term";
import EmulatedComputer from "./computer";
import termFont from "@squid-dev/cc-web-term/assets/term_font.png";
export type ComputerProps = {
files?: Record<string, string | ArrayBuffer>,
peripherals?: Partial<Record<Side, PeripheralKind | null>>,
}
/**
* Renders a computer in the world.
*
* @param props The properties for this component
* @returns The resulting JSX element.
*/
const Computer: FunctionalComponent<ComputerProps> = ({ files, peripherals }) => {
const [label, setLabel] = useState<string | null>(null);
const [isOn, setOn] = useState(false);
const computer = useMemo(() => {
const computer = new EmulatedComputer((label, on) => {
setLabel(label);
setOn(on);
});
for (const [side, peripheral] of Object.entries(peripherals ?? {})) {
computer.setPeripheral(side as Side, peripheral);
}
for (const [path, contents] of Object.entries(files ?? {})) {
computer.addFile(path, contents);
}
return computer;
}, [setLabel, setOn, files, peripherals]);
useEffect(() => {
computer.start();
return () => computer.dispose();
}, [computer]);
return <Terminal
id={0} label={label} on={isOn} focused={true} font={termFont}
computer={computer} terminal={computer.terminal} changed={computer.semaphore}
/>;
};
export default Computer;

View File

@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
import "setimmediate";
import type { ComputerDisplay, ComputerHandle } from "cct/classes";
export type { ComputerDisplay, ComputerHandle, PeripheralKind, Side } from "cct/classes";
const load = async (): Promise<(computer: ComputerDisplay) => ComputerHandle> => {
const [classes, { version, resources }] = await Promise.all([import("cct/classes"), import("cct/resources")]);
let addComputer: ((computer: ComputerDisplay) => ComputerHandle) | null = null;
const encoder = new TextEncoder();
window.$javaCallbacks = {
setup: add => addComputer = add,
modVersion: version,
listResources: () => Object.keys(resources),
getResource: path => new Int8Array(encoder.encode(resources[path]))
};
classes.main();
if (!addComputer) throw new Error("Callbacks.setup was never called");
return addComputer;
};
let addComputer: Promise<(computer: ComputerDisplay) => ComputerHandle> | null = null;
/**
* Load our emulator and start a new computer.
*
* @param computer The display the computer's terminal should be drawn to.
* @returns The {@link ComputerHandle} for this computer.
*/
export const start = (computer: ComputerDisplay): Promise<ComputerHandle> => {
if (addComputer == null) addComputer = load();
return addComputer.then(f => f(computer));
};

View File

@ -2,8 +2,7 @@
//
// SPDX-License-Identifier: MPL-2.0
import { Component, Computer, h, render, type PeripheralKind } from "copycat/embed";
import type { ComponentChild, FunctionalComponent } from "preact";
import { Component, type ComponentChild, type FunctionalComponent, h, render } from "preact";
import settingsFile from "./mount/.settings";
import exampleAudioUrl from "./mount/example.dfpwm";
@ -12,6 +11,9 @@ import exampleNfp from "./mount/example.nfp";
import exampleNft from "./mount/example.nft";
import exprTemplate from "./mount/expr_template.lua";
import startupFile from "./mount/startup.lua";
import type { PeripheralKind } from "./emu/java";
import Computer from "./emu";
import "./styles.css";
const defaultFiles: Record<string, string> = {
".settings": settingsFile,
@ -25,24 +27,24 @@ const clamp = (value: number, min: number, max: number): number => {
if (value < min) return min;
if (value > max) return max;
return value;
}
};
const download = async (url: string): Promise<Uint8Array> => {
const download = async (url: string): Promise<ArrayBuffer> => {
const result = await fetch(url);
if (result.status != 200) throw new Error(`${url} responded with ${result.status} ${result.statusText}`);
return new Uint8Array(await result.arrayBuffer());
return await result.arrayBuffer();
};
let dfpwmAudio: Promise<Uint8Array> | null = null;
let dfpwmAudio: Promise<ArrayBuffer> | null = null;
const Click: FunctionalComponent<{ run: () => void }> = ({ run }) =>
<button type="button" class="example-run" onClick={run}>Run </button>
<button type="button" class="example-run" onClick={run}>Run </button>;
type WindowProps = {};
type Example = {
files: Record<string, string | Uint8Array>,
files: Record<string, string | ArrayBuffer>,
peripheral: PeripheralKind | null,
}
@ -58,7 +60,7 @@ class Window extends Component<WindowProps, WindowState> {
private top: number = 0;
private dragging?: { downX: number, downY: number, initialX: number, initialY: number };
private snippets: { [file: string]: string } = {};
private snippets: Record<string, string> = {};
constructor(props: WindowProps, context: unknown) {
super(props, context);
@ -67,10 +69,10 @@ class Window extends Component<WindowProps, WindowState> {
visible: false,
example: null,
exampleIdx: 0,
}
};
}
componentDidMount() {
componentDidMount(): void {
const elements = document.querySelectorAll("pre[data-lua-kind]");
for (let i = 0; i < elements.length; i++) {
const element = elements[i] as HTMLElement;
@ -88,11 +90,11 @@ class Window extends Component<WindowProps, WindowState> {
const mount = element.getAttribute("data-mount");
const peripheral = element.getAttribute("data-peripheral");
render(<Click run={this.runExample(example, mount, peripheral)} />, element.parentElement!!);
render(<Click run={this.runExample(example, mount, peripheral)} />, element.parentElement!);
}
}
componentDidUpdate(_: WindowProps, { visible }: WindowState) {
componentDidUpdate(_: WindowProps, { visible }: WindowState): void {
if (!visible && this.state.visible) this.setPosition(this.left, this.top);
}
@ -120,7 +122,7 @@ class Window extends Component<WindowProps, WindowState> {
this.top = 20;
}
const files: Record<string, string | Uint8Array> = { "example.lua": example };
const files: Record<string, string | ArrayBuffer> = { "example.lua": example };
if (mount !== null) {
for (const toMount of mount.split(",")) {
const [name, path] = toMount.split(":", 2);
@ -147,14 +149,14 @@ class Window extends Component<WindowProps, WindowState> {
},
exampleIdx: exampleIdx + 1,
}));
}
};
}
private readonly close = () => this.setState({ visible: false });
private readonly close = (): void => this.setState({ visible: false });
// All the dragging code is terrible. However, I've had massive performance
// issues doing it other ways, so this'll have to do.
private onDown(e: Event, touch: Touch) {
private onDown(e: Event, touch: Touch): void {
e.stopPropagation();
e.preventDefault();
@ -168,10 +170,10 @@ class Window extends Component<WindowProps, WindowState> {
window.addEventListener("mouseup", this.onUp, true);
window.addEventListener("touchend", this.onUp, true);
}
private readonly onMouseDown = (e: MouseEvent) => this.onDown(e, e);
private readonly onTouchDown = (e: TouchEvent) => this.onDown(e, e.touches[0]);
private readonly onMouseDown = (e: MouseEvent): void => this.onDown(e, e);
private readonly onTouchDown = (e: TouchEvent): void => this.onDown(e, e.touches[0]);
private onDrag(e: Event, touch: Touch) {
private onDrag(e: Event, touch: Touch): void {
e.stopPropagation();
e.preventDefault();
@ -182,11 +184,11 @@ class Window extends Component<WindowProps, WindowState> {
dragging.initialX + (touch.clientX - dragging.downX),
dragging.initialY + (touch.clientY - dragging.downY),
);
};
private readonly onMouseDrag = (e: MouseEvent) => this.onDrag(e, e);
private readonly onTouchDrag = (e: TouchEvent) => this.onDrag(e, e.touches[0]);
}
private readonly onMouseDrag = (e: MouseEvent): void => this.onDrag(e, e);
private readonly onTouchDrag = (e: TouchEvent): void => this.onDrag(e, e.touches[0]);
private readonly onUp = (e: Event) => {
private readonly onUp = (e: Event): void => {
e.stopPropagation();
this.dragging = undefined;
@ -195,7 +197,7 @@ class Window extends Component<WindowProps, WindowState> {
window.removeEventListener("touchmove", this.onTouchDrag, true);
window.removeEventListener("mouseup", this.onUp, true);
window.removeEventListener("touchend", this.onUp, true);
}
};
private readonly setPosition = (left: number, top: number): void => {
const root = this.base as HTMLElement;
@ -203,10 +205,10 @@ class Window extends Component<WindowProps, WindowState> {
left = this.left = clamp(left, 0, window.innerWidth - root.offsetWidth);
top = this.top = clamp(top, 0, window.innerHeight - root.offsetHeight);
root.style.transform = `translate(${left}px, ${top}px)`;
}
};
}
const root = document.createElement("div");
document.body.appendChild(root);
render(<Window />, document.body, root);
render(<Window />, root);

View File

@ -17,7 +17,6 @@ declare module "*.nft" {
export default contents;
}
declare module "*.settings" {
const contents: string;
export default contents;
@ -33,33 +32,147 @@ declare module "*.dfpwm" {
export default contents;
}
declare module "cct/resources" {
export const version: string;
export const resources: Record<string, string>;
}
declare module "copycat/embed" {
import { h, Component, render, ComponentChild } from "preact";
declare module "cct/classes" {
export const main: () => void;
export type Side = "up" | "down" | "left" | "right" | "front" | "back";
export type PeripheralKind = "speaker";
export { h, Component, render };
/**
* Controls a specific computer on the Javascript side. See {@code js/ComputerAccess.java}.
*/
export interface ComputerDisplay {
/**
* Set this computer's current state
*
* @param label This computer's label
* @param on If this computer is on right now
*/
setState(label: string | null, on: boolean): void;
export type ComputerAccess = unknown;
/**
* 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
*/
updateTerminal(width: number, height: number, x: number, y: number, blink: boolean, cursorColour: number): void;
export type MainProps = {
hdFont?: boolean | string,
persistId?: number,
files?: { [filename: string]: string | ArrayBuffer },
label?: string,
width?: number,
height?: number,
resolve?: (computer: ComputerAccess) => void,
peripherals?: {
[side in Side]?: PeripheralKind | null
},
/**
* 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
*/
setTerminalLine(line: number, text: string, fore: string, back: string): void;
/**
* 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
*/
setPaletteColour(colour: number, r: number, g: number, b: number): void;
/**
* Mark the terminal as having changed. Should be called after all other terminal methods.
*/
flushTerminal(): void;
}
class Computer extends Component<MainProps, unknown> {
public render(props: MainProps, state: unknown): ComponentChild;
export interface ComputerHandle {
/**
* Queue an event on the computer.
*/
event(event: string, args: Array<unknown> | null): void;
/**
* Shut the computer down.
*/
shutdown(): void;
/**
* Turn the computer on.
*/
turnOn(): void;
/**
* Reboot the computer.
*/
reboot(): void;
/**
* Dispose of this computer, marking it as no longer running.
*/
dispose(): void;
/**
* Transfer some files to this computer.
*
* @param files A list of files and their contents.
*/
transferFiles(files: Array<{ name: string, contents: ArrayBuffer }>): void;
/**
* 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".
*/
setPeripheral(side: Side, kind: PeripheralKind | null): void;
/**
* Add a file to this computer's filesystem.
* @param path The path of the file.
* @param contents The path to the file.
*/
addFile(path: string, contents: string | ArrayBuffer): void;
}
export { Computer };
export interface Callbacks {
/**
* Get the current callback instance
*
* @param addComputer A computer to add a new computer.
*/
setup(addComputer: (computer: ComputerDisplay) => ComputerHandle): void;
/**
* The version of CC: Tweaked currently loaded.
*/
modVersion: string;
/**
* List all resources available in the ROM.
*/
listResources(): Array<string>;
/**
* Load a resource from the ROM.
*
* @param path The path to the resource to load.
*/
getResource(path: string): Int8Array;
}
}
declare namespace JSX {
export type Element = import("preact").JSX.Element;
export type IntrinsicElements = import("preact").JSX.IntrinsicElements;
export type ElementClass = import("preact").JSX.ElementClass;
}
declare var $javaCallbacks: import("cct/classes").Callbacks; // eslint-disable-line no-var

View File

@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: MPL-2.0
import { h, type FunctionComponent, type JSX } from "preact";
import { type FunctionComponent, type JSX, h } from "preact";
import useExport from "./WithExport";
const Item: FunctionComponent<{ item: string }> = ({ item }) => {
@ -14,12 +14,12 @@ const Item: FunctionComponent<{ item: string }> = ({ item }) => {
alt={itemName}
title={itemName}
className="recipe-icon"
/>
/>;
};
const EmptyItem: FunctionComponent = () => <span className="recipe-icon " />;
const Arrow: FunctionComponent<JSX.IntrinsicElements["svg"]> = (props) => <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.513 45.512" {...props}>
const Arrow: FunctionComponent<JSX.IntrinsicElements["svg"]> = props => <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.513 45.512" {...props}>
<g>
<path d="M44.275,19.739L30.211,5.675c-0.909-0.909-2.275-1.18-3.463-0.687c-1.188,0.493-1.959,1.654-1.956,2.938l0.015,5.903
l-21.64,0.054C1.414,13.887-0.004,15.312,0,17.065l0.028,11.522c0.002,0.842,0.338,1.648,0.935,2.242s1.405,0.927,2.247,0.925
@ -43,7 +43,7 @@ const Recipe: FunctionComponent<{ recipe: string }> = ({ recipe }) => {
<Item item={recipeInfo.output} />
{recipeInfo.count > 1 && <span className="recipe-count">{recipeInfo.count}</span>}
</div>
</div>
</div>;
};
export default Recipe;

View File

@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: MPL-2.0
import { h, createContext, type FunctionComponent, type VNode } from "preact";
import { type FunctionComponent, type VNode, createContext, h } from "preact";
import { useContext } from "preact/hooks";
export type DataExport = {
@ -21,7 +21,7 @@ const DataExport = createContext<DataExport>({
recipes: {},
});
export const useExport = () => useContext(DataExport);
export const useExport = (): DataExport => useContext(DataExport);
export default useExport;
export const WithExport: FunctionComponent<{ data: DataExport, children: VNode }> =

View File

@ -26,4 +26,4 @@ export const noChildren = function <T>(component: FunctionComponent<T>): Functio
};
wrapped.displayName = name;
return wrapped;
}
};

View File

@ -13,17 +13,17 @@
import fs from "fs/promises";
import { glob } from "glob";
import path from "path";
import { h, type JSX } from "preact";
import { type JSX, h } from "preact";
import renderToStaticMarkup from "preact-render-to-string";
import * as runtime from "preact/jsx-runtime";
import rehypeHighlight, { type Options as HighlightOptions } from "rehype-highlight";
import rehypeHighlight from "rehype-highlight";
import rehypeParse from "rehype-parse";
import rehypeReact, { type Options as ReactOptions } from "rehype-react";
import { unified, type Plugin } from "unified";
import { unified } from "unified";
// Our components
import Recipe from "./components/Recipe";
import { noChildren } from "./components/support";
import { WithExport, type DataExport } from "./components/WithExport";
import { type DataExport, WithExport } from "./components/WithExport";
(async () => {
if (process.argv.length !== 5) {
@ -43,13 +43,13 @@ import { WithExport, type DataExport } from "./components/WithExport";
// Run button into them.
["pre"]: (args: JSX.IntrinsicElements["pre"] & { "data-lua-kind"?: undefined }) => {
const element = <pre {...args} />;
return args["data-lua-kind"] ? <div className="lua-example">{element}</div> : element
return args["data-lua-kind"] ? <div className="lua-example">{element}</div> : element;
}
}
} as any,
};
const processor = unified()
.use(rehypeParse, { emitParseErrors: true })
.use(rehypeHighlight as unknown as Plugin<[HighlightOptions], import("hast").Root>, { prefix: "" })
.use(rehypeHighlight, { prefix: "" })
.use(rehypeReact, reactOptions);
const dataExport = JSON.parse(await fs.readFile(dataFile, "utf-8")) as DataExport;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,135 @@
// 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;
/**
* Compile-time generation of {@link LuaMethod} methods.
*
* @see TLuaMethodSupplier
* @see StaticGenerator
*/
@CompileTime
public class MethodReflection {
public static List<NamedMethod<LuaMethod>> getMethods(Class<?> klass) {
List<NamedMethod<LuaMethod>> out = new ArrayList<>();
getMethodsImpl(klass, (name, method, nonYielding) -> out.add(new NamedMethod<>(name, method, nonYielding, null)));
return out;
}
@Meta
private static native void getMethodsImpl(Class<?> type, MakeMethod make);
private static void getMethodsImpl(ReflectClass<Object> klass, Value<MakeMethod> make) {
if (!klass.getName().startsWith("dan200.computercraft.") && !klass.getName().startsWith("cc.tweaked.web.peripheral")) {
return;
}
if (klass.getName().contains("lambda")) return;
Class<?> actualClass;
try {
actualClass = Metaprogramming.getClassLoader().loadClass(klass.getName());
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
for (var method : Internal.getMethods(actualClass)) {
var name = method.name();
var nonYielding = method.nonYielding();
var actualField = method.method().getField("INSTANCE");
Metaprogramming.emit(() -> make.get().make(name, (LuaMethod) actualField.get(null), nonYielding));
}
}
public interface MakeMethod {
void make(String name, LuaMethod method, boolean nonYielding);
}
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));
}
}
}
}
}

View File

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

View File

@ -0,0 +1,51 @@
// 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) {
var methods = MethodReflection.getMethods(object.getClass());
for (var method : methods) consumer.accept(method.name(), method.method(), method);
return !methods.isEmpty();
}
@Override
public boolean forEachMethod(Object object, TargetedConsumer<LuaMethod> consumer) {
var methods = MethodReflection.getMethods(object.getClass());
for (var method : methods) consumer.accept(object, method.name(), method.method(), method);
var hasMethods = !methods.isEmpty();
if (object instanceof ObjectSource source) {
for (var extra : source.getExtra()) {
var extraMethods = MethodReflection.getMethods(extra.getClass());
if (!extraMethods.isEmpty()) hasMethods = true;
for (var method : extraMethods) consumer.accept(extra, method.name(), method.method(), method);
}
}
return hasMethods;
}
public static MethodSupplier<LuaMethod> create(List<GenericMethod> genericMethods) {
return INSTANCE;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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