Add a standalone CC:T UI

Does it count as an emulator when it's official? I hope not, as this'd
make it my fourth or fifth emulator at this point.

 * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *

Developing/debugging CraftOS is a massive pain to do inside Minecraft,
as any change to resources requires a compile+hot swap cycle (and
sometimes a `/reload` in-game). As such, it's often more convenient to
spin up an emulator, pointing it to load the ROM from CC:T's sources.

However, this isn't practical when also making changes to the Java
classes. In this case, we either need to go in-game, or build a custom
version of CCEmuX.

This commit offers an alternative option: we now have our own emulator,
which allows us to hot swap both Lua and Java to our heart's content.

Most of the code here is based on our monitor TBO renderer. We probably
could share some more of this, but there's not really a good place for
it - feels a bit weird just to chuck it in :core.

This is *not* a general-purpose emulator. It's limited in a lot of
ways (won't launch on Mac[^1], no support for multiple computers) - just
stick to what's there already.

[^1]: We require OpenGL 4.5 due to our use of DSA.
This commit is contained in:
Jonathan Coates 2023-10-28 17:55:56 +01:00
parent aee382ed70
commit 18c9723308
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
16 changed files with 1028 additions and 12 deletions

View File

@ -53,6 +53,7 @@ Files:
projects/common/src/main/resources/assets/computercraft/textures/*
projects/common/src/main/resources/pack.mcmeta
projects/common/src/main/resources/pack.png
projects/core/src/main/resources/assets/computercraft/textures/gui/term_font.png
projects/core/src/main/resources/data/computercraft/lua/rom/autorun/.ignoreme
projects/core/src/main/resources/data/computercraft/lua/rom/help/*
projects/core/src/main/resources/data/computercraft/lua/rom/programs/fun/advanced/levels/*

View File

@ -21,6 +21,7 @@ autoService = "1.1.1"
checkerFramework = "3.32.0"
cobalt = "0.7.3"
cobalt-next = "0.7.4" # Not a real version, used to constrain the version we accept.
commonsCli = "1.3.1"
fastutil = "8.5.9"
guava = "31.1-jre"
jetbrainsAnnotations = "24.0.1"
@ -61,14 +62,15 @@ githubRelease = "2.4.1"
ideaExt = "1.1.7"
illuaminate = "0.1.0-44-g9ee0055"
librarian = "1.+"
lwjgl = "3.3.1"
minotaur = "2.+"
mixinGradle = "0.7.+"
nullAway = "0.9.9"
spotless = "6.21.0"
taskTree = "2.1.1"
teavm = "0.9.0-SQUID.1"
vanillaGradle = "0.2.1-SNAPSHOT"
vineflower = "1.11.0"
teavm = "0.9.0-SQUID.1"
[libraries]
# Normal dependencies
@ -77,6 +79,7 @@ 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" }
commonsCli = { module = "commons-cli:commons-cli", version.ref = "commonsCli" }
fastutil = { module = "it.unimi.dsi:fastutil", version.ref = "fastutil" }
forgeSpi = { module = "net.minecraftforge:forgespi", version.ref = "forgeSpi" }
guava = { module = "com.google.guava:guava", version.ref = "guava" }
@ -122,6 +125,12 @@ junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", vers
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" }
slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" }
# LWJGL
lwjgl-bom = { module = "org.lwjgl:lwjgl-bom", version.ref = "lwjgl" }
lwjgl-core = { module = "org.lwjgl:lwjgl" }
lwjgl-opengl = { module = "org.lwjgl:lwjgl-opengl" }
lwjgl-glfw = { module = "org.lwjgl:lwjgl-glfw" }
# Build tools
cctJavadoc = { module = "cc.tweaked:cct-javadoc", version.ref = "cctJavadoc" }
checkstyle = { module = "com.puppycrawl.tools:checkstyle", version.ref = "checkstyle" }

View File

@ -62,6 +62,9 @@ ## Project Outline
- `lints`: This defines an [ErrorProne] plugin which adds a couple of compile-time checks to our code. This is what
enforces that no client-specific code is used inside the `main` source set (and a couple of other things!).
- `standalone`: This contains a standalone UI for computers, allowing debugging and development of CraftOS without
launching Minecraft.
- `web`: This contains the additional tooling for building [the documentation website][tweaked.cc], such as support for
rendering recipes

View File

@ -6,6 +6,7 @@
import dan200.computercraft.api.lua.LuaFunction;
import javax.annotation.Nullable;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
@ -18,15 +19,19 @@ public class TransferredFiles {
public static final String EVENT = "file_transfer";
private final AtomicBoolean consumed = new AtomicBoolean(false);
private final Runnable onConsumed;
private final @Nullable Runnable onConsumed;
private final List<TransferredFile> files;
public TransferredFiles(List<TransferredFile> files, Runnable onConsumed) {
public TransferredFiles(List<TransferredFile> files, @Nullable Runnable onConsumed) {
this.files = files;
this.onConsumed = onConsumed;
}
public TransferredFiles(List<TransferredFile> files) {
this(files, null);
}
/**
* All the files that are being transferred to this computer.
*
@ -40,6 +45,6 @@ public final List<TransferredFile> getFiles() {
private void consumed() {
if (consumed.getAndSet(true)) return;
onConsumed.run();
if (onConsumed != null) onConsumed.run();
}
}

View File

@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
plugins {
id("cc-tweaked.java-convention")
application
}
val lwjglNatives = Unit.run {
val name = System.getProperty("os.name")!!
val arch = System.getProperty("os.arch")
when {
arrayOf("Linux", "FreeBSD", "SunOS", "Unit").any { name.startsWith(it) } -> when {
arrayOf("arm", "aarch64").any { arch.startsWith(it) } -> "natives-linux${if (arch.contains("64") || arch.startsWith("armv8")) "-arm64" else "-arm32"}"
else -> "natives-linux"
}
arrayOf("Mac OS X", "Darwin").any { name.startsWith(it) } ->
"natives-macos${if (arch.startsWith("aarch64")) "-arm64" else ""}"
arrayOf("Windows").any { name.startsWith(it) } -> when {
arch.contains("64") -> "natives-windows${if (arch.startsWith("aarch64")) "-arm64" else ""}"
else -> "natives-windows-x86"
}
else -> throw GradleException("Unrecognized or unsupported platform.")
}
}
dependencies {
implementation(project(":core"))
implementation(libs.commonsCli)
implementation(libs.slf4j)
runtimeOnly(libs.slf4j.simple)
implementation(platform(libs.lwjgl.bom))
implementation(libs.lwjgl.core)
implementation(libs.lwjgl.glfw)
implementation(libs.lwjgl.opengl)
runtimeOnly(variantOf(libs.lwjgl.core) { classifier(lwjglNatives) })
runtimeOnly(variantOf(libs.lwjgl.glfw) { classifier(lwjglNatives) })
runtimeOnly(variantOf(libs.lwjgl.opengl) { classifier(lwjglNatives) })
}
application {
mainClass.set("cc.tweaked.standalone.Main")
}
tasks.named("run", JavaExec::class.java) {
workingDir = rootProject.projectDir
args = listOf("-r", project(":core").layout.projectDirectory.dir("src/main/resources").asFile.absolutePath)
}

View File

@ -0,0 +1,167 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.standalone;
import org.lwjgl.BufferUtils;
import org.lwjgl.opengl.GL45C;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.Deque;
import static org.lwjgl.opengl.GL45C.*;
/**
* A utility class for creating OpenGL objects.
* <p>
* This provides the following:
* <ul>
* <li>Provides automatic lifetime management of objects.</li>
* <li>Adds additional safety checks (i.e. values were allocated correctly).</li>
* <li>Attaches labels to each object, for easier debugging with RenderDoc.</li>
* </ul>
* <p>
* All objects are created using the new Direct State Access (DSA) interface. Consumers should also use DSA when working
* with these buffers.
*/
public class GLObjects implements AutoCloseable {
private final Deque<Runnable> toClose = new ArrayDeque<>();
public void add(Runnable task) {
toClose.push(task);
}
/**
* Create a new buffer associated with this instance.
*
* @param name The debugging name of this buffer.
* @return The newly created buffer.
*/
public int createBuffer(String name) {
var buffer = glCreateBuffers();
add(() -> glDeleteBuffers(buffer));
glObjectLabel(GL_BUFFER, buffer, "Buffer - " + name);
glNamedBufferData(buffer, 0, GL_STATIC_DRAW);
return buffer;
}
/**
* Create a new vertex array object.
*
* @param name The debugging name of this vertex array.
* @return The newly created vertex array.
*/
public int createVertexArray(String name) {
var vbo = glCreateVertexArrays();
add(() -> glDeleteVertexArrays(vbo));
glObjectLabel(GL_VERTEX_ARRAY, vbo, "Vertex Array - " + name);
return vbo;
}
/**
* Create a new texture associated with this instance.
*
* @param type The type of this texture, for instance {@link GL45C#GL_TEXTURE_2D}
* @param name The debugging name of this texture.
* @return The newly created texture.
*/
public int createTexture(int type, String name) {
var texture = glCreateTextures(type);
add(() -> glDeleteTextures(texture));
glObjectLabel(GL_TEXTURE, texture, "Texture - " + name);
return texture;
}
/**
* Create a texture, loading it from an image.
*
* @param path The path to the image. This should be on the classpath.
* @return The newly created texture.
* @throws IOException If the image could not be found.
*/
public int loadTexture(String path) throws IOException {
BufferedImage image;
try (var stream = getClass().getClassLoader().getResourceAsStream(path)) {
if (stream == null) throw new IOException("Cannot find " + path);
image = ImageIO.read(stream);
}
var textureData = BufferUtils.createByteBuffer(image.getWidth() * image.getHeight() * 4);
for (var y = 0; y < image.getHeight(); y++) {
for (var x = 0; x < image.getWidth(); x++) {
var argb = image.getRGB(x, y);
textureData.put((byte) ((argb >> 16) & 0xFF)).put((byte) ((argb >> 8) & 0xFF)).put((byte) (argb & 0xFF)).put((byte) ((argb >> 24) & 0xFF));
}
}
textureData.flip();
var texture = createTexture(GL_TEXTURE_2D, path);
glTextureParameteri(texture, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTextureParameteri(texture, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTextureStorage2D(texture, 1, GL_RGBA8, image.getWidth(), image.getHeight());
glTextureSubImage2D(texture, 0, 0, 0, image.getWidth(), image.getHeight(), GL_RGBA, GL_UNSIGNED_BYTE, textureData);
return texture;
}
/**
* Create and compile a shader.
*
* @param type The type of this shader, for instance {@link GL45C#GL_FRAGMENT_SHADER}.
* @param path The path to the shader file. This should be on the classpath.
* @return The newly created shader.
* @throws IOException If the shader could not be found.
*/
public int compileShader(int type, String path) throws IOException {
String contents;
try (var stream = getClass().getClassLoader().getResourceAsStream(path)) {
if (stream == null) throw new IOException("Could not load shader " + path);
contents = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
var shader = glCreateShader(type);
if (shader <= 0) throw new IllegalStateException("Could not create shader");
add(() -> glDeleteShader(shader));
glObjectLabel(GL_SHADER, shader, "Shader - " + path);
glShaderSource(shader, contents);
glCompileShader(shader);
if (glGetShaderi(shader, GL_COMPILE_STATUS) == 0) {
var error = glGetShaderInfoLog(shader, 32768);
throw new IllegalStateException("Could not compile shader " + path + ": " + error);
}
return shader;
}
/**
* Create a new program.
*
* @param name The debugging name of this program.
* @return The newly created program.
*/
public int createProgram(String name) {
var program = glCreateProgram();
if (program <= 0) throw new IllegalStateException("Could not create shader program");
add(() -> glDeleteProgram(program));
glObjectLabel(GL_PROGRAM, program, "Program - " + name);
return program;
}
@Override
public void close() {
Runnable close;
while ((close = toClose.pollLast()) != null) close.run();
}
}

View File

@ -0,0 +1,176 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.standalone;
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 org.lwjgl.glfw.GLFW;
import org.lwjgl.glfw.GLFWDropCallback;
import org.lwjgl.glfw.GLFWKeyCallbackI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.List;
/**
* Manages the input to a computer. This receives GLFW events (i.e. {@link GLFWKeyCallbackI} and queues them to be
* run on the computer.
*/
public class InputState {
private static final Logger LOG = LoggerFactory.getLogger(InputState.class);
private static final float TERMINATE_TIME = 0.5f;
private static final float KEY_SUPPRESS_DELAY = 0.2f;
private final Computer computer;
private final BitSet keysDown = new BitSet(256);
private float terminateTimer = -1;
private float rebootTimer = -1;
private float shutdownTimer = -1;
private int lastMouseButton = -1;
private int lastMouseX = -1;
private int lastMouseY = -1;
public InputState(Computer computer) {
this.computer = computer;
}
public void onCharEvent(int codepoint) {
if (codepoint >= 32 && codepoint <= 126 || codepoint >= 160 && codepoint <= 255) {
// Queue the char event for any printable chars in byte range
computer.queueEvent("char", new Object[]{ Character.toString(codepoint) });
}
}
public void onKeyEvent(int key, int action, int modifiers) {
switch (action) {
case GLFW.GLFW_PRESS, GLFW.GLFW_REPEAT -> keyPressed(key, modifiers);
case GLFW.GLFW_RELEASE -> keyReleased(key);
}
}
private void keyPressed(int key, int modifiers) {
if (key == GLFW.GLFW_KEY_ESCAPE) return;
if ((modifiers & GLFW.GLFW_MOD_CONTROL) != 0) {
switch (key) {
case GLFW.GLFW_KEY_T -> {
if (terminateTimer < 0) terminateTimer = 0;
}
case GLFW.GLFW_KEY_S -> {
if (shutdownTimer < 0) shutdownTimer = 0;
}
case GLFW.GLFW_KEY_R -> {
if (rebootTimer < 0) rebootTimer = 0;
}
}
}
if (key >= 0 && terminateTimer < KEY_SUPPRESS_DELAY && rebootTimer < KEY_SUPPRESS_DELAY && shutdownTimer < KEY_SUPPRESS_DELAY) {
// Queue the "key" event and add to the down set
var repeat = keysDown.get(key);
keysDown.set(key);
computer.queueEvent("key", new Object[]{ key, repeat });
}
}
private void keyReleased(int key) {
// Queue the "key_up" event and remove from the down set
if (key >= 0 && keysDown.get(key)) {
keysDown.set(key, false);
computer.queueEvent("key_up", new Object[]{ key });
}
switch (key) {
case GLFW.GLFW_KEY_T -> terminateTimer = -1;
case GLFW.GLFW_KEY_R -> rebootTimer = -1;
case GLFW.GLFW_KEY_S -> shutdownTimer = -1;
case GLFW.GLFW_KEY_LEFT_CONTROL, GLFW.GLFW_KEY_RIGHT_CONTROL ->
terminateTimer = rebootTimer = shutdownTimer = -1;
}
}
public void onMouseClick(int button, int action) {
switch (action) {
case GLFW.GLFW_PRESS -> {
computer.queueEvent("mouse_click", new Object[]{ button + 1, lastMouseX + 1, lastMouseY + 1 });
lastMouseButton = button;
}
case GLFW.GLFW_RELEASE -> {
if (button == lastMouseButton) {
computer.queueEvent("mouse_click", new Object[]{ button + 1, lastMouseX + 1, lastMouseY + 1 });
lastMouseButton = -1;
}
}
}
}
public void onMouseMove(int mouseX, int mouseY) {
if (mouseX == lastMouseX && mouseY == lastMouseY) return;
lastMouseX = mouseX;
lastMouseY = mouseY;
if (lastMouseButton != -1) {
computer.queueEvent("mouse_drag", new Object[]{ lastMouseButton + 1, mouseX + 1, mouseY + 1 });
}
}
public void onMouseScroll(double yOffset) {
if (yOffset != 0) {
computer.queueEvent("mouse_scroll", new Object[]{ yOffset < 0 ? 1 : -1, lastMouseX + 1, lastMouseY + 1 });
}
}
public void onFileDrop(int count, long names) {
var paths = new Path[count];
for (var i = 0; i < count; ++i) paths[i] = Paths.get(GLFWDropCallback.getName(names, i));
List<TransferredFile> files = new ArrayList<>();
for (var path : paths) {
if (!Files.isRegularFile(path)) continue;
byte[] contents;
try {
contents = Files.readAllBytes(path);
} catch (IOException e) {
LOG.error("Failed to read {}", path, e);
continue;
}
files.add(new TransferredFile(path.getFileName().toString(), new ArrayByteChannel(contents)));
}
if (!files.isEmpty()) computer.queueEvent(TransferredFiles.EVENT, new Object[]{ new TransferredFiles(files) });
}
public void update() {
if (terminateTimer >= 0 && terminateTimer < TERMINATE_TIME && (terminateTimer += 0.05f) > TERMINATE_TIME) {
computer.queueEvent("terminate", null);
}
if (shutdownTimer >= 0 && shutdownTimer < TERMINATE_TIME && (shutdownTimer += 0.05f) > TERMINATE_TIME) {
computer.shutdown();
}
if (rebootTimer >= 0 && rebootTimer < TERMINATE_TIME && (rebootTimer += 0.05f) > TERMINATE_TIME) {
if (computer.isOn()) {
computer.reboot();
} else {
computer.turnOn();
}
}
}
}

View File

@ -0,0 +1,403 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.standalone;
import dan200.computercraft.core.ComputerContext;
import dan200.computercraft.core.computer.Computer;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.core.terminal.TextBuffer;
import dan200.computercraft.core.util.Colour;
import org.apache.commons.cli.*;
import org.lwjgl.glfw.GLFW;
import org.lwjgl.glfw.GLFWErrorCallback;
import org.lwjgl.opengl.GL;
import org.lwjgl.opengl.GLUtil;
import org.lwjgl.system.Checks;
import org.lwjgl.system.MemoryStack;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.ByteBuffer;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
import static org.lwjgl.glfw.Callbacks.glfwFreeCallbacks;
import static org.lwjgl.glfw.GLFW.*;
import static org.lwjgl.opengl.GL45C.*;
import static org.lwjgl.system.MemoryUtil.NULL;
/**
* A standalone UI for CC: Tweaked computers.
* <p>
* This displays a computer terminal using OpenGL and GLFW, without having to load all of Minecraft.
* <p>
* The rendering code largely follows that of monitors: we store the terminal data in a TBO, performing the bulk of the
* rendering logic within the fragment shader ({@code terminal.fsh}).
*/
public class Main {
private static final Logger LOG = LoggerFactory.getLogger(Main.class);
private static final boolean DEBUG = Checks.DEBUG;
private record TermSize(int width, int height) {
public static final TermSize DEFAULT = new TermSize(51, 19);
public static final Pattern PATTERN = Pattern.compile("^(\\d+)x(\\d+)$");
}
private static <T> T getParsedOptionValue(CommandLine cli, String opt, Class<T> klass) throws ParseException {
var res = cli.getOptionValue(opt);
if (klass == Path.class) {
try {
return klass.cast(Path.of(res));
} catch (InvalidPathException e) {
throw new ParseException("'" + res + "' is not a valid path (" + e.getReason() + ")");
}
} else if (klass == TermSize.class) {
var matcher = TermSize.PATTERN.matcher(res);
if (!matcher.matches()) throw new ParseException("'" + res + "' is not a valid terminal size.");
return klass.cast(new TermSize(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))));
} else {
return klass.cast(TypeHandler.createValue(res, klass));
}
}
public static void main(String[] args) throws InterruptedException {
var options = new Options();
options.addOption(Option.builder("r").argName("PATH").longOpt("resources").hasArg()
.desc("The path to the resources directory")
.build());
options.addOption(Option.builder("c").argName("PATH").longOpt("computer").hasArg()
.desc("The root directory of the computer. Defaults to a temporary directory.")
.build());
options.addOption(Option.builder("t").argName("WIDTHxHEIGHT").longOpt("term-size").hasArg()
.desc("The size of the terminal, defaults to 51x19")
.build());
options.addOption(new Option("h", "help", false, "Print help message"));
Path resourcesDirectory;
Path computerDirectory;
TermSize termSize;
try {
var cli = new DefaultParser().parse(options, args);
if (!cli.hasOption("r")) throw new ParseException("--resources directory is required");
resourcesDirectory = getParsedOptionValue(cli, "r", Path.class);
computerDirectory = cli.hasOption("c") ? getParsedOptionValue(cli, "c", Path.class) : null;
termSize = cli.hasOption("t") ? getParsedOptionValue(cli, "t", TermSize.class) : TermSize.DEFAULT;
} catch (ParseException e) {
System.err.println(e.getLocalizedMessage());
var writer = new PrintWriter(System.err);
new HelpFormatter().printUsage(writer, HelpFormatter.DEFAULT_WIDTH, "standalone.jar", options);
writer.flush();
System.exit(1);
return;
}
var context = ComputerContext.builder(new StandaloneGlobalEnvironment(resourcesDirectory)).build();
try (var gl = new GLObjects()) {
var isDirty = new AtomicBoolean(true);
var computer = new Computer(
context,
new StandaloneComputerEnvironment(computerDirectory),
new Terminal(termSize.width(), termSize.height(), true, () -> isDirty.set(true)),
0
);
computer.turnOn();
runAndInit(gl, computer, isDirty);
} catch (Exception e) {
LOG.error("A fatal error occurred", e);
System.exit(1);
} finally {
context.ensureClosed(1, TimeUnit.SECONDS);
}
}
private static final int SCALE = 2;
private static final int MARGIN = 2;
private static final int PIXEL_WIDTH = 6;
private static final int PIXEL_HEIGHT = 9;
// Offsets for our shader attributes - see also terminal.vsh.
private static final int ATTRIBUTE_POSITION = 0;
private static final int ATTRIBUTE_UV = 1;
// Offsets for our shader uniforms - see also terminal.fsh.
private static final int UNIFORM_FONT = 0;
private static final int UNIFORM_TERMINAL = 1;
private static final int UNIFORM_TERMINAL_DATA = 0;
private static final int UNIFORM_CURSOR_BLINK = 2;
// Offsets for our textures.
private static final int TEXTURE_FONT = 0;
private static final int TEXTURE_TBO = 1;
/**
* Size of the terminal UBO.
*
* @see #setUniformData(ByteBuffer, Terminal)
* @see #UNIFORM_TERMINAL_DATA
*/
private static final int TERMINAL_DATA_SIZE = 4 * 4 * 16 + 4 + 4 + 2 * 4 + 4;
private static void runAndInit(GLObjects gl, Computer computer, AtomicBoolean isDirty) throws IOException {
var terminal = computer.getEnvironment().getTerminal();
var inputState = new InputState(computer);
// Setup an error callback.
GLFWErrorCallback.createPrint(System.err).set();
gl.add(() -> Objects.requireNonNull(glfwSetErrorCallback(null)).free());
// Initialize GLFW.
if (!glfwInit()) throw new IllegalStateException("Unable to initialize GLFW");
gl.add(GLFW::glfwTerminate);
// Configure GLFW
glfwDefaultWindowHints();
glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); // Hide the window - we manually show it later.
glfwWindowHint(GLFW_RESIZABLE, GL_FALSE);// Force the window to remain the terminal size.
// Configure OpenGL
glfwWindowHint(GLFW_CLIENT_API, GLFW_OPENGL_API);
glfwWindowHint(GLFW_CONTEXT_CREATION_API, GLFW_NATIVE_CONTEXT_API);
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 5);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
if (DEBUG) glfwWindowHint(GLFW_CONTEXT_DEBUG, GLFW_TRUE);
var window = glfwCreateWindow(
SCALE * (MARGIN * 2 + PIXEL_WIDTH * terminal.getWidth()),
SCALE * (MARGIN * 2 + PIXEL_HEIGHT * terminal.getHeight()),
"CC: Tweaked - Standalone", NULL, NULL
);
if (window == NULL) throw new RuntimeException("Failed to create the GLFW window");
gl.add(() -> {
glfwFreeCallbacks(window);
glfwDestroyWindow(window);
});
// Get the window size so we can centre it.
try (var stack = MemoryStack.stackPush()) {
var width = stack.mallocInt(1);
var height = stack.mallocInt(1);
glfwGetWindowSize(window, width, height);
// Get the resolution of the primary monitor
var mode = glfwGetVideoMode(glfwGetPrimaryMonitor());
if (mode != null) {
glfwSetWindowPos(window, (mode.width() - width.get(0)) / 2, (mode.height() - height.get(0)) / 2);
}
}
// Add all our callbacks
glfwSetKeyCallback(window, (w, key, scancode, action, mods) -> inputState.onKeyEvent(key, action, mods));
glfwSetCharModsCallback(window, (w, codepoint, mods) -> inputState.onCharEvent(codepoint));
glfwSetDropCallback(window, (w, count, files) -> inputState.onFileDrop(count, files));
glfwSetMouseButtonCallback(window, (w, button, action, mods) -> inputState.onMouseClick(button, action));
glfwSetCursorPosCallback(window, (w, x, y) -> {
var charX = (int) (((x / SCALE) - MARGIN) / PIXEL_WIDTH);
var charY = (int) (((y / SCALE) - MARGIN) / PIXEL_HEIGHT);
charX = Math.min(Math.max(charX, 0), terminal.getWidth() - 1);
charY = Math.min(Math.max(charY, 0), terminal.getHeight() - 1);
inputState.onMouseMove(charX, charY);
});
glfwSetScrollCallback(window, (w, xOffset, yOffset) -> inputState.onMouseScroll(yOffset));
glfwMakeContextCurrent(window);
glfwSwapInterval(1); // Enable v-sync
glfwShowWindow(window);
// Initialise the OpenGL state
GL.createCapabilities();
if (DEBUG) {
GLUtil.setupDebugMessageCallback();
glDebugMessageControl(GL_DONT_CARE, GL_DONT_CARE, GL_DONT_CARE, (int[]) null, true);
}
// Load the font texture and bind it.
var fontTexture = gl.loadTexture("assets/computercraft/textures/gui/term_font.png");
glBindTextureUnit(TEXTURE_FONT, fontTexture);
// Create a texture and backing buffer for our TBO and bind it.
var termBuffer = gl.createBuffer("Terminal TBO");
var termTexture = gl.createTexture(GL_TEXTURE_BUFFER, "Terminal TBO");
glTextureBuffer(termTexture, GL_R8UI, termBuffer);
glBindTextureUnit(TEXTURE_TBO, termTexture);
// Load the main terminal shader.
var termProgram = compileProgram(gl);
glProgramUniform1i(termProgram, UNIFORM_FONT, TEXTURE_FONT);
glProgramUniform1i(termProgram, UNIFORM_TERMINAL, TEXTURE_TBO);
glProgramUniform1i(termProgram, UNIFORM_CURSOR_BLINK, 0);
// Create a backing buffer for our UBO and bind it.
var termDataBuffer = gl.createBuffer("Terminal Data");
glBindBufferBase(GL_UNIFORM_BUFFER, UNIFORM_TERMINAL_DATA, termDataBuffer);
// Create our vertex buffer object. This is just a simple triangle strip of our four corners.
var termVertices = gl.createBuffer("Terminal Vertices");
glNamedBufferData(termVertices, new float[]{
-1.0f, 1.0f, -MARGIN, -MARGIN,
-1.0f, -1.0f, -MARGIN, PIXEL_HEIGHT * terminal.getHeight() + MARGIN,
1.0f, 1.0f, PIXEL_WIDTH * terminal.getWidth() + MARGIN, -MARGIN,
1.0f, -1.0f, PIXEL_WIDTH * terminal.getWidth() + MARGIN, PIXEL_HEIGHT * terminal.getHeight() + MARGIN,
}, GL_STATIC_DRAW);
// And our VBA
var termVertexArray = gl.createVertexArray("Terminal VAO");
glEnableVertexArrayAttrib(termVertexArray, 0);
glVertexArrayAttribFormat(termVertexArray, 0, 2, GL_FLOAT, false, 0); // Position
glEnableVertexArrayAttrib(termVertexArray, 1);
glVertexArrayAttribFormat(termVertexArray, 1, 2, GL_FLOAT, false, 8); // UV
// FIXME: Can we merge this into one call?
glVertexArrayVertexBuffer(termVertexArray, 0, termVertices, 0, 16);
glVertexArrayVertexBuffer(termVertexArray, 1, termVertices, 0, 16);
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
// We run a single loop for both rendering and ticking computers. This is dubious for all sorts of reason, but
// good enough for us.
var lastTickTime = GLFW.glfwGetTime();
var lastCursorBlink = false;
while (!glfwWindowShouldClose(window)) {
// Tick the computer
computer.tick();
inputState.update();
var needRedraw = false;
// Update the terminal data if needed.
if (isDirty.getAndSet(false)) {
needRedraw = true;
try (var stack = MemoryStack.stackPush()) {
var buffer = stack.malloc(terminal.getWidth() * terminal.getHeight() * 3);
writeTerminalContents(buffer, terminal);
glNamedBufferData(termBuffer, buffer, GL_STATIC_DRAW);
}
try (var stack = MemoryStack.stackPush()) {
var buffer = stack.malloc(TERMINAL_DATA_SIZE);
setUniformData(buffer, terminal);
glNamedBufferData(termDataBuffer, buffer, GL_STATIC_DRAW);
}
}
// Update the cursor blink if needed.
var cursorBlink = terminal.getCursorBlink() && (int) (lastTickTime * 20 / 8) % 2 == 0;
if (cursorBlink != lastCursorBlink) {
needRedraw = true;
glProgramUniform1i(termProgram, UNIFORM_CURSOR_BLINK, cursorBlink ? 1 : 0);
lastCursorBlink = cursorBlink;
}
// Redraw the terminal if needed.
if (needRedraw) {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // clear the framebuffer
glUseProgram(termProgram);
glBindVertexArray(termVertexArray);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glfwSwapBuffers(window); // swap the color buffers
}
// Then wait for the next frame.
var deadline = lastTickTime + 0.05;
lastTickTime = GLFW.glfwGetTime();
while (lastTickTime < deadline) {
GLFW.glfwWaitEventsTimeout(deadline - lastTickTime);
lastTickTime = GLFW.glfwGetTime();
}
}
}
private static int compileProgram(GLObjects gl) throws IOException {
try (var shaders = new GLObjects()) {
var vertexShader = shaders.compileShader(GL_VERTEX_SHADER, "terminal.vsh");
var fragmentShader = shaders.compileShader(GL_FRAGMENT_SHADER, "terminal.fsh");
var program = gl.createProgram("Terminal program");
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
glLinkProgram(program);
if (glGetProgrami(program, GL_LINK_STATUS) == 0) {
LOG.warn("Error encountered when linking shader: {}", glGetProgramInfoLog(program, 32768));
}
return program;
}
}
/**
* Write the current contents of the terminal to a buffer, ready to be copied to our TBO.
* <p>
* Each cell is stored as three packed bytes - character, foreground, background. This is then bound to the
* {@code Tbo} uniform within the shader, and read to lookup specific the current pixel.
*
* @param buffer The buffer to write to.
* @param terminal The current terminal.
*/
private static void writeTerminalContents(ByteBuffer buffer, Terminal terminal) {
int width = terminal.getWidth(), height = terminal.getHeight();
var pos = 0;
for (var y = 0; y < height; y++) {
TextBuffer text = terminal.getLine(y), textColour = terminal.getTextColourLine(y), background = terminal.getBackgroundColourLine(y);
for (var x = 0; x < width; x++) {
buffer.put(pos, (byte) (text.charAt(x) & 0xFF));
buffer.put(pos + 1, (byte) (15 - Terminal.getColour(textColour.charAt(x), Colour.WHITE)));
buffer.put(pos + 2, (byte) (15 - Terminal.getColour(background.charAt(x), Colour.BLACK)));
pos += 3;
}
}
buffer.limit(pos);
}
/**
* Write the additional terminal properties (palette, size, cursor) to a buffer, ready to be copied to our UBO.
* <p>
* This is bound to the {@code TermData} uniform, and read to look up terminal-wide properties.
*
* @param buffer The buffer to write to.
* @param terminal The current terminal.
*/
private static void setUniformData(ByteBuffer buffer, Terminal terminal) {
var pos = 0;
var palette = terminal.getPalette();
for (var i = 0; i < 16; i++) {
var colour = palette.getColour(i);
buffer.putFloat(pos, (float) colour[0]).putFloat(pos + 4, (float) colour[1]).putFloat(pos + 8, (float) colour[2]);
pos += 4 * 4; // std140 requires these are 4-wide
}
var cursorX = terminal.getCursorX();
var cursorY = terminal.getCursorY();
var showCursor = terminal.getCursorBlink() && cursorX >= 0 && cursorX < terminal.getWidth() && cursorY >= 0 && cursorY < terminal.getHeight();
buffer
.putInt(pos, terminal.getWidth()).putInt(pos + 4, terminal.getHeight())
.putInt(pos + 8, showCursor ? cursorX : -2)
.putInt(pos + 12, showCursor ? cursorY : -2)
.putInt(pos + 16, 15 - terminal.getTextColour());
buffer.limit(TERMINAL_DATA_SIZE);
}
}

View File

@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.standalone;
import dan200.computercraft.api.filesystem.WritableMount;
import dan200.computercraft.core.computer.ComputerEnvironment;
import dan200.computercraft.core.filesystem.MemoryMount;
import dan200.computercraft.core.filesystem.WritableFileMount;
import dan200.computercraft.core.metrics.MetricsObserver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.nio.file.Path;
/**
* The {@link ComputerEnvironment} for our standalone emulator.
*/
public class StandaloneComputerEnvironment implements ComputerEnvironment {
private static final Logger LOG = LoggerFactory.getLogger(StandaloneComputerEnvironment.class);
@Nullable
private final Path root;
public StandaloneComputerEnvironment(@Nullable Path root) {
this.root = root;
}
@Override
public int getDay() {
return 0;
}
@Override
public double getTimeOfDay() {
return 0;
}
@Override
public WritableMount createRootMount() {
if (root == null) {
LOG.info("Creating in-memory mount.");
return new MemoryMount();
} else {
LOG.info("Creating mount at {}.", root);
return new WritableFileMount(root.toFile(), 1_000_000L);
}
}
@Override
public MetricsObserver getMetrics() {
return MetricsObserver.discard();
}
}

View File

@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2023 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
package cc.tweaked.standalone;
import dan200.computercraft.api.filesystem.Mount;
import dan200.computercraft.core.computer.GlobalEnvironment;
import dan200.computercraft.core.filesystem.FileMount;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* The {@link GlobalEnvironment} for our standalone emulator.
*/
public class StandaloneGlobalEnvironment implements GlobalEnvironment {
private static final Logger LOG = LoggerFactory.getLogger(StandaloneGlobalEnvironment.class);
private final Path resourceRoot;
public StandaloneGlobalEnvironment(Path resourceRoot) {
this.resourceRoot = resourceRoot;
}
@Override
public String getHostString() {
return "ComputerCraft (standalone)";
}
@Override
public String getUserAgent() {
return "computercraft/1.0";
}
@Override
public Mount createResourceMount(String domain, String subPath) {
return new FileMount(resourceRoot.resolve("data").resolve(domain).resolve(subPath));
}
@Nullable
@Override
public InputStream createResourceFile(String domain, String subPath) {
var path = resourceRoot.resolve("data").resolve(domain).resolve(subPath).toAbsolutePath();
try {
return Files.newInputStream(path);
} catch (IOException e) {
LOG.error("Failed to create resource file from {}.", path);
return null;
}
}
}

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

View File

@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: 2020 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
#version 450 core
#define FONT_WIDTH 6.0
#define FONT_HEIGHT 9.0
layout(location = 0) uniform sampler2D Font;
layout(location = 1) uniform usamplerBuffer Tbo;
layout(std140, binding = 0) uniform TermData {
vec3 Palette[16];
int Width;
int Height;
ivec2 CursorPos;
int CursorColour;
};
layout(location = 2) uniform int CursorBlink;
in vec2 fontPos;
out vec4 fragColor;
vec2 texture_corner(int index) {
float x = 1.0 + float(index % 16) * (FONT_WIDTH + 2.0);
float y = 1.0 + float(index / 16) * (FONT_HEIGHT + 2.0);
return vec2(x, y);
}
vec4 recolour(vec4 texture, int colour) {
return vec4(texture.rgb * Palette[colour], texture.rgba);
}
void main() {
vec2 term_pos = vec2(fontPos.x / FONT_WIDTH, fontPos.y / FONT_HEIGHT);
vec2 corner = floor(term_pos);
ivec2 cell = ivec2(corner);
int index = 3 * (clamp(cell.x, 0, Width - 1) + clamp(cell.y, 0, Height - 1) * Width);
// 1 if 0 <= x, y < Width, Height, 0 otherwise
vec2 outside = step(vec2(0.0, 0.0), vec2(cell)) * step(vec2(cell), vec2(float(Width) - 1.0, float(Height) - 1.0));
float mult = outside.x * outside.y;
int character = int(texelFetch(Tbo, index).r);
int fg = int(texelFetch(Tbo, index + 1).r);
int bg = int(texelFetch(Tbo, index + 2).r);
vec2 pos = (term_pos - corner) * vec2(FONT_WIDTH, FONT_HEIGHT);
vec4 charTex = recolour(texture(Font, (texture_corner(character) + pos) / 256.0), fg);
// Applies the cursor on top of the current character if we're blinking and in the current cursor's cell. We do it
// this funky way to avoid branches.
vec4 cursorTex = recolour(texture(Font, (texture_corner(95) + pos) / 256.0), CursorColour); // 95 = '_'
vec4 img = mix(charTex, cursorTex, cursorTex.a * float(CursorBlink) * (CursorPos == cell ? 1.0 : 0.0));
fragColor = vec4(mix(Palette[bg], img.rgb, img.a * mult), 1.0);
}

View File

@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2020 The CC: Tweaked Developers
//
// SPDX-License-Identifier: MPL-2.0
#version 330 core
layout (location = 0) in vec2 Position;
layout (location = 1) in vec2 UV0;
out vec2 fontPos;
void main() {
gl_Position = vec4(Position, 0.0, 1.0);
fontPos = UV0;
}

View File

@ -153,14 +153,11 @@ public void dispose() {
@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(),
() -> {
}),
});
computer.queueEvent(TransferredFiles.EVENT, new Object[]{ new TransferredFiles(
Arrays.stream(files)
.map(x -> new TransferredFile(x.getName(), new ArrayByteChannel(bytesOfBuffer(x.getContents()))))
.toList()
) });
}
@Override

View File

@ -63,6 +63,7 @@ include(":forge-api")
include(":forge")
include(":lints")
include(":standalone")
include(":web")
for (project in rootProject.children) {