Просмотр исходного кода

add new app and add text for bt serial

MX 2 лет назад
Родитель
Сommit
4a8686d23d

+ 2 - 1
ReadMe.md

@@ -13,7 +13,7 @@ Apps contains changes needed to compile them on latest firmware, fixes has been
 
 The Flipper and its community wouldn't be as rich as it is without your contributions and support. Thank you for all you have done.
 
-### Apps checked & updated at `5 Sep 16:53 GMT +3`
+### Apps checked & updated at `6 Sep 14:55 GMT +3`
 
 
 # Default pack
@@ -229,6 +229,7 @@ The Flipper and its community wouldn't be as rich as it is without your contribu
 | Resistance calculator | ![Tools Badge] | [by instantiator](https://github.com/instantiator/flipper-zero-experimental-apps) |  | [![UFW Badge]](https://lab.flipper.net/apps/resistors) |
 | VB Lab Migration Assistant | ![Tools Badge] | [by GMMan (cyanic)](https://github.com/GMMan/flipperzero-vb-migrate) |  | [![Author Badge]](https://lab.flipper.net/apps/vb_migrate) |
 | Simple calendar app | ![Tools Badge] | [by Adiras](https://github.com/Adiras/flipperzero-calendar) |  | ![None Badge] |
+| JavaScript | ![Tools Badge] | [by zap8600](https://github.com/zap8600/js-f0) |  | ![None Badge] |
 | USB HID Autofire | ![USB Badge] | [by pbek](https://github.com/pbek/usb_hid_autofire) |  | ![None Badge] |
 
 

+ 12 - 11
non_catalog_apps/bt_serial_example/fbs.c

@@ -6,7 +6,9 @@ void draw_callback(Canvas* canvas, void* ctx) {
     FBS* app = ctx;
     furi_check(furi_mutex_acquire(app->app_mutex, FuriWaitForever) == FuriStatusOk);
 
-    canvas_draw_str_aligned(canvas, 64, 32, AlignCenter, AlignCenter, (char*)app->display_text);
+    canvas_draw_str_aligned(canvas, 64, 17, AlignCenter, AlignCenter, "App is running");
+    canvas_draw_str_aligned(canvas, 64, 27, AlignCenter, AlignCenter, "Please open CLI");
+    canvas_draw_str_aligned(canvas, 64, 37, AlignCenter, AlignCenter, "and type: log info");
 
     furi_mutex_release(app->app_mutex);
 }
@@ -38,7 +40,6 @@ void fbs_free(FBS* app) {
     furi_record_close(RECORD_GUI);
     app->gui = NULL;
     view_port_free(app->view_port);
-    free(app->display_text);
 
     furi_mutex_free(app->app_mutex);
     furi_message_queue_free(app->event_queue);
@@ -56,17 +57,17 @@ static uint16_t bt_serial_event_callback(SerialServiceEvent event, void* context
     uint16_t ret = 0;
 
     if(event.event == SerialServiceEventTypeDataReceived) {
-        FURI_LOG_D(TAG, "SerialServiceEventTypeDataReceived");
-        FURI_LOG_D(TAG, "Size: %u", event.data.size);
-        FURI_LOG_D(TAG, "Data: ");
+        FURI_LOG_I(TAG, "SerialServiceEventTypeDataReceived");
+        FURI_LOG_I(TAG, "Size: %u", event.data.size);
+        FURI_LOG_I(TAG, "Data: ");
         for(size_t i = 0; i < event.data.size; i++) {
             printf("%X ", event.data.buffer[i]);
         }
         printf("\r\n");
     } else if(event.event == SerialServiceEventTypeDataSent) {
-        FURI_LOG_D(TAG, "SerialServiceEventTypeDataSent");
-        FURI_LOG_D(TAG, "Size: %u", event.data.size);
-        FURI_LOG_D(TAG, "Data: ");
+        FURI_LOG_I(TAG, "SerialServiceEventTypeDataSent");
+        FURI_LOG_I(TAG, "Size: %u", event.data.size);
+        FURI_LOG_I(TAG, "Data: ");
         for(size_t i = 0; i < event.data.size; i++) {
             printf("%X ", event.data.buffer[i]);
         }
@@ -80,12 +81,12 @@ int32_t fbs_app(void* p) {
     FBS* app = fbs_alloc();
 
     if(furi_hal_bt_is_active()) {
-        FURI_LOG_D(TAG, "BT is working, hijacking the serial connection...");
+        FURI_LOG_I(TAG, "BT is working, hijacking the serial connection...");
         furi_hal_bt_serial_set_event_callback(
             BT_SERIAL_BUFFER_SIZE, bt_serial_event_callback, app);
         furi_hal_bt_start_advertising();
     } else {
-        FURI_LOG_D(TAG, "Please, enable the Bluetooth and restart the app");
+        FURI_LOG_I(TAG, "Please, enable the Bluetooth and restart the app");
     }
 
     InputEvent event;
@@ -102,6 +103,6 @@ int32_t fbs_app(void* p) {
     furi_hal_bt_serial_set_event_callback(0, NULL, NULL);
 
     fbs_free(app);
-    FURI_LOG_D(TAG, "Released everything");
+    FURI_LOG_I(TAG, "Released everything");
     return 0;
 }

+ 0 - 2
non_catalog_apps/bt_serial_example/fbs.h

@@ -13,8 +13,6 @@ typedef struct {
     Bt* bt;
     bool bt_connected;
 
-    char* display_text;
-
     ViewPort* view_port;
     Gui* gui;
 

+ 23 - 0
non_catalog_apps/js_f0/README.md

@@ -0,0 +1,23 @@
+# js-f0
+JavaScript... on the Flipper Zero?
+## Notice
+I'm taking a break from this. I've worked too much on this, and it feels really repetitive now. I'll still accept pull requests for any changes.
+## How?
+This is all possible using the [microvium JavaScript engine](https://github.com/coder-mike/microvium). Check it out!
+## Installation
+Download the artifact from the most recent successful [action](https://github.com/zap8600/js-f0/actions) and put it in `/ext/apps` on your Flipper Zero.
+## Usage
+1. Install microvium on your computer with `npm install -g microvium`.
+2. Create a new script with the following contents:
+```js
+// script.mvm.js
+console.log = vmImport(7);
+function main() {
+  console.log('Hello, World!');
+}
+vmExport(1, main);
+```
+3. Compile it with `microvium script.mvm.js`. This will make a file in the same directory called `script.mvm-bc`.
+4. Copy `script.mvm-bc` to `/ext/apps_data/js` on your Flipper Zero.
+5. Run `JavaScript` on your Flipper Zero.
+6. Press the center button to open the console and find "Hello, world!" in the log! 

+ 16 - 0
non_catalog_apps/js_f0/application.fam

@@ -0,0 +1,16 @@
+App(
+    appid="js",
+    name="JavaScript",
+    apptype=FlipperAppType.EXTERNAL,
+    entry_point="js_app",
+    stack_size=4 * 1024,
+    requires=["gui"],
+    fap_private_libs=[
+        Lib(
+            name="microvium",
+            cflags=["-Wno-implicit-function-declaration", "-Wno-unused-parameter", "-Wno-char-subscripts", "-Wno-double-promotion", "-Wno-redundant-decls"],
+        ),
+    ],
+    fap_icon="js.png",
+    fap_category="Tools",
+)

+ 496 - 0
non_catalog_apps/js_f0/js.c

@@ -0,0 +1,496 @@
+// main.c
+#include <stdlib.h>
+#include <stdio.h>
+#include <assert.h>
+#include <furi.h>
+#include <gui/gui.h>
+#include <gui/view_dispatcher.h>
+#include <gui/view.h>
+#include <gui/modules/text_box.h>
+#include <gui/modules/dialog_ex.h>
+#include <storage/storage.h>
+
+#include "microvium.h"
+
+#define JS_APP_PATH_FOLDER STORAGE_APP_DATA_PATH_PREFIX
+#define TAG "microvium"
+
+static int32_t js_run(void* context);
+
+// A function in the host (this file) for the VM to call
+#define IMPORT_FLIPPER_FURI_DELAY_MS 1
+#define IMPORT_FLIPPER_CANVAS_STOP 2
+#define IMPORT_FLIPPER_CANVAS_SET_FONT 3
+#define IMPORT_FLIPPER_CANVAS_DRAW_STR 4
+#define IMPORT_FLIPPER_CANVAS_DRAW_STR_ALIGNED 5
+#define IMPORT_CONSOLE_CLEAR 6
+#define IMPORT_CONSOLE_LOG 7
+#define IMPORT_CONSOLE_WARN 8
+
+// A function exported by VM to for the host to call
+const mvm_VMExportID MAIN = 1;
+/* Use when needed
+const mvm_VMExportID INIT = 2;
+*/
+
+mvm_TeError resolveImport(mvm_HostFunctionID id, void*, mvm_TfHostFunction* out);
+mvm_TeError flipper_furi_delay_ms(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount);
+mvm_TeError flipper_canvas_stop(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount);
+mvm_TeError flipper_canvas_set_font(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount);
+mvm_TeError flipper_canvas_draw_str(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount);
+mvm_TeError flipper_canvas_draw_str_aligned(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount);
+mvm_TeError console_clear(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount);
+mvm_TeError console_log(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount);
+mvm_TeError console_warn(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount);
+
+typedef enum {
+    MyEventTypeKey,
+    MyEventTypeDone,
+} MyEventType;
+
+typedef struct {
+    MyEventType type; // The reason for this event.
+    InputEvent input; // This data is specific to keypress data.
+} MyEvent;
+
+typedef enum {
+    JSDisplay,
+    JSConsole,
+    // JSConfirm,
+} ViewId;
+
+FuriMessageQueue* queue;
+ViewId current_view;
+
+ViewDispatcher* view_dispatcher;
+
+TextBox* text_box;
+
+/*
+bool confirmGot = false;
+bool confirmRes = false;
+*/
+
+typedef struct {
+    FuriThread* thread;
+} JSRtThread;
+
+typedef struct {
+    FuriString* conLog;
+} Console;
+
+Console* console;
+
+typedef enum {
+    CNone,
+    CDrawStr,
+    CDrawStrAli,
+} CDrawEvent;
+
+typedef struct {
+    CDrawEvent cEvent;
+    Font font;
+    const char* str;
+    int x;
+    int y;
+    Align horizontal;
+    Align vertical;
+} Display;
+
+Display* display;
+
+size_t fileSize;
+uint8_t* fileBuff;
+
+static void draw_callback(Canvas* canvas, void* context) {
+    UNUSED(context);
+    canvas_set_font(canvas, display->font);
+    if(display->cEvent == CDrawStr) {
+        canvas_draw_str(canvas, display->x, display->y, display->str);
+    } else if(display->cEvent == CDrawStrAli) {
+        canvas_draw_str_aligned(
+            canvas, display->x, display->y, display->horizontal, display->vertical, display->str);
+    }
+}
+
+static bool input_callback(InputEvent* input_event, void* context) {
+    UNUSED(context);
+    bool handled = false;
+    // we set our callback context to be the view_dispatcher.
+
+    if(input_event->type == InputTypeShort) {
+        if(input_event->key == InputKeyBack) {
+            // Default back handler.
+            handled = false;
+        } else if(input_event->key == InputKeyOk) {
+            // switch the view!
+            view_dispatcher_send_custom_event(view_dispatcher, 42);
+            handled = true;
+        }
+    }
+
+    return handled;
+}
+
+bool navigation_event_callback(void* context) {
+    UNUSED(context);
+    // We did not handle the event, so return false.
+    return false;
+}
+
+bool custom_event_callback(void* context, uint32_t event) {
+    UNUSED(context);
+    bool handled = false;
+
+    if(event == 42) {
+        if(current_view == JSDisplay) {
+            current_view = JSConsole;
+        }
+
+        view_dispatcher_switch_to_view(view_dispatcher, current_view);
+        handled = true;
+    }
+
+    // NOTE: The return value is not currently used by the ViewDispatcher.
+    return handled;
+}
+
+static uint32_t exit_console_callback(void* context) {
+    UNUSED(context);
+    return JSDisplay;
+}
+
+static int32_t js_run(void* context) {
+    UNUSED(context);
+
+    mvm_TeError err;
+    mvm_VM* vm;
+    mvm_Value main;
+    mvm_Value result;
+
+    // Restore the VM from the snapshot
+    err = mvm_restore(&vm, fileBuff, fileSize, NULL, resolveImport);
+    if(err != MVM_E_SUCCESS) {
+        FURI_LOG_E(TAG, "Error with restore: %d", err);
+        return err;
+    }
+
+    // Find the "sayHello" function exported by the VM
+    err = mvm_resolveExports(vm, &MAIN, &main, 1);
+    if(err != MVM_E_SUCCESS) {
+        FURI_LOG_E(TAG, "Error with exports: %d", err);
+        return err;
+    }
+
+    // Call "main"
+    err = mvm_call(vm, main, &result, NULL, 0);
+    if(err != MVM_E_SUCCESS) {
+        FURI_LOG_E(TAG, "Error with call: %d", err);
+        return err;
+    }
+
+    // Clean up
+    mvm_runGC(vm, true);
+
+    return 0;
+}
+
+int32_t js_app() {
+    JSRtThread* jsThread = malloc(sizeof(JSRtThread));
+
+    Storage* storage = furi_record_open(RECORD_STORAGE);
+    File* bytecode = storage_file_alloc(storage);
+    storage_file_open(bytecode, APP_DATA_PATH("script.mvm-bc"), FSAM_READ, FSOM_OPEN_EXISTING);
+    fileSize = storage_file_size(bytecode);
+    FURI_LOG_D("microvium", "File Size: %d", fileSize);
+    fileBuff = malloc(fileSize);
+    storage_file_read(bytecode, fileBuff, fileSize);
+    storage_file_close(bytecode);
+    storage_file_free(bytecode);
+    furi_record_close(RECORD_STORAGE);
+
+    jsThread->thread = furi_thread_alloc_ex("microium", 1024, js_run, jsThread);
+
+    view_dispatcher = view_dispatcher_alloc();
+
+    // For this demo, we just use view_dispatcher as our application context.
+    void* context = view_dispatcher;
+
+    View* view1 = view_alloc();
+    view_set_context(view1, context);
+    view_set_draw_callback(view1, draw_callback);
+    view_set_input_callback(view1, input_callback);
+    view_set_orientation(view1, ViewOrientationHorizontal);
+
+    text_box = text_box_alloc();
+    text_box_set_font(text_box, TextBoxFontText);
+    view_set_previous_callback(text_box_get_view(text_box), exit_console_callback);
+
+    /*
+    DialogEx* dialog_ex = dialog_ex_alloc();
+    dialog_ex_set_left_button_text(dialog_ex, "No");
+    dialog_ex_set_right_button_text(dialog_ex, "Yes");
+    dialog_ex_set_context(dialog_ex, context);
+    dialog_ex_set_result_callback(dialog_ex, confirm_callback);
+    */
+
+    // set param 1 of custom event callback (impacts tick and navigation too).
+    view_dispatcher_set_event_callback_context(view_dispatcher, context);
+    view_dispatcher_set_navigation_event_callback(view_dispatcher, navigation_event_callback);
+    view_dispatcher_set_custom_event_callback(view_dispatcher, custom_event_callback);
+    view_dispatcher_enable_queue(view_dispatcher);
+    view_dispatcher_add_view(view_dispatcher, JSDisplay, view1);
+    view_dispatcher_add_view(view_dispatcher, JSConsole, text_box_get_view(text_box));
+    //view_dispatcher_add_view(view_dispatcher, JSConfirm, dialog_ex_get_view(dialog_ex));
+
+    Gui* gui = furi_record_open(RECORD_GUI);
+    view_dispatcher_attach_to_gui(view_dispatcher, gui, ViewDispatcherTypeFullscreen);
+    current_view = JSDisplay;
+    view_dispatcher_switch_to_view(view_dispatcher, current_view);
+
+    // console init
+    console = malloc(sizeof(Console));
+    console->conLog = furi_string_alloc();
+
+    // display init and set defaults
+    display = malloc(sizeof(Display));
+    display->cEvent = CNone;
+    display->font = FontSecondary;
+
+    furi_thread_start(jsThread->thread);
+    view_dispatcher_run(view_dispatcher);
+
+    furi_thread_join(jsThread->thread);
+    furi_thread_free(jsThread->thread);
+    free(jsThread);
+
+    furi_string_free(console->conLog);
+    free(console);
+
+    free(display);
+
+    view_dispatcher_remove_view(view_dispatcher, JSDisplay);
+    view_dispatcher_remove_view(view_dispatcher, JSConsole);
+    furi_record_close(RECORD_GUI);
+    view_dispatcher_free(view_dispatcher);
+
+    return 0;
+}
+
+void fatalError(void* vm, int e) {
+    UNUSED(vm);
+    FURI_LOG_E(TAG, "Error: %d\n", e);
+    furi_crash("Microvium fatal error");
+}
+
+/*
+ * This function is called by `mvm_restore` to search for host functions
+ * imported by the VM based on their ID. Given an ID, it needs to pass back
+ * a pointer to the corresponding C function to be used by the VM.
+ */
+mvm_TeError resolveImport(mvm_HostFunctionID funcID, void* context, mvm_TfHostFunction* out) {
+    UNUSED(context);
+    if(funcID == IMPORT_FLIPPER_FURI_DELAY_MS) {
+        *out = flipper_furi_delay_ms;
+    } else if(funcID == IMPORT_FLIPPER_CANVAS_SET_FONT) {
+        *out = flipper_canvas_set_font;
+        return MVM_E_SUCCESS;
+    } else if(funcID == IMPORT_FLIPPER_CANVAS_DRAW_STR) {
+        *out = flipper_canvas_draw_str;
+        return MVM_E_SUCCESS;
+    } else if(funcID == IMPORT_FLIPPER_CANVAS_DRAW_STR_ALIGNED) {
+        *out = flipper_canvas_draw_str_aligned;
+    } else if(funcID == IMPORT_CONSOLE_LOG) {
+        *out = console_log;
+        return MVM_E_SUCCESS;
+    } else if(funcID == IMPORT_CONSOLE_CLEAR) {
+        *out = console_clear;
+        return MVM_E_SUCCESS;
+    } else if(funcID == IMPORT_CONSOLE_WARN) {
+        *out = console_warn;
+        return MVM_E_SUCCESS;
+    }
+    return MVM_E_UNRESOLVED_IMPORT;
+}
+
+mvm_TeError flipper_furi_delay_ms(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount) {
+    UNUSED(funcID);
+    UNUSED(result);
+    furi_assert(argCount == 1);
+    FURI_LOG_D(TAG, "delay_ms()");
+    furi_delay_ms((int32_t)mvm_toInt32(vm, args[0]));
+    return MVM_E_SUCCESS;
+}
+
+mvm_TeError flipper_canvas_stop(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount) {
+    UNUSED(vm);
+    UNUSED(funcID);
+    UNUSED(result);
+    UNUSED(args);
+    furi_assert(argCount == 0);
+    FURI_LOG_D(TAG, "canvas_stop()");
+    display->cEvent = CNone;
+    return MVM_E_SUCCESS;
+}
+
+mvm_TeError flipper_canvas_set_font(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount) {
+    UNUSED(funcID);
+    UNUSED(result);
+    furi_assert(argCount == 1);
+    FURI_LOG_D(TAG, "canvas_set_font()");
+    // display->cEvent = CSetFont;
+    display->font = mvm_toInt32(vm, args[0]);
+    // display->cEvent = CNone;
+    return MVM_E_SUCCESS;
+}
+
+mvm_TeError flipper_canvas_draw_str(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount) {
+    UNUSED(funcID);
+    UNUSED(result);
+    furi_assert(argCount == 3);
+    FURI_LOG_D(TAG, "canvas_draw_str()");
+    display->x = (int32_t)mvm_toInt32(vm, args[0]);
+    display->y = (int32_t)mvm_toInt32(vm, args[1]);
+    display->str = (const char*)mvm_toStringUtf8(vm, args[2], NULL);
+    display->cEvent = CDrawStr;
+    return MVM_E_SUCCESS;
+}
+
+mvm_TeError flipper_canvas_draw_str_aligned(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount) {
+    UNUSED(funcID);
+    UNUSED(result);
+    furi_assert(argCount == 5);
+    FURI_LOG_D(TAG, "canvas_draw_str_aligned()");
+    display->x = (int32_t)mvm_toInt32(vm, args[0]);
+    display->y = (int32_t)mvm_toInt32(vm, args[1]);
+    display->horizontal = (int32_t)mvm_toInt32(vm, args[2]);
+    display->vertical = (int32_t)mvm_toInt32(vm, args[3]);
+    display->str = (const char*)mvm_toStringUtf8(vm, args[4], NULL);
+    display->cEvent = CDrawStrAli;
+    return MVM_E_SUCCESS;
+}
+
+mvm_TeError console_clear(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount) {
+    UNUSED(vm);
+    UNUSED(funcID);
+    UNUSED(result);
+    UNUSED(args);
+    furi_assert(argCount == 0);
+    FURI_LOG_D(TAG, "console.clear()");
+    furi_string_reset(console->conLog);
+    text_box_set_text(text_box, furi_string_get_cstr(console->conLog));
+    return MVM_E_SUCCESS;
+}
+
+mvm_TeError console_log(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount) {
+    UNUSED(funcID);
+    UNUSED(result);
+    FURI_LOG_D(TAG, "console.log()");
+    for(int i = 0; i < argCount - 1; i++) {
+        if(mvm_typeOf(vm, args[i]) == VM_T_NUMBER) {
+            furi_string_cat_printf(console->conLog, "%d", (int)mvm_toInt32(vm, args[i]));
+        } else if(mvm_typeOf(vm, args[i]) == VM_T_STRING) {
+            furi_string_cat_printf(
+                console->conLog, "%s", (const char*)mvm_toStringUtf8(vm, args[i], NULL));
+        }
+    }
+    furi_string_cat_printf(console->conLog, "\n");
+    text_box_set_text(text_box, furi_string_get_cstr(console->conLog));
+    return MVM_E_SUCCESS;
+}
+
+mvm_TeError console_warn(
+    mvm_VM* vm,
+    mvm_HostFunctionID funcID,
+    mvm_Value* result,
+    mvm_Value* args,
+    uint8_t argCount) {
+    UNUSED(funcID);
+    UNUSED(result);
+    furi_assert(argCount == 1);
+    FURI_LOG_D(TAG, "console.warn()");
+    for(int i = 0; i < argCount - 1; i++) {
+        if(mvm_typeOf(vm, args[i]) == VM_T_NUMBER) {
+            FURI_LOG_W(TAG, "%d", (int)mvm_toInt32(vm, args[i]));
+        } else if(mvm_typeOf(vm, args[i]) == VM_T_STRING) {
+            FURI_LOG_W(TAG, "%s", (const char*)mvm_toStringUtf8(vm, args[i], NULL));
+        }
+    }
+    FURI_LOG_W(TAG, "\n");
+    return MVM_E_SUCCESS;
+}

BIN
non_catalog_apps/js_f0/js.png


+ 10085 - 0
non_catalog_apps/js_f0/lib/microvium/microvium.c

@@ -0,0 +1,10085 @@
+// Copyright 2020 Michael Hunter. Part of the Microvium project. Links to full code at https://microvium.com for license details.
+
+/*
+ * Microvium Bytecode Interpreter
+ *
+ * Version: 8.0.0
+ *
+ * This file contains the Microvium virtual machine C implementation.
+ *
+ * The key functions are mvm_restore() and mvm_call(), which perform the
+ * initialization and run loop respectively.
+ *
+ * I've written Microvium in C because lots of embedded projects for small
+ * processors are written in pure C, and so integration for them will be easier.
+ * Also, there are a surprising number of C++ compilers in the embedded world
+ * that deviate from the standard, and I don't want to be testing on all of them
+ * individually.
+ *
+ * For the moment, I'm keeping Microvium all in one file for usability. Users
+ * can treat this file as a black box that contains the VM, and there's only one
+ * file they need to have built into their project in order to have Microvium
+ * running. The build process also pulls in the dependent header files, so
+ * there's only one header file and it's the one that users of Microvium need to
+ * see. Certain compilers and optimization settings also do a better job when
+ * related functions are co-located the same compilation unit.
+ *
+ * User-facing functions and definitions are all prefixed with `mvm_` to
+ * namespace them separately from other functions in their project, some of
+ * which use the prefix `vm_` and some without a prefix. (TODO: this should be
+ * consolidated)
+ */
+
+#include "microvium.h"
+
+#include <ctype.h>
+#include <stdlib.h>
+#include <inttypes.h>
+#include <stdio.h> // Note: only uses snprintf from stdio.h
+
+// See microvium.c for design notes.
+
+
+#include "stdbool.h"
+#include "stdint.h"
+#include "string.h"
+#include "stdlib.h"
+
+#include "microvium.h"
+#include "microvium_port.h"
+
+
+#include "stdint.h"
+
+// These sections appear in the bytecode in the order they appear in this
+// enumeration.
+typedef enum mvm_TeBytecodeSection {
+  /**
+   * Import Table
+   *
+   * List of host function IDs (vm_TsImportTableEntry) which are called by the
+   * VM. References from the VM to host functions are represented as indexes
+   * into this table. These IDs are resolved to their corresponding host
+   * function pointers when a VM is restored.
+   */
+  BCS_IMPORT_TABLE,
+
+  /**
+   * A list of immutable `vm_TsExportTableEntry` that the VM exports, mapping
+   * export IDs to their corresponding VM Value. Mostly these values will just
+   * be function pointers.
+   */
+  // TODO: We need to test what happens if we export numbers and objects
+  BCS_EXPORT_TABLE,
+
+  /**
+   * Short Call Table. Table of vm_TsShortCallTableEntry.
+   *
+   * To make the representation of function calls in IL more compact, up to 256
+   * of the most frequent function calls are listed in this table, including the
+   * function target and the argument count.
+   *
+   * See `LBL_CALL_SHORT`
+   */
+  BCS_SHORT_CALL_TABLE,
+
+  /**
+   * Builtins
+   *
+   * See `mvm_TeBuiltins`
+   *
+   * Table of `Value`s that need to be directly identifiable by the engine, such
+   * as the Array prototype.
+   *
+   * These are not copied into RAM, they are just constant values like the
+   * exports, but like other values in ROM they are permitted to hold mutable
+   * values by pointing (as BytecodeMappedPtr) to the corresponding global
+   * variable slot.
+   *
+   * Note: at one point, I had these as single-byte offsets into the global
+   * variable space, but this made the assumption that all accessible builtins
+   * are also mutable, which is probably not true. The new design makes the
+   * opposite assumption: most builtins will be immutable at runtime (e.g.
+   * nobody changes the array prototype), so they can be stored in ROM and
+   * referenced by immutable Value pointers, making them usable but not
+   * consuming RAM at all. It's the exception rather than the rule that some of
+   * these may be mutable and require indirection through the global slot table.
+   */
+  BCS_BUILTINS,
+
+  /**
+   * Interned Strings Table
+   *
+   * To keep property lookup efficient, Microvium requires that strings used as
+   * property keys can be compared using pointer equality. This requires that
+   * there is only one instance of each string (see
+   * https://en.wikipedia.org/wiki/String_interning). This table is the
+   * alphabetical listing of all the strings in ROM (or at least, all those
+   * which are valid property keys). See also TC_REF_INTERNED_STRING.
+   *
+   * There may be two string tables: one in ROM and one in RAM. The latter is
+   * required in general if the program might use arbitrarily-computed strings
+   * as property keys. For efficiency, the ROM string table is contiguous and
+   * sorted, to allow for binary searching, while the RAM string table is a
+   * linked list for efficiency in appending (expected to be used only
+   * occasionally).
+   */
+  BCS_STRING_TABLE,
+
+  /**
+   * Functions and other immutable data structures.
+   *
+   * While the whole bytecode is essentially "ROM", only this ROM section
+   * contains addressable allocations.
+   */
+  BCS_ROM,
+
+  /**
+   * Globals
+   *
+   * One `Value` entry for the initial value of each global variable. The number
+   * of global variables is determined by the size of this section.
+   *
+   * This section will be copied into RAM at startup (restore).
+   *
+   * Note: the global slots are used both for global variables and for "handles"
+   * (these are different to the user-defined handles for referencing VM objects
+   * from user space). Handles allow ROM allocations to reference RAM
+   * allocations, even though the ROM can't be updated when the RAM allocation
+   * moves during a GC collection. A handle is a slot in the "globals" space,
+   * where the slot itself is pointed to by a ROM value and it points to the
+   * corresponding RAM value. During a GC cycle, the RAM value may move and the
+   * handle slot is updated, but the handle slot itself doesn't move. See
+   * `offsetToDynamicPtr` in `encode-snapshot.ts`.
+   *
+   * The handles appear as the *last* global slots, and will generally not be
+   * referenced by `LOAD_GLOBAL` instructions.
+   *
+   * WARNING: the globals section is the last section before the heap, so no ROM
+   * pointer should point after the globals section. Some functions (e.g.
+   * vm_getHandleTargetOrNull) assume this to be true.
+   */
+  BCS_GLOBALS,
+
+  /**
+   * Heap Section: heap allocations.
+   *
+   * This section is copied into RAM when the VM is restored. It becomes the
+   * initial value of the GC heap. It contains allocations that are mutable
+   * (like the DATA section) but also subject to garbage collection.
+   *
+   * Note: the heap must be at the end, because it is the only part that changes
+   * size from one snapshot to the next. There is code that depends on this
+   * being the last section because the size of this section is computed as
+   * running to the end of the bytecode image.
+   */
+  BCS_HEAP,
+
+  BCS_SECTION_COUNT,
+} mvm_TeBytecodeSection;
+
+typedef enum mvm_TeBuiltins {
+  BIN_INTERNED_STRINGS,
+  BIN_ARRAY_PROTO,
+  BIN_STR_PROTOTYPE, // If the string "prototype" is interned, this builtin points to it.
+  BIN_ASYNC_CONTINUE, // A function used to construct a closure for the job queue to complete async operations
+  BIN_ASYNC_CATCH_BLOCK, // A block, bundled as a function, for the root try-catch in async functions
+  BIN_ASYNC_HOST_CALLBACK, // Bytecode to use as the callback for host async operations
+  BIN_PROMISE_PROTOTYPE,
+
+  BIN_BUILTIN_COUNT
+} mvm_TeBuiltins;
+
+// Minimal bytecode is 32 bytes (sizeof(mvm_TsBytecodeHeader) + BCS_SECTION_COUNT*2 + BIN_BUILTIN_COUNT*2)
+typedef struct mvm_TsBytecodeHeader { // Size = 12B + sectionOffsets
+  uint8_t bytecodeVersion; // MVM_ENGINE_MAJOR_VERSION
+  uint8_t headerSize;
+  uint8_t requiredEngineVersion; // MVM_ENGINE_MINOR_VERSION
+  uint8_t reserved; // =0
+
+  uint16_t bytecodeSize; // Including header
+  uint16_t crc; // CCITT16 (header and data, of everything after the CRC)
+
+  uint32_t requiredFeatureFlags;
+
+  /*
+  Note: the sections are assumed to be in order as per mvm_TeBytecodeSection, so
+  that the size of a section can be computed as the difference between the
+  adjacent offsets. The last section runs up until the end of the bytecode.
+  */
+  uint16_t sectionOffsets[BCS_SECTION_COUNT]; // 8 sections, 16B
+} mvm_TsBytecodeHeader;
+
+typedef enum mvm_TeFeatureFlags {
+  FF_FLOAT_SUPPORT = 0,
+} mvm_TeFeatureFlags;
+
+typedef struct vm_TsExportTableEntry {
+  mvm_VMExportID exportID;
+  mvm_Value exportValue;
+} vm_TsExportTableEntry;
+
+typedef struct vm_TsShortCallTableEntry {
+  /* Note: the `function` field has been broken up into separate low and high
+   * bytes, `functionL` and `functionH` respectively, for alignment purposes,
+   * since this is a 3-byte structure occurring in a packed table.
+   *
+   * `functionL` and `functionH` together make an `mvm_Value` which should be a
+   * callable value (a pointer to a `TsBytecodeFunc`, `TsHostFunc`, or
+   * `TsClosure`). TODO: I don't think this currently works, and I'm not even
+   * sure how we would test it. */
+  uint8_t functionL;
+  uint8_t functionH;
+  uint8_t argCount;
+} vm_TsShortCallTableEntry;
+
+
+
+
+/*
+Note: the instruction set documentation is in
+`microvium/doc/internals/instruction-set`
+
+Microvium categorizes operations into groups based on common features. The first
+nibble of an instruction is its vm_TeOpcode. This is followed by 4 bits which
+can either be interpreted as a data parameter or as another opcode (e.g.
+vm_TeOpcodeEx1). I call the first nibble the "primary opcode" and the second
+nibble is the "secondary opcode".
+
+There are a number of possible secondary opcodes, and each group has common
+preparation logic across the group. Preparation logic means the code that runs
+before the operation. For example, many operations require popping a value off
+the stack before operating on the value. The VM implementation is more compact
+if the pop code is common to all instructions that do the pop.
+
+Operations can have different "follow through" logic grouped arbitrarily, since
+the implementation of all instructions requires a "jump", those that have common
+follow through logic simply jump to the same follow through without additional
+cost, which eventually lands up back at the loop start. So the instruction
+grouping does not need to cater for follow through logic, only preparation
+logic.
+
+To keep operation commonality as seamlessly as possible, the VM implementation
+use 16-bit "registers", which have overloaded meaning depending on the context:
+
+  - `reg1`
+    - Initially holds the zero-extended 4-bit secondary nibble
+    - Operations that load an 8- or 16-bit literal will overwrite `reg1` with
+      the literal.
+    - "Pure" operations use reg1 as the first popped operand (none of the pure
+      operations have an embedded literal). "Pure" are what I'm calling
+      operations whose entire effect is to pop some operands off the stack,
+      operate on them, and push a result back onto the stack. For example,
+      `ADD`.
+    - `reg1` is also used as the "result" value for the common push-result tail
+      logic
+  - `reg2`
+    - used as the second popped value of binary operations
+    - used as the value to store, store-like operations
+  - `reg3`
+    - can be used arbitrarily by operations and does not have a common meaning
+
+Additionally, the number operations have variations that work on 32 or 64 bit
+values. These have their own local/ephemeral registers:
+
+  - `reg1I`: the value of the reg1 register unpacked to a `uint32_t`
+  - `reg2I`: the value of the reg2 register unpacked to a `uint32_t`
+  - `reg1F`: the value of the reg1 register unpacked to a `double`
+  - `reg2F`: the value of the reg2 register unpacked to a `double`
+
+Operation groups and their corresponding preparation logic
+
+  - vm_TeOpcodeEx1:
+    - The prep does not read a literal (all these instructions are single-byte).
+    - The prep pops 0, 1, or 2 values from the stack depending on the
+      instruction range
+
+  - vm_TeOpcodeEx2:
+    - Prep reads 8-bit literal into reg1
+      - Two separate instruction ranges specify whether to sign extend or not.
+    - Two instruction ranges specify whether the prep will also pop an arg into
+      reg2.
+
+  - vm_TeOpcodeEx3:
+    - Prep reads a 16-bit value from byte stream into reg1. This can be
+      interpreted as either signed or unsigned by the particular instruction.
+    - A sub-range within the instruction specifies whether an argument is popped
+      from the stack.
+    - (Edit: there are violations of this pattern because I ran out space in
+      vm_TeOpcodeEx1)
+
+  - vm_TeOpcodeEx4:
+    - Not really any common logic. Just a bucket of miscellaneous instructions.
+
+  - vm_TeNumberOp:
+    - These are all dual-implementation instructions which have both 32 and 64
+      bit implementations.
+    - Prep pops one or two values off the stack and reads them into reg1 and
+      reg2 respectively. The choice of 1 or 2 depends on the sub-range. If
+      popping one value, the second is left as zero.
+    - Prep unpacks to either int32 or float64 depending on the corresponding
+      data types.
+    - The operations can dispatch to a different tail/follow through routine
+      depending on whether they overflow or not.
+
+  - vm_TeBitwiseOp:
+    - These operations all operate on 32-bit integers and produce 32-bit integer
+      results.
+    - Prep pops one or two values off the stack and reads them into reg1 and
+      reg2 respectively. The choice of 1 or 2 depends on the sub-range. If
+      popping one value, the second is left as zero.
+    - Prep unpacks reg1 and reg2 to int32
+
+Follow-through/tail routines:
+
+  - Push float (reg1F)
+  - Push int32 (reg1I)
+  - Push 16-bit result (reg1)
+
+*/
+
+// TODO: I think this instruction set needs an overhaul. The categorization has
+// become chaotic and not that efficient.
+
+// TODO: If we wanted to make space in the primary opcode range, we could remove
+// `VM_OP_LOAD_ARG_1` and just leave `VM_OP2_LOAD_ARG_2`, since static analysis
+// should be able to convert many instances of `LoadArg` into `LoadVar`
+
+// 4-bit enum
+typedef enum vm_TeOpcode {
+  VM_OP_LOAD_SMALL_LITERAL  = 0x0, // (+ 4-bit vm_TeSmallLiteralValue)
+  VM_OP_LOAD_VAR_1          = 0x1, // (+ 4-bit variable index relative to stack pointer)
+  VM_OP_LOAD_SCOPED_1       = 0x2, // (+ 4-bit scoped variable index)
+  VM_OP_LOAD_ARG_1          = 0x3, // (+ 4-bit arg index)
+  VM_OP_CALL_1              = 0x4, // (+ 4-bit index into short-call table)
+  VM_OP_FIXED_ARRAY_NEW_1   = 0x5, // (+ 4-bit length)
+  VM_OP_EXTENDED_1          = 0x6, // (+ 4-bit vm_TeOpcodeEx1)
+  VM_OP_EXTENDED_2          = 0x7, // (+ 4-bit vm_TeOpcodeEx2)
+  VM_OP_EXTENDED_3          = 0x8, // (+ 4-bit vm_TeOpcodeEx3)
+  VM_OP_CALL_5              = 0x9, // (+ 4-bit arg count + 16-bit target)
+
+  VM_OP_DIVIDER_1, // <-- ops after this point pop at least one argument (reg2)
+
+  VM_OP_STORE_VAR_1         = 0xA, // (+ 4-bit variable index relative to stack pointer)
+  VM_OP_STORE_SCOPED_1      = 0xB, // (+ 4-bit scoped variable index)
+  VM_OP_ARRAY_GET_1         = 0xC, // (+ 4-bit item index)
+  VM_OP_ARRAY_SET_1         = 0xD, // (+ 4-bit item index)
+  VM_OP_NUM_OP              = 0xE, // (+ 4-bit vm_TeNumberOp)
+  VM_OP_BIT_OP              = 0xF, // (+ 4-bit vm_TeBitwiseOp)
+
+  VM_OP_END
+} vm_TeOpcode;
+
+typedef enum vm_TeOpcodeEx1 {
+  VM_OP1_RETURN                  = 0x0,
+  VM_OP1_THROW                   = 0x1,
+
+  // (target) -> TsClosure
+  VM_OP1_CLOSURE_NEW             = 0x2,
+
+  // (TsClass, ...args) -> object
+  VM_OP1_NEW                     = 0x3, // (+ 8-bit unsigned arg count. Target is dynamic)
+
+  VM_OP1_RESERVED_VIRTUAL_NEW    = 0x4,
+
+  VM_OP1_SCOPE_NEW               = 0x5, // (+ 8-bit variable count)
+
+  // (value) -> mvm_TeType
+  VM_OP1_TYPE_CODE_OF            = 0x6, // More efficient than VM_OP1_TYPEOF
+
+  VM_OP1_POP                     = 0x7, // Pop one item
+
+  VM_OP1_TYPEOF                  = 0x8,
+
+  VM_OP1_OBJECT_NEW              = 0x9,
+
+  // boolean -> boolean
+  VM_OP1_LOGICAL_NOT             = 0xA,
+
+  VM_OP1_DIVIDER_1, // <-- ops after this point are treated as having at least 2 stack arguments
+
+  // (object, prop) -> any
+  VM_OP1_OBJECT_GET_1            = 0xB, // (field ID is dynamic)
+
+  // (string, string) -> string
+  // (number, number) -> number
+  VM_OP1_ADD                     = 0xC,
+
+  // (any, any) -> boolean
+  VM_OP1_EQUAL                   = 0xD,
+  VM_OP1_NOT_EQUAL               = 0xE,
+
+  // (object, prop, any) -> void
+  VM_OP1_OBJECT_SET_1            = 0xF, // (field ID is dynamic)
+
+  VM_OP1_END
+} vm_TeOpcodeEx1;
+
+// All of these operations are implemented with an 8-bit literal embedded into
+// the instruction. The literal is stored in reg1.
+typedef enum vm_TeOpcodeEx2 {
+  VM_OP2_BRANCH_1            = 0x0, // (+ 8-bit signed offset)
+
+  VM_OP2_STORE_ARG           = 0x1, // (+ 8-bit unsigned arg index)
+  VM_OP2_STORE_SCOPED_2      = 0x2, // (+ 8-bit unsigned scoped variable index)
+  VM_OP2_STORE_VAR_2         = 0x3, // (+ 8-bit unsigned variable index relative to stack pointer)
+  VM_OP2_ARRAY_GET_2_RESERVED = 0x4, // (+ 8-bit unsigned field index)
+  VM_OP2_ARRAY_SET_2_RESERVED = 0x5, // (+ 8-bit unsigned field index)
+
+  VM_OP2_DIVIDER_1, // <-- ops before this point pop from the stack into reg2
+
+  VM_OP2_JUMP_1              = 0x6, // (+ 8-bit signed offset)
+  VM_OP2_CALL_HOST           = 0x7, // (+ 8-bit arg count + 8-bit unsigned index into resolvedImports)
+  VM_OP2_CALL_3              = 0x8, // (+ 8-bit unsigned arg count. Target is dynamic)
+  VM_OP2_CALL_6              = 0x9, // (+ 8-bit index into short-call table)
+
+  VM_OP2_LOAD_SCOPED_2       = 0xA, // (+ 8-bit unsigned scoped variable index)
+  VM_OP2_LOAD_VAR_2          = 0xB, // (+ 8-bit unsigned variable index relative to stack pointer)
+  VM_OP2_LOAD_ARG_2          = 0xC, // (+ 8-bit unsigned arg index)
+
+  VM_OP2_EXTENDED_4          = 0xD, // (+ 8-bit unsigned vm_TeOpcodeEx4)
+
+  VM_OP2_ARRAY_NEW           = 0xE, // (+ 8-bit capacity count)
+  VM_OP2_FIXED_ARRAY_NEW_2   = 0xF, // (+ 8-bit length count)
+
+  VM_OP2_END
+} vm_TeOpcodeEx2;
+
+// Most of these instructions all have an embedded 16-bit literal value
+typedef enum vm_TeOpcodeEx3 {
+  // Note: Pop[0] can be used as a single-byte NOP instruction
+  VM_OP3_POP_N               = 0x0, // (+ 8-bit pop count) Pops N items off the stack
+  VM_OP3_SCOPE_DISCARD       = 0x1, // Set the closure reg to undefined
+  VM_OP3_SCOPE_CLONE         = 0x2,
+  VM_OP3_AWAIT               = 0x3, // (no literal operands)
+  VM_OP3_AWAIT_CALL          = 0x4, // (+ 8-bit arg count)
+  VM_OP3_ASYNC_RESUME        = 0x5, // (+ 8-bit stack restoration slot count)
+
+  VM_OP3_RESERVED_3          = 0x6,
+
+  VM_OP3_DIVIDER_1, // <-- ops before this point are miscellaneous and don't automatically get any literal values or stack values
+
+  VM_OP3_JUMP_2              = 0x7, // (+ 16-bit signed offset)
+  VM_OP3_LOAD_LITERAL        = 0x8, // (+ 16-bit value)
+  VM_OP3_LOAD_GLOBAL_3       = 0x9, // (+ 16-bit global variable index)
+  VM_OP3_LOAD_SCOPED_3       = 0xA, // (+ 16-bit scoped variable index)
+
+  VM_OP3_DIVIDER_2, // <-- ops after this point pop an argument into reg2
+
+  VM_OP3_BRANCH_2            = 0xB, // (+ 16-bit signed offset)
+  VM_OP3_STORE_GLOBAL_3      = 0xC, // (+ 16-bit global variable index)
+  VM_OP3_STORE_SCOPED_3      = 0xD, // (+ 16-bit scoped variable index)
+
+  VM_OP3_OBJECT_GET_2        = 0xE, // (+ 16-bit property key)
+  VM_OP3_OBJECT_SET_2        = 0xF, // (+ 16-bit property key)
+
+  VM_OP3_END
+} vm_TeOpcodeEx3;
+
+// This is a bucket of less frequently used instructions that didn't fit into
+// the other opcode ranges. We can put up to 256 opcodes here.
+typedef enum vm_TeOpcodeEx4 {
+  VM_OP4_START_TRY           = 0x00, // (+ 16-bit label to the catch block)
+  VM_OP4_END_TRY             = 0x01, // (No literal operands)
+  VM_OP4_OBJECT_KEYS         = 0x02, // (No literal operands)
+  VM_OP4_UINT8_ARRAY_NEW     = 0x03, // (No literal operands)
+
+  // (constructor, props) -> TsClass
+  VM_OP4_CLASS_CREATE        = 0x04, // Creates TsClass (does not in instantiate a class)
+
+  VM_OP4_TYPE_CODE_OF        = 0x05, // Opcode for mvm_typeOf
+
+  VM_OP4_LOAD_REG_CLOSURE    = 0x06, // (No literal operands)
+
+  VM_OP4_SCOPE_PUSH          = 0x07, // (+ 8-bit unsigned slot count) also sets last slot to parent scope
+  VM_OP4_SCOPE_POP           = 0x08, // Sets the closure reg to the parent of the current closure
+  VM_OP4_SCOPE_SAVE          = 0x09, // Pops the current closure off the closure stack and pushes it to the variable stack
+
+  VM_OP4_ASYNC_START         = 0x0A, // + 7-bit closure slot count and 1-bit flag for parent-capturing.
+  VM_OP4_ASYNC_RETURN        = 0x0B, // (No literal operands)
+  VM_OP4_ENQUEUE_JOB         = 0x0C, // (No literal operands)
+  VM_OP4_ASYNC_COMPLETE      = 0x0D, // (No literal operands)
+
+
+  VM_OP4_END
+} vm_TeOpcodeEx4;
+
+
+// Number operations. These are operations which take one or two arguments from
+// the stack and coerce them to numbers. Each of these will have two
+// implementations: one for 32-bit int, and one for 64-bit float.
+typedef enum vm_TeNumberOp {
+
+  // (number, number) -> boolean
+  VM_NUM_OP_LESS_THAN        = 0x0,
+  VM_NUM_OP_GREATER_THAN     = 0x1,
+  VM_NUM_OP_LESS_EQUAL       = 0x2,
+  VM_NUM_OP_GREATER_EQUAL    = 0x3,
+
+  // (number, number) -> number
+  VM_NUM_OP_ADD_NUM          = 0x4,
+  VM_NUM_OP_SUBTRACT         = 0x5,
+  VM_NUM_OP_MULTIPLY         = 0x6,
+  VM_NUM_OP_DIVIDE           = 0x7,
+  VM_NUM_OP_DIVIDE_AND_TRUNC = 0x8, // Represented in JS as `x / y | 0`
+  VM_NUM_OP_REMAINDER        = 0x9,
+  VM_NUM_OP_POWER            = 0xA,
+
+  VM_NUM_OP_DIVIDER, // <-- ops after this point are unary
+
+  // number -> number
+  VM_NUM_OP_NEGATE           = 0xB,
+  VM_NUM_OP_UNARY_PLUS       = 0xC,
+
+  VM_NUM_OP_END
+} vm_TeNumberOp;
+
+// Bitwise operations:
+typedef enum vm_TeBitwiseOp {
+  // (bits, bits) -> bits
+  VM_BIT_OP_SHR_ARITHMETIC = 0x0, // Aka signed shift right. Aka sign-propagating right shift.
+  VM_BIT_OP_SHR_LOGICAL    = 0x1, // Aka unsigned shift right. Aka zero-fill right shift.
+  VM_BIT_OP_SHL            = 0x2, // Shift left
+
+  VM_BIT_OP_END_OF_SHIFT_OPERATORS, // <-- ops before this point need their operand in the 0-32 range
+
+  VM_BIT_OP_OR             = 0x3,
+  VM_BIT_OP_AND            = 0x4,
+  VM_BIT_OP_XOR            = 0x5,
+
+  VM_BIT_OP_DIVIDER_2, // <-- ops after this point are unary
+
+  // bits -> bits
+  VM_BIT_OP_NOT            = 0x6,
+
+  VM_BIT_OP_END
+} vm_TeBitwiseOp;
+
+// vm_TeSmallLiteralValue : 4-bit enum
+//
+// Note: Only up to 16 values are allowed here.
+typedef enum vm_TeSmallLiteralValue {
+  VM_SLV_DELETED         = 0x0,
+  VM_SLV_UNDEFINED       = 0x1,
+  VM_SLV_NULL            = 0x2,
+  VM_SLV_FALSE           = 0x3,
+  VM_SLV_TRUE            = 0x4,
+  VM_SLV_INT_MINUS_1     = 0x5,
+  VM_SLV_INT_0           = 0x6,
+  VM_SLV_INT_1           = 0x7,
+  VM_SLV_INT_2           = 0x8,
+  VM_SLV_INT_3           = 0x9,
+  VM_SLV_INT_4           = 0xA,
+  VM_SLV_INT_5           = 0xB,
+} vm_TeSmallLiteralValue;
+
+
+
+#define MVM_EXPECTED_PORT_FILE_VERSION 1
+
+// -------------------------- Port-file defaults -----------------------------
+
+
+#ifndef MVM_PORT_VERSION
+#define MVM_STACK_SIZE 256
+#endif
+
+#ifndef MVM_ALLOCATION_BUCKET_SIZE
+#define MVM_ALLOCATION_BUCKET_SIZE 256
+#endif
+
+#ifndef MVM_MAX_HEAP_SIZE
+#define MVM_MAX_HEAP_SIZE 1024
+#endif
+
+#ifndef MVM_NATIVE_POINTER_IS_16_BIT
+#define MVM_NATIVE_POINTER_IS_16_BIT 0
+#endif
+
+#ifndef MVM_FLOAT64_NAN
+#define MVM_FLOAT64_NAN ((MVM_FLOAT64)(INFINITY * 0.0))
+#endif
+
+#ifndef MVM_SAFE_MODE
+#define MVM_SAFE_MODE 1
+#endif
+
+#ifndef MVM_DONT_TRUST_BYTECODE
+#define MVM_DONT_TRUST_BYTECODE 1
+#endif
+
+#ifndef MVM_VERY_EXPENSIVE_MEMORY_CHECKS
+#define MVM_VERY_EXPENSIVE_MEMORY_CHECKS 0
+#endif
+
+#ifndef MVM_LONG_PTR_NEW
+#define MVM_LONG_PTR_NEW(p) ((MVM_LONG_PTR_TYPE)p)
+#endif
+
+#ifndef MVM_LONG_PTR_TRUNCATE
+#define MVM_LONG_PTR_TRUNCATE(p) ((void*)p)
+#endif
+
+#ifndef MVM_LONG_PTR_ADD
+#define MVM_LONG_PTR_ADD(p, s) ((MVM_LONG_PTR_TYPE)((uint8_t*)p + (intptr_t)s))
+#endif
+
+#ifndef MVM_LONG_PTR_SUB
+#define MVM_LONG_PTR_SUB(p2, p1) ((int16_t)((uint8_t*)p2 - (uint8_t*)p1))
+#endif
+
+#ifndef MVM_READ_LONG_PTR_1
+#define MVM_READ_LONG_PTR_1(lpSource) (*((uint8_t *)lpSource))
+#endif
+#ifndef MVM_READ_LONG_PTR_2
+#define MVM_READ_LONG_PTR_2(lpSource) (*((uint16_t *)lpSource))
+#endif
+
+#ifndef MVM_LONG_MEM_CMP
+#define MVM_LONG_MEM_CMP(p1, p2, size) memcmp(p1, p2, size)
+#endif
+
+#ifndef MVM_LONG_MEM_CPY
+#define MVM_LONG_MEM_CPY(target, source, size) memcpy(target, source, size)
+#endif
+
+#ifndef MVM_FATAL_ERROR
+#include <assert.h>
+#define MVM_FATAL_ERROR(vm, e) (assert(false), exit(e))
+#endif
+
+#ifndef MVM_ALL_ERRORS_FATAL
+#define MVM_ALL_ERRORS_FATAL 0
+#endif
+
+#ifndef MVM_SWITCH
+#define MVM_SWITCH(tag, upper) switch (tag)
+#endif
+
+#ifndef MVM_CASE
+#define MVM_CASE(value) case value
+#endif
+
+#ifndef MVM_INCLUDE_SNAPSHOT_CAPABILITY
+#define MVM_INCLUDE_SNAPSHOT_CAPABILITY 1
+#endif
+
+
+
+#define MVM_NEED_DEFAULT_CRC_FUNC 0
+
+#if MVM_INCLUDE_SNAPSHOT_CAPABILITY
+
+  #ifndef MVM_CALC_CRC16_CCITT
+    #define MVM_CALC_CRC16_CCITT(pData, size) (mvm_default_crc16(pData, size))
+    #undef MVM_NEED_DEFAULT_CRC_FUNC
+    #define MVM_NEED_DEFAULT_CRC_FUNC 1
+  #endif
+
+#endif // MVM_INCLUDE_SNAPSHOT_CAPABILITY
+
+#ifndef MVM_CHECK_CRC16_CCITT
+  #define MVM_CHECK_CRC16_CCITT(lpData, size, expected) (mvm_default_crc16(lpData, size) == expected)
+  #undef MVM_NEED_DEFAULT_CRC_FUNC
+  #define MVM_NEED_DEFAULT_CRC_FUNC 1
+#endif
+
+#if MVM_NEED_DEFAULT_CRC_FUNC
+  static uint16_t mvm_default_crc16(MVM_LONG_PTR_TYPE lp, uint16_t size) {
+    uint16_t r = 0xFFFF;
+    while (size--)
+    {
+      r  = (uint8_t)(r >> 8) | (r << 8);
+      r ^= MVM_READ_LONG_PTR_1(lp);
+      lp = MVM_LONG_PTR_ADD(lp, 1);
+      r ^= (uint8_t)(r & 0xff) >> 4;
+      r ^= (r << 8) << 4;
+      r ^= ((r & 0xff) << 4) << 1;
+    }
+    return r;
+  }
+#endif // MVM_NEED_DEFAULT_CRC_FUNC
+
+#ifndef MVM_USE_SINGLE_RAM_PAGE
+#define MVM_USE_SINGLE_RAM_PAGE 0
+#endif
+
+#if MVM_USE_SINGLE_RAM_PAGE
+  #ifndef MVM_RAM_PAGE_ADDR
+  #define MVM_RAM_PAGE_ADDR 0x12340000
+  #endif
+#endif
+
+#ifndef MVM_MALLOC
+#define MVM_MALLOC(size) malloc(size)
+#endif
+
+#ifndef MVM_FREE
+#define MVM_FREE(p) free(p)
+#endif
+
+#ifndef MVM_CONTEXTUAL_MALLOC
+#define MVM_CONTEXTUAL_MALLOC(size, context) MVM_MALLOC(size)
+#endif
+
+#ifndef MVM_CONTEXTUAL_FREE
+#define MVM_CONTEXTUAL_FREE(p, context) MVM_FREE(p)
+#endif
+
+// "Hidden" functions cover some internal functions that can be optionally
+// exposed by overriding MVM_HIDDEN. This is used by the WASM library to gain
+// lower-level access to some internals for the purpose of efficiency. These are
+// still undocumented implementation details and may change at any time, but
+// dependents like the WASM library are updated in sync with the engine.
+#if MVM_EXPOSE_INTERNALS
+#define MVM_HIDDEN
+#else
+#define MVM_HIDDEN static
+#endif
+
+// This function might be unused if the user has overridden the
+// MVM_CHECK_CRC16_CCITT macro.
+#ifdef __GNUC__
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wunused-function"
+#endif
+static uint16_t default_crc16(MVM_LONG_PTR_TYPE lp, uint16_t size) {
+  uint16_t r = 0xFFFF;
+  while (size--)
+  {
+    r  = (uint8_t)(r >> 8) | (r << 8);
+    r ^= MVM_READ_LONG_PTR_1(lp);
+    lp = MVM_LONG_PTR_ADD(lp, 1);
+    r ^= (uint8_t)(r & 0xff) >> 4;
+    r ^= (r << 8) << 4;
+    r ^= ((r & 0xff) << 4) << 1;
+  }
+  return r;
+}
+#ifdef __GNUC__
+#pragma GCC diagnostic pop
+#endif
+
+// ---------------------------------------------------------------------------
+
+typedef mvm_VM VM;
+typedef mvm_TeError TeError;
+
+#ifndef MVM_IMPORT_MATH
+// By default, import math.h if floating point is used. But user can override
+// this if they want to provide non-math.h implementations
+#define MVM_IMPORT_MATH MVM_SUPPORT_FLOAT
+#endif
+
+#ifndef MVM_FLOAT_IS_NAN
+#define MVM_FLOAT_IS_NAN(x) isnan(x)
+#endif
+
+#ifndef MVM_FLOAT_IS_NEG_ZERO
+// Note: VisualC++ (and maybe other compilers) seem to have `0.0==-0.0` evaluate
+// to true, which is why there's the second check here
+#define MVM_FLOAT_IS_NEG_ZERO(x) ((x == -0.0) && (signbit(x) != 0))
+#endif
+
+#ifndef MVM_FLOAT_IS_FINITE
+#define MVM_FLOAT_IS_FINITE(x) isfinite(x)
+#endif
+
+#ifndef MVM_FLOAT_NEG_ZERO
+#define MVM_FLOAT_NEG_ZERO (-0.0)
+#endif
+
+#ifndef MVM_SNPRINTF
+#define MVM_SNPRINTF snprintf
+#endif
+
+#ifndef MVM_MALLOC
+#define MVM_MALLOC malloc
+#endif
+
+#ifndef MVM_FREE
+#define MVM_FREE free
+#endif
+
+/**
+ * mvm_Value
+ *
+ * Hungarian prefix: v
+ *
+ * Internally, the name `Value` refers to `mvm_Value`
+ *
+ * The Microvium Value type is 16 bits with a 1 or 2 bit discriminator in the
+ * lowest bits:
+ *
+ *  - If the lowest bit is `0`, interpret the value as a `ShortPtr`. Note that
+ *    in a snapshot bytecode file, a ShortPtr is measured relative to the
+ *    beginning of the RAM section of the file.
+ *  - If the lowest bits are `11`, interpret the high 14-bits as a signed 14 bit
+ *    integer. The Value is an `VirtualInt14`
+ *  - If the lowest bits are `01`, interpret the high 15-bits as a
+ *    `BytecodeMappedPtr` or a well-known value.
+ */
+typedef mvm_Value Value;
+
+static inline bool Value_isShortPtr(Value value) { return (value & 1) == 0; }
+static inline bool Value_isBytecodeMappedPtrOrWellKnown(Value value) { return (value & 3) == 1; }
+static inline bool Value_isVirtualInt14(Value value) { return (value & 3) == 3; }
+static inline bool Value_isVirtualUInt12(Value value) { return (value & 0xC003) == 3; }
+static inline bool Value_isVirtualUInt8(Value value) { return (value & 0xFC03) == 3; }
+
+/**
+ * ShortPtr
+ *
+ * Hungarian prefix: sp
+ *
+ * A ShortPtr is a 16-bit **non-nullable** reference which references into GC
+ * memory, but not to data memory or bytecode.
+ *
+ * Note: To avoid confusion of when to use different kinds of null values,
+ * ShortPtr should be considered non-nullable. When null is required, use
+ * VM_VALUE_NULL for consistency, which is not defined as a short pointer.
+ *
+ * The GC assumes that anything with a low bit 0 is a non-null pointer into GC
+ * memory (it does not do null checking on these, since this is a hot loop).
+ *
+ * Note: At runtime, pointers _to_ GC memory must always be encoded as
+ * `ShortPtr` or indirectly through a BytecodeMappedPtr to a global variable.
+ * This is because the GC assumes (for efficiency reasons) only values with the
+ * lower bit `0` need to be traced/moved.
+ *
+ * A ShortPtr is interpreted one of 3 ways depending on the context:
+ *
+ *   1. On 16-bit architectures (when MVM_NATIVE_POINTER_IS_16_BIT is set),
+ *      while the script is running, ShortPtr can be a native pointer, allowing
+ *      for fast access. On other architectures, ShortPtr is encoded as an
+ *      offset from the beginning of the virtual heap.
+ *
+ *   2. On non-16-bit architectures (when MVM_NATIVE_POINTER_IS_16_BIT is not
+ *      set), ShortPtr is an offset into the allocation buckets. Access is
+ *      linear time to the number of buckets, but the buckets are compacted
+ *      together during a GC cycle so the number should typically be 1 or low.
+ *
+ *   3. In the hibernating GC heap, in the snapshot, ShortPtr is treated as an
+ *      offset into the bytecode image, but always an offset back into the
+ *      GC-RAM section. See `loadPointers`
+ *
+ * TODO: Rather than just MVM_NATIVE_POINTER_IS_16_BIT, we could better serve
+ * small 32-bit devices by having a "page" #define that is added to ShortPtr to
+ * get the real address. This is because on ARM architectures, the RAM pointers
+ * are mapped to a higher address space.
+ *
+ * A ShortPtr must never exist in a ROM slot, since they need to have a
+ * consistent representation in all cases, and ROM slots are not visited by
+ * `loadPointers`. Also short pointers are used iff they point to GC memory,
+ * which is subject to relocation and therefore cannot be referenced from an
+ * immutable medium.
+ *
+ * If the lowest bit of the `ShortPtr` is 0 (i.e. points to an even boundary),
+ * then the `ShortPtr` is also a valid `Value`.
+ *
+ * NULL short pointers are only allowed in some special circumstances, but are
+ * mostly not valid.
+ */
+typedef uint16_t ShortPtr;
+
+/**
+ * Bytecode-mapped Pointer
+ *
+ * If `b` is a BytecodeMappedPtr then `b & 0xFFFE` is treated as an offset into
+ * the bytecode address space, and its meaning depends on where in the bytecode
+ * image it points:
+ *
+ *
+ * 1. If the offset points to the BCS_ROM section of bytecode, it is interpreted
+ *    as pointing to that ROM allocation or function.
+ *
+ * 2. If the offset points to the BCS_GLOBALS region of the bytecode image, the
+ *    `BytecodeMappedPtr` is treated being a reference to the allocation
+ *    referenced by the corresponding global variable.
+ *
+ * This allows ROM Values, such as literal, exports, and builtins, to reference
+ * RAM allocations. *Note*: for the moment, behavior is not defined if the
+ * corresponding global has non-pointer contents, such as an Int14 or well-known
+ * value. In future this may be explicitly allowed.
+ *
+ * A `BytecodeMappedPtr` is only a pointer type and is not defined to encode the
+ * well-known values or null.
+ *
+ * Note that in practice, BytecodeMappedPtr is not used anywhere except in
+ * decoding DynamicPtr.
+ *
+ * See `BytecodeMappedPtr_decode_long`
+ */
+typedef uint16_t BytecodeMappedPtr;
+
+/**
+ * Dynamic Pointer
+ *
+ * Hungarian prefix: `dp`
+ *
+ * A `Value` that is a pointer. I.e. its lowest bits are not `11` and it does
+ * not encode a well-known value. Can be one of:
+ *
+ *  - `ShortPtr`
+ *  - `BytecodeMappedPtr`
+ *  - `VM_VALUE_NULL`
+ *
+ * Note that the only valid representation of null for this point is
+ * `VM_VALUE_NULL`, not 0.
+ */
+typedef Value DynamicPtr;
+
+/**
+ * ROM Pointer
+ *
+ * Hungarian prefix: none
+ *
+ * A `DynamicPtr` which is known to only point to ROM
+ */
+typedef Value RomPtr;
+
+/**
+ * Int14 encoded as a Value
+ *
+ * Hungarian prefix: `vi`
+ *
+ * A 14-bit signed integer represented in the high 14 bits of a 16-bit Value,
+ * with the low 2 bits set to the bits `11`, as per the `Value` type.
+ */
+typedef Value VirtualInt14;
+
+/**
+ * Hungarian prefix: `lp`
+ *
+ * A nullable-pointer that can reference bytecode and RAM in the same address
+ * space. Not necessarily 16-bit.
+ *
+ * The null representation for LongPtr is assumed to be 0.
+ *
+ * Values of this type are only managed through macros in the port file, never
+ * directly, since the exact type depends on the architecture.
+ *
+ * See description of MVM_LONG_PTR_TYPE
+ */
+typedef MVM_LONG_PTR_TYPE LongPtr;
+
+#define READ_FIELD_2(longPtr, structType, fieldName) \
+  LongPtr_read2_aligned(LongPtr_add(longPtr, OFFSETOF(structType, fieldName)))
+
+#define READ_FIELD_1(longPtr, structType, fieldName) \
+  LongPtr_read1(LongPtr_add(longPtr, OFFSETOF(structType, fieldName)))
+
+// NOTE: In no way are assertions meant to be present in production. They're
+// littered everywhere on the assumption that they consume no overhead.
+#if MVM_SAFE_MODE
+  #define VM_ASSERT(vm, predicate) do { if (!(predicate)) MVM_FATAL_ERROR(vm, MVM_E_ASSERTION_FAILED); } while (false)
+#else
+  #define VM_ASSERT(vm, predicate)
+#endif
+
+#ifndef __has_builtin
+  #define __has_builtin(x) 0
+#endif
+
+// Offset of field in a struct
+#define OFFSETOF(TYPE, ELEMENT) ((uint16_t)(uintptr_t)&(((TYPE *)0)->ELEMENT))
+
+// Maximum size of an allocation (4kB)
+#define MAX_ALLOCATION_SIZE 0xFFF
+
+// This is the only valid way of representing NaN
+#define VM_IS_NAN(v) ((v) == VM_VALUE_NAN)
+// This is the only valid way of representing infinity
+#define VM_IS_INF(v) ((v) == VM_VALUE_INF)
+// This is the only valid way of representing -infinity
+#define VM_IS_NEG_INF(v) ((v) == VM_VALUE_NEG_INF)
+// This is the only valid way of representing negative zero
+#define VM_IS_NEG_ZERO(v) ((v) == VM_VALUE_NEG_ZERO)
+
+#define VM_NOT_IMPLEMENTED(vm) MVM_FATAL_ERROR(vm, MVM_E_NOT_IMPLEMENTED)
+#define VM_RESERVED(vm) MVM_FATAL_ERROR(vm, MVM_E_UNEXPECTED)
+
+// An error corresponding to an internal inconsistency in the VM. Such an error
+// cannot be caused by incorrect usage of the VM. In safe mode, this function
+// should terminate the application. If not in safe mode, it is assumed that
+// this function will never be invoked.
+#define VM_UNEXPECTED_INTERNAL_ERROR(vm) (MVM_FATAL_ERROR(vm, MVM_E_UNEXPECTED), -1)
+
+#define VM_VALUE_OF_DYNAMIC(v) ((void*)((TsAllocationHeader*)v + 1))
+#define VM_DYNAMIC_TYPE(v) (((TsAllocationHeader*)v)->type)
+
+#define VM_MAX_INT14 0x1FFF
+#define VM_MIN_INT14 (-0x2000)
+
+#if MVM_SAFE_MODE
+#define VM_EXEC_SAFE_MODE(code) code
+#define VM_SAFE_CHECK_NOT_NULL(v) do { if ((v) == NULL) return MVM_E_UNEXPECTED; } while (false)
+#define VM_SAFE_CHECK_NOT_NULL_2(v) do { if ((v) == NULL) { MVM_FATAL_ERROR(vm, MVM_E_UNEXPECTED); return NULL; } } while (false)
+#define VM_ASSERT_UNREACHABLE(vm) MVM_FATAL_ERROR(vm, MVM_E_UNEXPECTED)
+#else
+#define VM_EXEC_SAFE_MODE(code)
+#define VM_SAFE_CHECK_NOT_NULL(v)
+#define VM_SAFE_CHECK_NOT_NULL_2(v)
+#define VM_ASSERT_UNREACHABLE(vm)
+#endif
+
+#if MVM_DONT_TRUST_BYTECODE || MVM_SAFE_MODE
+// TODO: I think I need to do an audit of all the assertions and errors in the code, and make sure they're categorized correctly as bytecode errors or not
+#define VM_INVALID_BYTECODE(vm) MVM_FATAL_ERROR(vm, MVM_E_INVALID_BYTECODE)
+#define VM_BYTECODE_ASSERT(vm, condition) do { if (!(condition)) VM_INVALID_BYTECODE(vm); } while (false)
+#else
+#define VM_INVALID_BYTECODE(vm)
+#define VM_BYTECODE_ASSERT(vm, condition)
+#endif
+
+#ifndef CODE_COVERAGE
+/*
+ * A set of macros for manual code coverage analysis (because the off-the-shelf
+ * tools appear to be quite expensive). This should be overridden in the port
+ * file for the unit tests. Each instance of this macro should occur on its own
+ * line. The unit tests can dumbly scan the source text for instances of this
+ * macro to establish what code paths _should_ be hit. Each instance should have
+ * its own unique numeric ID.
+ *
+ * If the ID is omitted or a non-integer placeholder (e.g. "x"), the script `npm
+ * run update-coverage-markers` will fill in a valid ID.
+ *
+ * Explicit IDs are used instead of line numbers because a previous analysis
+ * remains roughly correct even after the code has changed.
+ */
+#define CODE_COVERAGE(id)
+#define CODE_COVERAGE_UNTESTED(id)
+#define CODE_COVERAGE_UNIMPLEMENTED(id)
+#define CODE_COVERAGE_ERROR_PATH(id)
+
+/**
+ * In addition to recording code coverage, it's useful to have information about
+ * the coverage information for table entries. Code and tables can be
+ * alternative representations of the same thing. For example, a lookup table
+ * can be represented as a switch statement. However, only the switch statement
+ * form typically shows up in code coverage analysis. With Microvium coverage
+ * analysis, tables are covered as well.
+ *
+ * If the ID is omitted or a non-integer placeholder (e.g. "x"), the script `npm
+ * run update-coverage-markers` will fill in a valid ID.
+ *
+ * @param indexInTable The runtime expression for the case that is actually hit.
+ * @param tableSize The size of the table (can be a runtime expression)
+ * @param id A unique numeric ID to uniquely identify the marker
+ */
+#define TABLE_COVERAGE(indexInTable, tableSize, id)
+#endif
+
+#ifndef MVM_SUPPORT_FLOAT
+#define MVM_SUPPORT_FLOAT 1
+#endif
+
+#ifndef MVM_PORT_INT32_OVERFLOW_CHECKS
+#define MVM_PORT_INT32_OVERFLOW_CHECKS 1
+#endif
+
+#ifndef MVM_SAFE_MODE
+#define MVM_SAFE_MODE 0
+#endif
+
+// TODO: The example port file sets to 1 because we want it enabled in the
+// tests. But really we should have a separate test port file.
+#ifndef MVM_VERY_EXPENSIVE_MEMORY_CHECKS
+#define MVM_VERY_EXPENSIVE_MEMORY_CHECKS 0
+#endif
+
+#ifndef MVM_DONT_TRUST_BYTECODE
+#define MVM_DONT_TRUST_BYTECODE 0
+#endif
+
+#ifndef MVM_SWITCH
+#define MVM_SWITCH(tag, upper) switch (tag)
+#endif
+
+#ifndef MVM_CASE
+#define MVM_CASE(value) case value
+#endif
+
+#ifndef MVM_DEBUG_UTILS
+#define MVM_DEBUG_UTILS 0
+#endif
+
+// Allocation headers on functions are different. Nothing needs the allocation
+// size specifically, so the 12 size bits are repurposed.
+/** Flag bit to indicate continuation vs normal func. (1 = continuation) */
+#define VM_FUNCTION_HEADER_CONTINUATION_FLAG 0x0800
+/** (Continuations only) Mask of number of quad-words that the continuation is
+ * behind its containing function */
+#define VM_FUNCTION_HEADER_BACK_POINTER_MASK 0x07FF
+/** (Normal funcs only) Mask of required stack height in words */
+#define VM_FUNCTION_HEADER_STACK_HEIGHT_MASK 0x00FF
+
+// Minimum number of items to have in an array when expanding it
+#define VM_ARRAY_INITIAL_CAPACITY 4
+
+/**
+ * Type code indicating the type of data.
+ *
+ * This enumeration is divided into reference types (TC_REF_) and value types
+ * (TC_VAL_). Reference type codes are used on allocations, whereas value type
+ * codes are never used on allocations. The space for the type code in the
+ * allocation header is 4 bits, so there are up to 16 reference types and these
+ * must be the first 16 types in the enumeration.
+ *
+ * The reference type range is subdivided into containers or non-containers. The
+ * GC uses this distinction to decide whether the body of the allocation should
+ * be interpreted as `Value`s (i.e. may contain pointers). To minimize the code,
+ * either ALL words in a container are `Value`s, or none.
+ *
+ * Value types are for the values that can be represented within the 16-bit
+ * mvm_Value without interpreting it as a pointer.
+ */
+typedef enum TeTypeCode {
+  // Note: only type code values in the range 0-15 can be used as the types for
+  // allocations, since the allocation header allows 4 bits for the type. Types
+  // 0-8 are non-container types, 0xC-F are container types (9-B reserved).
+  // Every word in a container must be a `Value`. No words in a non-container
+  // can be a `Value` (the GC uses this to distinguish whether an allocation may
+  // contain pointers, and the signature of each word). Note that buffer-like
+  // types would not count as containers by this definition.
+
+  /* --------------------------- Reference types --------------------------- */
+
+  // A type used during garbage collection. Allocations of this type have a
+  // single 16-bit forwarding pointer in the allocation.
+  TC_REF_TOMBSTONE          = 0x0,
+
+  TC_REF_INT32              = 0x1, // 32-bit signed integer
+  TC_REF_FLOAT64            = 0x2, // 64-bit float
+
+  /**
+   * UTF8-encoded string that may or may not be unique.
+   *
+   * Note: If a TC_REF_STRING is in bytecode, it is because it encodes a value
+   * that is illegal as a property index in Microvium (i.e. it encodes an
+   * integer).
+   */
+  TC_REF_STRING             = 0x3,
+
+  /**
+   * TC_REF_INTERNED_STRING
+   *
+   * A string whose address uniquely identifies its contents, and does not
+   * encode an integer in the range 0 to 0x1FFF.
+   *
+   * To keep property lookup efficient, Microvium requires that strings used as
+   * property keys can be compared using pointer equality. This requires that
+   * there is only one instance of each of those strings (see
+   * https://en.wikipedia.org/wiki/String_interning).
+   *
+   * A string with the type code TC_REF_INTERNED_STRING means that it exists in
+   * one of the interning tables (either the one in ROM or the one in RAM). Not
+   * all strings are interned, because it would be expensive if every string
+   * concatenation resulted in a search of the intern table and possibly a new
+   * entry (imagine if every JSON string landed up in the table!).
+   *
+   * In practice we do this:
+   *
+   *  - All valid non-index property keys in ROM are interned. If a string is in
+   *    ROM but it is not interned, the engine can conclude that it is not a
+   *    valid property key or it is an index.
+   *  - Strings constructed in RAM are only interned when they're used to access
+   *    properties.
+   */
+  TC_REF_INTERNED_STRING    = 0x4,
+
+  TC_REF_FUNCTION           = 0x5, // TsBytecodeFunc
+  TC_REF_HOST_FUNC          = 0x6, // TsHostFunc
+
+  TC_REF_UINT8_ARRAY        = 0x7, // Byte buffer
+  TC_REF_SYMBOL             = 0x8, // Reserved: Symbol
+
+  /* --------------------------- Container types --------------------------- */
+  TC_REF_DIVIDER_CONTAINER_TYPES,  // <--- Marker. Types after or including this point but less than 0x10 are container types
+
+  TC_REF_CLASS              = 0x9, // TsClass
+  TC_REF_VIRTUAL            = 0xA, // Reserved: TsVirtual
+  TC_REF_RESERVED_1         = 0xB, // Reserved
+  TC_REF_PROPERTY_LIST      = 0xC, // TsPropertyList - Object represented as linked list of properties
+  TC_REF_ARRAY              = 0xD, // TsArray
+  TC_REF_FIXED_LENGTH_ARRAY = 0xE, // TsFixedLengthArray
+  TC_REF_CLOSURE            = 0xF, // TsClosure (see description on struct)
+
+  /* ----------------------------- Value types ----------------------------- */
+  TC_VAL_INT14              = 0x10,
+
+  TC_VAL_UNDEFINED          = 0x11,
+  TC_VAL_NULL               = 0x12,
+  TC_VAL_TRUE               = 0x13,
+  TC_VAL_FALSE              = 0x14,
+  TC_VAL_NAN                = 0x15,
+  TC_VAL_NEG_ZERO           = 0x16,
+  TC_VAL_DELETED            = 0x17, // Placeholder for properties and list items that have been deleted or holes in arrays
+  TC_VAL_STR_LENGTH         = 0x18, // The string "length"
+  TC_VAL_STR_PROTO          = 0x19, // The string "__proto__"
+
+  /**
+   * TC_VAL_NO_OP_FUNC
+   *
+   * Represents a function that does nothing and returns undefined.
+   *
+   * This is required by async-await for the case where you void-call an async
+   * function and it needs to synthesize a dummy callback that does nothing,
+   * particularly for a host async function to call back.
+   */
+  TC_VAL_NO_OP_FUNC         = 0x1A,
+
+  TC_END,
+} TeTypeCode;
+
+// Note: VM_VALUE_NAN must be used instead of a pointer to a double that has a
+// NaN value (i.e. the values must be normalized to use the following table).
+// Operations will assume this canonical form.
+
+// Note: the `(... << 2) | 1` is so that these values don't overlap with the
+// ShortPtr or BytecodeMappedPtr address spaces.
+
+// Indexes of internal slots for objects. Note that even-valued indexed slots
+// can only hold negative int14 values since those slots are overloading
+// property key slots. The slot indexes can only start at 2 because the first 2
+// "slots" are the dpNext and dpProto of TsPropertyList.
+typedef enum vm_TeObjectInternalSlots {
+  VM_OIS_NEXT = 0,
+  VM_OIS_PROTO = 1,
+
+  VM_OIS_PROTO_SLOT_MAGIC_KEY = 2,
+  VM_OIS_PROTO_SLOT_COUNT = 3,
+
+  VM_OIS_PROMISE_STATUS = 2, // vm_TePromiseStatus
+  VM_OIS_PROMISE_OUT = 3, // Result if resolved, error if rejected, undefined or subscriber or subscriber-list if pending
+} vm_TeObjectInternalSlots;
+
+#define VIRTUAL_INT14_ENCODE(i) ((uint16_t)(((unsigned int)(i) << 2) | 3))
+
+// Note: these values must be negative int14 values
+typedef enum vm_TePromiseStatus {
+  VM_PROMISE_STATUS_PENDING = VIRTUAL_INT14_ENCODE(-1),
+  VM_PROMISE_STATUS_RESOLVED = VIRTUAL_INT14_ENCODE(-2),
+  VM_PROMISE_STATUS_REJECTED = VIRTUAL_INT14_ENCODE(-3),
+} vm_TePromiseStatus;
+
+#define VM_PROTO_SLOT_MAGIC_KEY_VALUE VIRTUAL_INT14_ENCODE(-0x2000)
+
+// Some well-known values
+typedef enum vm_TeWellKnownValues {
+  // Note: well-known values share the bytecode address space, so we can't have
+  // too many here before user-defined allocations start to become unreachable.
+  // The first addressable user allocation in a bytecode image is around address
+  // 0x2C (measured empirically -- see test `1.empty-export`) if the image has
+  // one export and one string in the string table, which means the largest
+  // well-known-value can be the prior address `0x2C-4=0x28` (encoded as a
+  // bytecode pointer will be 0x29), corresponding to type-code 0x1B.
+
+  VM_VALUE_UNDEFINED     = (((int)TC_VAL_UNDEFINED - 0x11) << 2) | 1, // = 1
+  VM_VALUE_NULL          = (((int)TC_VAL_NULL - 0x11) << 2) | 1,
+  VM_VALUE_TRUE          = (((int)TC_VAL_TRUE - 0x11) << 2) | 1,
+  VM_VALUE_FALSE         = (((int)TC_VAL_FALSE - 0x11) << 2) | 1,
+  VM_VALUE_NAN           = (((int)TC_VAL_NAN - 0x11) << 2) | 1,
+  VM_VALUE_NEG_ZERO      = (((int)TC_VAL_NEG_ZERO - 0x11) << 2) | 1,
+  VM_VALUE_DELETED       = (((int)TC_VAL_DELETED - 0x11) << 2) | 1,
+  VM_VALUE_STR_LENGTH    = (((int)TC_VAL_STR_LENGTH - 0x11) << 2) | 1,
+  VM_VALUE_STR_PROTO     = (((int)TC_VAL_STR_PROTO - 0x11) << 2) | 1,
+  VM_VALUE_NO_OP_FUNC    = (((int)TC_VAL_NO_OP_FUNC - 0x11) << 2) | 1,
+
+  VM_VALUE_WELLKNOWN_END,
+} vm_TeWellKnownValues;
+
+typedef struct TsArray {
+ /*
+  * Note: the capacity of the array is the length of the TsFixedLengthArray
+  * pointed to by dpData, or 0 if dpData is VM_VALUE_NULL. The logical length
+  * of the array is determined by viLength.
+  *
+  * Note: If dpData is not null, it must be a unique pointer (it must be the
+  * only pointer that points to that allocation)
+  *
+  * Note: for arrays in GC memory, their dpData must point to GC memory as well
+  *
+  * Note: Values in dpData that are beyond the logical length MUST be filled
+  * with VM_VALUE_DELETED.
+  */
+
+  DynamicPtr dpData; // Points to TsFixedLengthArray or VM_VALUE_NULL
+  VirtualInt14 viLength;
+} TsArray;
+
+typedef struct TsFixedLengthArray {
+  // Note: the length of the fixed-length-array is determined by the allocation header
+  Value items[1];
+} TsFixedLengthArray;
+
+typedef struct vm_TsStack vm_TsStack;
+
+/**
+ * Used to represent JavaScript objects.
+ *
+ * The `proto` pointer points to the prototype of the object.
+ *
+ * Properties on object are stored in a linked list of groups. Each group has a
+ * `next` pointer to the next group (list). When assigning to a new property,
+ * rather than resizing a group, the VM will just append a new group to the list
+ * (a group with just the one new property).
+ *
+ * Only the `proto` field of the first group of properties in an object is used.
+ *
+ * The garbage collector compacts multiple groups into one large one, so it
+ * doesn't matter that appending a single property requires a whole new group on
+ * its own or that they have unused proto properties.
+ *
+ * Note: at one stage, I thought that objects could be treated like arrays and
+ * just expand geometrically rather than as linked lists. This would work, but
+ * then like dynamic arrays they would need to be 2 allocations instead of 1
+ * because we can't find all the references to the object each time it grows.
+ *
+ * Something I've thought of, but not considered too deeply yet, is the
+ * possibility of implementing objects in terms of dynamic arrays, to reuse the
+ * machinery of dynamic arrays in terms of growing and compacting. This could
+ * potentially make the engine smaller.
+ */
+typedef struct TsPropertyList {
+  // Note: if the property list is in GC memory, then dpNext must also point to
+  // GC memory, but dpProto can point to any memory (e.g. a prototype stored in
+  // ROM).
+
+  // Note: in the serialized form, the next pointer must be null
+  DynamicPtr dpNext; // TsPropertyList* or VM_VALUE_NULL, containing further appended properties
+  DynamicPtr dpProto; // Note: the prototype is only meaningful on the first in the list
+  /*
+  Followed by N of these pairs to the end of the allocated size:
+    Value key; // TC_VAL_INT14 or TC_REF_INTERNED_STRING
+    Value value;
+   */
+} TsPropertyList;
+
+/**
+ * A property list with a single property. See TsPropertyList for description.
+ */
+typedef struct TsPropertyCell /* extends TsPropertyList */ {
+  TsPropertyList base;
+  Value key; // TC_VAL_INT14 or TC_REF_INTERNED_STRING
+  Value value;
+} TsPropertyCell;
+
+/**
+ * A TsClosure (TC_REF_CLOSURE) is a function-like (callable) container that is
+ * overloaded to represent both closures and/or their variable environments.
+ *
+ * See also [closures](../doc/internals/closures.md)
+ *
+ * The first and last slots in a closure are special:
+ *
+ *   1. The first slot is the function `target`. If a CALL operation is executed
+ *      on a closure then the call is delegated to the function in the first
+ *      slot. It's permissable to use this slot for other purposes if the
+ *      closure will never be called.
+ *
+ *   2. The last slot is the `parentScope`. If the index provided to
+ *      `LoadScoped` or `StoreScoped` overflow the current closure then they
+ *      automatically index into the parent scope, recursively up the chain.
+ *      It's permissible to use this slot for custom purposes if the bytecode
+ *      will not try to access variables from a parent scope.
+ *
+ * The minimum closure size is 1 slot. This could happen if neither the function
+ * slot nor parent slot are used, and the scope contains a single variable.
+ *
+ * The bytecode instructions LoadScoped and StoreScoped write to the slots of
+ * the _current closure_ (TsRegisters.closure).
+ *
+ * The instruction VM_OP1_CLOSURE_NEW creates a closure with exactly 2 slots,
+ * where the first second is populated from the current closure.
+ *
+ * The instruction VM_OP1_SCOPE_PUSH creates a closure with any number of slots
+ * and no function pointer, and sets it as the current closure. From there, the
+ * IL can set it's own function pointer using `StoreScoped`.
+ */
+typedef struct TsClosure {
+  Value target; // function
+  /* followed optionally by other variables, and finally by a pointer to the
+  parent scope if needed */
+} TsClosure;
+
+/**
+ * This type is to provide support for a subset of the ECMAScript classes
+ * feature. Classes can be instantiated using `new`, but it is illegal to call
+ * them directly. Similarly, `new` doesn't work on arbitrary function.
+ */
+typedef struct TsClass {
+  Value constructorFunc; // Function type
+  Value staticProps;
+} TsClass;
+
+/**
+ * TsVirtual (at the time of this writing, this is just a placeholder type)
+ *
+ * This is a placeholder for an idea to have something like a "low-level proxy"
+ * type. See my private notes for details (if you have access to them). The
+ * `type` and `state` fields correspond roughly to the "handler" and "target"
+ * fields respectively in a normal ES `Proxy`.
+ */
+typedef struct TsVirtual {
+  Value state;
+  Value type;
+} TsVirtual;
+
+// External function by index in import table
+typedef struct TsHostFunc {
+  // Note: TC_REF_HOST_FUNC is not a container type, so it's fields are not
+  // traced by the GC.
+  //
+  // Note: most host function reference can be optimized to not require this
+  // allocation -- they can use VM_OP2_CALL_HOST directly. This allocation is
+  // only required then the reference to host function is ambiguous or there are
+  // calls to more than 256 host functions.
+  uint16_t indexInImportTable;
+} TsHostFunc;
+
+typedef struct TsBucket {
+  uint16_t offsetStart; // The number of bytes in the heap before this bucket
+  struct TsBucket* prev;
+  struct TsBucket* next;
+  /* Note: pEndOfUsedSpace used to be on the VM struct, rather than per-bucket.
+   * The main reason it's useful to have it on each bucket is in the hot GC-loop
+   * which needs to check if it's caught up with the write cursor in to-space or
+   * check if it's hit the end of the bucket. Without this value being in each
+   * bucket, the calculation to find the end of the bucket is expensive.
+   *
+   * Note that for the last bucket, `pEndOfUsedSpace` doubles up as the write
+   * cursor, since it's only recording the *used* space. The *capacity* of each
+   * bucket is not recorded, but the capacity of the *last* bucket is recorded
+   * in `pLastBucketEndCapacity` (on the VM and GC structures).  */
+  uint16_t* pEndOfUsedSpace;
+
+  /* ...data */
+} TsBucket;
+
+typedef struct TsBreakpoint {
+  struct TsBreakpoint* next;
+  int bytecodeAddress;
+} TsBreakpoint;
+
+/*
+  Minimum size:
+    - 6 pointers + 1 long pointer + 4 words
+    - = 24B on 16bit
+    - = 36B on 32bit.
+
+  Maximum size (on 64-bit machine):
+    - 9 pointers + 4 words
+    - = 80 bytes on 64-bit machine
+
+  See also the unit tests called "minimal-size"
+
+*/
+struct mvm_VM {
+  uint16_t* globals;
+  LongPtr lpBytecode;
+  vm_TsStack* stack;
+
+  // Last bucket of GC memory
+  TsBucket* pLastBucket;
+  // End of the capacity of the last bucket of GC memory
+  uint16_t* pLastBucketEndCapacity;
+  // Handles - values to treat as GC roots
+  mvm_Handle* gc_handles;
+
+  void* context;
+
+  #if MVM_INCLUDE_DEBUG_CAPABILITY
+  TsBreakpoint* pBreakpoints;
+  mvm_TfBreakpointCallback breakpointCallback;
+  #endif // MVM_INCLUDE_DEBUG_CAPABILITY
+
+  #ifdef MVM_GAS_COUNTER
+  int32_t stopAfterNInstructions; // Set to -1 to disable
+  #endif // MVM_GAS_COUNTER
+
+  uint16_t heapSizeUsedAfterLastGC;
+  uint16_t stackHighWaterMark;
+  uint16_t heapHighWaterMark;
+
+  #if MVM_VERY_EXPENSIVE_MEMORY_CHECKS
+  // Amount to shift the heap over during each collection cycle
+  uint8_t gc_heap_shift;
+  #endif
+
+  #if MVM_SAFE_MODE
+  // A number that increments at every possible opportunity for a GC cycle
+  uint8_t gc_potentialCycleNumber;
+  #endif // MVM_SAFE_MODE
+};
+
+typedef struct TsInternedStringCell {
+  ShortPtr spNext;
+  Value str;
+} TsInternedStringCell;
+
+// Possible values for the `flags` machine register
+typedef enum vm_TeActivationFlags {
+  // This is not an activation flag, but I'm putting it in this enum because it
+  // shares the same bit space as the flags.
+  AF_ARG_COUNT_MASK = 0x7F,
+
+  // Note: these flags start at bit 8 because they use the same word as the
+  // argument count and the high byte is used for flags, with the exception of
+  // AF_VOID_CALLED which is in the first byte because the flag is bundled with
+  // the argument count during a call operation.
+
+  // Set to 1 in the current activation frame if the caller call site is a void
+  // call (does not use the response). Note: this flag is in the high bit of the
+  // first byte, unlike the other bits which are in the second byte. See above
+  // for description.
+  AF_VOID_CALLED = 1 << 7,
+
+  // Flag to indicate if the most-recent CALL operation involved a stack-based
+  // function target (as opposed to a literal function target). If this is set,
+  // then the next RETURN instruction will also pop the function reference off
+  // the stack.
+  AF_PUSHED_FUNCTION = 1 << 8,
+
+  // Flag to indicate that returning from the current frame should return to the host
+  AF_CALLED_FROM_HOST = 1 << 9,
+
+  // Only used by mvm_callEx to indicate that the `this` value is already on the stack
+  AF_OVERRIDE_THIS = 1 << 10,
+} vm_TeActivationFlags;
+
+/**
+ * This struct is malloc'd from the host when the host calls into the VM
+ */
+typedef struct vm_TsRegisters { // 28 B on 32-bit machine
+  uint16_t* pFrameBase;
+  uint16_t* pStackPointer;
+  LongPtr lpProgramCounter;
+  // Note: I previously used to infer the location of the arguments based on the
+  // number of values PUSHed by a CALL instruction to preserve the activation
+  // state (i.e. 3 words). But now that distance is dynamic, so we need and
+  // explicit register.
+  Value* pArgs;
+  uint16_t* pCatchTarget; // NULL if no catch block, otherwise points to catch block
+  uint16_t argCountAndFlags; // Lower 8 bits are argument count, upper 8 bits are vm_TeActivationFlags
+  Value closure; // Closure scope or VM_VALUE_UNDEFINED
+
+  /**
+   * Contains the asynchronous callback for the call of the current activation
+   * record.
+   *
+   * - VM_VALUE_UNDEFINED - Normal call (no callback)
+   * - VM_VALUE_DELETED - (poison value) value no longer holds the callback for
+   *   the current activation (value has been trashed or consumed)
+   * - Pointer to function - Directly after AsyncCall operation
+   */
+  Value cpsCallback;
+
+  /**
+   * The (promise) job queue, for scheduling async callbacks. One of 4 states:
+   *
+   *   - Unallocated (no registers) - no jobs
+   *   - `undefined` means there are no promise jobs enqueued. The reason not to
+   *     use `NULL` (0) is because this value is reachable by the garbage
+   *     collector and so making it a consistent JavaScript value makes sense.
+   *   - A function value: indicates there is only one job in the queue, and the
+   *     `jobQueue` register points directly to it.
+   *   - A fixed-length array of 3 values: a tuple of `[prev, job, next]` as a
+   *     doubly-linked list node. Except that instead of a list, it forms a
+   *     cycle, so that the back of the "list" can be reached in `O(1)` time as
+   *     as the `prev` of the first item, without needing a second register to
+   *     point to the back of the list.
+   */
+  Value jobQueue;
+
+  #if MVM_SAFE_MODE
+  // This will be true if the VM is operating on the local variables rather
+  // than the shared vm_TsRegisters structure.
+  uint8_t usingCachedRegisters;
+  uint8_t _reserved; // My compiler seems to pad this out anyway
+  #endif
+
+} vm_TsRegisters;
+
+/**
+ * This struct is malloc'd from the host when the host calls into the VM and
+ * freed when the VM finally returns to the host. This struct embeds both the
+ * working registers and the call stack in the same allocation since they are
+ * needed at the same time and it's more efficient to do a single malloc where
+ * possible.
+ */
+struct vm_TsStack {
+  // Allocate registers along with the stack, because these are needed at the same time (i.e. while the VM is active)
+  vm_TsRegisters reg;
+  // Note: the stack grows upwards (towards higher addresses)
+  // ... (stack memory) ...
+};
+
+typedef struct TsAllocationHeader {
+  /* 4 least-significant-bits are the type code (TeTypeCode). Remaining 12 bits
+  are the allocation size, excluding the size of the header itself, in bytes
+  (measured in bytes so that we can represent the length of strings exactly).
+  See also `vm_getAllocationSizeExcludingHeaderFromHeaderWord` */
+  uint16_t headerData;
+} TsAllocationHeader;
+
+typedef struct TsBytecodeFunc {
+  uint8_t maxStackDepth;
+  /* Followed by the bytecode bytes */
+} TsBytecodeFunc;
+
+typedef struct vm_TsImportTableEntry {
+  mvm_HostFunctionID hostFunctionID;
+  /*
+  Note: I considered having a `paramCount` field in the header since a common
+  scenario would be copying the arguments into the parameter slots. However,
+  most parameters are not actually mutated in a function, so the LOAD_ARG
+  instruction could just be used directly to get the parameter value (if the
+  optimizer can detect such cases).
+  */
+} vm_TsImportTableEntry;
+
+#define GC_TRACE_STACK_COUNT 20
+
+typedef struct gc_TsGCCollectionState {
+  VM* vm;
+  TsBucket* firstBucket;
+  TsBucket* lastBucket;
+  uint16_t* lastBucketEndCapacity;
+} gc_TsGCCollectionState;
+
+typedef struct mvm_TsCallStackFrame {
+  uint16_t programCounter;
+  Value* frameBase;
+  int frameDepth; // Number of variables
+  Value* args;
+  int argCount;
+  Value* closure;
+  int closureSlotCount;
+} mvm_TsCallStackFrame;
+
+typedef struct mvm_TsHeapAllocationInfo {
+  // Note: the short names are because they display better in the debugger
+  uint16_t a; // Lower 16-bits of address
+  TeTypeCode t; // type code
+  uint16_t s; // size
+  uint16_t offset; // What the address would be if serialized in a snapshot
+  Value* address;
+} mvm_TsHeapAllocationInfo;
+
+#define TOMBSTONE_HEADER ((TC_REF_TOMBSTONE << 12) | 2)
+
+// A CALL instruction saves the current registers to the stack. I'm calling this
+// the "frame boundary" since it is a fixed-size sequence of words that marks
+// the boundary between stack frames. The shape of this saved state is coupled
+// to a few different places in the engine, so I'm versioning it here in case I
+// need to make changes
+#define VM_FRAME_BOUNDARY_VERSION 2
+
+// The number of words between one call stack frame and the next (i.e. the
+// number of saved registers during a CALL)
+#define VM_FRAME_BOUNDARY_SAVE_SIZE_WORDS 4
+
+static inline mvm_HostFunctionID vm_getHostFunctionId(VM*vm, uint16_t hostFunctionIndex);
+static TeError vm_createStackAndRegisters(VM* vm);
+static TeError vm_requireStackSpace(VM* vm, uint16_t* pStackPointer, uint16_t sizeRequiredInWords);
+static Value vm_convertToString(VM* vm, Value value);
+static Value vm_concat(VM* vm, Value* left, Value* right);
+static TeTypeCode deepTypeOf(VM* vm, Value value);
+static bool vm_isString(VM* vm, Value value);
+static int32_t vm_readInt32(VM* vm, TeTypeCode type, Value value);
+static TeError vm_resolveExport(VM* vm, mvm_VMExportID id, Value* result);
+static inline mvm_TfHostFunction* vm_getResolvedImports(VM* vm);
+static void gc_createNextBucket(VM* vm, uint16_t bucketSize, uint16_t minBucketSize);
+static void gc_freeGCMemory(VM* vm);
+static Value vm_allocString(VM* vm, size_t sizeBytes, void** data);
+static TeError toPropertyName(VM* vm, Value* value);
+static void toInternedString(VM* vm, Value* pValue);
+static uint16_t vm_stringSizeUtf8(VM* vm, Value str);
+static bool vm_ramStringIsNonNegativeInteger(VM* vm, Value str);
+static TeError toInt32Internal(mvm_VM* vm, Value value, int32_t* out_result);
+static inline uint16_t vm_getAllocationSizeExcludingHeaderFromHeaderWord(uint16_t headerWord);
+static inline LongPtr LongPtr_add(LongPtr lp, int16_t offset);
+static inline uint16_t LongPtr_read2_aligned(LongPtr lp);
+static inline uint16_t LongPtr_read2_unaligned(LongPtr lp);
+static void memcpy_long(void* target, LongPtr source, size_t size);
+static void loadPointers(VM* vm, uint8_t* heapStart);
+static inline ShortPtr ShortPtr_encode(VM* vm, void* ptr);
+static inline uint8_t LongPtr_read1(LongPtr lp);
+static LongPtr DynamicPtr_decode_long(VM* vm, DynamicPtr ptr);
+static inline int16_t LongPtr_sub(LongPtr lp1, LongPtr lp2);
+static inline uint16_t readAllocationHeaderWord(void* pAllocation);
+static inline uint16_t readAllocationHeaderWord_long(LongPtr pAllocation);
+static inline void* mvm_allocateWithConstantHeader(VM* vm, uint16_t header, uint16_t sizeIncludingHeader);
+static inline uint16_t vm_makeHeaderWord(VM* vm, TeTypeCode tc, uint16_t size);
+static int memcmp_long(LongPtr p1, LongPtr p2, size_t size);
+static LongPtr getBytecodeSection(VM* vm, mvm_TeBytecodeSection id, LongPtr* out_end);
+static inline void* LongPtr_truncate(VM* vm, LongPtr lp);
+static inline LongPtr LongPtr_new(void* p);
+static inline uint16_t* getBottomOfStack(vm_TsStack* stack);
+static inline uint16_t* getTopOfStackSpace(vm_TsStack* stack);
+static inline void* getBucketDataBegin(TsBucket* bucket);
+static uint16_t getBucketOffsetEnd(TsBucket* bucket);
+static uint16_t getSectionSize(VM* vm, mvm_TeBytecodeSection section);
+static Value vm_intToStr(VM* vm, int32_t i);
+static Value vm_newStringFromCStrNT(VM* vm, const char* s);
+static TeError vm_validatePortFileMacros(MVM_LONG_PTR_TYPE lpBytecode, mvm_TsBytecodeHeader* pHeader, void* context);
+static LongPtr vm_toStringUtf8_long(VM* vm, Value value, size_t* out_sizeBytes);
+static LongPtr vm_findScopedVariable(VM* vm, uint16_t index);
+static inline Value vm_readScopedFromThisClosure(VM* vm, uint16_t varIndex);
+static inline void vm_writeScopedToThisClosure(VM* vm, uint16_t varIndex, Value value);
+static Value vm_cloneContainer(VM* vm, Value* pArr);
+static Value vm_safePop(VM* vm, Value* pStackPointerAfterDecr);
+static LongPtr vm_getStringData(VM* vm, Value value);
+static inline VirtualInt14 VirtualInt14_encode(VM* vm, int16_t i);
+static inline int16_t VirtualInt14_decode(VM* vm, VirtualInt14 viInt);
+static inline TeTypeCode vm_getTypeCodeFromHeaderWord(uint16_t headerWord);
+static bool DynamicPtr_isRomPtr(VM* vm, DynamicPtr dp);
+static inline void mvm_checkValueAccess(VM* vm, uint8_t potentialCycleNumber);
+static inline uint16_t vm_getAllocationSize(void* pAllocation);
+static inline uint16_t vm_getAllocationSize_long(LongPtr lpAllocation);
+static inline TeTypeCode vm_getAllocationType(void* pAllocation);
+static inline mvm_TeBytecodeSection vm_sectionAfter(VM* vm, mvm_TeBytecodeSection section);
+static void* ShortPtr_decode(VM* vm, ShortPtr shortPtr);
+static TeError vm_newError(VM* vm, TeError err);
+static void* vm_malloc(VM* vm, size_t size);
+static void vm_free(VM* vm, void* ptr);
+static inline uint16_t* getTopOfStackSpace(vm_TsStack* stack);
+static Value* vm_getHandleTargetOrNull(VM* vm, Value value);
+static Value vm_resolveIndirections(VM* vm, Value value);
+static mvm_TeError vm_uint8ArrayNew(VM* vm, Value* slot);
+static Value getBuiltin(VM* vm, mvm_TeBuiltins builtinID);
+static uint16_t* vm_scopePushOrNew(VM* vm, int slotCount, bool captureParent);
+static inline Value vm_encodeBytecodeOffsetAsPointer(VM* vm, uint16_t offset);
+static void vm_enqueueJob(VM* vm, Value jobClosure);
+static Value vm_dequeueJob(VM* vm);
+static void* DynamicPtr_decode_native(VM* vm, DynamicPtr ptr);
+static mvm_Value* vm_push(mvm_VM* vm, mvm_Value value);
+static Value vm_pop(mvm_VM* vm);
+static Value vm_asyncStartUnsafe(mvm_VM* vm, Value* out_result);
+static Value vm_objectCreate(VM* vm, Value prototype, int internalSlotCount);
+static void vm_scheduleContinuation(VM* vm, Value continuation, Value isSuccess, Value resultOrError);
+static Value vm_newArray(VM* vm, uint16_t capacity);
+static void vm_arrayPush(VM* vm, Value* pvArr, Value* pvItem);
+static void growArray(VM* vm, Value* pvArr, uint16_t newLength, uint16_t newCapacity);
+
+#if MVM_SUPPORT_FLOAT
+MVM_FLOAT64 mvm_toFloat64(mvm_VM* vm, Value value);
+#endif // MVM_SUPPORT_FLOAT
+
+// The MVM_HIDDEN functions are not exposed by default but can be linked to if
+// needed. This is currently used by the WASM wrapper to get low-level access to
+// some features.
+MVM_HIDDEN TeError vm_objectKeys(VM* vm, Value* pObject);
+MVM_HIDDEN TeError getProperty(VM* vm, Value* pObjectValue, Value* pPropertyName, Value* out_propertyValue);
+MVM_HIDDEN TeError setProperty(VM* vm, Value* pObject, Value* pPropertyName, Value* pPropertyValue);
+MVM_HIDDEN void* mvm_allocate(VM* vm, uint16_t sizeBytes, uint8_t /*TeTypeCode*/ typeCode);
+MVM_HIDDEN void mvm_subscribeToPromise(VM* vm, Value vPromise, Value vCallback);
+
+#if MVM_SAFE_MODE
+static inline uint16_t vm_getResolvedImportCount(VM* vm);
+#endif // MVM_SAFE_MODE
+
+#if MVM_DEBUG_UTILS
+void mvm_checkHeap(mvm_VM* vm);
+int mvm_readHeapCount(VM* vm);
+void mvm_checkValue(mvm_VM* vm, Value value);
+mvm_TsCallStackFrame* mvm_readCallStack(VM* vm, int* out_frameCount);
+mvm_TsHeapAllocationInfo* mvm_readHeap(VM* vm, int* out_count);
+#endif
+
+static const Value smallLiterals[] = {
+  /* VM_SLV_UNDEFINED */    VM_VALUE_DELETED,
+  /* VM_SLV_UNDEFINED */    VM_VALUE_UNDEFINED,
+  /* VM_SLV_NULL */         VM_VALUE_NULL,
+  /* VM_SLV_FALSE */        VM_VALUE_FALSE,
+  /* VM_SLV_TRUE */         VM_VALUE_TRUE,
+  /* VM_SLV_INT_MINUS_1 */  VIRTUAL_INT14_ENCODE(-1),
+  /* VM_SLV_INT_0 */        VIRTUAL_INT14_ENCODE(0),
+  /* VM_SLV_INT_1 */        VIRTUAL_INT14_ENCODE(1),
+  /* VM_SLV_INT_2 */        VIRTUAL_INT14_ENCODE(2),
+  /* VM_SLV_INT_3 */        VIRTUAL_INT14_ENCODE(3),
+  /* VM_SLV_INT_4 */        VIRTUAL_INT14_ENCODE(4),
+  /* VM_SLV_INT_5 */        VIRTUAL_INT14_ENCODE(5),
+};
+#define smallLiteralsSize (sizeof smallLiterals / sizeof smallLiterals[0])
+
+static const char PROTO_STR[] = "__proto__";
+static const char LENGTH_STR[] = "length";
+
+static const char TYPE_STRINGS[] =
+  "undefined\0boolean\0number\0string\0function\0object\0symbol\0bigint";
+// 0          10       18      25      32        41      48      55
+
+// Character offsets into TYPE_STRINGS
+static const uint8_t typeStringOffsetByType[VM_T_END] = {
+  0 , /* VM_T_UNDEFINED   */
+  41, /* VM_T_NULL        */
+  10, /* VM_T_BOOLEAN     */
+  18, /* VM_T_NUMBER      */
+  25, /* VM_T_STRING      */
+  32, /* VM_T_FUNCTION    */
+  41, /* VM_T_OBJECT      */
+  41, /* VM_T_ARRAY       */
+  41, /* VM_T_UINT8_ARRAY */
+  32, /* VM_T_CLASS       */
+  48, /* VM_T_SYMBOL      */
+  55, /* VM_T_BIG_INT     */
+};
+
+// TeTypeCode -> mvm_TeType
+static const uint8_t typeByTC[TC_END] = {
+  VM_T_END,         /* TC_REF_TOMBSTONE          */
+  VM_T_NUMBER,      /* TC_REF_INT32              */
+  VM_T_NUMBER,      /* TC_REF_FLOAT64            */
+  VM_T_STRING,      /* TC_REF_STRING             */
+  VM_T_STRING,      /* TC_REF_INTERNED_STRING    */
+  VM_T_FUNCTION,    /* TC_REF_FUNCTION           */
+  VM_T_FUNCTION,    /* TC_REF_HOST_FUNC          */
+  VM_T_UINT8_ARRAY, /* TC_REF_UINT8_ARRAY        */
+  VM_T_SYMBOL,      /* TC_REF_SYMBOL             */
+  VM_T_CLASS,       /* TC_REF_CLASS              */
+  VM_T_END,         /* TC_REF_VIRTUAL            */
+  VM_T_END,         /* TC_REF_RESERVED_1         */
+  VM_T_OBJECT,      /* TC_REF_PROPERTY_LIST      */
+  VM_T_ARRAY,       /* TC_REF_ARRAY              */
+  VM_T_ARRAY,       /* TC_REF_FIXED_LENGTH_ARRAY */
+  VM_T_FUNCTION,    /* TC_REF_CLOSURE            */
+  VM_T_NUMBER,      /* TC_VAL_INT14              */
+  VM_T_UNDEFINED,   /* TC_VAL_UNDEFINED          */
+  VM_T_NULL,        /* TC_VAL_NULL               */
+  VM_T_BOOLEAN,     /* TC_VAL_TRUE               */
+  VM_T_BOOLEAN,     /* TC_VAL_FALSE              */
+  VM_T_NUMBER,      /* TC_VAL_NAN                */
+  VM_T_NUMBER,      /* TC_VAL_NEG_ZERO           */
+  VM_T_UNDEFINED,   /* TC_VAL_DELETED            */
+  VM_T_STRING,      /* TC_VAL_STR_LENGTH         */
+  VM_T_STRING,      /* TC_VAL_STR_PROTO          */
+  VM_T_FUNCTION,    /* TC_VAL_NO_OP_FUNC         */
+};
+
+#define GC_ALLOCATE_TYPE(vm, type, typeCode) \
+  (type*)mvm_allocateWithConstantHeader(vm, vm_makeHeaderWord(vm, typeCode, sizeof (type)), 2 + sizeof (type))
+
+#if MVM_SUPPORT_FLOAT
+static int32_t mvm_float64ToInt32(MVM_FLOAT64 value);
+#endif
+
+#if MVM_VERY_EXPENSIVE_MEMORY_CHECKS
+  #define VM_POTENTIAL_GC_POINT(vm) do { \
+    mvm_runGC(vm, false); \
+    VM_EXEC_SAFE_MODE(vm->gc_potentialCycleNumber++;) \
+  } while (0)
+#else
+  #define VM_POTENTIAL_GC_POINT(vm) do { \
+    VM_EXEC_SAFE_MODE(vm->gc_potentialCycleNumber++;) \
+  } while (0)
+#endif
+
+// MVM_LOCAL declares a local variable whose value would become invalidated if
+// the GC performs a cycle. All access to the local should use MVM_GET_LOCAL AND
+// MVM_SET_LOCAL. This only needs to be used for pointer values or values that
+// might hold a pointer.
+#if MVM_SAFE_MODE
+#define MVM_LOCAL(type, varName, initial) type varName ## Value = initial; uint8_t _ ## varName ## PotentialCycleNumber = vm->gc_potentialCycleNumber
+#define MVM_GET_LOCAL(varName) (mvm_checkValueAccess(vm, _ ## varName ## PotentialCycleNumber), varName ## Value)
+#define MVM_SET_LOCAL(varName, value) varName ## Value = value; _ ## varName ## PotentialCycleNumber = vm->gc_potentialCycleNumber
+#else
+#define MVM_LOCAL(type, varName, initial) type varName = initial
+#define MVM_GET_LOCAL(varName) (varName)
+#define MVM_SET_LOCAL(varName, value) varName = value
+#endif // MVM_SAFE_MODE
+
+// Various things require the registers (vm->stack->reg) to be up to date
+#define VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm) \
+  VM_ASSERT(vm, !vm->stack || !vm->stack->reg.usingCachedRegisters)
+
+/**
+ * (this used to be in the port file but I've moved it out because the semantics
+ * may be confusing and are difficult to communicate clearly. See
+ * https://github.com/coder-mike/microvium/issues/47)
+ *
+ * Set to 1 to enable overflow checking for 32 bit integers in compliance with
+ * the ECMAScript standard (ES262).
+ *
+ * If set to 0, then operations on 32-bit signed integers have wrap-around
+ * (overflow) behavior, like the typical runtime behavior when adding 32-bit
+ * signed integers in C.
+ *
+ * Explanation: Microvium tries to use 32-bit integer arithmetic where possible,
+ * because it's more efficient than the standard 64-bit floating point
+ * operations, especially on small microcontrollers. To give the appearance of
+ * 64-bit floating point, Microvium needs to check when the result of such
+ * operations overflows the 32-bit range and needs to be re-calculated using
+ * proper 64-bit floating point operations. These overflow checks can be
+ * disabled to improve performance and reduce engine size.
+ *
+ * Example: `2_000_000_000 + 2_000_000_000` will add to:
+ *
+ *   - `4_000_000_000` if `MVM_PORT_INT32_OVERFLOW_CHECKS` is `1`
+ *   - `-294_967_296` if `MVM_PORT_INT32_OVERFLOW_CHECKS` is `0`
+ *
+ */
+#ifndef MVM_PORT_INT32_OVERFLOW_CHECKS
+#define MVM_PORT_INT32_OVERFLOW_CHECKS 1
+#endif
+
+
+
+#if MVM_IMPORT_MATH
+#include "math.h"
+#endif
+
+/**
+ * Public API to call into the VM to run the given function with the given
+ * arguments (also contains the run loop).
+ *
+ * Control returns from `mvm_call` either when it hits an error or when it
+ * executes a RETURN instruction within the called function.
+ *
+ * If the return code is MVM_E_UNCAUGHT_EXCEPTION then `out_result` points to the exception.
+ */
+TeError mvm_call(VM* vm, Value targetFunc, Value* out_result, Value* args, uint8_t argCount) {
+  /*
+  Note: when microvium calls the host, only `mvm_call` is on the call stack.
+  This is for the objective of being lightweight. Each stack frame in an
+  embedded environment can be quite expensive in terms of memory because of all
+  the general-purpose registers that need to be preserved.
+  */
+
+  // -------------------------------- Definitions -----------------------------
+
+  #define CACHE_REGISTERS() do { \
+    VM_ASSERT(vm, reg->usingCachedRegisters == false); \
+    VM_EXEC_SAFE_MODE(reg->usingCachedRegisters = true;) \
+    lpProgramCounter = reg->lpProgramCounter; \
+    pFrameBase = reg->pFrameBase; \
+    pStackPointer = reg->pStackPointer; \
+    VM_EXEC_SAFE_MODE(reg->pStackPointer = NULL;) \
+    VM_EXEC_SAFE_MODE(reg->lpProgramCounter = LongPtr_new(NULL);) \
+  } while (false)
+
+  #define FLUSH_REGISTER_CACHE() do { \
+    VM_ASSERT(vm, reg->usingCachedRegisters == true); \
+    VM_EXEC_SAFE_MODE(reg->usingCachedRegisters = false;) \
+    reg->lpProgramCounter = lpProgramCounter; \
+    reg->pFrameBase = pFrameBase; \
+    reg->pStackPointer = pStackPointer; \
+    VM_EXEC_SAFE_MODE(pFrameBase = NULL;) \
+    VM_EXEC_SAFE_MODE(pStackPointer = NULL;) \
+    VM_EXEC_SAFE_MODE(lpProgramCounter = LongPtr_new(NULL);) \
+  } while (false)
+
+  #define READ_PGM_1(target) do { \
+    VM_ASSERT(vm, reg->usingCachedRegisters == true); \
+    target = LongPtr_read1(lpProgramCounter);\
+    lpProgramCounter = LongPtr_add(lpProgramCounter, 1); \
+  } while (false)
+
+  #define READ_PGM_2(target) do { \
+    VM_ASSERT(vm, reg->usingCachedRegisters == true); \
+    target = LongPtr_read2_unaligned(lpProgramCounter); \
+    lpProgramCounter = LongPtr_add(lpProgramCounter, 2); \
+  } while (false)
+
+  #define PUSH(v) do { \
+    VM_ASSERT(vm, reg->usingCachedRegisters == true); \
+    VM_ASSERT(vm, pStackPointer < getTopOfStackSpace(vm->stack)); \
+    *pStackPointer = (v); \
+    pStackPointer++; \
+  } while (false)
+
+  #if MVM_SAFE_MODE
+    #define POP() vm_safePop(vm, --pStackPointer)
+  #else
+    #define POP() (*(--pStackPointer))
+  #endif
+
+  // Push the current registers onto the call stack
+  #define PUSH_REGISTERS(lpReturnAddress) do { \
+    VM_ASSERT(vm, VM_FRAME_BOUNDARY_VERSION == 2); \
+    PUSH((uint16_t)(uintptr_t)pStackPointer - (uint16_t)(uintptr_t)pFrameBase); \
+    PUSH(reg->closure); \
+    PUSH(reg->argCountAndFlags); \
+    PUSH((uint16_t)LongPtr_sub(lpReturnAddress, vm->lpBytecode)); \
+  } while (false)
+
+  // Inverse of PUSH_REGISTERS
+  #define POP_REGISTERS() do { \
+    VM_ASSERT(vm, VM_FRAME_BOUNDARY_VERSION == 2); \
+    lpProgramCounter = LongPtr_add(vm->lpBytecode, POP()); \
+    reg->argCountAndFlags = POP(); \
+    reg->closure = POP(); \
+    pStackPointer--; \
+    pFrameBase = (uint16_t*)((uint8_t*)pStackPointer - *pStackPointer); \
+    reg->pArgs = pFrameBase - VM_FRAME_BOUNDARY_SAVE_SIZE_WORDS - (reg->argCountAndFlags & AF_ARG_COUNT_MASK); \
+  } while (false)
+
+  // Push a catch target, where `handler` is the bytecode landing pad
+  #define PUSH_CATCH_TARGET(handler) do { \
+    /* Note: the value stored on the stack is essentially an auto-relative
+    pointer stored as an Int14. It will always be negative because the catch
+    target is always behind the stack pointer */ \
+    int16_t temp = reg->pCatchTarget ? (int16_t)(reg->pCatchTarget - pStackPointer) : 0; \
+    pStackPointer[0] = VirtualInt14_encode(vm, temp); \
+    /* Note: pCatchTarget points to the base of the catch target, which the
+    address before incrementing */  \
+    reg->pCatchTarget = pStackPointer++; \
+    PUSH(handler); \
+  } while (false)
+
+  // Unwinds the catch target at pStackPointer
+  #define UNWIND_CATCH_TARGET() do { \
+    int16_t temp = VirtualInt14_decode(vm, pStackPointer[0]); \
+    reg->pCatchTarget = temp ? pStackPointer + temp : NULL; \
+  } while (false)
+
+  // Reinterpret reg1 as 8-bit signed
+  #define SIGN_EXTEND_REG_1() reg1 = (uint16_t)((int16_t)((int8_t)reg1))
+
+  #define INSTRUCTION_RESERVED() VM_ASSERT(vm, false)
+
+  // ------------------------------ Common Variables --------------------------
+
+  VM_SAFE_CHECK_NOT_NULL(vm);
+  if (argCount) VM_SAFE_CHECK_NOT_NULL(args);
+
+  TeError err = MVM_E_SUCCESS;
+
+  // These are cached values of `vm->stack->reg`, for quick access. Note: I've
+  // chosen only the most important registers to be cached here, in the hope
+  // that the C compiler will promote these eagerly to the CPU registers,
+  // although it may choose not to.
+  register uint16_t* pFrameBase;
+  register uint16_t* pStackPointer; // Name is confusing. This is the stack pointer, not a pointer to the stack pointer.
+  register LongPtr lpProgramCounter;
+
+  // These are general-purpose scratch "registers". Note: probably the compiler
+  // would be fine at performing register allocation if we didn't have specific
+  // register variables, but having them explicit forces us to think about what
+  // state is being used and designing the code to minimize it.
+  register uint16_t reg1;
+  register uint16_t reg2;
+  register uint16_t reg3;
+  uint16_t* regP1;
+  uint16_t* regP2;
+  LongPtr regLP1;
+
+  uint16_t* globals;
+  vm_TsRegisters* reg;
+  vm_TsRegisters registerValuesAtEntry;
+
+  #if MVM_DONT_TRUST_BYTECODE
+    LongPtr maxProgramCounter;
+    LongPtr minProgramCounter = getBytecodeSection(vm, BCS_ROM, &maxProgramCounter);
+  #endif
+
+  // Note: these initial values are not actually used, but some compilers give a
+  // warning if you omit them.
+  pFrameBase = 0;
+  pStackPointer = 0;
+  lpProgramCounter = 0;
+  reg1 = 0;
+  reg2 = 0;
+  reg3 = 0;
+  regP1 = NULL;
+  regLP1 = NULL;
+
+  // ------------------------------ Initialization ---------------------------
+
+  CODE_COVERAGE(4); // Hit
+
+  // Create the call stack if it doesn't exist
+  if (!vm->stack) {
+    CODE_COVERAGE(230); // Hit
+    err = vm_createStackAndRegisters(vm);
+    if (err != MVM_E_SUCCESS) {
+      return err;
+    }
+  } else {
+    CODE_COVERAGE(232); // Hit
+  }
+
+  globals = vm->globals;
+  reg = &vm->stack->reg;
+
+  registerValuesAtEntry = *reg;
+
+  // Because we're coming from C-land, any exceptions that happen during
+  // mvm_call should register as host errors
+  reg->pCatchTarget = NULL;
+
+  // Copy the state of the VM registers into the logical variables for quick access
+  CACHE_REGISTERS();
+
+  // ---------------------- Push host arguments to the stack ------------------
+
+  // 126 is the maximum because we also push the `this` value implicitly
+  if (argCount > (AF_ARG_COUNT_MASK - 1)) {
+    CODE_COVERAGE_ERROR_PATH(220); // Not hit
+    return MVM_E_TOO_MANY_ARGUMENTS;
+  } else {
+    CODE_COVERAGE(15); // Hit
+  }
+
+  err = vm_requireStackSpace(vm, pStackPointer, argCount + 2); // +1 for `this`, +1 for class if needed
+  if (err != MVM_E_SUCCESS) goto SUB_EXIT;
+
+  PUSH(targetFunc); // class or function
+  if (reg->argCountAndFlags & AF_OVERRIDE_THIS) {
+    CODE_COVERAGE_UNTESTED(662); // Hit
+    // This is a bit of a hack. If mvm_call is called from mvm_callEx, then
+    // mvm_callEx will have already set the `this` value on the stack in this
+    // position.
+    pStackPointer++;
+    reg->argCountAndFlags &= ~AF_OVERRIDE_THIS;
+  } else {
+    CODE_COVERAGE(663); // Hit
+    PUSH(VM_VALUE_UNDEFINED); // Push `this` pointer of undefined
+  }
+
+  TABLE_COVERAGE(argCount ? 1 : 0, 2, 513); // Hit 2/2
+  reg1 = argCount;
+  while (reg1--) {
+    PUSH(*args++);
+  }
+
+  // ---------------------------- Call target function ------------------------
+
+  reg1 /* argCountAndFlags */ = (argCount + 1) | AF_PUSHED_FUNCTION | AF_CALLED_FROM_HOST; // +1 for the `this` value
+  reg2 /* target */ = vm_resolveIndirections(vm, targetFunc);
+  reg3 /* cpsCallback */ = VM_VALUE_UNDEFINED;
+
+  // When calling mvm_call from C, if the target is a class then we implicitly
+  // `new` the class. This doesn't violate anything from the spec because it
+  // doesn't affect JS calls, but it makes interacting with classes from C much
+  // easier.
+  if (deepTypeOf(vm, targetFunc) == TC_REF_CLASS) {
+    goto SUB_NEW;
+  } else {
+    goto SUB_CALL;
+  }
+
+  goto SUB_CALL;
+
+  // --------------------------------- Run Loop ------------------------------
+
+  // This forms the start of the run loop
+  //
+  // Some useful debug watches:
+  //
+  //   - Program counter: /* pc */ (uint16_t)((uint8_t*)lpProgramCounter - (uint8_t*)vm->lpBytecode)
+  //                      /* pc */ (uint16_t)((uint8_t*)vm->stack->reg.lpProgramCounter - (uint8_t*)vm->lpBytecode)
+  //
+  //   - Frame height (in words):  /* fh */ (uint16_t*)pStackPointer - (uint16_t*)pFrameBase
+  //                               /* fh */ (uint16_t*)vm->stack->reg.pStackPointer - (uint16_t*)vm->stack->reg.pFrameBase
+  //
+  //   - Frame:                    /* frame */ (uint16_t*)pFrameBase,10
+  //                               /* frame */ (uint16_t*)vm->stack->reg.pFrameBase,10
+  //
+  //   - Stack height (in words): /* sp */ (uint16_t*)pStackPointer - (uint16_t*)(vm->stack + 1)
+  //                              /* sp */ (uint16_t*)vm->stack->reg.pStackPointer - (uint16_t*)(vm->stack + 1)
+  //
+  //   - Frame base (in words): /* bp */ (uint16_t*)pFrameBase - (uint16_t*)(vm->stack + 1)
+  //                            /* bp */ (uint16_t*)vm->stack->reg.pFrameBase - (uint16_t*)(vm->stack + 1)
+  //
+  //   - Arg count:             /* argc */ vm->stack->reg.argCountAndFlags & 0x7F
+  //   - First 4 arg values:    /* args */ vm->stack->reg.pArgs,4
+  //
+  //   - Caller PC:             /* caller-pc */ pFrameBase[-1]
+  //
+  // Notes:
+  //
+  //   - The value of VM_VALUE_UNDEFINED is 0x001
+  //   - If a value is _odd_, interpret it as a bytecode address by dividing by 2
+  //
+
+SUB_DO_NEXT_INSTRUCTION:
+  CODE_COVERAGE(59); // Hit
+
+  VM_ASSERT(vm, reg->usingCachedRegisters);
+
+  #ifdef MVM_GAS_COUNTER
+  if (vm->stopAfterNInstructions >= 0) {
+    CODE_COVERAGE(650); // Hit
+    if (vm->stopAfterNInstructions == 0) {
+      CODE_COVERAGE(651); // Hit
+      err = MVM_E_INSTRUCTION_COUNT_REACHED;
+      goto SUB_EXIT;
+    } else {
+      CODE_COVERAGE(652); // Hit
+      vm->stopAfterNInstructions--;
+    }
+  }
+  #endif
+
+  // Check we're within range
+  #if MVM_DONT_TRUST_BYTECODE
+  if ((lpProgramCounter < minProgramCounter) || (lpProgramCounter >= maxProgramCounter)) {
+    VM_INVALID_BYTECODE(vm);
+  }
+  #endif
+
+  // Check breakpoints
+  #if MVM_INCLUDE_DEBUG_CAPABILITY
+    if (vm->pBreakpoints) {
+      TsBreakpoint* pBreakpoint = vm->pBreakpoints;
+      uint16_t currentBytecodeAddress = LongPtr_sub(lpProgramCounter, vm->lpBytecode);
+      do {
+        if ((pBreakpoint->bytecodeAddress == -1) || (pBreakpoint->bytecodeAddress == (int)currentBytecodeAddress)) {
+          FLUSH_REGISTER_CACHE();
+          mvm_TfBreakpointCallback breakpointCallback = vm->breakpointCallback;
+          if (breakpointCallback)
+            breakpointCallback(vm, currentBytecodeAddress);
+          CACHE_REGISTERS();
+          break;
+        }
+        pBreakpoint = pBreakpoint->next;
+      } while (pBreakpoint);
+    }
+  #endif // MVM_INCLUDE_DEBUG_CAPABILITY
+
+  // Instruction bytes are divided into two nibbles
+  READ_PGM_1(reg3);
+  reg1 = reg3 & 0xF; // Primary opcode
+  reg3 = reg3 >> 4;  // Secondary opcode or data
+
+  if (reg3 >= VM_OP_DIVIDER_1) {
+    CODE_COVERAGE(428); // Hit
+    reg2 = POP();
+  } else {
+    CODE_COVERAGE(429); // Hit
+  }
+
+  VM_ASSERT(vm, reg3 < VM_OP_END);
+  MVM_SWITCH(reg3, (VM_OP_END - 1)) {
+
+/* ------------------------------------------------------------------------- */
+/*                         VM_OP_LOAD_SMALL_LITERAL                          */
+/*   Expects:                                                                */
+/*     reg1: small literal ID                                                */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE(VM_OP_LOAD_SMALL_LITERAL): {
+      CODE_COVERAGE(60); // Hit
+      TABLE_COVERAGE(reg1, smallLiteralsSize, 448); // Hit 11/12
+
+      #if MVM_DONT_TRUST_BYTECODE
+      if (reg1 >= smallLiteralsSize) {
+        err = vm_newError(vm, MVM_E_INVALID_BYTECODE);
+        goto SUB_EXIT;
+      }
+      #endif
+      reg1 = smallLiterals[reg1];
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP_LOAD_VAR_1                              */
+/*   Expects:                                                                */
+/*     reg1: variable index                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_LOAD_VAR_1):
+      CODE_COVERAGE(61); // Hit
+    SUB_OP_LOAD_VAR:
+      reg1 = pStackPointer[-reg1 - 1];
+      if (reg1 == VM_VALUE_DELETED) {
+        err = vm_newError(vm, MVM_E_TDZ_ERROR);
+        goto SUB_EXIT;
+      }
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+
+/* ------------------------------------------------------------------------- */
+/*                            VM_OP_LOAD_SCOPED_1                            */
+/*   Expects:                                                                */
+/*     reg1: variable index                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_LOAD_SCOPED_1):
+      CODE_COVERAGE(62); // Hit
+      LongPtr lpVar;
+    SUB_OP_LOAD_SCOPED:
+      lpVar = vm_findScopedVariable(vm, reg1);
+      reg1 = LongPtr_read2_aligned(lpVar);
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP_LOAD_ARG_1                              */
+/*   Expects:                                                                */
+/*     reg1: argument index                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_LOAD_ARG_1):
+      CODE_COVERAGE(63); // Hit
+      goto SUB_OP_LOAD_ARG;
+
+/* ------------------------------------------------------------------------- */
+/*                               VM_OP_CALL_1                                */
+/*   Expects:                                                                */
+/*     reg1: index into short-call table                                     */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_CALL_1): {
+      CODE_COVERAGE_UNTESTED(66); // Not hit
+      goto SUB_CALL_SHORT;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                               VM_OP_FIXED_ARRAY_NEW_1                     */
+/*   Expects:                                                                */
+/*     reg1: length of new fixed-length-array                                */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_FIXED_ARRAY_NEW_1): {
+      CODE_COVERAGE_UNTESTED(134); // Not hit
+      goto SUB_FIXED_ARRAY_NEW;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP_EXTENDED_1                              */
+/*   Expects:                                                                */
+/*     reg1: vm_TeOpcodeEx1                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_EXTENDED_1):
+      CODE_COVERAGE(69); // Hit
+      goto SUB_OP_EXTENDED_1;
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP_EXTENDED_2                              */
+/*   Expects:                                                                */
+/*     reg1: vm_TeOpcodeEx2                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_EXTENDED_2):
+      CODE_COVERAGE(70); // Hit
+      goto SUB_OP_EXTENDED_2;
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP_EXTENDED_3                              */
+/*   Expects:                                                                */
+/*     reg1: vm_TeOpcodeEx3                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_EXTENDED_3):
+      CODE_COVERAGE(71); // Hit
+      goto SUB_OP_EXTENDED_3;
+
+/* ------------------------------------------------------------------------- */
+/*                                VM_OP_CALL_5                               */
+/*   Expects:                                                                */
+/*     reg1: argCount                                                        */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_CALL_5): {
+      /* Note: this isn't actually used at the moment, because we don't have the
+      static analysis to statically determine the target. But my expectation is
+      that when we have this static analysis, most function calls are going to
+      take this form, where the arg count is small and the target is statically
+      determined, but where it's not worth it to put the call into the
+      short-call table. */
+      CODE_COVERAGE_UNTESTED(72); // Not hit
+      // Uses 16 bit literal for function offset
+      READ_PGM_2(reg2);
+      reg3 /* scope */ = VM_VALUE_UNDEFINED;
+      goto SUB_CALL_BYTECODE_FUNC;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP_STORE_VAR_1                             */
+/*   Expects:                                                                */
+/*     reg1: variable index relative to stack pointer                        */
+/*     reg2: value to store                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_STORE_VAR_1): {
+      CODE_COVERAGE(73); // Hit
+    SUB_OP_STORE_VAR:
+      // Note: the value to store has already been popped off the stack at this
+      // point. The index 0 refers to the slot currently at the top of the
+      // stack.
+      pStackPointer[-reg1 - 1] = reg2;
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                           VM_OP_STORE_SCOPED_1                            */
+/*   Expects:                                                                */
+/*     reg1: variable index                                                  */
+/*     reg2: value to store                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_STORE_SCOPED_1): {
+      CODE_COVERAGE(74); // Hit
+      LongPtr lpVar;
+    SUB_OP_STORE_SCOPED:
+      lpVar = vm_findScopedVariable(vm, reg1);
+      Value* pVar = (Value*)LongPtr_truncate(vm, lpVar);
+      // It would be an illegal operation to write to a closure variable stored in ROM
+      VM_BYTECODE_ASSERT(vm, lpVar == LongPtr_new(pVar));
+      *pVar = reg2;
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                            VM_OP_ARRAY_GET_1                              */
+/*   Expects:                                                                */
+/*     reg1: item index (4-bit)                                             */
+/*     reg2: reference to array                                              */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_ARRAY_GET_1): {
+      CODE_COVERAGE_UNTESTED(75); // Not hit
+
+      // I think it makes sense for this instruction only to be an optimization for fixed-length arrays
+      VM_ASSERT(vm, deepTypeOf(vm, reg2) == TC_REF_FIXED_LENGTH_ARRAY);
+      regLP1 = DynamicPtr_decode_long(vm, reg2);
+      // These indexes should be compiler-generated, so they should never be out of range
+      VM_ASSERT(vm, reg1 < (vm_getAllocationSize_long(regLP1) >> 1));
+      regLP1 = LongPtr_add(regLP1, reg2 << 1);
+      reg1 = LongPtr_read2_aligned(regLP1);
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                            VM_OP_ARRAY_SET_1                              */
+/*   Expects:                                                                */
+/*     reg1: item index (4-bit)                                              */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_ARRAY_SET_1): {
+      CODE_COVERAGE_UNTESTED(76); // Not hit
+      reg2 = POP(); // array reference
+      // I think it makes sense for this instruction only to be an optimization for fixed-length arrays
+      VM_ASSERT(vm, deepTypeOf(vm, reg3) == TC_REF_FIXED_LENGTH_ARRAY);
+      // We can only write to it if it's in RAM, so it must be a short-pointer
+      regP1 = (Value*)ShortPtr_decode(vm, reg3);
+      // These indexes should be compiler-generated, so they should never be out of range
+      VM_ASSERT(vm, reg1 < (vm_getAllocationSize(regP1) >> 1));
+      regP1[reg1] = reg2;
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP_NUM_OP                                 */
+/*   Expects:                                                                */
+/*     reg1: vm_TeNumberOp                                                   */
+/*     reg2: first popped operand                                            */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_NUM_OP): {
+      CODE_COVERAGE(77); // Hit
+      goto SUB_OP_NUM_OP;
+    } // End of case VM_OP_NUM_OP
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP_BIT_OP                                 */
+/*   Expects:                                                                */
+/*     reg1: vm_TeBitwiseOp                                                  */
+/*     reg2: first popped operand                                            */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP_BIT_OP): {
+      CODE_COVERAGE(92); // Hit
+      goto SUB_OP_BIT_OP;
+    }
+
+  } // End of primary switch
+
+  // All cases should loop explicitly back
+  VM_ASSERT_UNREACHABLE(vm);
+
+/* ------------------------------------------------------------------------- */
+/*                             SUB_OP_LOAD_ARG                               */
+/*   Expects:                                                                */
+/*     reg1: argument index                                                  */
+/* ------------------------------------------------------------------------- */
+SUB_OP_LOAD_ARG: {
+  CODE_COVERAGE(32); // Hit
+  reg2 /* argCountAndFlags */ = reg->argCountAndFlags;
+  if (reg1 /* argIndex */ < (reg2 & AF_ARG_COUNT_MASK) /* argCount */) {
+    CODE_COVERAGE(64); // Hit
+    reg1 /* result */ = reg->pArgs[reg1 /* argIndex */];
+  } else {
+    CODE_COVERAGE(65); // Hit
+    reg1 = VM_VALUE_UNDEFINED;
+  }
+  goto SUB_TAIL_POP_0_PUSH_REG1;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                               SUB_CALL_SHORT                               */
+/*   Expects:                                                                */
+/*     reg1: index into short-call table                                     */
+/* ------------------------------------------------------------------------- */
+
+SUB_CALL_SHORT: {
+  CODE_COVERAGE_UNTESTED(173); // Not hit
+  LongPtr lpShortCallTable = getBytecodeSection(vm, BCS_SHORT_CALL_TABLE, NULL);
+  LongPtr lpShortCallTableEntry = LongPtr_add(lpShortCallTable, reg1 * sizeof (vm_TsShortCallTableEntry));
+
+  #if MVM_SAFE_MODE
+    LongPtr lpShortCallTableEnd;
+    getBytecodeSection(vm, BCS_SHORT_CALL_TABLE, &lpShortCallTableEnd);
+    VM_ASSERT(vm, lpShortCallTableEntry < lpShortCallTableEnd);
+  #endif
+
+  reg2 /* target */ = LongPtr_read2_aligned(lpShortCallTableEntry);
+  lpShortCallTableEntry = LongPtr_add(lpShortCallTableEntry, 2);
+
+  // Note: reg1 holds the new argCountAndFlags, but the flags are zero in this situation
+  reg1 /* argCountAndFlags */ = LongPtr_read1(lpShortCallTableEntry);
+
+  reg3 /* scope */ = VM_VALUE_UNDEFINED;
+
+  // The high bit of function indicates if this is a call to the host
+  bool isHostCall = reg2 & 1;
+
+  if (isHostCall) {
+    CODE_COVERAGE_UNTESTED(67); // Not hit
+    goto SUB_CALL_HOST_COMMON;
+  } else {
+    CODE_COVERAGE_UNTESTED(68); // Not hit
+    reg2 >>= 1;
+    goto SUB_CALL_BYTECODE_FUNC;
+  }
+} // SUB_CALL_SHORT
+
+/* ------------------------------------------------------------------------- */
+/*                              SUB_OP_BIT_OP                                */
+/*   Expects:                                                                */
+/*     reg1: vm_TeBitwiseOp                                                  */
+/*     reg2: first popped operand                                            */
+/* ------------------------------------------------------------------------- */
+SUB_OP_BIT_OP: {
+  int32_t reg1I = 0;
+  int32_t reg2I = 0;
+  int8_t reg2B = 0;
+
+  reg3 = reg1;
+
+  // Convert second operand to an int32
+  reg2I = mvm_toInt32(vm, reg2);
+
+  // If it's a binary operator, then we pop a second operand
+  if (reg3 < VM_BIT_OP_DIVIDER_2) {
+    CODE_COVERAGE(117); // Hit
+    reg1 = POP();
+    reg1I = mvm_toInt32(vm, reg1);
+
+    // If we're doing a shift operation, the operand is in the 0-32 range
+    if (reg3 < VM_BIT_OP_END_OF_SHIFT_OPERATORS) {
+      reg2B = reg2I & 0x1F;
+    }
+  } else {
+    CODE_COVERAGE(118); // Hit
+  }
+
+  VM_ASSERT(vm, reg3 < VM_BIT_OP_END);
+  MVM_SWITCH (reg3, (VM_BIT_OP_END - 1)) {
+    MVM_CASE(VM_BIT_OP_SHR_ARITHMETIC): {
+      CODE_COVERAGE(93); // Hit
+      reg1I = reg1I >> reg2B;
+      break;
+    }
+    MVM_CASE(VM_BIT_OP_SHR_LOGICAL): {
+      CODE_COVERAGE(94); // Hit
+      // Cast the number to unsigned int so that the C interprets the shift
+      // as unsigned/logical rather than signed/arithmetic.
+      reg1I = (int32_t)((uint32_t)reg1I >> reg2B);
+      #if MVM_SUPPORT_FLOAT && MVM_PORT_INT32_OVERFLOW_CHECKS
+        // This is a rather annoying edge case if you ask me, since all
+        // other bitwise operations yield signed int32 results every time.
+        // If the shift is by exactly zero units, then negative numbers
+        // become positive and overflow the signed-32 bit type. Since we
+        // don't have an unsigned 32 bit type, this means they need to be
+        // extended to floats.
+        // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_Operators#Signed_32-bit_integers
+        if ((reg2B == 0) & (reg1I < 0)) {
+          FLUSH_REGISTER_CACHE();
+          reg1 = mvm_newNumber(vm, (MVM_FLOAT64)((uint32_t)reg1I));
+          CACHE_REGISTERS();
+          goto SUB_TAIL_POP_0_PUSH_REG1;
+        }
+      #endif // MVM_PORT_INT32_OVERFLOW_CHECKS
+      break;
+    }
+    MVM_CASE(VM_BIT_OP_SHL): {
+      CODE_COVERAGE(95); // Hit
+      reg1I = reg1I << reg2B;
+      break;
+    }
+    MVM_CASE(VM_BIT_OP_OR): {
+      CODE_COVERAGE(96); // Hit
+      reg1I = reg1I | reg2I;
+      break;
+    }
+    MVM_CASE(VM_BIT_OP_AND): {
+      CODE_COVERAGE(97); // Hit
+      reg1I = reg1I & reg2I;
+      break;
+    }
+    MVM_CASE(VM_BIT_OP_XOR): {
+      CODE_COVERAGE(98); // Hit
+      reg1I = reg1I ^ reg2I;
+      break;
+    }
+    MVM_CASE(VM_BIT_OP_NOT): {
+      CODE_COVERAGE(99); // Hit
+      reg1I = ~reg2I;
+      break;
+    }
+  }
+
+  CODE_COVERAGE(101); // Hit
+
+  // Convert the result from a 32-bit integer
+  if ((reg1I >= VM_MIN_INT14) && (reg1I <= VM_MAX_INT14)) {
+    CODE_COVERAGE(34); // Hit
+    reg1 = VirtualInt14_encode(vm, (uint16_t)reg1I);
+  } else {
+    CODE_COVERAGE(35); // Hit
+    FLUSH_REGISTER_CACHE();
+    reg1 = mvm_newInt32(vm, reg1I);
+    CACHE_REGISTERS();
+  }
+
+  goto SUB_TAIL_POP_0_PUSH_REG1;
+} // End of SUB_OP_BIT_OP
+
+/* ------------------------------------------------------------------------- */
+/*                             SUB_OP_EXTENDED_1                             */
+/*   Expects:                                                                */
+/*     reg1: vm_TeOpcodeEx1                                                  */
+/* ------------------------------------------------------------------------- */
+
+SUB_OP_EXTENDED_1: {
+  CODE_COVERAGE(102); // Hit
+
+  reg3 = reg1;
+
+  VM_ASSERT(vm, reg3 <= VM_OP1_END);
+  MVM_SWITCH (reg3, VM_OP1_END - 1) {
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP1_RETURN                                */
+/*   Expects:                                                                */
+/*     reg1: vm_TeOpcodeEx1                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_RETURN): {
+      CODE_COVERAGE(107); // Hit
+      reg1 = POP();
+      goto SUB_RETURN;
+    }
+
+    MVM_CASE (VM_OP1_THROW): {
+      CODE_COVERAGE(106); // Hit
+
+      reg1 = POP(); // The exception value
+      goto SUB_THROW;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                                 VM_OP1_CLOSURE_NEW                        */
+/*   Expects:                                                                */
+/*     reg3: vm_TeOpcodeEx1                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_CLOSURE_NEW): {
+      CODE_COVERAGE(599); // Hit
+
+      FLUSH_REGISTER_CACHE();
+      Value* pClosure = mvm_allocate(vm, 4, TC_REF_CLOSURE);
+      CACHE_REGISTERS();
+      reg1 = ShortPtr_encode(vm, pClosure);
+      *pClosure++ = POP(); // The function pointer
+      *pClosure = reg->closure; // Capture the current scope
+
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                          VM_OP1_NEW                                       */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_NEW): {
+      CODE_COVERAGE(347); // Hit
+      READ_PGM_1(reg1); // arg count
+      reg1 /*argCountAndFlags*/ |= AF_PUSHED_FUNCTION;
+      goto SUB_NEW;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                                 VM_OP1_SCOPE_NEW                          */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_SCOPE_NEW): {
+      CODE_COVERAGE(605); // Hit
+      // A SCOPE_NEW is just like a SCOPE_PUSH without capturing the parent
+      reg3 /*capture parent*/ = false;
+      goto SUB_OP_SCOPE_PUSH_OR_NEW;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP1_TYPE_CODE_OF                          */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_TYPE_CODE_OF): {
+      CODE_COVERAGE_UNTESTED(607); // Not hit
+      reg1 = POP();
+      reg1 = mvm_typeOf(vm, reg1);
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP1_POP                                   */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_POP): {
+      CODE_COVERAGE(138); // Hit
+      pStackPointer--;
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP1_TYPEOF                                */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_TYPEOF): {
+      CODE_COVERAGE(167); // Hit
+      // TODO: This is should really be done using some kind of built-in helper
+      // function, but we don't support those yet. The trouble with this
+      // implementation is that it's doing a string allocation every time. Also
+      // the new string is not an interned string so it's expensive to compare
+      // `typeof x === y`. Basically this is just a stop-gap.
+      reg1 = mvm_typeOf(vm, pStackPointer[-1]);
+      VM_ASSERT(vm, reg1 < sizeof typeStringOffsetByType);
+      reg1 = typeStringOffsetByType[reg1];
+      VM_ASSERT(vm, reg1 < sizeof(TYPE_STRINGS) - 1);
+      const char* str = &TYPE_STRINGS[reg1];
+      FLUSH_REGISTER_CACHE();
+      reg1 = vm_newStringFromCStrNT(vm, str);
+      CACHE_REGISTERS();
+      goto SUB_TAIL_POP_1_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP1_OBJECT_NEW                            */
+/*   Expects:                                                                */
+/*     (nothing)                                                             */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_OBJECT_NEW): {
+      CODE_COVERAGE(112); // Hit
+      FLUSH_REGISTER_CACHE();
+      TsPropertyList* pObject = GC_ALLOCATE_TYPE(vm, TsPropertyList, TC_REF_PROPERTY_LIST);
+      CACHE_REGISTERS();
+      reg1 = ShortPtr_encode(vm, pObject);
+      pObject->dpNext = VM_VALUE_NULL;
+      pObject->dpProto = VM_VALUE_NULL;
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                               VM_OP1_LOGICAL_NOT                          */
+/*   Expects:                                                                */
+/*     (nothing)                                                             */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_LOGICAL_NOT): {
+      CODE_COVERAGE(113); // Hit
+      reg2 = POP(); // value to negate
+      reg1 = mvm_toBool(vm, reg2) ? VM_VALUE_FALSE : VM_VALUE_TRUE;
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP1_OBJECT_GET_1                          */
+/*   Expects:                                                                */
+/*     reg1: objectValue                                                     */
+/*     reg2: propertyName                                                    */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_OBJECT_GET_1): {
+      CODE_COVERAGE(114); // Hit
+      FLUSH_REGISTER_CACHE();
+      err = getProperty(vm, reg->pStackPointer - 2, reg->pStackPointer - 1, reg->pStackPointer - 2);
+      CACHE_REGISTERS();
+      if (err != MVM_E_SUCCESS) goto SUB_EXIT;
+      goto SUB_TAIL_POP_1_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                                 VM_OP1_ADD                                */
+/*   Expects:                                                                */
+/*     reg1: left operand                                                    */
+/*     reg2: right operand                                                   */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_ADD): {
+      CODE_COVERAGE(115); // Hit
+      reg1 = pStackPointer[-2];
+      reg2 = pStackPointer[-1];
+
+      // Special case for adding unsigned 12 bit numbers, for example in most
+      // loops. 12 bit unsigned addition does not require any overflow checks
+      if (Value_isVirtualUInt12(reg1) && Value_isVirtualUInt12(reg2)) {
+        CODE_COVERAGE(116); // Hit
+        reg1 = reg1 + reg2 - VirtualInt14_encode(vm, 0);
+        goto SUB_TAIL_POP_2_PUSH_REG1;
+      } else {
+        CODE_COVERAGE(119); // Hit
+      }
+      if (vm_isString(vm, reg1) || vm_isString(vm, reg2)) {
+        CODE_COVERAGE(120); // Hit
+        FLUSH_REGISTER_CACHE();
+        // Note: the intermediate values are saved back to the stack so that
+        // they're preserved if there is a GC collection. Even these conversions
+        // can trigger a GC collection
+        reg->pStackPointer[-2] = vm_convertToString(vm, reg->pStackPointer[-2]);
+        reg->pStackPointer[-1] = vm_convertToString(vm, reg->pStackPointer[-1]);
+        reg1 = vm_concat(vm, &reg->pStackPointer[-2], &reg->pStackPointer[-1]);
+        CACHE_REGISTERS();
+        goto SUB_TAIL_POP_2_PUSH_REG1;
+      } else {
+        CODE_COVERAGE(121); // Hit
+        // Interpret like any of the other numeric operations
+        // TODO: If VM_NUM_OP_ADD_NUM might cause a GC collection, then we shouldn't be popping here
+        POP();
+        reg1 = VM_NUM_OP_ADD_NUM;
+        goto SUB_OP_NUM_OP;
+      }
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                                 VM_OP1_EQUAL                              */
+/*   Expects:                                                                */
+/*     reg1: left operand                                                    */
+/*     reg2: right operand                                                   */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_EQUAL): {
+      CODE_COVERAGE(122); // Hit
+      // TODO: This popping should be done on the egress rather than the ingress
+      reg2 = POP();
+      reg1 = POP();
+      FLUSH_REGISTER_CACHE();
+      bool eq = mvm_equal(vm, reg1, reg2);
+      CACHE_REGISTERS();
+      if (eq) {
+        CODE_COVERAGE(483); // Hit
+        reg1 = VM_VALUE_TRUE;
+      } else {
+        CODE_COVERAGE(484); // Hit
+        reg1 = VM_VALUE_FALSE;
+      }
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                                 VM_OP1_NOT_EQUAL                          */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_NOT_EQUAL): {
+      reg1 = pStackPointer[-2];
+      reg2 = pStackPointer[-1];
+      // TODO: there seem to be so many places where we have to flush the
+      // register cache, that I'm wondering if it's actually a net benefit. It
+      // would be worth doing an experiment to see if the code size is smaller
+      // without the register cache. Also, is it strictly necessary to flush all
+      // the registers or can we maybe define a lightweight flush that just
+      // flushes the stack pointer?
+      FLUSH_REGISTER_CACHE();
+      bool eq = mvm_equal(vm, reg1, reg2);
+      CACHE_REGISTERS();
+      if(eq) {
+        CODE_COVERAGE(123); // Hit
+        reg1 = VM_VALUE_FALSE;
+      } else {
+        CODE_COVERAGE(485); // Hit
+        reg1 = VM_VALUE_TRUE;
+      }
+      goto SUB_TAIL_POP_2_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                                 VM_OP1_OBJECT_SET_1                       */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP1_OBJECT_SET_1): {
+      CODE_COVERAGE(124); // Hit
+      FLUSH_REGISTER_CACHE();
+      err = setProperty(vm, reg->pStackPointer - 3, reg->pStackPointer - 2, reg->pStackPointer - 1);
+      CACHE_REGISTERS();
+      if (err != MVM_E_SUCCESS) {
+        CODE_COVERAGE_UNTESTED(265); // Not hit
+        goto SUB_EXIT;
+      } else {
+        CODE_COVERAGE(322); // Hit
+      }
+      goto SUB_TAIL_POP_3_PUSH_0;
+    }
+
+  } // End of VM_OP_EXTENDED_1 switch
+
+  // All cases should jump to whatever tail they intend. Nothing should get here
+  VM_ASSERT_UNREACHABLE(vm);
+
+} // End of SUB_OP_EXTENDED_1
+
+
+
+/* ------------------------------------------------------------------------- */
+/*                             SUB_THROW                                     */
+/*   Expects:                                                                */
+/*     reg1: The exception value                                             */
+/* ------------------------------------------------------------------------- */
+
+SUB_THROW: {
+  // Find the closest catch block
+  regP1 = reg->pCatchTarget;
+
+  // If none, it's an uncaught exception
+  if (regP1 == NULL) {
+    CODE_COVERAGE(208); // Hit
+
+    if (out_result) {
+      *out_result = reg1;
+    }
+    err = MVM_E_UNCAUGHT_EXCEPTION;
+    goto SUB_EXIT;
+  } else {
+    CODE_COVERAGE(209); // Hit
+  }
+
+  VM_ASSERT(vm, Value_isVirtualInt14(regP1[0]));
+
+  VM_ASSERT(vm, pStackPointer >= getBottomOfStack(vm->stack));
+  VM_ASSERT(vm, pStackPointer < getTopOfStackSpace(vm->stack));
+
+  // Unwind the stack. regP1 is the stack pointer address we want to land up at
+  while (pFrameBase > regP1) {
+    CODE_COVERAGE(211); // Hit
+
+    // Near the beginning of mvm_call, we set `catchTarget` to NULL
+    // (and then restore at the end), which should direct exceptions through
+    // the path of "uncaught exception" above, so no frame here should ever
+    // be a host frame.
+    VM_ASSERT(vm, !(reg->argCountAndFlags & AF_CALLED_FROM_HOST));
+
+    // In the current frame structure, the size of the preceding frame is
+    // saved 4 words ahead of the frame base
+    pStackPointer = pFrameBase;
+    POP_REGISTERS();
+  }
+
+  pStackPointer = regP1;
+
+  // The next catch target is the outer one.
+  UNWIND_CATCH_TARGET();
+
+  // Jump to the catch block
+  reg2 = pStackPointer[1];
+  VM_ASSERT(vm, Value_isBytecodeMappedPtrOrWellKnown(reg2));
+  lpProgramCounter = LongPtr_add(vm->lpBytecode, reg2 & ~1);
+
+  // Push the exception to the stack for the catch block to use
+  goto SUB_TAIL_POP_0_PUSH_REG1;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                              SUB_OP_SCOPE_PUSH_OR_NEW                     */
+/*   Expects:                                                                */
+/*     reg3: true if the last slot should be set to the parent closure       */
+/* ------------------------------------------------------------------------- */
+SUB_OP_SCOPE_PUSH_OR_NEW: {
+  CODE_COVERAGE(645); // Hit
+  READ_PGM_1(reg1); // Scope slot count
+  FLUSH_REGISTER_CACHE();
+  vm_scopePushOrNew(vm, reg1, reg3);
+  CACHE_REGISTERS();
+  goto SUB_TAIL_POP_0_PUSH_0;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                             SUB_OP_NUM_OP                                 */
+/*   Expects:                                                                */
+/*     reg1: vm_TeNumberOp                                                   */
+/*     reg2: first popped operand                                            */
+/* ------------------------------------------------------------------------- */
+SUB_OP_NUM_OP: {
+  CODE_COVERAGE(25); // Hit
+
+  int32_t reg1I = 0;
+  int32_t reg2I = 0;
+
+  reg3 = reg1;
+
+  // If it's a binary operator, then we pop a second operand
+  if (reg3 < VM_NUM_OP_DIVIDER) {
+    CODE_COVERAGE(440); // Hit
+    reg1 = POP();
+
+    if (toInt32Internal(vm, reg1, &reg1I) != MVM_E_SUCCESS) {
+      CODE_COVERAGE(444); // Hit
+      #if MVM_SUPPORT_FLOAT
+      goto SUB_NUM_OP_FLOAT64;
+      #endif // MVM_SUPPORT_FLOAT
+    } else {
+      CODE_COVERAGE(445); // Hit
+    }
+  } else {
+    CODE_COVERAGE(441); // Hit
+    reg1 = 0;
+  }
+
+  // Convert second operand to a int32 (or the only operand if it's a unary op)
+  if (toInt32Internal(vm, reg2, &reg2I) != MVM_E_SUCCESS) {
+    CODE_COVERAGE(442); // Hit
+    // If we failed to convert to int32, then we need to process the operation as a float
+    #if MVM_SUPPORT_FLOAT
+    goto SUB_NUM_OP_FLOAT64;
+    #endif // MVM_SUPPORT_FLOAT
+  } else {
+    CODE_COVERAGE(443); // Hit
+  }
+
+  VM_ASSERT(vm, reg3 < VM_NUM_OP_END);
+  MVM_SWITCH (reg3, (VM_NUM_OP_END - 1)) {
+    MVM_CASE(VM_NUM_OP_LESS_THAN): {
+      CODE_COVERAGE(78); // Hit
+      reg1 = reg1I < reg2I;
+      goto SUB_TAIL_PUSH_REG1_BOOL;
+    }
+    MVM_CASE(VM_NUM_OP_GREATER_THAN): {
+      CODE_COVERAGE(79); // Hit
+      reg1 = reg1I > reg2I;
+      goto SUB_TAIL_PUSH_REG1_BOOL;
+    }
+    MVM_CASE(VM_NUM_OP_LESS_EQUAL): {
+      CODE_COVERAGE(80); // Hit
+      reg1 = reg1I <= reg2I;
+      goto SUB_TAIL_PUSH_REG1_BOOL;
+    }
+    MVM_CASE(VM_NUM_OP_GREATER_EQUAL): {
+      CODE_COVERAGE(81); // Hit
+      reg1 = reg1I >= reg2I;
+      goto SUB_TAIL_PUSH_REG1_BOOL;
+    }
+    MVM_CASE(VM_NUM_OP_ADD_NUM): {
+      CODE_COVERAGE(82); // Hit
+      #if MVM_SUPPORT_FLOAT && MVM_PORT_INT32_OVERFLOW_CHECKS
+        #if __has_builtin(__builtin_add_overflow)
+          if (__builtin_add_overflow(reg1I, reg2I, &reg1I)) {
+            goto SUB_NUM_OP_FLOAT64;
+          }
+        #else // No builtin overflow
+          int32_t result = reg1I + reg2I;
+          // Check overflow https://blog.regehr.org/archives/1139
+          if (((reg1I ^ result) & (reg2I ^ result)) < 0) goto SUB_NUM_OP_FLOAT64;
+          reg1I = result;
+        #endif // No builtin overflow
+      #else // No overflow checks
+        reg1I = reg1I + reg2I;
+      #endif
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_SUBTRACT): {
+      CODE_COVERAGE(83); // Hit
+      #if MVM_SUPPORT_FLOAT && MVM_PORT_INT32_OVERFLOW_CHECKS
+        #if __has_builtin(__builtin_sub_overflow)
+          if (__builtin_sub_overflow(reg1I, reg2I, &reg1I)) {
+            goto SUB_NUM_OP_FLOAT64;
+          }
+        #else // No builtin overflow
+          reg2I = -reg2I;
+          int32_t result = reg1I + reg2I;
+          // Check overflow https://blog.regehr.org/archives/1139
+          if (((reg1I ^ result) & (reg2I ^ result)) < 0) goto SUB_NUM_OP_FLOAT64;
+          reg1I = result;
+        #endif // No builtin overflow
+      #else // No overflow checks
+        reg1I = reg1I - reg2I;
+      #endif
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_MULTIPLY): {
+      CODE_COVERAGE(84); // Hit
+      #if MVM_SUPPORT_FLOAT && MVM_PORT_INT32_OVERFLOW_CHECKS
+        #if __has_builtin(__builtin_mul_overflow)
+          if (__builtin_mul_overflow(reg1I, reg2I, &reg1I)) {
+            goto SUB_NUM_OP_FLOAT64;
+          }
+        #else // No builtin overflow
+          // There isn't really an efficient way to determine multiplied
+          // overflow on embedded devices without accessing the hardware
+          // status registers. The fast shortcut here is to just assume that
+          // anything more than 14-bit multiplication could overflow a 32-bit
+          // integer.
+          if (Value_isVirtualInt14(reg1) && Value_isVirtualInt14(reg2)) {
+            reg1I = reg1I * reg2I;
+          } else {
+            goto SUB_NUM_OP_FLOAT64;
+          }
+        #endif // No builtin overflow
+      #else // No overflow checks
+        reg1I = reg1I * reg2I;
+      #endif
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_DIVIDE): {
+      CODE_COVERAGE(85); // Hit
+      #if MVM_SUPPORT_FLOAT
+        // With division, we leave it up to the user to write code that
+        // performs integer division instead of floating point division, so
+        // this instruction is always the case where they're doing floating
+        // point division.
+        goto SUB_NUM_OP_FLOAT64;
+      #else // !MVM_SUPPORT_FLOAT
+        err = vm_newError(vm, MVM_E_OPERATION_REQUIRES_FLOAT_SUPPORT);
+        goto SUB_EXIT;
+      #endif
+    }
+    MVM_CASE(VM_NUM_OP_DIVIDE_AND_TRUNC): {
+      CODE_COVERAGE(86); // Hit
+      if (reg2I == 0) {
+        reg1I = 0;
+        break;
+      }
+      reg1I = reg1I / reg2I;
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_REMAINDER): {
+      CODE_COVERAGE(87); // Hit
+      if (reg2I == 0) {
+        CODE_COVERAGE(26); // Hit
+        reg1 = VM_VALUE_NAN;
+        goto SUB_TAIL_POP_0_PUSH_REG1;
+      }
+      CODE_COVERAGE(90); // Hit
+      reg1I = reg1I % reg2I;
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_POWER): {
+      CODE_COVERAGE(88); // Hit
+      #if MVM_SUPPORT_FLOAT
+        // Maybe in future we can we implement an integer version.
+        goto SUB_NUM_OP_FLOAT64;
+      #else // !MVM_SUPPORT_FLOAT
+        err = vm_newError(vm, MVM_E_OPERATION_REQUIRES_FLOAT_SUPPORT);
+        goto SUB_EXIT;
+      #endif
+    }
+    MVM_CASE(VM_NUM_OP_NEGATE): {
+      CODE_COVERAGE(89); // Hit
+      #if MVM_SUPPORT_FLOAT && MVM_PORT_INT32_OVERFLOW_CHECKS
+        // Note: Zero negates to negative zero, which is not representable as an int32
+        if ((reg2I == INT32_MIN) || (reg2I == 0)) goto SUB_NUM_OP_FLOAT64;
+      #endif
+        reg1I = -reg2I;
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_UNARY_PLUS): {
+      reg1I = reg2I;
+      break;
+    }
+  } // End of switch vm_TeNumberOp for int32
+
+  // Convert the result from a 32-bit integer
+  if ((reg1I >= VM_MIN_INT14) && (reg1I <= VM_MAX_INT14)) {
+    CODE_COVERAGE(103); // Hit
+    reg1 = VirtualInt14_encode(vm, (uint16_t)reg1I);
+  } else {
+    CODE_COVERAGE(104); // Hit
+    FLUSH_REGISTER_CACHE();
+    reg1 = mvm_newInt32(vm, reg1I);
+    CACHE_REGISTERS();
+  }
+
+  goto SUB_TAIL_POP_0_PUSH_REG1;
+} // End of case SUB_OP_NUM_OP
+
+/* ------------------------------------------------------------------------- */
+/*                             SUB_OP_EXTENDED_2                             */
+/*   Expects:                                                                */
+/*     reg1: vm_TeOpcodeEx2                                                  */
+/* ------------------------------------------------------------------------- */
+
+SUB_OP_EXTENDED_2: {
+  CODE_COVERAGE(127); // Hit
+  reg3 = reg1;
+
+  // All the ex-2 instructions have an 8-bit parameter. This is stored in
+  // reg1 for consistency with 4-bit and 16-bit literal modes
+  READ_PGM_1(reg1);
+
+  // Some operations pop an operand off the stack. This goes into reg2
+  if (reg3 < VM_OP2_DIVIDER_1) {
+    CODE_COVERAGE(128); // Hit
+    reg2 = POP();
+  } else {
+    CODE_COVERAGE(129); // Hit
+  }
+
+  VM_ASSERT(vm, reg3 < VM_OP2_END);
+  MVM_SWITCH (reg3, (VM_OP2_END - 1)) {
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP2_BRANCH_1                               */
+/*   Expects:                                                                */
+/*     reg1: signed 8-bit offset to branch to, encoded in 16-bit unsigned    */
+/*     reg2: condition to branch on                                          */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_BRANCH_1): {
+      CODE_COVERAGE(130); // Hit
+      SIGN_EXTEND_REG_1();
+      goto SUB_BRANCH_COMMON;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP2_STORE_ARG                              */
+/*   Expects:                                                                */
+/*     reg1: unsigned index of argument in which to store                    */
+/*     reg2: value to store                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_STORE_ARG): {
+      CODE_COVERAGE_UNTESTED(131); // Not hit
+      #if MVM_DONT_TRUST_BYTECODE
+        // The ability to write to argument slots is intended as an optimization
+        // feature to elide the parameter variable slots and instead use the
+        // argument slots directly. But this only works if the optimizer can
+        // prove that unprovided parameters are never written to (or that all
+        // parameters are satisfied by arguments). If you don't trust the
+        // optimizer, it's possible the callee attempts to write to the
+        // caller-provided argument slots that don't exist.
+        if (reg1 >= (reg->argCountAndFlags & AF_ARG_COUNT_MASK)) {
+          err = vm_newError(vm, MVM_E_INVALID_BYTECODE);
+          goto SUB_EXIT;
+        }
+      #endif
+      reg->pArgs[reg1] = reg2;
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP2_STORE_SCOPED_2                         */
+/*   Expects:                                                                */
+/*     reg1: unsigned index of global in which to store                      */
+/*     reg2: value to store                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_STORE_SCOPED_2): {
+      CODE_COVERAGE(132); // Hit
+      goto SUB_OP_STORE_SCOPED;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP2_STORE_VAR_2                            */
+/*   Expects:                                                                */
+/*     reg1: unsigned index of variable in which to store, relative to SP    */
+/*     reg2: value to store                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_STORE_VAR_2): {
+      CODE_COVERAGE_UNTESTED(133); // Not hit
+      goto SUB_OP_STORE_VAR;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP2_JUMP_1                                 */
+/*   Expects:                                                                */
+/*     reg1: signed 8-bit offset to branch to, encoded in 16-bit unsigned    */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_JUMP_1): {
+      CODE_COVERAGE(136); // Hit
+      SIGN_EXTEND_REG_1();
+      goto SUB_JUMP_COMMON;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP2_CALL_HOST                              */
+/*   Expects:                                                                */
+/*     reg1: arg count                                                       */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_CALL_HOST): {
+      CODE_COVERAGE_UNTESTED(137); // Not hit
+      // TODO: Unit tests for the host calling itself etc.
+
+      // Put function index into reg2
+      READ_PGM_1(reg2);
+      // Note: reg1 is the argCount and also argCountAndFlags, because the flags
+      // are all zero in this case. In particular, the target is specified as an
+      // instruction literal, so `AF_PUSHED_FUNCTION` is false.
+      goto SUB_CALL_HOST_COMMON;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP2_CALL_3                                 */
+/*   Expects:                                                                */
+/*     reg1: arg count | isVoidCall flag 0x80                                */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_CALL_3): {
+      CODE_COVERAGE(142); // Hit
+
+      // Note: The first 7 bits of `reg1` are the argument count, and the 8th
+      // bit, as per the instruction format, is the `AF_VOID_CALLED` flag. None
+      // of the CALL instruction formats use the high byte, so it's reserved for
+      // general activation flags. Here we set flag AF_PUSHED_FUNCTION to
+      // indicate that a `CALL_3` operation requires that the function pointer
+      // is pushed to the stack and needs to be popped at the return point.
+
+      reg3 /* cpsCallback */ = VM_VALUE_UNDEFINED;
+
+      goto SUB_CALL_DYNAMIC;
+    }
+
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP2_CALL_6                              */
+/*   Expects:                                                                */
+/*     reg1: index into short-call table                                      */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_CALL_6): {
+      CODE_COVERAGE_UNTESTED(145); // Not hit
+      goto SUB_CALL_SHORT;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP2_LOAD_SCOPED_2                          */
+/*   Expects:                                                                */
+/*     reg1: unsigned closure scoped variable index                          */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_LOAD_SCOPED_2): {
+      CODE_COVERAGE(146); // Hit
+      goto SUB_OP_LOAD_SCOPED;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP2_LOAD_VAR_2                           */
+/*   Expects:                                                                */
+/*     reg1: unsigned variable index relative to stack pointer               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_LOAD_VAR_2): {
+      CODE_COVERAGE_UNTESTED(147); // Not hit
+      goto SUB_OP_LOAD_VAR;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP2_LOAD_ARG_2                           */
+/*   Expects:                                                                */
+/*     reg1: unsigned variable index relative to stack pointer               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_LOAD_ARG_2): {
+      CODE_COVERAGE_UNTESTED(148); // Not hit
+      VM_NOT_IMPLEMENTED(vm);
+      err = MVM_E_FATAL_ERROR_MUST_KILL_VM;
+      goto SUB_EXIT;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP2_EXTENDED_4                            */
+/*   Expects:                                                                */
+/*     reg1: The Ex-4 instruction opcode                                     */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_EXTENDED_4): {
+      CODE_COVERAGE(149); // Hit
+      goto SUB_OP_EXTENDED_4;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                              VM_OP2_ARRAY_NEW                             */
+/*   reg1: Array capacity                                                    */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_ARRAY_NEW): {
+      CODE_COVERAGE(100); // Hit
+
+      FLUSH_REGISTER_CACHE();
+      reg1 /* arr */ = vm_newArray(vm, reg1 /* capacity */);
+      CACHE_REGISTERS();
+
+      PUSH(reg1 /* arr */);
+
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                               VM_OP1_FIXED_ARRAY_NEW_2                    */
+/*   Expects:                                                                */
+/*     reg1: Fixed-array length (8-bit)                                      */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP2_FIXED_ARRAY_NEW_2): {
+      CODE_COVERAGE_UNTESTED(135); // Not hit
+      goto SUB_FIXED_ARRAY_NEW;
+    }
+
+  } // End of vm_TeOpcodeEx2 switch
+
+  // All cases should jump to whatever tail they intend. Nothing should get here
+  VM_ASSERT_UNREACHABLE(vm);
+
+} // End of SUB_OP_EXTENDED_2
+
+/* ------------------------------------------------------------------------- */
+/*                             SUB_FIXED_ARRAY_NEW                           */
+/*   Expects:                                                                */
+/*     reg1: length of fixed-array to create                                 */
+/* ------------------------------------------------------------------------- */
+
+SUB_FIXED_ARRAY_NEW: {
+  FLUSH_REGISTER_CACHE();
+  uint16_t* arr = mvm_allocate(vm, reg1 * 2, TC_REF_FIXED_LENGTH_ARRAY);
+  CACHE_REGISTERS();
+  uint16_t* p = arr;
+  // Note: when reading a DELETED value from the array, it will read as
+  // `undefined`. When fixed-length arrays are used to hold closure values, the
+  // `DELETED` value can be used to represent the TDZ.
+  while (reg1--)
+    *p++ = VM_VALUE_DELETED;
+  reg1 = ShortPtr_encode(vm, arr);
+  goto SUB_TAIL_POP_0_PUSH_REG1;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                             SUB_OP_EXTENDED_3                             */
+/*   Expects:                                                                */
+/*     reg1: vm_TeOpcodeEx3                                                  */
+/* ------------------------------------------------------------------------- */
+
+SUB_OP_EXTENDED_3: {
+  CODE_COVERAGE(150); // Hit
+  reg3 = reg1;
+
+  // Most Ex-3 instructions have a 16-bit parameter
+  if (reg3 >= VM_OP3_DIVIDER_1) {
+    CODE_COVERAGE(603); // Hit
+    READ_PGM_2(reg1);
+  } else {
+    CODE_COVERAGE(606); // Hit
+  }
+
+  if (reg3 >= VM_OP3_DIVIDER_2) {
+    CODE_COVERAGE(151); // Hit
+    reg2 = POP();
+  } else {
+    CODE_COVERAGE(152); // Hit
+  }
+
+  VM_ASSERT(vm, reg3 < VM_OP3_END);
+  MVM_SWITCH (reg3, (VM_OP3_END - 1)) {
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_POP_N                                  */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_POP_N): {
+      CODE_COVERAGE(602); // Hit
+      READ_PGM_1(reg1);
+      while (reg1--)
+        (void)POP();
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* -------------------------------------------------------------------------*/
+/*                             VM_OP3_SCOPE_DISCARD                         */
+/*   Expects:                                                               */
+/*     Nothing                                                              */
+/* -------------------------------------------------------------------------*/
+
+    MVM_CASE (VM_OP3_SCOPE_DISCARD): {
+      CODE_COVERAGE(634); // Hit
+      reg->closure = VM_VALUE_UNDEFINED;
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_SCOPE_CLONE                            */
+/*                                                                           */
+/*   Clones the top closure scope (which must exist) and sets it as the      */
+/*   new scope                                                               */
+/*                                                                           */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_SCOPE_CLONE): {
+      CODE_COVERAGE(635); // Hit
+
+      VM_ASSERT(vm, reg->closure != VM_VALUE_UNDEFINED);
+      FLUSH_REGISTER_CACHE();
+      Value newScope = vm_cloneContainer(vm, &reg->closure);
+      CACHE_REGISTERS();
+      reg->closure = newScope;
+
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_AWAIT                                  */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_AWAIT): {
+      /*
+      This instruction is invoked at a syntactic `await` point, which is after
+      the awaited expression has been pushed to the stack. If the awaited thing
+      (e.g. promise) has been elided due to CPS-optimization, the awaited value
+      will be VM_VALUE_UNDEFINED
+      */
+      goto SUB_AWAIT;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_AWAIT_CALL                             */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_AWAIT_CALL): {
+      CODE_COVERAGE(667); // Hit
+      // reg1 = arg count
+      READ_PGM_1(reg1);
+      // It doesn't make sense for the arg count word to contain the
+      // AF_VOID_CALLED flag because the point of an await-call is that the
+      // result is awaited, so it's not a void call.
+      VM_ASSERT(vm, (reg1 & AF_ARG_COUNT_MASK) == reg1);
+
+      // Note: the AWAIT instruction will set up the current closure function.
+      // This is valid because the callback should only be called
+      // asynchronously. And it's efficient because the AWAIT instruction needs
+      // to do it anyway if it subscribes to the promise result of the callee.
+      reg2 = VM_VALUE_DELETED; // Poison value in case the callee calls the callback synchronously.
+
+      // The current closure can be a continuation closure by assigning its
+      // function to the resume point.
+      VM_ASSERT(vm, deepTypeOf(vm, reg->closure) == TC_REF_CLOSURE);
+      regP1 /* current scope */ = ShortPtr_decode(vm, reg->closure);
+      regP1[0] = reg2;
+
+      // Similar to VM_OP2_CALL_3 except that cpsCallback points to the current closure
+      reg3 /* cpsCallback */ = reg->closure;
+
+      goto SUB_CALL_DYNAMIC;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_ASYNC_RESUME                           */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    // This instruction is the first instruction executed after an await point
+    // in an async function.
+    MVM_CASE (VM_OP3_ASYNC_RESUME): {
+      CODE_COVERAGE(668); // Hit
+
+      READ_PGM_1(reg1 /* stack restoration slot count */);
+      READ_PGM_1(reg2 /* top catch block */);
+
+      // Safety mechanism: wipe the closure function so that if the continuation
+      // is called illegally, it will be flagged. Note that there is already a
+      // wrapper function around the continuation closure when the host calls it,
+      // so this is just for catching internal bugs.
+      #if MVM_SAFE_MODE
+        regLP1 = vm_findScopedVariable(vm, 0);
+        regP1 = (Value*)LongPtr_truncate(vm, regLP1);
+        *regP1 = VM_VALUE_DELETED;
+      #endif
+
+      // The synchronous stack will be empty when the async function is resumed
+      VM_ASSERT(vm, pFrameBase == pStackPointer);
+
+      // Push the synchronous return value onto the stack. It doesn't really
+      // matter what this value is because the VM should only be resumed from
+      // the job queue (or the host can call a callback, but it should also not
+      // be expecting anything in particular for the result). This is kept in
+      // var[0] on the stack by common agreement with other operation. E.g.
+      // `ASYNC_RETURN` returns the value that's in this slot.
+      PUSH(VM_VALUE_UNDEFINED); // pFrameBase[0]
+
+      // Set up a catch target at this location on the stack (var slots 1 and 2)
+      VM_ASSERT(vm, pStackPointer == pFrameBase + 1);
+      // There should be no parent catch target because async functions can only
+      // be resumed from the job queue or a non-reentrant call from the host.
+      VM_ASSERT(vm, reg->pCatchTarget == NULL);
+      PUSH_CATCH_TARGET(getBuiltin(vm, BIN_ASYNC_CATCH_BLOCK));
+
+      // Restore stack (user defined catch blocks and expression temporaries).
+      // Slot 0 and 1 in the closure are for the continuation and callback. The
+      // next slots after that are reserved for dumping and restoring the stack
+      // state.
+      regP1 /* closure */ = (Value*)DynamicPtr_decode_native(vm, reg->closure);
+      VM_ASSERT(vm, vm_getAllocationSize(regP1) >= (2 + reg1) * 2);
+      regP1 += 2; // Skip over continuation and callback
+
+      TABLE_COVERAGE(reg1 ? 1 : 0, 2, 685); // Hit 2/2
+      while (reg1--) {
+        PUSH(*regP1);
+        // Wipe the closure slot. My reasoning is that async functions may be
+        // long-lived, and it's possible that the stack temporaries hold
+        // references to large structures, so we don't want them to be
+        // GC-reachable for the lifetime of the async function.
+        *regP1 = VM_VALUE_DELETED;
+        regP1++;
+      }
+
+      // Restore the catchTarget. It's statically determined how far behind the
+      // stack pointer the catch target is. It will never be null because async
+      // functions always have the root catch block.
+      TABLE_COVERAGE(reg->pCatchTarget == &pStackPointer[-reg2] ? 1 : 0, 2, 690); // Hit 2/2
+      reg->pCatchTarget = &pStackPointer[-reg2];
+      VM_ASSERT(vm, reg->pCatchTarget >= &pFrameBase[1]);
+      VM_ASSERT(vm, reg->pCatchTarget < pStackPointer);
+
+      // Push asynchronous result to the stack. Note: it's illegal for an agent
+      // to participate in CPS and not pass exactly three arguments `(this,
+      // isSuccess, result)`.
+      VM_ASSERT(vm, (reg->argCountAndFlags & AF_ARG_COUNT_MASK) == 3);
+
+      // Note: the signature here is (this, isSuccess, value)
+      reg2 /* isSuccess */ = reg->pArgs[1];
+      reg1 /* result */ = reg->pArgs[2];
+
+      if (reg2 /* isSuccess */ == VM_VALUE_FALSE) {
+        CODE_COVERAGE(669); // Hit
+        // Throw the value in reg1 (the error). The root catch block we pushed
+        // earlier will catch it.
+        goto SUB_THROW;
+      } else {
+        CODE_COVERAGE(686); // Hit
+      }
+      // Microvium CPS protocol requires that the first parameter is a boolean
+      // to indicate success or failure
+      VM_ASSERT(vm, reg2 == VM_VALUE_TRUE);
+      CODE_COVERAGE(670); // Hit
+
+      // Push the result to the stack and then continue with the function
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_JUMP_2                                 */
+/*   Expects:                                                                */
+/*     reg1: signed offset                                                   */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_JUMP_2): {
+      CODE_COVERAGE(153); // Hit
+      goto SUB_JUMP_COMMON;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_LOAD_LITERAL                           */
+/*   Expects:                                                                */
+/*     reg1: literal value                                                   */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_LOAD_LITERAL): {
+      CODE_COVERAGE(154); // Hit
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_LOAD_GLOBAL_3                          */
+/*   Expects:                                                                */
+/*     reg1: global variable index                                           */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_LOAD_GLOBAL_3): {
+      CODE_COVERAGE(155); // Hit
+      reg1 = globals[reg1];
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_LOAD_SCOPED_3                          */
+/*   Expects:                                                                */
+/*     reg1: scoped variable index                                           */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_LOAD_SCOPED_3): {
+      CODE_COVERAGE_UNTESTED(600); // Not hit
+      goto SUB_OP_LOAD_SCOPED;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_BRANCH_2                               */
+/*   Expects:                                                                */
+/*     reg1: signed offset                                                   */
+/*     reg2: condition                                                       */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_BRANCH_2): {
+      CODE_COVERAGE(156); // Hit
+      goto SUB_BRANCH_COMMON;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_STORE_GLOBAL_3                         */
+/*   Expects:                                                                */
+/*     reg1: global variable index                                           */
+/*     reg2: value to store                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_STORE_GLOBAL_3): {
+      CODE_COVERAGE(157); // Hit
+      globals[reg1] = reg2;
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_STORE_SCOPED_3                         */
+/*   Expects:                                                                */
+/*     reg1: scoped variable index                                           */
+/*     reg2: value to store                                                  */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_STORE_SCOPED_3): {
+      CODE_COVERAGE_UNTESTED(601); // Not hit
+      goto SUB_OP_STORE_SCOPED;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_OBJECT_GET_2                           */
+/*   Expects:                                                                */
+/*     reg1: property key value                                              */
+/*     reg2: object value                                                    */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_OBJECT_GET_2): {
+      CODE_COVERAGE_UNTESTED(158); // Not hit
+      VM_NOT_IMPLEMENTED(vm);
+      err = MVM_E_FATAL_ERROR_MUST_KILL_VM;
+      goto SUB_EXIT;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_OBJECT_SET_2                           */
+/*   Expects:                                                                */
+/*     reg1: property key value                                              */
+/*     reg2: value                                                           */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP3_OBJECT_SET_2): {
+      CODE_COVERAGE_UNTESTED(159); // Not hit
+      VM_NOT_IMPLEMENTED(vm);
+      err = MVM_E_FATAL_ERROR_MUST_KILL_VM;
+      goto SUB_EXIT;
+    }
+
+  } // End of vm_TeOpcodeEx3 switch
+  // All cases should jump to whatever tail they intend. Nothing should get here
+  VM_ASSERT_UNREACHABLE(vm);
+} // End of SUB_OP_EXTENDED_3
+
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP3_OBJECT_SET_2                           */
+/*   Expects:                                                                */
+/*     reg1: The Ex-4 instruction opcode                                     */
+/* ------------------------------------------------------------------------- */
+SUB_OP_EXTENDED_4: {
+  MVM_SWITCH(reg1, (VM_NUM_OP4_END - 1)) {
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP4_START_TRY                              */
+/*   Expects: nothing                                                        */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE(VM_OP4_START_TRY): {
+      CODE_COVERAGE(206); // Hit
+
+      // Location to jump to if there's an exception
+      READ_PGM_2(reg2);
+      PUSH_CATCH_TARGET(reg2);
+
+      goto SUB_TAIL_POP_0_PUSH_0;
+    } // End of VM_OP4_START_TRY
+
+    MVM_CASE(VM_OP4_END_TRY): {
+      CODE_COVERAGE(207); // Hit
+
+      // Note: EndTry can be invoked either at the normal ending of a `try`
+      // block, or during a `return` out of a try block. In the former case, the
+      // stack will already be at the level it was after the StartTry, but in
+      // the latter case the stack level could be anything since `return` won't
+      // go to the effort of popping intermediate variables off the stack.
+
+      VM_ASSERT(vm, reg->pCatchTarget != NULL); // Must be in a try block (StartTry must have been called)
+      pStackPointer = reg->pCatchTarget;
+      UNWIND_CATCH_TARGET();
+      VM_ASSERT(vm, pStackPointer >= pFrameBase); // EndTry can only end a try within the current frame
+
+      goto SUB_TAIL_POP_0_PUSH_0;
+    } // End of VM_OP4_END_TRY
+
+    MVM_CASE(VM_OP4_OBJECT_KEYS): {
+      CODE_COVERAGE(223); // Hit
+
+      // Note: leave object on the stack in case a GC cycle is triggered by the array allocation
+      FLUSH_REGISTER_CACHE();
+      err = vm_objectKeys(vm, &reg->pStackPointer[-1]);
+      // TODO: We could maybe eliminate the common CACHE_REGISTERS operation if
+      // the exit path checked the flag and cached for us.
+      CACHE_REGISTERS();
+
+      goto SUB_TAIL_POP_0_PUSH_0; // Pop the object and push the keys
+    } // End of VM_OP4_OBJECT_KEYS
+
+    MVM_CASE(VM_OP4_UINT8_ARRAY_NEW): {
+      CODE_COVERAGE(324); // Hit
+
+      FLUSH_REGISTER_CACHE();
+      err = vm_uint8ArrayNew(vm, &reg->pStackPointer[-1]);
+      CACHE_REGISTERS();
+
+      goto SUB_TAIL_POP_0_PUSH_0;
+    } // End of VM_OP4_OBJECT_KEYS
+
+/* ------------------------------------------------------------------------- */
+/*                          VM_OP4_CLASS_CREATE                              */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP4_CLASS_CREATE): {
+      CODE_COVERAGE(614); // Hit
+      // TODO: I think we could save some flash space if we grouped all the
+      // opcodes together according to whether they flush the register cache.
+      // Also maybe they could be dispatched through a lookup table.
+      FLUSH_REGISTER_CACHE();
+      TsClass* pClass = mvm_allocate(vm, sizeof (TsClass), TC_REF_CLASS);
+      CACHE_REGISTERS();
+      pClass->constructorFunc = pStackPointer[-2];
+      pClass->staticProps = pStackPointer[-1];
+      pStackPointer[-2] = ShortPtr_encode(vm, pClass);
+      goto SUB_TAIL_POP_1_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                          VM_OP4_TYPE_CODE_OF                              */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP4_TYPE_CODE_OF): {
+      CODE_COVERAGE(631); // Hit
+      reg1 = mvm_typeOf(vm, pStackPointer[-1]);
+      reg1 = VirtualInt14_encode(vm, reg1);
+      goto SUB_TAIL_POP_1_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                          VM_OP4_LOAD_REG_CLOSURE                          */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP4_LOAD_REG_CLOSURE): {
+      CODE_COVERAGE(644); // Hit
+      reg1 = reg->closure;
+      goto SUB_TAIL_POP_0_PUSH_REG1;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                          VM_OP4_SCOPE_PUSH                                */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+
+    MVM_CASE (VM_OP4_SCOPE_PUSH): {
+      CODE_COVERAGE(648); // Hit
+      reg3 /*capture parent*/ = true;
+      goto SUB_OP_SCOPE_PUSH_OR_NEW;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                             VM_OP4_SCOPE_POP                              */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/* ------------------------------------------------------------------------- */
+    MVM_CASE (VM_OP4_SCOPE_POP): {
+      CODE_COVERAGE(649); // Hit
+      reg1 = reg->closure;
+      VM_ASSERT(vm, reg1 != VM_VALUE_UNDEFINED);
+      LongPtr lpClosure = DynamicPtr_decode_long(vm, reg1);
+      uint16_t headerWord = readAllocationHeaderWord_long(lpClosure);
+      uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+      // The pointer to the parent scope is the last slot in the closure
+      reg1 = LongPtr_read2_aligned(LongPtr_add(lpClosure, size - 2));
+      reg->closure = reg1;
+
+      VM_ASSERT(vm, vm_getTypeCodeFromHeaderWord(headerWord) == TC_REF_CLOSURE);
+      VM_ASSERT(vm, size >= 2);
+      VM_ASSERT(vm, (deepTypeOf(vm, reg1) == TC_REF_CLOSURE) || (deepTypeOf(vm, reg1) == TC_VAL_DELETED));
+
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/** -------------------------------------------------------------------------
+ *                             VM_OP4_SCOPE_SAVE
+ *
+ * Saves the current closure by pushing it to the stack.
+ *
+ *   Expects:
+ *     Nothing
+ * ------------------------------------------------------------------------- */
+    MVM_CASE (VM_OP4_SCOPE_SAVE): {
+      CODE_COVERAGE(728); // Hit
+      PUSH(reg->closure);
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                            VM_OP4_ASYNC_START                             */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/*                                                                           */
+/*   This should be the first instruction in an async function.              */
+/* ------------------------------------------------------------------------- */
+    MVM_CASE (VM_OP4_ASYNC_START): {
+      CODE_COVERAGE(696); // Hit
+      READ_PGM_1(reg1); // Closure size and parent reference flag
+
+      // Reserve a slot for the result. Note that `ASYNC_START` is the first
+      // instruction in an async function, so the result is stored at `var[0]`
+      VM_ASSERT(vm, pFrameBase == pStackPointer);
+      PUSH(VM_VALUE_UNDEFINED);
+
+      FLUSH_REGISTER_CACHE();
+
+      TABLE_COVERAGE((reg1 & 0x80) ? 1 : 0, 2, 683); // Hit 2/2
+      TABLE_COVERAGE((reg1 & 0x7F) > 2 ? 1 : 0, 2, 684); // Hit 2/2
+
+      // Acquire the callback that this async function needs to call when it's
+      // done. If caller used CPS, the callback is the one provided by the
+      // caller, otherwise this will synthesize a Promise and return a callback
+      // that resolves or rejects the promise.
+      reg2 = vm_asyncStartUnsafe(vm,
+        reg->pFrameBase /* synchronous result slot */
+      );
+      vm_push(vm, reg2); // GC-reachable, because vm_scopePushOrNew performs an allocation
+
+      // Create closure scope for async function
+      regP1 /* scope */ = vm_scopePushOrNew(vm,
+        reg1 & 0x7F, // slotCount
+        reg1 & 0x80 // isParentCapturing
+      );
+      // The callback gets stored in
+      regP1[1] /* callback */ = vm_pop(vm);
+
+      CACHE_REGISTERS();
+
+      // Async catch target (logic basically copied from VM_OP4_START_TRY)
+      VM_ASSERT(vm, pStackPointer == pFrameBase + 1);
+      PUSH_CATCH_TARGET(getBuiltin(vm, BIN_ASYNC_CATCH_BLOCK));
+
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                            VM_OP4_ASYNC_RETURN                            */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/*                                                                           */
+/* ------------------------------------------------------------------------- */
+    MVM_CASE (VM_OP4_ASYNC_RETURN): {
+      // This operation is used in place of a normal RETURN when compiling an
+      // async function. It indirectly calls the callback function with the
+      // result instead of passing it to the synchronous caller (it does so via
+      // the job queue).
+
+      CODE_COVERAGE(732); // Not hit
+
+      reg2 /* result */ = POP();
+
+      // Pop the async catch block. We know that this is always stored in the
+      // same slot. It doesn't matter what's on top of it.
+      pStackPointer = &pFrameBase[1];
+      UNWIND_CATCH_TARGET();
+
+      PUSH(/* result */ reg2);
+      PUSH(/* isSuccess */ VM_VALUE_TRUE);
+      goto SUB_ASYNC_COMPLETE;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                            VM_OP4_ENQUEUE_JOB                             */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/*                                                                           */
+/* ------------------------------------------------------------------------- */
+    MVM_CASE (VM_OP4_ENQUEUE_JOB): {
+      // This instruction enqueues the current closure to the job queue (for the
+      // moment there is only one job queue, for executing async callbacks)
+      CODE_COVERAGE_UNTESTED(671); // Not hit
+      // Need to flush registers because `vm_enqueueJob` can trigger GC collection
+      FLUSH_REGISTER_CACHE();
+      vm_enqueueJob(vm, reg->closure);
+      CACHE_REGISTERS();
+      goto SUB_TAIL_POP_0_PUSH_0;
+    }
+
+/* ------------------------------------------------------------------------- */
+/*                            VM_OP4_ASYNC_COMPLETE                             */
+/*   Expects:                                                                */
+/*     Nothing                                                               */
+/*                                                                           */
+/* ------------------------------------------------------------------------- */
+    MVM_CASE (VM_OP4_ASYNC_COMPLETE): {
+      // This instruction implements the completion of an async function. The
+      // main functionality is in SUB_ASYNC_COMPLETE which is shared between the
+      // 3 different async completion paths: return (AsyncReturn), catch
+      // (builtin BIN_ASYNC_CATCH_BLOCK), and host-callback (builtin
+      // BIN_ASYNC_HOST_CALLBACK).
+
+      CODE_COVERAGE(697); // Hit
+
+      goto SUB_ASYNC_COMPLETE;
+    }
+
+  } // End of switch inside SUB_OP_EXTENDED_4
+} // End of SUB_OP_EXTENDED_4
+
+/* -------------------------------------------------------------------------
+ *                             SUB_AWAIT
+ *
+ * Persists the current stack values to the closure, unwinds the stack, and
+ * returns to the caller. If awaited value is a promise, it also subscribes to
+ * the promise.
+ *
+ *   Expects:
+ *     - value to await is at the top of the stack
+ *     - in an async function with corresponding stack and closure structure
+ *
+ * ------------------------------------------------------------------------- */
+SUB_AWAIT: {
+  CODE_COVERAGE(666); // Hit
+
+  reg1 /* value to await */ = POP();
+
+  // We need to preserve the stack by copying it to the closure. regP1 is
+  // the cursor on the stack that we're copying out of and regP2 is the
+  // cursor on the heap (closure) that we're copying into.
+
+  // We only need to preserve slots from `&pFrameBase[3]` onwards because
+  // the first 3 slots are the synchronous return value and the top-level
+  // catch block.
+  regP1 = &pFrameBase[3];
+  VM_ASSERT(vm, pStackPointer >= regP1);
+  // var[0] is the synchronous return value and var[1-2] are the top-level
+  // catch block. Don't need to copy these.
+  reg2 /*closure*/ = reg->closure;
+  VM_ASSERT(vm, reg2 != VM_VALUE_DELETED);
+  // Note: the closure must be in RAM because we're modifying it
+  regP2 = DynamicPtr_decode_native(vm, reg2 /*closure*/);
+  VM_ASSERT(vm, vm_getAllocationType(regP2) == TC_REF_CLOSURE);
+  // The closure must be large enough to store all the stack variables. The
+  // +4 here is 4 bytes for the first 2 slots of the closure which hold the
+  // continuation function pointer and the callback function pointer.
+  VM_ASSERT(vm, vm_getAllocationSize(regP2) >= ((intptr_t)pStackPointer - (intptr_t)regP1) + 4);
+
+  /*
+  Await/resume bytecode structure
+
+    - [1B]: VM_OP3_AWAIT instruction (synchronous return point)
+    - [0-3B]: padding to 4-byte boundary
+    - [2B]: function header
+    - [2B]: VM_OP3_ASYNC_RESUME + 8-bit slot count + 8-bit catchTarget info
+  */
+
+  // Round up to nearest 4-byte boundary to find the start of the
+  // continuation (since this needs to be addressable and bytecode is only
+  // addressable at 4-byte alignment). This is a bit cumbersome because I'm
+  // not assuming that LongPtr can be directly cast to an integer type.
+  reg2 /* pc offset in bytecode */ = LongPtr_sub(lpProgramCounter, vm->lpBytecode);
+  reg2 /* resume point offset in bytecode */ = (reg2 + (
+    + 2 // skip over function header
+    + 3 // round up to 4-byte boundary
+    )) & 0xFFFC;
+
+  // The resume point should be immediately preceeded by a function header
+  VM_ASSERT(vm,
+    vm_getTypeCodeFromHeaderWord(
+      LongPtr_read2_aligned(LongPtr_add(vm->lpBytecode, reg2 - 2))
+    ) == TC_REF_FUNCTION);
+
+  // The first instruction at the resume point is expected to be the async-resume instruction
+  VM_ASSERT(vm, LongPtr_read1(LongPtr_add(vm->lpBytecode, reg2)) == ((VM_OP_EXTENDED_3 << 4) | VM_OP3_ASYNC_RESUME));
+
+  regP2[0] /* resume point bytecode pointer */ = vm_encodeBytecodeOffsetAsPointer(vm, reg2);
+
+
+  // Preserve the stack
+  regP2 = &regP2[2]; // Skip continuation pointer and callback slot
+  TABLE_COVERAGE(regP1 < pStackPointer ? 1 : 0, 2, 687); // Hit 2/2
+  while (regP1 < pStackPointer) {
+    *regP2++ = *regP1++;
+  }
+
+  // Unwind the exception stack
+  pStackPointer = &pFrameBase[1]; // The catch block is always stored in slots 1-2
+  VM_ASSERT(vm, pStackPointer[1] == getBuiltin(vm, BIN_ASYNC_CATCH_BLOCK));
+  UNWIND_CATCH_TARGET();
+
+  // Optimization: if the AWAIT instruction is awaiting the result of a
+  // function call, then the call was compiled as an AWAIT_CALL instruction
+  // to pass a continuation callback to the callee. If the callee supports
+  // CPS then it will "accept" the continuation by returning
+  // VM_VALUE_DELETED as the result, to indicate an elided promise.
+  if (reg1 /* value to await */ == VM_VALUE_DELETED) {
+    CODE_COVERAGE(688); // Hit
+    // Return the synchronous return value which is specified as being in
+    // var[0] for all async functions. The synchronous return value could be
+    // VM_VALUE_UNDEFINED if we're currently in a state where we're resumed
+    // from the job queue, or if the call is a void-call, or it could be
+    // VM_VALUE_DELETED if the caller used CPS, or it could be a promise if
+    // the caller was not void-calling and not await-calling. The value is
+    // established in the `ASYNC_START` operation or `ASYNC_RESUME`
+    // operation.
+    reg1 = pFrameBase[0];
+    goto SUB_RETURN;
+  }
+
+  CODE_COVERAGE(689); // Hit
+
+  // If the value to await is not elided by a CPS-optimized call, then it
+  // must be a promise. If it's not a promise, then we have a type error.
+  // This doesn't match the ECMAScript standard because in normal JS you can
+  // await anything, but I think it's a reasonable behavior and a subset of
+  // the full spec.
+
+  TeTypeCode tc = deepTypeOf(vm, reg1 /* value to await */);
+  if (tc != TC_REF_PROPERTY_LIST) {
+    CODE_COVERAGE_ERROR_PATH(705); // Not hit
+    // TODO: type errors should actually be catchable
+    err = vm_newError(vm, MVM_E_TYPE_ERROR_AWAIT_NON_PROMISE);
+    goto SUB_EXIT;
+  }
+  regP1 = ShortPtr_decode(vm, reg1 /* value to await */);
+  // Brand check
+  if (regP1[VM_OIS_PROTO] != getBuiltin(vm, BIN_PROMISE_PROTOTYPE)) {
+    CODE_COVERAGE_ERROR_PATH(706); // Not hit
+    err = vm_newError(vm, MVM_E_TYPE_ERROR_AWAIT_NON_PROMISE);
+    goto SUB_EXIT;
+  }
+
+
+  FLUSH_REGISTER_CACHE();
+  mvm_subscribeToPromise(vm, reg1 /* promise */, reg->closure);
+  CACHE_REGISTERS();
+
+  // The stack has been unwound, so it should just be the synchronous return
+  // value remaining
+  VM_ASSERT(vm, pStackPointer == pFrameBase + 1);
+  reg1 = POP(); // Return value
+  goto SUB_RETURN;
+}
+
+/* -------------------------------------------------------------------------
+ *                             SUB_ASYNC_COMPLETE
+ *
+ * This subroutine implements the completion of an async function, which
+ * schedules any callbacks to be called on the job queue and then returns from
+ * the current function. This subroutine is shared between the 3 different async
+ * completion paths: return (AsyncReturn), catch (builtin
+ * BIN_ASYNC_CATCH_BLOCK), and host-callback (builtin BIN_ASYNC_HOST_CALLBACK).
+ *
+ *   Expects:
+ *
+ *     - result/error and isSuccess on the stack
+ *     - callbackOrPromise in scope[1] of the current closure
+ *     - the synchronous return value in var[0]
+ *
+ * ------------------------------------------------------------------------- */
+SUB_ASYNC_COMPLETE: {
+  CODE_COVERAGE(698); // Hit
+
+  // I think all paths leading here will get the stack into a consistent state
+  VM_ASSERT(vm, pStackPointer == pFrameBase + 3);
+
+  reg2 = POP(); // isSuccess
+  reg2 = mvm_toBool(vm, reg2) ? VM_VALUE_TRUE : VM_VALUE_FALSE; // Coerce to boolean
+  reg3 = POP(); // result/error
+  reg1 = vm_readScopedFromThisClosure(vm, 1); // callbackOrPromise
+
+  FLUSH_REGISTER_CACHE();
+  TeTypeCode tc = deepTypeOf(vm, reg1);
+  if (tc == TC_VAL_NO_OP_FUNC) {
+    // If the callback is a no-op, then we don't need to schedule it on the job
+    CODE_COVERAGE(664); // Hit
+  } else if (tc == TC_REF_CLOSURE) {
+    // The callback is a direct continuation
+    CODE_COVERAGE(665); // Hit
+    vm_scheduleContinuation(vm, reg1, reg2, reg3);
+  } else {
+    // Otherwise, the callback slot holds a promise. This happens if the current
+    // async operation was not called in an await-call or void-call, so a
+    // promise was synthesized.
+    CODE_COVERAGE(699); // Hit
+    VM_ASSERT(vm, tc == TC_REF_PROPERTY_LIST);
+
+    // Assuming promises are in RAM because we can subscribe to them any time
+    // and there is currently no static analysis that can prove otherwise.
+    Value* pPromise = ShortPtr_decode(vm, reg1);
+    // The promise is generated internally, and the slot is not accessible by
+    // user code, so it will always be a promise if it's not a function
+    // callback.
+    VM_ASSERT(vm, pPromise[VM_OIS_PROTO] == getBuiltin(vm, BIN_PROMISE_PROTOTYPE));
+    VM_ASSERT(vm, vm_getAllocationSize(pPromise) >= 8); // At least 4 slots
+
+    // The promise is guaranteed to be a in pending state because AsyncComplete
+    // is the only way to transition out of the pending state, and this will
+    // only be invoked once. This closure self-destructs by setting closure slot
+    // 0 to a no-op function, so that successive calls will have no effect.
+    VM_ASSERT(vm, pPromise[VM_OIS_PROMISE_STATUS] == VM_PROMISE_STATUS_PENDING);
+
+    Value callbackList = pPromise[VM_OIS_PROMISE_OUT];
+
+    // Mark the promise as settled
+    pPromise[VM_OIS_PROMISE_STATUS] = reg2 == VM_VALUE_TRUE ? VM_PROMISE_STATUS_RESOLVED : VM_PROMISE_STATUS_REJECTED;
+    pPromise[VM_OIS_PROMISE_OUT] = reg3; // Note: need to assign this before vm_scheduleContinuation to avoid GC issues
+
+    tc = deepTypeOf(vm, callbackList);
+    if (tc == TC_VAL_UNDEFINED) {
+      // Subscriber list is empty
+      CODE_COVERAGE(719); // Hit
+    } else if (tc == TC_REF_CLOSURE) {
+      // Single subscriber
+      CODE_COVERAGE(720); // Hit
+      vm_scheduleContinuation(vm, callbackList, reg2, reg3);
+    } else {
+      // Multiple subscribers
+      CODE_COVERAGE(721); // Hit
+      VM_ASSERT(vm, tc == TC_REF_ARRAY);
+      TsArray* pArray = (TsArray*)ShortPtr_decode(vm, callbackList);
+      int len = VirtualInt14_decode(vm, pArray->viLength);
+      vm_push(vm, reg3 /* resultOrError */); // GC-reachable
+      vm_push(vm, pArray->dpData); // GC-reachable
+      TABLE_COVERAGE(len > 2 ? 1 : 0, 2, 722); // Hit 2/2
+      for (int i = 0; i < len; i++) {
+        // Note: the subscribers list is may move due to GC collections
+        // caused by scheduling the continuation.
+        Value* subscribers = ShortPtr_decode(vm, reg->pStackPointer[-1]);
+        Value callback = subscribers[i];
+        vm_scheduleContinuation(vm, callback, reg2, reg->pStackPointer[-2]);
+      }
+      vm_pop(vm); // dpData
+      vm_pop(vm); // resultOrError
+    }
+  }
+  CACHE_REGISTERS();
+
+  // Invalidate the current closure so if it's called again it won't do anything
+  vm_writeScopedToThisClosure(vm, 0, VM_VALUE_NO_OP_FUNC);
+
+  VM_ASSERT(vm, pStackPointer == pFrameBase + 1); // I think at this point the stack should be empty except for the return value
+  reg1 = pFrameBase[0]; // Synchronous return value (e.g. the Promise)
+  goto SUB_RETURN;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                             SUB_BRANCH_COMMON                             */
+/*   Expects:                                                                */
+/*     reg1: signed 16-bit amount to jump by if the condition is truthy      */
+/*     reg2: condition to branch on                                          */
+/* ------------------------------------------------------------------------- */
+SUB_BRANCH_COMMON: {
+  CODE_COVERAGE(160); // Hit
+  if (mvm_toBool(vm, reg2)) {
+    lpProgramCounter = LongPtr_add(lpProgramCounter, (int16_t)reg1);
+  }
+  goto SUB_TAIL_POP_0_PUSH_0;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                             SUB_JUMP_COMMON                               */
+/*   Expects:                                                                */
+/*     reg1: signed 16-bit amount to jump by                                 */
+/* ------------------------------------------------------------------------- */
+SUB_JUMP_COMMON: {
+  CODE_COVERAGE(161); // Hit
+  lpProgramCounter = LongPtr_add(lpProgramCounter, (int16_t)reg1);
+  goto SUB_TAIL_POP_0_PUSH_0;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                                                                           */
+/*                                  SUB_RETURN                               */
+/*                                                                           */
+/*   Return from the current frame                                           */
+/*                                                                           */
+/*   Expects:                                                                */
+/*     reg1: the return value                                                */
+/* ------------------------------------------------------------------------- */
+SUB_RETURN: {
+  CODE_COVERAGE(105); // Hit
+
+  // Pop variables
+  pStackPointer = pFrameBase;
+
+  // Save argCountAndFlags from this frame
+  reg3 = reg->argCountAndFlags;
+
+  // Restore caller state
+  POP_REGISTERS();
+
+  // If the catch target isn't earlier than the stack pointer then possibly the
+  // catch blocks weren't unwound properly (e.g. the compiler didn't generate
+  // matching EndTry instructions).
+  VM_ASSERT(vm, reg->pCatchTarget < pStackPointer);
+
+  goto SUB_POP_ARGS;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                                                                           */
+/*                                SUB_POP_ARGS                               */
+/*                                                                           */
+/*   The second part of a "RETURN". Assumes that we're already in the        */
+/*   caller stack frame by this point.                                       */
+/*                                                                           */
+/*   Expects:                                                                */
+/*     reg1: returning result                                                */
+/*     reg3: argCountAndFlags for callee frame                               */
+/* ------------------------------------------------------------------------- */
+SUB_POP_ARGS: {
+  // Pop arguments
+  pStackPointer -= (reg3 & AF_ARG_COUNT_MASK);
+
+  // Pop function reference
+  if (reg3 & AF_PUSHED_FUNCTION) {
+    CODE_COVERAGE(108); // Hit
+    (void)POP();
+  } else {
+    CODE_COVERAGE_UNTESTED(109); // Hit
+  }
+
+  // We don't preserve this register across function calls, so when we return
+  // from a function, we no longer know what the callback is for the caller
+  // frame. VM_VALUE_DELETED is used as a poison value here.
+  reg->cpsCallback = VM_VALUE_DELETED;
+
+  // Called from the host?
+  if (reg3 & AF_CALLED_FROM_HOST) {
+    CODE_COVERAGE(221); // Hit
+    goto SUB_RETURN_TO_HOST;
+  } else if (reg3 & AF_VOID_CALLED) {
+    CODE_COVERAGE(733); // Not hit
+    // The call operation was a void call, so don't push the return value
+    goto SUB_TAIL_POP_0_PUSH_0;
+  } else {
+    CODE_COVERAGE(111); // Hit
+    // The call operation was a non-void-call, so push the return value
+    goto SUB_TAIL_POP_0_PUSH_REG1;
+  }
+}
+
+/* ------------------------------------------------------------------------- */
+/*                                                                           */
+/*                            SUB_RETURN_TO_HOST                             */
+/*                                                                           */
+/*   Return control to the host                                              */
+/*                                                                           */
+/*   This is after popping the arguments                                     */
+/*                                                                           */
+/*   Expects:                                                                */
+/*     reg1: the return value                                                */
+/* ------------------------------------------------------------------------- */
+SUB_RETURN_TO_HOST: {
+  CODE_COVERAGE(110); // Hit
+
+  // Provide the return value to the host
+  if (out_result) {
+    *out_result = reg1;
+  }
+
+  // Next job in job queue
+  if ((reg->jobQueue != VM_VALUE_UNDEFINED) && (pStackPointer == getBottomOfStack(vm->stack))) {
+    CODE_COVERAGE(680); // Hit
+
+    // Whatever the result has been set to for the primary call target, we don't
+    // want to change to the result of any job
+    out_result = NULL;
+
+    FLUSH_REGISTER_CACHE();
+    reg1 /* argCountAndFlags */ = 0 | AF_CALLED_FROM_HOST; // No args, and return to host when complete
+    reg2 /* target */ = vm_dequeueJob(vm);
+    VM_ASSERT(vm, deepTypeOf(vm, reg2) == TC_REF_CLOSURE); // I expect it to be a closure, although not technically required here
+    reg3 /* cpsCallback */ = VM_VALUE_UNDEFINED;
+    CACHE_REGISTERS();
+
+    goto SUB_CALL;
+  } else {
+    CODE_COVERAGE(681); // Hit
+  }
+
+  goto SUB_EXIT;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                                                                           */
+/*                                    SUB_CALL                               */
+/*                                                                           */
+/*   Performs a dynamic call to a given function value                       */
+/*                                                                           */
+/*   Expects:                                                                */
+/*     reg1: argCountAndFlags excluding AF_PUSHED_FUNCTION                   */
+/*     reg3: new value for CPS callback                                      */
+/* ------------------------------------------------------------------------- */
+SUB_CALL_DYNAMIC: {
+  reg1 /* argCountAndFlags */ |= AF_PUSHED_FUNCTION;
+  reg2 /* target */ = pStackPointer[-(int16_t)(reg1 & AF_ARG_COUNT_MASK) - 1]; // The function was pushed before the arguments
+  goto SUB_CALL;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                                                                           */
+/*                                    SUB_NEW                                */
+/*                                                                           */
+/*   Performs a dynamic call to a given function value                       */
+/*                                                                           */
+/*   Expects:                                                                */
+/*     reg1: argCountAndFlags including AF_PUSHED_FUNCTION                   */
+/*     The stack should have the class, this (undefined), and args           */
+/* ------------------------------------------------------------------------- */
+SUB_NEW: {
+  regP1 = &pStackPointer[-(uint8_t)reg1 - 1]; // Pointer to class
+  reg2 /*class*/ = regP1[0];
+
+  // The class instance must be on the stack in the position where the function would normally go
+  VM_ASSERT(vm, reg1 /*argCountAndFlags*/ & AF_PUSHED_FUNCTION);
+
+  // Can only `new` classes in Microvium
+  if (deepTypeOf(vm, reg2) != TC_REF_CLASS) {
+    err = vm_newError(vm, MVM_E_USING_NEW_ON_NON_CLASS);
+    goto SUB_EXIT;
+  }
+
+  // We've already checked that the target of the `new` operation is a
+  // class. A class cannot existed without a `prototype` property. If the
+  // class was created at compile time, the "prototype" string will be
+  // embedded in the bytecode because the class definition uses it. If the
+  // class was created at runtime, the "prototype" string will *also* be
+  // embedded in the bytecode because classes at runtime are only created by
+  // sequences of instructions that also includes reference to the
+  // "prototype" string. So either way, the fact that we're at this point in
+  // the code means that the "prototype" string must exist as a builtin.
+  VM_ASSERT(vm, getBuiltin(vm, BIN_STR_PROTOTYPE) != VM_VALUE_UNDEFINED);
+
+  regLP1 = DynamicPtr_decode_long(vm, reg2);
+  regP1[0] /*func*/ = READ_FIELD_2(regLP1, TsClass, constructorFunc);
+  // Note: this trashes the `this` slot, but it's ok because we set it later to the new object
+  regP1[1] /*props*/ = READ_FIELD_2(regLP1, TsClass, staticProps);
+
+  PUSH(getBuiltin(vm, BIN_STR_PROTOTYPE)); // "prototype" string
+
+  // Get the prototype property of the class. Now regP1[1] is the prototype.
+  FLUSH_REGISTER_CACHE();
+  getProperty(vm, &regP1[1], &reg->pStackPointer[-1], &regP1[1]);
+
+  vm_pop(vm); // "prototype" string
+
+  TeTypeCode tc = deepTypeOf(vm, regP1[1] /* prototype */);
+  reg2 /* internalSlotCount */ = 0;
+  if (tc == TC_REF_PROPERTY_LIST) {
+    CODE_COVERAGE(723); // Hit
+    regP2 /* pPrototype */ = (Value*)ShortPtr_decode(vm, regP1[1]);
+    // Look for the magic value that tells us how many prototype slots there
+    // are.
+    if ((vm_getAllocationSize(regP2) >= 4) &&
+      (regP2[VM_OIS_PROTO_SLOT_MAGIC_KEY] == VM_PROTO_SLOT_MAGIC_KEY_VALUE)
+    ) {
+      reg2 /* internalSlotCount */ = VirtualInt14_decode(vm, regP2[VM_OIS_PROTO_SLOT_COUNT]);
+    }
+  } else if (tc == TC_VAL_NULL) {
+    CODE_COVERAGE_UNTESTED(724); // Not hit
+  } else {
+    CODE_COVERAGE_ERROR_PATH(725); // Not hit
+    err = vm_newError(vm, MVM_E_CLASS_PROTOTYPE_MUST_BE_NULL_OR_OBJECT);
+    goto SUB_EXIT;
+  }
+
+  Value* pObject = mvm_allocate(vm, sizeof(TsPropertyList) + reg2 * sizeof(Value), TC_REF_PROPERTY_LIST);
+  Value* p = pObject;
+  *p++ = VM_VALUE_NULL; // dpNext
+  *p++ = regP1[1]; // dpProto
+
+  // Internal slots
+  if (reg2) {
+    CODE_COVERAGE(726); // Hit
+    regP2 /* pPrototype */ = ShortPtr_decode(vm, regP1[1] /* dpProto */);
+    // Make sure the prototype actually has the slots we want to read
+    VM_ASSERT(vm, vm_getAllocationSize(regP2) >= 4 + reg2);
+    regP2 = &regP2[4]; // Skip header and the magic number and slot count
+    while (reg2--) {
+      *p++ = *regP2++;
+    }
+  } else {
+    CODE_COVERAGE(727); // Hit
+  }
+
+  regP1[1] /* this */ = ShortPtr_encode(vm, pObject);
+
+  CACHE_REGISTERS();
+
+  if (err != MVM_E_SUCCESS) goto SUB_EXIT;
+
+  // The slot that was used for the class is now used for the function reference
+  reg1 /*argCountAndFlags*/ |= AF_PUSHED_FUNCTION;
+  reg2 = regP1[0];
+  reg3 /* cpsCallback */ = VM_VALUE_UNDEFINED;
+
+  goto SUB_CALL;
+}
+/* ------------------------------------------------------------------------- */
+/*                                                                           */
+/*                                    SUB_CALL                               */
+/*                                                                           */
+/*   Performs a dynamic call to a given function value                       */
+/*                                                                           */
+/*   Expects:                                                                */
+/*     reg1: argCountAndFlags for the new frame                              */
+/*     reg2: target function value to call                                   */
+/*     reg3: new value for CPS callback                                      */
+/* ------------------------------------------------------------------------- */
+SUB_CALL: {
+  CODE_COVERAGE(224); // Hit
+
+  reg->cpsCallback = reg3;
+
+  reg3 /* scope */ = VM_VALUE_UNDEFINED;
+
+  while (true) {
+    TeTypeCode tc = deepTypeOf(vm, reg2 /* target */);
+    if (tc == TC_REF_FUNCTION) {
+      CODE_COVERAGE(141); // Hit
+      // The following trick of assuming the function offset is just
+      // `target >>= 1` is only true if the function is in ROM.
+      VM_ASSERT(vm, DynamicPtr_isRomPtr(vm, reg2 /* target */));
+      reg2 &= 0xFFFE;
+      goto SUB_CALL_BYTECODE_FUNC;
+    } else if (tc == TC_REF_HOST_FUNC) {
+      CODE_COVERAGE(143); // Hit
+      LongPtr lpHostFunc = DynamicPtr_decode_long(vm, reg2 /* target */);
+      reg2 = READ_FIELD_2(lpHostFunc, TsHostFunc, indexInImportTable);
+      goto SUB_CALL_HOST_COMMON;
+    } else if (tc == TC_REF_CLOSURE) {
+      CODE_COVERAGE(598); // Hit
+
+      // Closures are their own scope
+      reg3 /* scope */ = reg2;
+
+      LongPtr lpClosure = DynamicPtr_decode_long(vm, reg2 /* target */);
+      reg2 /* target */ = READ_FIELD_2(lpClosure, TsClosure, target);
+
+      // Redirect the call to closure target
+      continue;
+    } else if (tc == TC_VAL_NO_OP_FUNC) {
+      CODE_COVERAGE(653); // Hit
+      reg3 /* callee argCountAndFlags */ = reg1;
+      reg1 /* result */ = VM_VALUE_UNDEFINED;
+      goto SUB_POP_ARGS;
+    } else {
+      CODE_COVERAGE_UNTESTED(264); // Not hit
+      // Other value types are not callable
+      err = vm_newError(vm, MVM_E_TYPE_ERROR_TARGET_IS_NOT_CALLABLE);
+      goto SUB_EXIT;
+    }
+  }
+}
+
+/* ------------------------------------------------------------------------- */
+/*                          SUB_CALL_HOST_COMMON                             */
+/*   Expects:                                                                */
+/*     reg1: argCountAndFlags                                                */
+/*     reg2: index in import table                                           */
+/* ------------------------------------------------------------------------- */
+SUB_CALL_HOST_COMMON: {
+  CODE_COVERAGE(162); // Hit
+
+  // Note: the interface with the host doesn't include the `this` pointer as the
+  // first argument, so `args` points to the *next* argument.
+  reg3 /* argCount */ = (reg1 & AF_ARG_COUNT_MASK) - 1;
+
+  // Allocating the result on the stack so that it's reachable by the GC
+  Value* pResult = pStackPointer++;
+  *pResult = VM_VALUE_UNDEFINED;
+
+  // The function `mvm_asyncStart` needs to know the state of the callee flag
+  // AF_VOID_CALLED, but we need to save the original state to restore later.
+  uint16_t saveArgCountAndFlags = reg->argCountAndFlags;
+  reg->argCountAndFlags = reg1;
+
+  VM_ASSERT(vm, reg2 < vm_getResolvedImportCount(vm));
+  mvm_TfHostFunction hostFunction = vm_getResolvedImports(vm)[reg2];
+  mvm_HostFunctionID hostFunctionID = vm_getHostFunctionId(vm, reg2);
+
+  FLUSH_REGISTER_CACHE();
+
+  /*
+  Note: this subroutine does not call PUSH_REGISTERS to save the frame boundary.
+  Calls to the host can be thought of more like machine instructions than
+  distinct CALL operations in this sense, since they operate within the frame of
+  the caller.
+
+  This needs to work even if the host in turn calls the VM again during the call
+  out to the host. When the host calls the VM again, it will push a new stack
+  frame.
+  */
+
+  #if (MVM_SAFE_MODE)
+    // Take a copy of the registers so we can see later that they're restored to
+    // their correct values.
+    VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+    vm_TsRegisters regCopy = *reg;
+    // Except that the `closure` register may point to a heap value, so we need
+    // to track if it moves.
+    mvm_Handle hClosureCopy;
+    mvm_initializeHandle(vm, &hClosureCopy);
+    mvm_handleSet(&hClosureCopy, reg->closure);
+  #endif
+
+  regP1 /* pArgs */ = reg->pStackPointer - reg3 - 1;
+
+  // Call the host function
+  err = hostFunction(vm, hostFunctionID, pResult, regP1, (uint8_t)reg3);
+
+  #if (MVM_SAFE_MODE)
+    VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+    regCopy.closure = mvm_handleGet(&hClosureCopy);
+    mvm_releaseHandle(vm, &hClosureCopy);
+
+    /*
+    The host function should leave the VM registers in the same state.
+
+    `pStackPointer` can be modified temporarily because the host may call back
+    into the VM, but it should be restored again by the time the host returns,
+    otherwise the stack is unbalanced.
+
+    The other registers (e.g. lpProgramCounter) should only be modified by
+    bytecode instructions, which can be if the host calls back into the VM. But
+    if the host calls back into the VM, it will go through SUB_CALL which
+    performs a PUSH_REGISTERS to save the previous machine state, and then will
+    restore the machine state when it returns.
+
+    This check is also what confirms that we don't need a FLUSH_REGISTER_CACHE
+    and CACHE_REGISTERS around the host call, since the host doesn't modify or
+    use these registers, even if it calls back into the VM (with the exception
+    of the stack pointer which is used but restored again afterward).
+    */
+    regCopy.cpsCallback = reg->cpsCallback; // The cpsCallback register is not preserved
+    regCopy.jobQueue = reg->jobQueue; // The job queue may change
+    VM_ASSERT(vm, memcmp(&regCopy, reg, sizeof regCopy) == 0);
+  #endif
+
+  CACHE_REGISTERS();
+
+  // Restore caller argCountAndFlags
+  reg->argCountAndFlags = saveArgCountAndFlags;
+
+  // Represents an exception thrown by the host function that wasn't caught by
+  // the host. The pResult should reference the exception object.
+  if (err == MVM_E_UNCAUGHT_EXCEPTION) {
+    CODE_COVERAGE_UNTESTED(734); // Not hit
+    reg1 = *pResult;
+    err = MVM_E_SUCCESS;
+    // The throw will unwind the stack to the closest catch block, so we don't
+    // need to worry about unwinding the arguments off the stack.
+    goto SUB_THROW;
+  } else if (err != MVM_E_SUCCESS) {
+    CODE_COVERAGE_ERROR_PATH(735); // Not hit
+    goto SUB_EXIT;
+  } else {
+    CODE_COVERAGE(736); // Not hit
+  }
+
+  reg3 = reg1; // Callee argCountAndFlags
+  reg1 = *pResult;
+
+  // Pop the result slot
+  POP();
+
+  goto SUB_POP_ARGS;
+}
+
+/* ------------------------------------------------------------------------- */
+/*                         SUB_CALL_BYTECODE_FUNC                            */
+/*                                                                           */
+/*   Calls a bytecode function                                               */
+/*                                                                           */
+/*   Expects:                                                                */
+/*     reg1: new argCountAndFlags                                            */
+/*     reg2: offset of target function in bytecode                           */
+/*     reg3: scope, if reg1 & AF_SCOPE, else unused                          */
+/* ------------------------------------------------------------------------- */
+SUB_CALL_BYTECODE_FUNC: {
+  CODE_COVERAGE(163); // Hit
+
+  regP1 /* pArgs */ = pStackPointer - (reg1 & AF_ARG_COUNT_MASK);
+  regLP1 /* lpReturnAddress */ = lpProgramCounter;
+
+  // Move PC to point to new function code
+  lpProgramCounter = LongPtr_add(vm->lpBytecode, reg2);
+
+  reg2 /* function header */ = LongPtr_read2_aligned(LongPtr_add(lpProgramCounter, -2));
+
+  // If it's a continuation (async resume point), we actually want the function
+  // header of the containing function
+  if (reg2 & VM_FUNCTION_HEADER_CONTINUATION_FLAG) {
+    CODE_COVERAGE(737); // Not hit
+    reg2 /* back pointer */ = reg2 & VM_FUNCTION_HEADER_BACK_POINTER_MASK;
+    reg2 /* function header */ = LongPtr_read2_aligned(LongPtr_add(lpProgramCounter, - reg2 * 4 - 2));
+  } else {
+    CODE_COVERAGE(738); // Not hit
+  }
+
+  // Check the stack space required (before we PUSH_REGISTERS). Note that the
+  // frame size in words is stored in the header itself
+  reg2 /* requiredFrameSizeWords */ = reg2 /* function header */ & VM_FUNCTION_HEADER_STACK_HEIGHT_MASK;
+  reg2 /* requiredFrameSizeWords */ += VM_FRAME_BOUNDARY_SAVE_SIZE_WORDS;
+  // The +5 is for various temporaries that `mvm_call` pushes to the stack, and
+  // the result slot if we call the host
+  err = vm_requireStackSpace(vm, pStackPointer, reg2 /* requiredFrameSizeWords */ + 5);
+  if (err != MVM_E_SUCCESS) {
+    CODE_COVERAGE_ERROR_PATH(226); // Not hit
+    goto SUB_EXIT;
+  }
+
+  // Save old registers to the stack
+  PUSH_REGISTERS(regLP1);
+
+  // Set up new frame
+  pFrameBase = pStackPointer;
+  reg->argCountAndFlags = reg1;
+  reg->closure = reg3;
+  reg->pArgs = regP1;
+
+  goto SUB_TAIL_POP_0_PUSH_0;
+} // End of SUB_CALL_BYTECODE_FUNC
+
+/* ------------------------------------------------------------------------- */
+/*                             SUB_NUM_OP_FLOAT64                            */
+/*   Expects:                                                                */
+/*     reg1: left operand (second pop), or zero for unary ops                */
+/*     reg2: right operand (first pop), or single operand for unary ops      */
+/*     reg3: vm_TeNumberOp                                                   */
+/* ------------------------------------------------------------------------- */
+#if MVM_SUPPORT_FLOAT
+SUB_NUM_OP_FLOAT64: {
+  CODE_COVERAGE_UNIMPLEMENTED(447); // Hit
+
+  MVM_FLOAT64 reg1F = 0;
+  if (reg1) reg1F = mvm_toFloat64(vm, reg1);
+  MVM_FLOAT64 reg2F = mvm_toFloat64(vm, reg2);
+
+  VM_ASSERT(vm, reg3 < VM_NUM_OP_END);
+  MVM_SWITCH (reg3, (VM_NUM_OP_END - 1)) {
+    MVM_CASE(VM_NUM_OP_LESS_THAN): {
+      CODE_COVERAGE(449); // Hit
+      reg1 = reg1F < reg2F;
+      goto SUB_TAIL_PUSH_REG1_BOOL;
+    }
+    MVM_CASE(VM_NUM_OP_GREATER_THAN): {
+      CODE_COVERAGE(450); // Hit
+      reg1 = reg1F > reg2F;
+      goto SUB_TAIL_PUSH_REG1_BOOL;
+    }
+    MVM_CASE(VM_NUM_OP_LESS_EQUAL): {
+      CODE_COVERAGE(451); // Hit
+      reg1 = reg1F <= reg2F;
+      goto SUB_TAIL_PUSH_REG1_BOOL;
+    }
+    MVM_CASE(VM_NUM_OP_GREATER_EQUAL): {
+      CODE_COVERAGE(452); // Hit
+      reg1 = reg1F >= reg2F;
+      goto SUB_TAIL_PUSH_REG1_BOOL;
+    }
+    MVM_CASE(VM_NUM_OP_ADD_NUM): {
+      CODE_COVERAGE(453); // Hit
+      reg1F = reg1F + reg2F;
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_SUBTRACT): {
+      CODE_COVERAGE(454); // Hit
+      reg1F = reg1F - reg2F;
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_MULTIPLY): {
+      CODE_COVERAGE(455); // Hit
+      reg1F = reg1F * reg2F;
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_DIVIDE): {
+      CODE_COVERAGE(456); // Hit
+      reg1F = reg1F / reg2F;
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_DIVIDE_AND_TRUNC): {
+      CODE_COVERAGE(457); // Hit
+      reg1F = mvm_float64ToInt32((reg1F / reg2F));
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_REMAINDER): {
+      CODE_COVERAGE(458); // Hit
+      reg1F = fmod(reg1F, reg2F);
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_POWER): {
+      CODE_COVERAGE(459); // Hit
+      if (!isfinite(reg2F) && ((reg1F == 1.0) || (reg1F == -1.0))) {
+        reg1 = VM_VALUE_NAN;
+        goto SUB_TAIL_POP_0_PUSH_REG1;
+      }
+      reg1F = pow(reg1F, reg2F);
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_NEGATE): {
+      CODE_COVERAGE(460); // Hit
+      reg1F = -reg2F;
+      break;
+    }
+    MVM_CASE(VM_NUM_OP_UNARY_PLUS): {
+      CODE_COVERAGE(461); // Hit
+      reg1F = reg2F;
+      break;
+    }
+  } // End of switch vm_TeNumberOp for float64
+
+  // Convert the result from a float
+  FLUSH_REGISTER_CACHE();
+  reg1 = mvm_newNumber(vm, reg1F);
+  CACHE_REGISTERS();
+  goto SUB_TAIL_POP_0_PUSH_REG1;
+} // End of SUB_NUM_OP_FLOAT64
+#endif // MVM_SUPPORT_FLOAT
+
+/* --------------------------------------------------------------------------
+                                     TAILS
+
+These "tails" are the common epilogues to various instructions. Instructions in
+general must keep their arguments on the stack right until the end, to prevent
+any pointer arguments from becoming dangling if the instruction triggers a GC
+collection. So popping the arguments is done at the end of the instruction, and
+the number of pops is common to many different instructions.
+ * -------------------------------------------------------------------------- */
+
+SUB_TAIL_PUSH_REG1_BOOL:
+  CODE_COVERAGE(489); // Hit
+  reg1 = reg1 ? VM_VALUE_TRUE : VM_VALUE_FALSE;
+  goto SUB_TAIL_POP_0_PUSH_REG1;
+
+SUB_TAIL_POP_2_PUSH_REG1:
+  CODE_COVERAGE(227); // Hit
+  pStackPointer -= 1;
+  goto SUB_TAIL_POP_1_PUSH_REG1;
+
+SUB_TAIL_POP_0_PUSH_REG1:
+  CODE_COVERAGE(164); // Hit
+  PUSH(reg1);
+  goto SUB_TAIL_POP_0_PUSH_0;
+
+SUB_TAIL_POP_3_PUSH_0:
+  CODE_COVERAGE(611); // Hit
+  pStackPointer -= 3;
+  goto SUB_TAIL_POP_0_PUSH_0;
+
+SUB_TAIL_POP_1_PUSH_0:
+  CODE_COVERAGE(617); // Hit
+  pStackPointer -= 1;
+  goto SUB_TAIL_POP_0_PUSH_0;
+
+SUB_TAIL_POP_1_PUSH_REG1:
+  CODE_COVERAGE(126); // Hit
+  pStackPointer[-1] = reg1;
+  goto SUB_TAIL_POP_0_PUSH_0;
+
+SUB_TAIL_POP_0_PUSH_0:
+  CODE_COVERAGE(125); // Hit
+  if (err != MVM_E_SUCCESS) goto SUB_EXIT;
+  goto SUB_DO_NEXT_INSTRUCTION;
+
+SUB_EXIT:
+  CODE_COVERAGE(165); // Hit
+
+  #if MVM_SAFE_MODE
+  FLUSH_REGISTER_CACHE();
+  VM_ASSERT(vm, registerValuesAtEntry.pStackPointer <= reg->pStackPointer);
+  VM_ASSERT(vm, registerValuesAtEntry.pFrameBase <= reg->pFrameBase);
+  #endif
+
+  // I don't think there's anything that can happen during mvm_call that can
+  // justify the values of the registers at exit needing being different to
+  // those at entry. Restoring the entry registers here means that if we have an
+  // error or uncaught exception at any time during the call (including the case
+  // where it's within nested calls) then at least we unwind the stack and
+  // restore the original program counter, catchTarget, stackPointer etc.
+  // `registerValuesAtEntry` was also captured before we pushed the mvm_call
+  // arguments to the stack, so this also effectively pops the arguments off the
+  // stack.
+  registerValuesAtEntry.jobQueue = reg->jobQueue; // Except the job queue needs to be preserved
+  registerValuesAtEntry.closure = reg->closure; // And the closure may point to the GC so it may change physical value if there are garbage collections during the call.
+  *reg = registerValuesAtEntry;
+
+  // If the stack is empty, we can free it. It may not be empty if this is a
+  // reentrant call, in which case there would be other frames below this one.
+  if (reg->pStackPointer == getBottomOfStack(vm->stack)) {
+    CODE_COVERAGE(222); // Hit
+
+    vm_free(vm, vm->stack);
+    vm->stack = NULL;
+  }
+
+  return err;
+} // End of mvm_call
+
+/**
+ * Creates a new array of length 0 and the given capacity and initializes the
+ * allocated slots to VM_VALUE_DELETED.
+ */
+static Value vm_newArray(VM* vm, uint16_t capacity) {
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+  TABLE_COVERAGE(capacity ? 1 : 0, 2, 371); // Hit 2/2
+
+  TsArray* arr = GC_ALLOCATE_TYPE(vm, TsArray, TC_REF_ARRAY);
+  Value result = ShortPtr_encode(vm, arr);
+
+  arr->viLength = VirtualInt14_encode(vm, 0);
+  arr->dpData = VM_VALUE_NULL;
+
+  if (capacity) {
+    vm_push(vm, result); // GC-reachable
+    uint16_t* pData = mvm_allocate(vm, capacity * 2, TC_REF_FIXED_LENGTH_ARRAY);
+    result = vm_pop(vm);
+    arr = ShortPtr_decode(vm, result); // Invalidated
+    arr->dpData = ShortPtr_encode(vm, pData);
+    uint16_t* p = pData;
+    uint16_t n = capacity;
+    while (n--)
+      *p++ = VM_VALUE_DELETED;
+  }
+
+  return result;
+}
+
+/**
+ * Add an element to an array. Note that the array may need to expand, so the
+ * arguments should be passed in by reference to stable slots (e.g. stack slots
+ * or registers).
+ */
+static void vm_arrayPush(VM* vm, Value* pvArr, Value* pvItem) {
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  CODE_COVERAGE(710); // Hit
+
+  TsArray* pArr = ShortPtr_decode(vm, *pvArr);
+  uint16_t length = VirtualInt14_decode(vm, pArr->viLength);
+  uint16_t capacity;
+
+  if (pArr->dpData == VM_VALUE_NULL) {
+    CODE_COVERAGE_UNTESTED(711); // Not hit
+    capacity = 0;
+  } else {
+    CODE_COVERAGE(712); // Hit
+    capacity = vm_getAllocationSize(ShortPtr_decode(vm, pArr->dpData)) / 2;
+  }
+
+  // Need to expand?
+  if (length >= capacity) {
+    CODE_COVERAGE(713); // Hit
+    // Slow path
+    capacity = capacity * 2;
+    if (capacity < VM_ARRAY_INITIAL_CAPACITY) {
+      capacity = VM_ARRAY_INITIAL_CAPACITY;
+    }
+    // We're only adding 1 item to the array, so if we're doubling the capacity
+    // then we know that the new capacity will at least contain the new item.
+    VM_ASSERT(vm, capacity >= length + 1);
+
+    growArray(vm, pvArr, length + 1, capacity);
+  }
+
+  // Write the item to the array
+  pArr = ShortPtr_decode(vm, *pvArr); // May have moved
+  uint16_t* pData = ShortPtr_decode(vm, pArr->dpData);
+  pData[length] = *pvItem;
+  pArr->viLength = VirtualInt14_encode(vm, length + 1);
+}
+
+static void vm_scheduleContinuation(VM* vm, Value continuation, Value isSuccess, Value resultOrError) {
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+  CODE_COVERAGE(714); // Hit
+
+  vm_push(vm, resultOrError); // anchor to GC
+  vm_push(vm, continuation); // anchor to GC
+
+  Value* closure = mvm_allocate(vm, 4 * 2, TC_REF_CLOSURE);
+
+  closure[0] = getBuiltin(vm, BIN_ASYNC_CONTINUE);
+  closure[1] = vm_pop(vm); // continuation
+  closure[2] = isSuccess;
+  closure[3] = vm_pop(vm); // resultOrError
+
+  vm_enqueueJob(vm, ShortPtr_encode(vm, closure));
+}
+
+
+/**
+ * Creates a new closure with `slotCount` slots and sets it as the active
+ * closure. If `captureParent` is true then the last slot of the new closure
+ * will be set to reference the previously active closure.
+ */
+static uint16_t* vm_scopePushOrNew(VM* vm, int slotCount, bool captureParent) {
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+  int size = slotCount * 2;
+
+  uint16_t* newScope = mvm_allocate(vm, size, TC_REF_CLOSURE);
+
+  uint16_t* p = newScope;
+  while (--slotCount) { // Note: pre-decrement so will stop one short of the end
+    *p++ = VM_VALUE_DELETED; // Initial slot values
+  }
+  // Last slot
+  if (captureParent) {
+    CODE_COVERAGE(646); // Hit
+    *p = vm->stack->reg.closure; // Reference to parent (last slot)
+  } else {
+    CODE_COVERAGE(647); // Hit
+    *p = VM_VALUE_DELETED;
+  }
+  // Add to the scope chain
+  vm->stack->reg.closure = ShortPtr_encode(vm, newScope);
+
+  return newScope;
+}
+
+/**
+ * Same as mvm_call but takes a `thisValue`. I expect this to be the less common
+ * case, so I've separated it out to avoid the interface complexity of passing a
+ * `thisValue` when it's not needed.
+ */
+TeError mvm_callEx(VM* vm, Value targetFunc, Value thisValue, Value* out_result, Value* args, uint8_t argCount) {
+  mvm_TeError err;
+
+  CODE_COVERAGE_UNTESTED(659); // Hit
+
+  if (!vm->stack) {
+    CODE_COVERAGE_UNTESTED(660); // Not hit
+    err = vm_createStackAndRegisters(vm);
+    if (err != MVM_E_SUCCESS) {
+      return err;
+    }
+  } else {
+    CODE_COVERAGE_UNTESTED(661); // Hit
+  }
+
+  err = vm_requireStackSpace(vm, vm->stack->reg.pStackPointer, argCount + 2);
+  if (err) return err;
+
+  // Put the this value on the stack without bumping the stack pointer. I do it
+  // this way because mvm_call has checks on the stack balance so we can't just
+  // push it here and expect mvm_call to pop it later. The first position on the
+  // stack is reserved for the target function, and the second value will be the
+  // `this` value.
+  vm->stack->reg.pStackPointer[1] = thisValue;
+  // This is a little bit of a hack to tell mvm_call that `this` is already on
+  // the stack. I didn't want to play with the arguments to mvm_call because
+  // it's a public interface, and I didn't want to pass the `this` value through
+  // a register because that's less space efficient when this feature is not
+  // used.
+  vm->stack->reg.argCountAndFlags |= AF_OVERRIDE_THIS;
+
+  return mvm_call(vm, targetFunc, out_result, args, argCount);
+}
+
+const Value mvm_undefined = VM_VALUE_UNDEFINED;
+const Value mvm_null = VM_VALUE_NULL;
+
+static inline uint16_t vm_getAllocationSize(void* pAllocation) {
+  CODE_COVERAGE(12); // Hit
+  return vm_getAllocationSizeExcludingHeaderFromHeaderWord(((uint16_t*)pAllocation)[-1]);
+}
+
+static inline TeTypeCode vm_getAllocationType(void* pAllocation) {
+  CODE_COVERAGE(682); // Hit
+  return vm_getTypeCodeFromHeaderWord(((uint16_t*)pAllocation)[-1]);
+}
+
+static inline uint16_t vm_getAllocationSize_long(LongPtr lpAllocation) {
+  CODE_COVERAGE(514); // Hit
+  uint16_t headerWord = LongPtr_read2_aligned(LongPtr_add(lpAllocation, -2));
+  return vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+}
+
+static inline mvm_TeBytecodeSection vm_sectionAfter(VM* vm, mvm_TeBytecodeSection section) {
+  CODE_COVERAGE(13); // Hit
+  VM_ASSERT(vm, section < BCS_SECTION_COUNT - 1);
+  return (mvm_TeBytecodeSection)((uint8_t)section + 1);
+}
+
+static inline TeTypeCode vm_getTypeCodeFromHeaderWord(uint16_t headerWord) {
+  CODE_COVERAGE(1); // Hit
+  // The type code is in the high byte because it's the byte that occurs closest
+  // to the allocation itself, potentially allowing us in future to omit the
+  // size in the allocation header for some kinds of allocations.
+  return (TeTypeCode)(headerWord >> 12);
+}
+
+static inline uint16_t vm_makeHeaderWord(VM* vm, TeTypeCode tc, uint16_t size) {
+  CODE_COVERAGE(210); // Hit
+  VM_ASSERT(vm, size <= MAX_ALLOCATION_SIZE);
+  VM_ASSERT(vm, tc <= 0xF);
+  return ((tc << 12) | size);
+}
+
+static inline VirtualInt14 VirtualInt14_encode(VM* vm, int16_t i) {
+  CODE_COVERAGE(14); // Hit
+  VM_ASSERT(vm, (i >= VM_MIN_INT14) && (i <= VM_MAX_INT14));
+  return VIRTUAL_INT14_ENCODE(i);
+}
+
+static inline int16_t VirtualInt14_decode(VM* vm, VirtualInt14 viInt) {
+  CODE_COVERAGE(16); // Hit
+  VM_ASSERT(vm, Value_isVirtualInt14(viInt));
+  return (int16_t)viInt >> 2;
+}
+
+static void setHeaderWord(VM* vm, void* pAllocation, TeTypeCode tc, uint16_t size) {
+  CODE_COVERAGE(36); // Hit
+  ((uint16_t*)pAllocation)[-1] = vm_makeHeaderWord(vm, tc, size);
+}
+
+// Returns the allocation size, excluding the header itself
+static inline uint16_t vm_getAllocationSizeExcludingHeaderFromHeaderWord(uint16_t headerWord) {
+  CODE_COVERAGE(2); // Hit
+  // Note: The header size is measured in bytes and not words mainly to account
+  // for string allocations, which would be inconvenient to align to word
+  // boundaries.
+  return headerWord & 0xFFF;
+}
+
+#if MVM_SAFE_MODE
+static bool Value_encodesBytecodeMappedPtr(Value value) {
+  CODE_COVERAGE(37); // Hit
+  return ((value & 3) == 1) && value >= VM_VALUE_WELLKNOWN_END;
+}
+#endif // MVM_SAFE_MODE
+
+static inline uint16_t getSectionOffset(LongPtr lpBytecode, mvm_TeBytecodeSection section) {
+  CODE_COVERAGE(38); // Hit
+  LongPtr lpSection = LongPtr_add(lpBytecode, OFFSETOF(mvm_TsBytecodeHeader, sectionOffsets) + section * 2);
+  uint16_t offset = LongPtr_read2_aligned(lpSection);
+  return offset;
+}
+
+#if MVM_SAFE_MODE
+static inline uint16_t vm_getResolvedImportCount(VM* vm) {
+  CODE_COVERAGE(41); // Hit
+  uint16_t importTableSize = getSectionSize(vm, BCS_IMPORT_TABLE);
+  uint16_t importCount = importTableSize / sizeof(vm_TsImportTableEntry);
+  return importCount;
+}
+#endif // MVM_SAFE_MODE
+
+#if MVM_SAFE_MODE
+/**
+ * Returns true if the value is a pointer which points to ROM. Null is not a
+ * value that points to ROM.
+ */
+static bool DynamicPtr_isRomPtr(VM* vm, DynamicPtr dp) {
+  CODE_COVERAGE(39); // Hit
+  VM_ASSERT(vm, !Value_isVirtualInt14(dp));
+
+  if (dp == VM_VALUE_NULL) {
+    CODE_COVERAGE_UNTESTED(47); // Not hit
+    return false;
+  }
+
+  if (Value_isShortPtr(dp)) {
+    CODE_COVERAGE_UNTESTED(52); // Not hit
+    return false;
+  }
+  CODE_COVERAGE(91); // Hit
+
+  VM_ASSERT(vm, Value_encodesBytecodeMappedPtr(dp));
+  VM_ASSERT(vm, vm_sectionAfter(vm, BCS_ROM) < BCS_SECTION_COUNT);
+
+  uint16_t offset = dp & 0xFFFE;
+
+  return (offset >= getSectionOffset(vm->lpBytecode, BCS_ROM))
+    & (offset < getSectionOffset(vm->lpBytecode, vm_sectionAfter(vm, BCS_ROM)));
+}
+#endif // MVM_SAFE_MODE
+
+TeError mvm_restore(mvm_VM** result, MVM_LONG_PTR_TYPE lpBytecode, size_t bytecodeSize_, void* context, mvm_TfResolveImport resolveImport) {
+  // Note: these are declared here because some compilers give warnings when "goto" bypasses some variable declarations
+  mvm_TfHostFunction* resolvedImports;
+  uint16_t importTableOffset;
+  LongPtr lpImportTableStart;
+  LongPtr lpImportTableEnd;
+  mvm_TfHostFunction* resolvedImport;
+  LongPtr lpImportTableEntry;
+  uint16_t initialHeapOffset;
+  uint16_t initialHeapSize;
+
+  CODE_COVERAGE(3); // Hit
+
+  if (MVM_PORT_VERSION != MVM_EXPECTED_PORT_FILE_VERSION) {
+    return MVM_E_PORT_FILE_VERSION_MISMATCH;
+  }
+
+  #if MVM_SAFE_MODE
+    uint16_t x = 0x4243;
+    bool isLittleEndian = ((uint8_t*)&x)[0] == 0x43;
+    VM_ASSERT(NULL, isLittleEndian);
+    VM_ASSERT(NULL, sizeof (ShortPtr) == 2);
+  #endif
+
+  TeError err = MVM_E_SUCCESS;
+  VM* vm = NULL;
+
+  // Bytecode size field is located at the second word
+  if (bytecodeSize_ < sizeof (mvm_TsBytecodeHeader)) {
+    CODE_COVERAGE_ERROR_PATH(21); // Not hit
+    return MVM_E_INVALID_BYTECODE;
+  }
+  mvm_TsBytecodeHeader header;
+  memcpy_long(&header, lpBytecode, sizeof header);
+
+  // Note: the restore function takes an explicit bytecode size because there
+  // may be a size inherent to the medium from which the bytecode image comes,
+  // and we don't want to accidentally read past the end of this space just
+  // because the header apparently told us we could (since we could be reading a
+  // corrupt header).
+  uint16_t bytecodeSize = header.bytecodeSize;
+  if (bytecodeSize != bytecodeSize_) {
+    CODE_COVERAGE_ERROR_PATH(240); // Not hit
+    return MVM_E_INVALID_BYTECODE;
+  }
+
+  uint16_t expectedCRC = header.crc;
+  if (!MVM_CHECK_CRC16_CCITT(LongPtr_add(lpBytecode, 8), (uint16_t)bytecodeSize - 8, expectedCRC)) {
+    CODE_COVERAGE_ERROR_PATH(54); // Not hit
+    return MVM_E_BYTECODE_CRC_FAIL;
+  }
+
+  if (bytecodeSize < header.headerSize) {
+    CODE_COVERAGE_ERROR_PATH(241); // Not hit
+    return MVM_E_INVALID_BYTECODE;
+  }
+
+  if (header.bytecodeVersion != MVM_ENGINE_MAJOR_VERSION) {
+    CODE_COVERAGE_ERROR_PATH(430); // Not hit
+    return MVM_E_WRONG_BYTECODE_VERSION;
+  }
+
+  if (MVM_ENGINE_MINOR_VERSION < header.requiredEngineVersion) {
+    CODE_COVERAGE_ERROR_PATH(247); // Not hit
+    return MVM_E_REQUIRES_LATER_ENGINE;
+  }
+
+  uint32_t featureFlags = header.requiredFeatureFlags;;
+  if (MVM_SUPPORT_FLOAT && !(featureFlags & (1 << FF_FLOAT_SUPPORT))) {
+    CODE_COVERAGE_ERROR_PATH(180); // Not hit
+    return MVM_E_BYTECODE_REQUIRES_FLOAT_SUPPORT;
+  }
+
+  err = vm_validatePortFileMacros(lpBytecode, &header, context);
+  if (err) return err;
+
+  uint16_t importTableSize = header.sectionOffsets[vm_sectionAfter(vm, BCS_IMPORT_TABLE)] - header.sectionOffsets[BCS_IMPORT_TABLE];
+  uint16_t importCount = importTableSize / sizeof (vm_TsImportTableEntry);
+
+  uint16_t globalsSize = header.sectionOffsets[vm_sectionAfter(vm, BCS_GLOBALS)] - header.sectionOffsets[BCS_GLOBALS];
+
+  size_t allocationSize = sizeof(mvm_VM) +
+    sizeof(mvm_TfHostFunction) * importCount +  // Import table
+    globalsSize; // Globals
+  vm = (VM*)MVM_CONTEXTUAL_MALLOC(allocationSize, context);
+  if (!vm) {
+    CODE_COVERAGE_ERROR_PATH(139); // Not hit
+    err = MVM_E_MALLOC_FAIL;
+    goto SUB_EXIT;
+  }
+  #if MVM_SAFE_MODE
+    memset(vm, 0xCC, allocationSize);
+  #endif
+  memset(vm, 0, sizeof (mvm_VM));
+  resolvedImports = vm_getResolvedImports(vm);
+  vm->context = context;
+  vm->lpBytecode = lpBytecode;
+  vm->globals = (void*)(resolvedImports + importCount);
+  #ifdef MVM_GAS_COUNTER
+  vm->stopAfterNInstructions = -1;
+  #endif
+
+  importTableOffset = header.sectionOffsets[BCS_IMPORT_TABLE];
+  lpImportTableStart = LongPtr_add(lpBytecode, importTableOffset);
+  lpImportTableEnd = LongPtr_add(lpImportTableStart, importTableSize);
+  // Resolve imports (linking)
+  resolvedImport = resolvedImports;
+  lpImportTableEntry = lpImportTableStart;
+  while (lpImportTableEntry < lpImportTableEnd) {
+    CODE_COVERAGE(431); // Hit
+    mvm_HostFunctionID hostFunctionID = READ_FIELD_2(lpImportTableEntry, vm_TsImportTableEntry, hostFunctionID);
+    lpImportTableEntry = LongPtr_add(lpImportTableEntry, sizeof (vm_TsImportTableEntry));
+    mvm_TfHostFunction handler = NULL;
+    err = resolveImport(hostFunctionID, context, &handler);
+    if (err != MVM_E_SUCCESS) {
+      CODE_COVERAGE_ERROR_PATH(432); // Not hit
+      goto SUB_EXIT;
+    }
+    if (!handler) {
+      CODE_COVERAGE_ERROR_PATH(433); // Not hit
+      err = MVM_E_UNRESOLVED_IMPORT;
+      goto SUB_EXIT;
+    } else {
+      CODE_COVERAGE(434); // Hit
+    }
+    *resolvedImport++ = handler;
+  }
+
+  // The GC is empty to start
+  gc_freeGCMemory(vm);
+
+  // Initialize data
+  memcpy_long(vm->globals, getBytecodeSection(vm, BCS_GLOBALS, NULL), globalsSize);
+
+  // Initialize heap
+  initialHeapOffset = header.sectionOffsets[BCS_HEAP];
+  initialHeapSize = bytecodeSize - initialHeapOffset;
+  vm->heapSizeUsedAfterLastGC = initialHeapSize;
+  vm->heapHighWaterMark = initialHeapSize;
+
+  if (initialHeapSize) {
+    CODE_COVERAGE(435); // Hit
+    if (initialHeapSize > MVM_MAX_HEAP_SIZE) {
+      MVM_FATAL_ERROR(vm, MVM_E_OUT_OF_MEMORY);
+    }
+    // The initial heap needs to be 2-byte aligned because we start appending
+    // new allocations to the end of it directly.
+    VM_ASSERT(vm, initialHeapSize % 2 == 0);
+    gc_createNextBucket(vm, initialHeapSize, initialHeapSize);
+    VM_ASSERT(vm, !vm->pLastBucket->prev); // Only one bucket
+    uint16_t* heapStart = getBucketDataBegin(vm->pLastBucket);
+    memcpy_long(heapStart, LongPtr_add(lpBytecode, initialHeapOffset), initialHeapSize);
+    vm->pLastBucket->pEndOfUsedSpace = (uint16_t*)((intptr_t)vm->pLastBucket->pEndOfUsedSpace + initialHeapSize);
+
+    // The running VM assumes the invariant that all pointers to the heap are
+    // represented as ShortPtr (and no others). We only need to call
+    // `loadPointers` if there is an initial heap at all, otherwise there
+    // will be no pointers to it.
+    loadPointers(vm, (uint8_t*)heapStart);
+  } else {
+    CODE_COVERAGE(436); // Hit
+  }
+
+  #if MVM_DEBUG_UTILS
+  // Dummy code to prevent optimizer collection of debug utils, which may only
+  // be used in the debugger and so might be optimized out unless we pretend to
+  // use them. This code should never execute but I'm hoping that the optimizer
+  // doesn't realize that.
+  if ((intptr_t)vm == -1) {
+    mvm_checkHeap(vm);
+    mvm_readHeapCount(vm);
+    mvm_checkValue(vm, 0);
+    mvm_readCallStack(vm, 0);
+    mvm_readHeap(vm, 0);
+  }
+  #endif
+
+SUB_EXIT:
+  if (err != MVM_E_SUCCESS) {
+    CODE_COVERAGE_ERROR_PATH(437); // Not hit
+    *result = NULL;
+    if (vm) {
+      vm_free(vm, vm);
+      vm = NULL;
+    } else {
+      CODE_COVERAGE_ERROR_PATH(438); // Not hit
+    }
+  } else {
+    CODE_COVERAGE(439); // Hit
+  }
+  *result = vm;
+  return err;
+}
+
+static inline uint16_t getBytecodeSize(VM* vm) {
+  CODE_COVERAGE_UNTESTED(168); // Not hit
+  LongPtr lpBytecodeSize = LongPtr_add(vm->lpBytecode, OFFSETOF(mvm_TsBytecodeHeader, bytecodeSize));
+  return LongPtr_read2_aligned(lpBytecodeSize);
+}
+
+static LongPtr getBytecodeSection(VM* vm, mvm_TeBytecodeSection id, LongPtr* out_end) {
+  CODE_COVERAGE(170); // Hit
+  LongPtr lpBytecode = vm->lpBytecode;
+  LongPtr lpSections = LongPtr_add(lpBytecode, OFFSETOF(mvm_TsBytecodeHeader, sectionOffsets));
+  LongPtr lpSection = LongPtr_add(lpSections, id * 2);
+  uint16_t offset = LongPtr_read2_aligned(lpSection);
+  LongPtr result = LongPtr_add(lpBytecode, offset);
+  if (out_end) {
+    CODE_COVERAGE(171); // Hit
+    uint16_t endOffset;
+    if (id == BCS_SECTION_COUNT - 1) {
+      endOffset = getBytecodeSize(vm);
+    } else {
+      LongPtr lpNextSection = LongPtr_add(lpSection, 2);
+      endOffset = LongPtr_read2_aligned(lpNextSection);
+    }
+    *out_end = LongPtr_add(lpBytecode, endOffset);
+  } else {
+    CODE_COVERAGE(172); // Hit
+  }
+  return result;
+}
+
+static uint16_t getSectionSize(VM* vm, mvm_TeBytecodeSection section) {
+  CODE_COVERAGE(174); // Hit
+  uint16_t sectionStart = getSectionOffset(vm->lpBytecode, section);
+  uint16_t sectionEnd;
+  if (section == BCS_SECTION_COUNT - 1) {
+    CODE_COVERAGE_UNTESTED(175); // Not hit
+    sectionEnd = getBytecodeSize(vm);
+  } else {
+    CODE_COVERAGE(177); // Hit
+    VM_ASSERT(vm, section < BCS_SECTION_COUNT);
+    sectionEnd = getSectionOffset(vm->lpBytecode, vm_sectionAfter(vm, section));
+  }
+  VM_ASSERT(vm, sectionEnd >= sectionStart);
+  return sectionEnd - sectionStart;
+}
+
+/**
+ * Called at startup to translate all the pointers that point to GC memory into
+ * ShortPtr for efficiency and to maintain invariants assumed in other places in
+ * the code.
+ */
+static void loadPointers(VM* vm, uint8_t* heapStart) {
+  CODE_COVERAGE(178); // Hit
+  uint16_t n;
+  uint16_t v;
+  uint16_t* p;
+
+  // Roots in global variables
+  uint16_t globalsSize = getSectionSize(vm, BCS_GLOBALS);
+  p = vm->globals;
+  n = globalsSize / 2;
+  TABLE_COVERAGE(n ? 1 : 0, 2, 179); // Hit 1/2
+  while (n--) {
+    v = *p;
+    if (Value_isShortPtr(v)) {
+      *p = ShortPtr_encode(vm, heapStart + v);
+    }
+    p++;
+  }
+
+  // Pointers in heap memory
+  p = (uint16_t*)heapStart;
+  VM_ASSERT(vm, vm->pLastBucketEndCapacity == vm->pLastBucket->pEndOfUsedSpace);
+  uint16_t* heapEnd = vm->pLastBucketEndCapacity;
+  while (p < heapEnd) {
+    CODE_COVERAGE(181); // Hit
+    uint16_t header = *p++;
+    uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+    uint16_t words = (size + 1) / 2;
+    TeTypeCode tc = vm_getTypeCodeFromHeaderWord(header);
+
+    if (tc < TC_REF_DIVIDER_CONTAINER_TYPES) { // Non-container types
+      CODE_COVERAGE(182); // Hit
+      p += words;
+      continue;
+    } // Else, container types
+    CODE_COVERAGE(183); // Hit
+
+    while (words--) {
+      v = *p;
+      if (Value_isShortPtr(v)) {
+        *p = ShortPtr_encode(vm, heapStart + v);
+      }
+      p++;
+    }
+  }
+}
+
+void* mvm_getContext(VM* vm) {
+  return vm->context;
+}
+
+// Note: mvm_free frees the VM, while vm_free is the counterpart to vm_malloc
+void mvm_free(VM* vm) {
+  CODE_COVERAGE(166); // Hit
+
+  gc_freeGCMemory(vm);
+
+  // The stack may be allocated if `mvm_free` is called from the an error
+  // handler, right before terminating the thread or longjmp'ing out of the VM.
+  #if MVM_SAFE_MODE
+    if (vm->stack) {
+      // This at least zeros out the registers, so the machine will crash early if
+      // someone tries to the let it run after mvm_free
+      memset(vm->stack, 0, sizeof(*vm->stack));
+    }
+  #endif
+  // A compliant implementation of `free` will already check for null
+  vm_free(vm, vm->stack);
+
+  VM_EXEC_SAFE_MODE(memset(vm, 0, sizeof(*vm)));
+  vm_free(vm, vm);
+}
+
+/**
+ * @param sizeBytes Size in bytes of the allocation, *excluding* the header
+ * @param typeCode The type code to insert into the header
+ */
+MVM_HIDDEN void* mvm_allocate(VM* vm, uint16_t sizeBytes,  uint8_t /*TeTypeCode*/ typeCode) {
+  uint16_t* p;
+  uint16_t* end;
+
+  if (sizeBytes >= (MAX_ALLOCATION_SIZE + 1)) {
+    CODE_COVERAGE_ERROR_PATH(353); // Not hit
+    MVM_FATAL_ERROR(vm, MVM_E_ALLOCATION_TOO_LARGE);
+  } else {
+    CODE_COVERAGE(354); // Hit
+  }
+
+  // If we happened to trigger a GC collection, we need to know that the
+  // registers are flushed, if they're allocated at all
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  CODE_COVERAGE(184); // Hit
+  TsBucket* pBucket;
+  const uint16_t sizeIncludingHeader = (sizeBytes + 3) & 0xFFFE;
+  // + 2 bytes header, round up to 2-byte boundary
+  VM_ASSERT(vm, (sizeIncludingHeader & 1) == 0);
+
+  // Minimum allocation size is 4 bytes, because that's the size of a
+  // tombstone. Note that nothing in code will attempt to allocate less,
+  // since even a 1-char string (+null terminator) is a 4-byte allocation.
+  VM_ASSERT(vm, sizeIncludingHeader >= 4);
+
+  VM_POTENTIAL_GC_POINT(vm);
+
+RETRY:
+  pBucket = vm->pLastBucket;
+  if (!pBucket) {
+    CODE_COVERAGE(185); // Hit
+    goto GROW_HEAP_AND_RETRY;
+  }
+  p = pBucket->pEndOfUsedSpace;
+  end = (uint16_t*)((intptr_t)p + sizeIncludingHeader);
+  if (end > vm->pLastBucketEndCapacity) {
+    CODE_COVERAGE(186); // Hit
+    goto GROW_HEAP_AND_RETRY;
+  }
+  pBucket->pEndOfUsedSpace = end;
+
+  // Write header
+  *p++ = vm_makeHeaderWord(vm, (TeTypeCode)typeCode, sizeBytes);
+
+  return p;
+
+GROW_HEAP_AND_RETRY:
+  CODE_COVERAGE(187); // Hit
+  gc_createNextBucket(vm, MVM_ALLOCATION_BUCKET_SIZE, sizeIncludingHeader);
+  goto RETRY;
+}
+
+// Slow fallback for mvm_allocateWithConstantHeader
+static void* mvm_allocateWithConstantHeaderSlow(VM* vm, uint16_t header) {
+  CODE_COVERAGE(188); // Hit
+
+  // If we happened to trigger a GC collection, we need to know that the
+  // registers are flushed, if they're allocated at all
+  VM_ASSERT(vm, !vm->stack || !vm->stack->reg.usingCachedRegisters);
+
+  uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+  TeTypeCode tc = vm_getTypeCodeFromHeaderWord(header);
+  return mvm_allocate(vm, size, tc);
+}
+
+/*
+ * This function is like mvm_allocate except that it's optimized for
+ * situations where:
+ *
+ *   1. The header can be precomputed to a C constant, rather than assembling it
+ *      from the size and type
+ *   2. The size is known to be a multiple of 2 and at least 2 bytes
+ *
+ * This is more efficient in some cases because it has fewer checks and
+ * preprocessing to do. This function can probably be inlined in some cases.
+ *
+ * Note: the size is passed separately rather than computed from the header
+ * because this function is optimized for cases where the size is known at
+ * compile time (and even better if this function is inlined).
+ *
+ * WARNING: this does not initialize the data in the allocation.
+ */
+static inline void* mvm_allocateWithConstantHeader(VM* vm, uint16_t header, uint16_t sizeIncludingHeader) {
+  CODE_COVERAGE(189); // Hit
+
+  uint16_t* p;
+  uint16_t* end;
+
+  // If we happened to trigger a GC collection, we need to know that the
+  // registers are flushed, if they're allocated at all
+  VM_ASSERT(vm, !vm->stack || !vm->stack->reg.usingCachedRegisters);
+
+  VM_ASSERT(vm, sizeIncludingHeader % 2 == 0);
+  VM_ASSERT(vm, sizeIncludingHeader >= 4);
+  VM_ASSERT(vm, vm_getAllocationSizeExcludingHeaderFromHeaderWord(header) == sizeIncludingHeader - 2);
+
+  VM_POTENTIAL_GC_POINT(vm);
+
+  TsBucket* pBucket = vm->pLastBucket;
+  if (!pBucket) {
+    CODE_COVERAGE(190); // Hit
+    goto SLOW;
+  }
+  p = pBucket->pEndOfUsedSpace;
+  end = (uint16_t*)((intptr_t)p + sizeIncludingHeader);
+  if (end > vm->pLastBucketEndCapacity) {
+    CODE_COVERAGE(191); // Hit
+    goto SLOW;
+  }
+
+  pBucket->pEndOfUsedSpace = end;
+  *p++ = header;
+  return p;
+
+SLOW:
+  CODE_COVERAGE(192); // Hit
+  return mvm_allocateWithConstantHeaderSlow(vm, header);
+}
+
+// Looks for a variable in the closure scope chain based on its index. Scope
+// records can be stored in ROM in some optimized cases, so this returns a long
+// pointer.
+static LongPtr vm_findScopedVariable(VM* vm, uint16_t varIndex) {
+  // Slots are 2 bytes
+  uint16_t offset = varIndex << 1;
+  Value scope = vm->stack->reg.closure;
+  while (true)
+  {
+    // The bytecode is corrupt or the compiler has a bug if we hit the bottom of
+    // the scope chain without finding the variable.
+    VM_ASSERT(vm, scope != VM_VALUE_DELETED);
+
+    LongPtr lpArr = DynamicPtr_decode_long(vm, scope);
+    uint16_t headerWord = readAllocationHeaderWord_long(lpArr);
+    VM_ASSERT(vm, vm_getTypeCodeFromHeaderWord(headerWord) == TC_REF_CLOSURE);
+    uint16_t arraySize = vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+    if (offset < arraySize) {
+      return LongPtr_add(lpArr, offset);
+    } else {
+      offset -= arraySize;
+      // The reference to the parent is kept in the second slot
+      scope = LongPtr_read2_aligned(LongPtr_add(lpArr, arraySize - 2));
+    }
+  }
+}
+
+// Read a scoped variable from the current closure. Assumes the current closure
+// is stored in RAM.
+static inline Value vm_readScopedFromThisClosure(VM* vm, uint16_t varIndex) {
+  CODE_COVERAGE(700); // Hit
+  Value* closure = ShortPtr_decode(vm, vm->stack->reg.closure);
+  Value* slot = &closure[varIndex];
+  VM_ASSERT(vm, slot == LongPtr_truncate(vm, vm_findScopedVariable(vm, varIndex)));
+  return *slot;
+}
+
+// Read a scoped variable from the current closure. Assumes the current closure
+// is stored in RAM.
+static inline void vm_writeScopedToThisClosure(VM* vm, uint16_t varIndex, Value value) {
+  CODE_COVERAGE(701); // Hit
+  Value* closure = ShortPtr_decode(vm, vm->stack->reg.closure);
+  Value* slot = &closure[varIndex];
+  VM_ASSERT(vm, slot == LongPtr_truncate(vm, vm_findScopedVariable(vm, varIndex)));
+  *slot = value;
+}
+
+static inline void* getBucketDataBegin(TsBucket* bucket) {
+  CODE_COVERAGE(193); // Hit
+  return (void*)(bucket + 1);
+}
+
+/** The used heap size, excluding spare capacity in the last block, but
+ * including any uncollected garbage. */
+static uint16_t getHeapSize(VM* vm) {
+  TsBucket* lastBucket = vm->pLastBucket;
+  if (lastBucket) {
+    CODE_COVERAGE(194); // Hit
+    return getBucketOffsetEnd(lastBucket);
+  } else {
+    CODE_COVERAGE(195); // Hit
+    return 0;
+  }
+}
+
+void mvm_getMemoryStats(VM* vm, mvm_TsMemoryStats* r) {
+  CODE_COVERAGE(627); // Hit
+  VM_ASSERT(NULL, vm != NULL);
+  VM_ASSERT(vm, r != NULL);
+
+  memset(r, 0, sizeof *r);
+
+  // Core size
+  r->coreSize = sizeof(VM);
+  r->fragmentCount++;
+
+  // Import table size
+  r->importTableSize = getSectionSize(vm, BCS_IMPORT_TABLE) / sizeof (vm_TsImportTableEntry) * sizeof(mvm_TfHostFunction);
+
+  // Global variables size
+  r->globalVariablesSize = getSectionSize(vm, BCS_IMPORT_TABLE);
+
+  r->stackHighWaterMark = vm->stackHighWaterMark;
+
+  r->virtualHeapHighWaterMark = vm->heapHighWaterMark;
+
+  // Running Parameters
+  vm_TsStack* stack = vm->stack;
+  if (stack) {
+    CODE_COVERAGE(628); // Hit
+    r->fragmentCount++;
+    vm_TsRegisters* reg = &stack->reg;
+    r->registersSize = sizeof *reg;
+    r->stackHeight = (uint8_t*)reg->pStackPointer - (uint8_t*)getBottomOfStack(vm->stack);
+    r->stackAllocatedCapacity = MVM_STACK_SIZE;
+  }
+
+  // Heap Stats
+  TsBucket* pLastBucket = vm->pLastBucket;
+  size_t heapOverheadSize = 0;
+  if (pLastBucket) {
+    CODE_COVERAGE(629); // Hit
+    TsBucket* b;
+    for (b = pLastBucket; b; b = b->prev) {
+      r->fragmentCount++;
+      heapOverheadSize += sizeof (TsBucket); // Extra space for bucket header
+    }
+    r->virtualHeapUsed = getHeapSize(vm);
+    if (r->virtualHeapUsed > r->virtualHeapHighWaterMark)
+      r->virtualHeapHighWaterMark = r->virtualHeapUsed;
+    r->virtualHeapAllocatedCapacity = pLastBucket->offsetStart + (uint16_t)(uintptr_t)vm->pLastBucketEndCapacity - (uint16_t)(uintptr_t)getBucketDataBegin(pLastBucket);
+  }
+
+  // Total size
+  r->totalSize =
+    r->coreSize +
+    r->importTableSize +
+    r->globalVariablesSize +
+    r->registersSize +
+    r->stackAllocatedCapacity +
+    r->virtualHeapAllocatedCapacity +
+    heapOverheadSize;
+}
+
+/**
+ * Expand the VM heap by allocating a new "bucket" of memory from the host.
+ *
+ * @param bucketSize The ideal size of the contents of the new bucket
+ * @param minBucketSize The smallest the bucketSize can be reduced and still be valid
+ */
+static void gc_createNextBucket(VM* vm, uint16_t bucketSize, uint16_t minBucketSize) {
+  CODE_COVERAGE(7); // Hit
+  uint16_t heapSize = getHeapSize(vm);
+
+  if (bucketSize < minBucketSize) {
+    CODE_COVERAGE_UNTESTED(196); // Not hit
+    bucketSize = minBucketSize;
+  }
+
+  VM_ASSERT(vm, minBucketSize <= bucketSize);
+
+  // If this tips us over the top of the heap, then we run a collection
+  if (heapSize + bucketSize > MVM_MAX_HEAP_SIZE) {
+    CODE_COVERAGE_UNTESTED(197); // Hit
+    mvm_runGC(vm, false);
+    heapSize = getHeapSize(vm);
+  }
+
+  // Can't fit?
+  if (heapSize + minBucketSize > MVM_MAX_HEAP_SIZE) {
+    CODE_COVERAGE_ERROR_PATH(5); // Not hit
+    MVM_FATAL_ERROR(vm, MVM_E_OUT_OF_MEMORY);
+  }
+
+  // Can fit, but only by chopping the end off the new bucket?
+  if (heapSize + bucketSize > MVM_MAX_HEAP_SIZE) {
+    CODE_COVERAGE_UNTESTED(6); // Not hit
+    bucketSize = MVM_MAX_HEAP_SIZE - heapSize;
+  }
+
+  size_t allocSize = sizeof (TsBucket) + bucketSize;
+  TsBucket* bucket = vm_malloc(vm, allocSize);
+  if (!bucket) {
+    CODE_COVERAGE_ERROR_PATH(198); // Not hit
+    MVM_FATAL_ERROR(vm, MVM_E_MALLOC_FAIL);
+  }
+  #if MVM_SAFE_MODE
+    memset(bucket, 0x7E, allocSize);
+  #endif
+  bucket->prev = vm->pLastBucket;
+  bucket->next = NULL;
+  bucket->pEndOfUsedSpace = getBucketDataBegin(bucket);
+
+  TABLE_COVERAGE(bucket->prev ? 1 : 0, 2, 11); // Hit 2/2
+
+  // Note: we start the next bucket at the allocation cursor, not at what we
+  // previously called the end of the previous bucket
+  bucket->offsetStart = heapSize;
+  vm->pLastBucketEndCapacity = (uint16_t*)((intptr_t)bucket->pEndOfUsedSpace + bucketSize);
+  if (vm->pLastBucket) {
+    CODE_COVERAGE(199); // Hit
+    vm->pLastBucket->next = bucket;
+  } else {
+    CODE_COVERAGE(200); // Hit
+  }
+  vm->pLastBucket = bucket;
+}
+
+static void gc_freeGCMemory(VM* vm) {
+  CODE_COVERAGE(10); // Hit
+  TABLE_COVERAGE(vm->pLastBucket ? 1 : 0, 2, 201); // Hit 2/2
+  while (vm->pLastBucket) {
+    CODE_COVERAGE(169); // Hit
+    TsBucket* prev = vm->pLastBucket->prev;
+    vm_free(vm, vm->pLastBucket);
+    TABLE_COVERAGE(prev ? 1 : 0, 2, 202); // Hit 1/2
+    vm->pLastBucket = prev;
+  }
+  vm->pLastBucketEndCapacity = NULL;
+}
+
+#if MVM_INCLUDE_SNAPSHOT_CAPABILITY || (!MVM_NATIVE_POINTER_IS_16_BIT && !MVM_USE_SINGLE_RAM_PAGE)
+/**
+ * Given a pointer `ptr` into the heap, this returns the equivalent offset from
+ * the start of the heap (0 meaning that `ptr` points to the beginning of the
+ * heap).
+ *
+ * This is used in 2 places:
+ *
+ *   1. On a 32-bit machine, this is used to get a 16-bit equivalent encoding for ShortPtr
+ *   2. On any machine, this is used in serializePtr for creating snapshots
+ */
+static uint16_t pointerOffsetInHeap(VM* vm, TsBucket* pLastBucket, void* ptr) {
+  CODE_COVERAGE(203); // Hit
+  /*
+   * This algorithm iterates through the buckets in the heap backwards. Although
+   * this is technically linear cost, in reality I expect that the pointer will
+   * be found in the very first searched bucket almost all the time. This is
+   * because the GC compacts everything into a single bucket, and because the
+   * most recently bucket is also likely to be the most frequently accessed.
+   *
+   * See ShortPtr_decode for more description
+   */
+  TsBucket* bucket = pLastBucket;
+  while (bucket) {
+    // Note: using `<=` here because the pointer is permitted to point to the
+    // end of the heap.
+    if ((ptr >= (void*)bucket) && (ptr <= (void*)bucket->pEndOfUsedSpace)) {
+      CODE_COVERAGE(204); // Hit
+      uint16_t offsetInBucket = (uint16_t)((intptr_t)ptr - (intptr_t)getBucketDataBegin(bucket));
+      VM_ASSERT(vm, offsetInBucket < 0x8000);
+      uint16_t offsetInHeap = bucket->offsetStart + offsetInBucket;
+
+      // It isn't strictly necessary that all short pointers are 2-byte aligned,
+      // but it probably indicates a mistake somewhere if a short pointer is not
+      // 2-byte aligned, since `Value` cannot be a `ShortPtr` unless it's 2-byte
+      // aligned.
+      VM_ASSERT(vm, (offsetInHeap & 1) == 0);
+
+      VM_ASSERT(vm, offsetInHeap < getHeapSize(vm));
+
+      return offsetInHeap;
+    } else {
+      CODE_COVERAGE(205); // Hit
+    }
+
+    bucket = bucket->prev;
+  }
+
+  // A failure here means we're trying to encode a pointer that doesn't map
+  // to something in GC memory, which is a mistake.
+  MVM_FATAL_ERROR(vm, MVM_E_UNEXPECTED);
+  return 0;
+}
+#endif // MVM_INCLUDE_SNAPSHOT_CAPABILITY || (!MVM_NATIVE_POINTER_IS_16_BIT && !MVM_USE_SINGLE_RAM_PAGE)
+
+// Encodes a bytecode offset as a Value
+static inline Value vm_encodeBytecodeOffsetAsPointer(VM* vm, uint16_t offset) {
+  // Only offsets with 4-byte alignment can be represented as VM values
+  VM_ASSERT(vm, offset & 0xFFFC);
+  // Bytecode pointers end in binary 01
+  return offset | 1;
+}
+
+#if MVM_NATIVE_POINTER_IS_16_BIT
+  static inline void* ShortPtr_decode(VM* vm, ShortPtr ptr) {
+    return (void*)ptr;
+  }
+  static inline ShortPtr ShortPtr_encode(VM* vm, void* ptr) {
+    return (ShortPtr)ptr;
+  }
+  static inline ShortPtr ShortPtr_encodeInToSpace(gc_TsGCCollectionState* gc, void* ptr) {
+    return (ShortPtr)ptr;
+  }
+#elif MVM_USE_SINGLE_RAM_PAGE
+  static inline void* ShortPtr_decode(VM* vm, ShortPtr ptr) {
+    /**
+     * Minor performance note:
+     *
+     * I think I recall that the ARM instruction set can inline 16-bit literal
+     * values but not 32-bit values. This is one of the reasons why this uses
+     * the "high bits" and not just some arbitrary pointer addition. Basically,
+     * I'm trying to make this as efficient as possible, since pointers are used
+     * everywhere
+     */
+    return (void*)(((intptr_t)MVM_RAM_PAGE_ADDR) | ptr);
+  }
+  static inline ShortPtr ShortPtr_encode(VM* vm, void* ptr) {
+    VM_ASSERT(vm, ((intptr_t)ptr - (intptr_t)MVM_RAM_PAGE_ADDR) <= 0xFFFF);
+    return (ShortPtr)(uintptr_t)ptr;
+  }
+  static inline ShortPtr ShortPtr_encodeInToSpace(gc_TsGCCollectionState* gc, void* ptr) {
+    VM_ASSERT(gc->vm, ((intptr_t)ptr - (intptr_t)MVM_RAM_PAGE_ADDR) <= 0xFFFF);
+    return (ShortPtr)(uintptr_t)ptr;
+  }
+#else // !MVM_NATIVE_POINTER_IS_16_BIT && !MVM_USE_SINGLE_RAM_PAGE
+  static void* ShortPtr_decode(VM* vm, ShortPtr shortPtr) {
+    // It isn't strictly necessary that all short pointers are 2-byte aligned,
+    // but it probably indicates a mistake somewhere if a short pointer is not
+    // 2-byte aligned, since `Value` cannot be a `ShortPtr` unless it's 2-byte
+    // aligned. Among other things, this catches VM_VALUE_NULL.
+    VM_ASSERT(vm, (shortPtr & 1) == 0);
+
+    // The shortPtr is treated as an offset into the heap
+    uint16_t offsetInHeap = shortPtr;
+    VM_ASSERT(vm, offsetInHeap < getHeapSize(vm));
+
+    /*
+    Note: this is a linear search through the buckets, but a redeeming factor is
+    that GC compacts the heap into a single bucket, so the number of buckets is
+    small at any one time. Also, most-recently-allocated data are likely to be
+    in the last bucket and accessed fastest. Also, the representation of the
+    function is only needed on more powerful platforms. For 16-bit platforms,
+    the implementation of ShortPtr_decode is a no-op.
+    */
+
+    TsBucket* bucket = vm->pLastBucket;
+    while (true) {
+      // All short pointers must map to some memory in a bucket, otherwise the pointer is corrupt
+      VM_ASSERT(vm, bucket != NULL);
+
+      if (offsetInHeap >= bucket->offsetStart) {
+        uint16_t offsetInBucket = offsetInHeap - bucket->offsetStart;
+        void* result = (void*)((intptr_t)getBucketDataBegin(bucket) + offsetInBucket);
+        return result;
+      }
+      bucket = bucket->prev;
+    }
+  }
+
+  /**
+   * Like ShortPtr_encode except conducted against an arbitrary bucket list.
+   *
+   * Used internally by ShortPtr_encode and ShortPtr_encodeInToSpace.
+   */
+  static inline ShortPtr ShortPtr_encode_generic(VM* vm, TsBucket* pLastBucket, void* ptr) {
+    return pointerOffsetInHeap(vm, pLastBucket, ptr);
+  }
+
+  // Encodes a pointer as pointing to a value in the current heap
+  static inline ShortPtr ShortPtr_encode(VM* vm, void* ptr) {
+    return ShortPtr_encode_generic(vm, vm->pLastBucket, ptr);
+  }
+
+  // Encodes a pointer as pointing to a value in the _new_ heap (tospace) during
+  // an ongoing garbage collection.
+  static inline ShortPtr ShortPtr_encodeInToSpace(gc_TsGCCollectionState* gc, void* ptr) {
+    return ShortPtr_encode_generic(gc->vm, gc->lastBucket, ptr);
+  }
+#endif
+
+static LongPtr BytecodeMappedPtr_decode_long(VM* vm, BytecodeMappedPtr ptr) {
+  CODE_COVERAGE(214); // Hit
+
+  // BytecodeMappedPtr values are treated as offsets into a bytecode image if
+  // you zero the lowest 2 bits
+  uint16_t offsetInBytecode = ptr & 0xFFFC;
+
+  LongPtr lpBytecode = vm->lpBytecode;
+
+  // A BytecodeMappedPtr can either point to ROM or via a global variable to
+  // RAM. Here to discriminate the two, we're assuming the handles section comes
+  // first
+  VM_ASSERT(vm, BCS_ROM < BCS_GLOBALS);
+  uint16_t globalsOffset = getSectionOffset(lpBytecode, BCS_GLOBALS);
+
+  if (offsetInBytecode < globalsOffset) { // Points to ROM section?
+    CODE_COVERAGE(215); // Hit
+    VM_ASSERT(vm, offsetInBytecode >= getSectionOffset(lpBytecode, BCS_ROM));
+    VM_ASSERT(vm, offsetInBytecode < getSectionOffset(lpBytecode, vm_sectionAfter(vm, BCS_ROM)));
+    VM_ASSERT(vm, (offsetInBytecode & 3) == 0);
+
+    // The pointer just references ROM
+    return LongPtr_add(lpBytecode, offsetInBytecode);
+  } else { // Else, must point to RAM via a global variable
+    CODE_COVERAGE(216); // Hit
+    VM_ASSERT(vm, offsetInBytecode >= getSectionOffset(lpBytecode, BCS_GLOBALS));
+    VM_ASSERT(vm, offsetInBytecode < getSectionOffset(lpBytecode, vm_sectionAfter(vm, BCS_GLOBALS)));
+    VM_ASSERT(vm, (offsetInBytecode & 3) == 0);
+
+    uint16_t offsetInGlobals = offsetInBytecode - globalsOffset;
+    Value handleValue = *(Value*)((intptr_t)vm->globals + offsetInGlobals);
+
+    // Note: handle values can't be null, because handles are used to point from
+    // ROM to RAM and ROM will never change. So if the value in ROM was null
+    // then it will always be null and not need a handle. And if the value in
+    // ROM points to an allocation in RAM then that allocation is permanently
+    // reachable.
+    VM_ASSERT(vm, Value_isShortPtr(handleValue));
+
+    return LongPtr_new(ShortPtr_decode(vm, handleValue));
+  }
+}
+
+static LongPtr DynamicPtr_decode_long(VM* vm, DynamicPtr ptr) {
+  CODE_COVERAGE(217); // Hit
+
+  if (Value_isShortPtr(ptr))  {
+    CODE_COVERAGE(218); // Hit
+    return LongPtr_new(ShortPtr_decode(vm, ptr));
+  }
+
+  if (ptr == VM_VALUE_NULL || ptr == VM_VALUE_UNDEFINED) {
+    CODE_COVERAGE(219); // Hit
+    return LongPtr_new(NULL);
+  }
+  CODE_COVERAGE(242); // Hit
+
+  // This function is for decoding pointers, so if this isn't a pointer then
+  // there's a problem.
+  VM_ASSERT(vm, !Value_isVirtualInt14(ptr));
+
+  // At this point, it's not a short pointer, so it must be a bytecode-mapped
+  // pointer
+  VM_ASSERT(vm, Value_encodesBytecodeMappedPtr(ptr));
+
+  // I'm expecting this to be inlined by the compiler
+  return BytecodeMappedPtr_decode_long(vm, ptr);
+}
+
+/*
+ * Decode a DynamicPtr when the target is known to live in natively-addressable
+ * memory (i.e. heap memory). If the target might be in ROM, use
+ * DynamicPtr_decode_long.
+ */
+static void* DynamicPtr_decode_native(VM* vm, DynamicPtr ptr) {
+  CODE_COVERAGE(253); // Hit
+  LongPtr lp = DynamicPtr_decode_long(vm, ptr);
+  void* p = LongPtr_truncate(vm, lp);
+  // Assert that the resulting native pointer is equivalent to the long pointer.
+  // I.e. that we didn't lose anything in the truncation (i.e. that it doesn't
+  // point to ROM).
+  VM_ASSERT(vm, LongPtr_new(p) == lp);
+  return p;
+}
+
+// I'm using inline wrappers around the port macros because I want to add a
+// layer of type safety.
+static inline LongPtr LongPtr_new(void* p) {
+  CODE_COVERAGE(284); // Hit
+  return MVM_LONG_PTR_NEW(p);
+}
+static inline void* LongPtr_truncate(VM* vm, LongPtr lp) {
+  CODE_COVERAGE(332); // Hit
+  void* result = MVM_LONG_PTR_TRUNCATE(lp);
+  VM_ASSERT(vm, lp == LongPtr_new(result));
+  return result;
+}
+static inline LongPtr LongPtr_add(LongPtr lp, int16_t offset) {
+  CODE_COVERAGE(333); // Hit
+  return MVM_LONG_PTR_ADD(lp, offset);
+}
+static inline int16_t LongPtr_sub(LongPtr lp1, LongPtr lp2) {
+  CODE_COVERAGE(334); // Hit
+  return (int16_t)(MVM_LONG_PTR_SUB(lp1, lp2));
+}
+static inline uint8_t LongPtr_read1(LongPtr lp) {
+  CODE_COVERAGE(335); // Hit
+  return (uint8_t)(MVM_READ_LONG_PTR_1(lp));
+}
+// Read a 16-bit value from a long pointer, if the target is 16-bit aligned
+static inline uint16_t LongPtr_read2_aligned(LongPtr lp) {
+  CODE_COVERAGE(336); // Hit
+  // Expect an even boundary. Weird things happen on some platforms if you try
+  // to read unaligned memory through aligned instructions.
+  VM_ASSERT(0, ((uint16_t)(uintptr_t)lp & 1) == 0);
+  return (uint16_t)(MVM_READ_LONG_PTR_2(lp));
+}
+// Read a 16-bit value from a long pointer, if the target is not 16-bit aligned
+static inline uint16_t LongPtr_read2_unaligned(LongPtr lp) {
+  CODE_COVERAGE(626); // Hit
+  return (uint32_t)(MVM_READ_LONG_PTR_1(lp)) |
+    ((uint32_t)(MVM_READ_LONG_PTR_1((MVM_LONG_PTR_ADD(lp, 1)))) << 8);
+}
+static inline uint32_t LongPtr_read4(LongPtr lp) {
+  // We don't often read 4 bytes, since the word size for microvium is 2 bytes.
+  // When we do need to, I think it's safer to just read it as 2 separate words
+  // since we don't know for sure that we're not executing on a 32 bit machine
+  // that can't do unaligned access. All memory in microvium is at least 16-bit
+  // aligned, with the exception of bytecode instructions, but those do not
+  // contain 32-bit literals.
+  CODE_COVERAGE(337); // Hit
+  return (uint32_t)(MVM_READ_LONG_PTR_2(lp)) |
+    ((uint32_t)(MVM_READ_LONG_PTR_2((MVM_LONG_PTR_ADD(lp, 2)))) << 16);
+}
+
+static uint16_t getBucketOffsetEnd(TsBucket* bucket) {
+  CODE_COVERAGE(338); // Hit
+  return bucket->offsetStart + (uint16_t)(uintptr_t)bucket->pEndOfUsedSpace - (uint16_t)(uintptr_t)getBucketDataBegin(bucket);
+}
+
+static uint16_t gc_getHeapSize(gc_TsGCCollectionState* gc) {
+  CODE_COVERAGE(351); // Hit
+  TsBucket* pLastBucket = gc->lastBucket;
+  if (pLastBucket) {
+    CODE_COVERAGE(352); // Hit
+    return getBucketOffsetEnd(pLastBucket);
+  } else {
+    CODE_COVERAGE(355); // Hit
+    return 0;
+  }
+}
+
+static void gc_newBucket(gc_TsGCCollectionState* gc, uint16_t newSpaceSize, uint16_t minNewSpaceSize) {
+  CODE_COVERAGE(356); // Hit
+  uint16_t heapSize = gc_getHeapSize(gc);
+
+  if (newSpaceSize < minNewSpaceSize) {
+    CODE_COVERAGE_UNTESTED(357); // Not hit
+    newSpaceSize = minNewSpaceSize;
+  } else {
+    CODE_COVERAGE(358); // Hit
+  }
+
+  // Since this is during a GC, it should be impossible for us to need more heap
+  // than is allowed, since the original heap should never have exceeded the
+  // MVM_MAX_HEAP_SIZE.
+  VM_ASSERT(NULL, heapSize + minNewSpaceSize <= MVM_MAX_HEAP_SIZE);
+
+  // Can fit, but only by chopping the end off the new bucket?
+  if (heapSize + newSpaceSize > MVM_MAX_HEAP_SIZE) {
+    CODE_COVERAGE_UNTESTED(8); // Not hit
+    newSpaceSize = MVM_MAX_HEAP_SIZE - heapSize;
+  } else {
+    CODE_COVERAGE(360); // Hit
+  }
+
+  TsBucket* pBucket = (TsBucket*)vm_malloc(gc->vm, sizeof (TsBucket) + newSpaceSize);
+  if (!pBucket) {
+    CODE_COVERAGE_ERROR_PATH(376); // Not hit
+    MVM_FATAL_ERROR(NULL, MVM_E_MALLOC_FAIL);
+    return;
+  }
+  pBucket->next = NULL;
+  uint16_t* pDataInBucket = (uint16_t*)(pBucket + 1);
+  if (((intptr_t)pDataInBucket) & 1) {
+    CODE_COVERAGE_ERROR_PATH(377); // Not hit
+    MVM_FATAL_ERROR(NULL, MVM_E_MALLOC_MUST_RETURN_POINTER_TO_EVEN_BOUNDARY);
+    return;
+  }
+  pBucket->offsetStart = heapSize;
+  pBucket->prev = gc->lastBucket;
+  pBucket->pEndOfUsedSpace = getBucketDataBegin(pBucket);
+  if (!gc->firstBucket) {
+    CODE_COVERAGE(392); // Hit
+    gc->firstBucket = pBucket;
+  } else {
+    CODE_COVERAGE(393); // Hit
+  }
+  if (gc->lastBucket) {
+    CODE_COVERAGE(394); // Hit
+    gc->lastBucket->next = pBucket;
+  } else {
+    CODE_COVERAGE(395); // Hit
+  }
+  gc->lastBucket = pBucket;
+  gc->lastBucketEndCapacity = (uint16_t*)((intptr_t)pDataInBucket + newSpaceSize);
+}
+
+static void gc_processShortPtrValue(gc_TsGCCollectionState* gc, Value* pValue) {
+  CODE_COVERAGE(407); // Hit
+
+  uint16_t* writePtr;
+  const Value spSrc = *pValue;
+  VM* const vm = gc->vm;
+
+  uint16_t* const pSrc = (uint16_t*)ShortPtr_decode(vm, spSrc);
+  // ShortPtr is defined as not encoding null
+  VM_ASSERT(vm, pSrc != NULL);
+
+  const uint16_t headerWord = pSrc[-1];
+
+  // If there's a tombstone, then we've already collected this allocation
+  if (headerWord == TOMBSTONE_HEADER) {
+    CODE_COVERAGE(464); // Hit
+    *pValue = pSrc[0];
+    return;
+  } else {
+    CODE_COVERAGE(465); // Hit
+    // Tombstones always have exactly the header `TOMBSTONE_HEADER`. If it has
+    // the type-code of TC_REF_TOMBSTONE but is not a tombstone then it's likely
+    // a corrupt header or pValue does not point to a legitimate allocation.
+    VM_ASSERT(vm, vm_getTypeCodeFromHeaderWord(headerWord) != TC_REF_TOMBSTONE);
+  }
+  // Otherwise, we need to move the allocation
+
+SUB_MOVE_ALLOCATION:
+  // Note: the variables before this point are `const` because an allocation
+  // movement can be aborted half way and tried again (in particular, see the
+  // property list compaction). It's only right at the end of this function
+  // where the writePtr is "committed" to the gc structure.
+
+  VM_ASSERT(vm, gc->lastBucket != NULL);
+  writePtr = gc->lastBucket->pEndOfUsedSpace;
+  uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+  uint16_t words = (size + 3) / 2; // Rounded up, including header
+
+  // Check we have space
+  if (writePtr + words > gc->lastBucketEndCapacity) {
+    CODE_COVERAGE(466); // Hit
+    uint16_t minRequiredSpace = words * 2;
+    gc_newBucket(gc, MVM_ALLOCATION_BUCKET_SIZE, minRequiredSpace);
+
+    goto SUB_MOVE_ALLOCATION;
+  } else {
+    CODE_COVERAGE(467); // Hit
+  }
+
+  // Write the header
+  *writePtr++ = headerWord;
+  words--;
+
+  uint16_t* pOld = pSrc;
+  uint16_t* pNew = writePtr;
+
+  // Copy the allocation body
+  uint16_t* readPtr = pSrc;
+  while (words--)
+    *writePtr++ = *readPtr++;
+
+  // Dynamic arrays and property lists are compacted here
+  TeTypeCode tc = vm_getTypeCodeFromHeaderWord(headerWord);
+  if (tc == TC_REF_ARRAY) {
+    CODE_COVERAGE(468); // Hit
+    TsArray* arr = (TsArray*)pNew;
+    DynamicPtr dpData = arr->dpData;
+    if (dpData != VM_VALUE_NULL) {
+      CODE_COVERAGE(469); // Hit
+      VM_ASSERT(vm, Value_isShortPtr(dpData));
+
+      // Note: this decodes the pointer against fromspace
+      TsFixedLengthArray* pData = ShortPtr_decode(vm, dpData);
+
+      uint16_t len = VirtualInt14_decode(vm, arr->viLength);
+      #if MVM_SAFE_MODE
+        uint16_t headerWord = readAllocationHeaderWord(pData);
+        uint16_t dataTC = vm_getTypeCodeFromHeaderWord(headerWord);
+        // Note: because dpData is a unique pointer, we can be sure that it
+        // hasn't already been moved in response to some other reference to
+        // it (it's not a tombstone yet).
+        VM_ASSERT(vm, dataTC == TC_REF_FIXED_LENGTH_ARRAY);
+        uint16_t dataSize = vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+        uint16_t capacity = dataSize / 2;
+        VM_ASSERT(vm, len <= capacity);
+      #endif
+
+      if (len > 0) {
+        CODE_COVERAGE(470); // Hit
+        // We just truncate the fixed-length-array to match the programmed
+        // length of the dynamic array, which is necessarily equal or less than
+        // its previous value. The GC will copy the data later and update the
+        // data pointer as it would normally do when following pointers.
+        setHeaderWord(vm, pData, TC_REF_FIXED_LENGTH_ARRAY, len * 2);
+      } else {
+        CODE_COVERAGE_UNTESTED(472); // Not hit
+        // Or if there's no length, we can remove the data altogether.
+        arr->dpData = VM_VALUE_NULL;
+      }
+    } else {
+      CODE_COVERAGE(473); // Hit
+    }
+  } else if (tc == TC_REF_PROPERTY_LIST) {
+    CODE_COVERAGE(474); // Hit
+    TsPropertyList* props = (TsPropertyList*)pNew;
+
+    Value dpNext = props->dpNext;
+
+    // If the object has children (detached extensions to the main
+    // allocation), we take this opportunity to compact them into the parent
+    // allocation to save space and improve access performance.
+    if (dpNext != VM_VALUE_NULL) {
+      CODE_COVERAGE(478); // Hit
+      // Note: The "root" property list counts towards the total but its
+      // fields do not need to be copied because it's already copied, above
+      uint16_t headerWord = readAllocationHeaderWord(props);
+      uint16_t allocationSize = vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+      uint16_t totalPropCount = (allocationSize - sizeof(TsPropertyList)) / 4;
+
+      do {
+        // Note: while `next` is not strictly a ShortPtr in general, when used
+        // within GC allocations it will never point to an allocation in ROM
+        // or data memory, since it's only used to extend objects with new
+        // properties.
+        VM_ASSERT(vm, Value_isShortPtr(dpNext));
+        TsPropertyList* child = (TsPropertyList*)ShortPtr_decode(vm, dpNext);
+
+        uint16_t headerWord = readAllocationHeaderWord(child);
+        uint16_t allocationSize = vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+        uint16_t childPropCount = (allocationSize - sizeof(TsPropertyList)) / 4;
+        totalPropCount += childPropCount;
+
+        uint16_t* end = writePtr + childPropCount;
+        // Check we have space for the new properties
+        if (end > gc->lastBucketEndCapacity) {
+          CODE_COVERAGE(479); // Hit
+          // If we don't have space, we need to revert and try again. The
+          // "revert" isn't explict. It depends on the fact that the gc.writePtr
+          // hasn't been committed yet, and no mutations have been applied to
+          // the source memory (i.e. the tombstone hasn't been written yet).
+          uint16_t minRequiredSpace = sizeof (TsPropertyList) + totalPropCount * 4;
+          gc_newBucket(gc, MVM_ALLOCATION_BUCKET_SIZE, minRequiredSpace);
+          goto SUB_MOVE_ALLOCATION;
+        } else {
+          CODE_COVERAGE(480); // Hit
+        }
+
+        uint16_t* pField = (uint16_t*)(child + 1);
+
+        // Copy the child fields directly into the parent
+        while (childPropCount--) {
+          *writePtr++ = *pField++; // key
+          *writePtr++ = *pField++; // value
+        }
+        dpNext = child->dpNext;
+        TABLE_COVERAGE(dpNext ? 1 : 0, 2, 490); // Hit 1/2
+      } while (dpNext != VM_VALUE_NULL);
+
+      // We've collapsed all the lists into one, so let's adjust the header
+      uint16_t newSize = sizeof (TsPropertyList) + totalPropCount * 4;
+      if (newSize > MAX_ALLOCATION_SIZE) {
+        CODE_COVERAGE_ERROR_PATH(491); // Not hit
+        MVM_FATAL_ERROR(vm, MVM_E_ALLOCATION_TOO_LARGE);
+        return;
+      }
+
+      setHeaderWord(vm, props, TC_REF_PROPERTY_LIST, newSize);
+      props->dpNext = VM_VALUE_NULL;
+    }
+  } else {
+    CODE_COVERAGE(492); // Hit
+  }
+
+  // Commit the move (grow the target heap and add the tombstone)
+
+  gc->lastBucket->pEndOfUsedSpace = writePtr;
+
+  ShortPtr spNew = ShortPtr_encodeInToSpace(gc, pNew);
+
+  pOld[-1] = TOMBSTONE_HEADER;
+  pOld[0] = spNew; // Forwarding pointer
+
+  *pValue = spNew;
+}
+
+static inline void gc_processValue(gc_TsGCCollectionState* gc, Value* pValue) {
+  // Note: only short pointer values are allowed to point to GC memory,
+  // and we only need to follow references that go to GC memory.
+  if (Value_isShortPtr(*pValue)) {
+    CODE_COVERAGE(446); // Hit
+    gc_processShortPtrValue(gc, pValue);
+  } else {
+    CODE_COVERAGE(463); // Hit
+  }
+}
+
+void mvm_runGC(VM* vm, bool squeeze) {
+  CODE_COVERAGE(593); // Hit
+
+  /*
+  This is a semispace collection model based on Cheney's algorithm
+  https://en.wikipedia.org/wiki/Cheney%27s_algorithm. It collects by moving
+  reachable allocations from the fromspace to the tospace and then releasing the
+  fromspace. It starts by moving allocations reachable by the roots, and then
+  iterates through moved allocations, checking the pointers therein, moving the
+  allocations they reference.
+
+  When an object is moved, the space it occupied is changed to a tombstone
+  (TC_REF_TOMBSTONE) which contains a forwarding pointer. When a pointer in
+  tospace is seen to point to an allocation in fromspace, if the fromspace
+  allocation is a tombstone then the pointer can be updated to the forwarding
+  pointer.
+
+  This algorithm relies on allocations in tospace each have a header. Some
+  allocations, such as property cells, don't have a header, but will only be
+  found in fromspace. When copying objects into tospace, the detached property
+  cells are merged into the object's head allocation.
+
+  Note: all pointer _values_ are only processed once each (since their
+  corresponding container is only processed once). This means that fromspace and
+  tospace can be treated as distinct spaces. An unprocessed pointer is
+  interpreted in terms of _fromspace_. Forwarding pointers and pointers in
+  processed allocations always reference _tospace_.
+  */
+
+  #if MVM_VERY_EXPENSIVE_MEMORY_CHECKS
+  mvm_checkHeap(vm);
+  #endif
+
+  uint16_t n;
+  uint16_t* p;
+
+  uint16_t heapSize = getHeapSize(vm);
+  if (heapSize > vm->heapHighWaterMark)
+    vm->heapHighWaterMark = heapSize;
+
+  // A collection of variables shared by GC routines
+  gc_TsGCCollectionState gc;
+  memset(&gc, 0, sizeof gc);
+  gc.vm = vm;
+
+  // We don't know how big the heap needs to be, so we just allocate the same
+  // amount of space as used last time and then expand as-needed
+  uint16_t estimatedSize = vm->heapSizeUsedAfterLastGC;
+
+  #if MVM_VERY_EXPENSIVE_MEMORY_CHECKS
+    // Move the heap address space by 2 bytes on each cycle (overflows at 256).
+    vm->gc_heap_shift += 2;
+    if (vm->gc_heap_shift == 0) {
+      // Minimum of 2 bytes just so we have consistency when it overflows
+      vm->gc_heap_shift = 2;
+    }
+    // We shift up the address space by `gc_heap_shift` amount by just
+    // allocating a bucket of that size at the beginning and marking it full.
+    gc_newBucket(&gc, vm->gc_heap_shift, 0);
+    // The heap must be parsable, so we need to have an allocation header to
+    // mark the space. In general, we do not allow allocations to be smaller
+    // than 4 bytes because a tombstone is 4 bytes. However, there can be no
+    // references to this "allocation" so no tombstone is required, so it can
+    // be as small as 2 bytes. I'm using a string here because it's a
+    // "non-container" type, so the GC will not interpret its contents.
+    VM_ASSERT(vm, vm->gc_heap_shift >= 2);
+    *gc.lastBucket->pEndOfUsedSpace = vm_makeHeaderWord(vm, TC_REF_STRING, vm->gc_heap_shift - 2);
+  #endif // MVM_VERY_EXPENSIVE_MEMORY_CHECKS
+
+  if (!estimatedSize) {
+    CODE_COVERAGE(494); // Hit
+    // Actually the value-copying algorithm can't deal with creating the heap from nothing, and
+    // I don't want to slow it down by adding extra checks, so we always create at least a small
+    // heap.
+    estimatedSize = 64;
+  } else {
+    CODE_COVERAGE(493); // Hit
+  }
+  gc_newBucket(&gc, estimatedSize, 0);
+
+  // Roots in global variables (including indirection handles)
+  // Note: Interned strings are referenced from a handle and so will be GC'd here
+  // TODO: It would actually be good to have a test case showing that the string interning table is handled properly during GC
+  uint16_t globalsSize = getSectionSize(vm, BCS_GLOBALS);
+  p = vm->globals;
+  n = globalsSize / 2;
+  TABLE_COVERAGE(n ? 1 : 0, 2, 495); // Hit 1/2
+  while (n--)
+    gc_processValue(&gc, p++);
+
+  // Roots in gc_handles
+  mvm_Handle* handle = vm->gc_handles;
+  TABLE_COVERAGE(handle ? 1 : 0, 2, 496); // Hit 2/2
+  while (handle) {
+    gc_processValue(&gc, &handle->_value);
+    TABLE_COVERAGE(handle->_next ? 1 : 0, 2, 497); // Hit 2/2
+    handle = handle->_next;
+  }
+
+  // Roots on the stack or registers
+  vm_TsStack* stack = vm->stack;
+  if (stack) {
+    CODE_COVERAGE(498); // Hit
+    vm_TsRegisters* reg = &stack->reg;
+    VM_ASSERT(vm, reg->usingCachedRegisters == false);
+
+    // Roots in registers
+    gc_processValue(&gc, &reg->closure);
+    gc_processValue(&gc, &reg->cpsCallback);
+    gc_processValue(&gc, &reg->jobQueue);
+
+    // Roots on call stack
+    uint16_t* beginningOfStack = getBottomOfStack(stack);
+    uint16_t* beginningOfFrame = reg->pFrameBase;
+    uint16_t* endOfFrame = reg->pStackPointer;
+
+    while (true) {
+      VM_ASSERT(vm, beginningOfFrame >= beginningOfStack);
+
+      // Loop through words in frame
+      p = beginningOfFrame;
+      while (p != endOfFrame) {
+        VM_ASSERT(vm, p < endOfFrame);
+        // TODO: It would be an interesting exercise to see if the GC can be written into a single function so that we don't need to pass around the &gc struct everywhere
+        gc_processValue(&gc, p++);
+      }
+
+      if (beginningOfFrame == beginningOfStack) {
+        break;
+      }
+      VM_ASSERT(vm, beginningOfFrame >= beginningOfStack);
+
+      // The following statements assume a particular stack shape
+      VM_ASSERT(vm, VM_FRAME_BOUNDARY_VERSION == 2);
+
+      // Skip over the registers that are saved during a CALL instruction
+      endOfFrame = beginningOfFrame - 4;
+
+      // The saved scope pointer
+      Value* pScope = endOfFrame + 1;
+      gc_processValue(&gc, pScope);
+
+      // The first thing saved during a CALL is the size of the preceding frame
+      beginningOfFrame = (uint16_t*)((uint8_t*)endOfFrame - *endOfFrame);
+
+      TABLE_COVERAGE(beginningOfFrame == beginningOfStack ? 1 : 0, 2, 499); // Hit 2/2
+    }
+  } else {
+    CODE_COVERAGE(500); // Hit
+  }
+
+  // Now we process moved allocations to make sure objects they point to are
+  // also moved, and to update pointers to reference the new space
+
+  TsBucket* bucket = gc.firstBucket;
+  TABLE_COVERAGE(bucket ? 1 : 0, 2, 501); // Hit 1/2
+  // Loop through buckets
+  while (bucket) {
+    uint16_t* p = (uint16_t*)getBucketDataBegin(bucket);
+
+    // Loop through allocations in bucket. Note that this loop will hit exactly
+    // the end of the bucket even when there are multiple buckets, because empty
+    // space in a bucket is truncated when a new one is created (in
+    // gc_processValue)
+    while (p != bucket->pEndOfUsedSpace) { // Hot loop
+      VM_ASSERT(vm, p < bucket->pEndOfUsedSpace);
+      uint16_t header = *p++;
+      uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+
+      uint16_t* next = p + ((size + 1) >> 1); // round up
+
+      // Note: we're comparing the header words here to compare the type code.
+      // The RHS here is constant
+      if (header < (uint16_t)(TC_REF_DIVIDER_CONTAINER_TYPES << 12)) { // Non-container types
+        CODE_COVERAGE(502); // Hit
+        p = next;
+        continue;
+      } else { // Else, container types
+        CODE_COVERAGE(505); // Hit
+
+        // Note: we round down in calculating the number of words in the container
+        // that may contain a valid pointer. In particular this allows zero-length
+        // containers to have a size of 1 byte, which is rounded up to a 2-byte
+        // allocation (the minimum size large enough for the tombstone) but rounded
+        // down to zer when treated as the container dimension.
+        uint16_t words = size >> 1; // round down
+        while (words--) { // Hot loop
+          if (Value_isShortPtr(*p))
+            gc_processValue(&gc, p);
+          p++;
+        }
+        p = next;
+      }
+    }
+
+    // Go to next bucket
+    bucket = bucket->next;
+    TABLE_COVERAGE(bucket ? 1 : 0, 2, 506); // Hit 2/2
+  }
+
+  // Release old heap
+  TsBucket* oldBucket = vm->pLastBucket;
+  TABLE_COVERAGE(oldBucket ? 1 : 0, 2, 507); // Hit 2/2
+  while (oldBucket) {
+    TsBucket* prev = oldBucket->prev;
+    vm_free(vm, oldBucket);
+    oldBucket = prev;
+  }
+
+  // Adopt new heap
+  vm->pLastBucket = gc.lastBucket;
+  vm->pLastBucketEndCapacity = gc.lastBucketEndCapacity;
+
+  uint16_t finalUsedSize = getHeapSize(vm);
+  vm->heapSizeUsedAfterLastGC = finalUsedSize;
+
+  if (squeeze && (finalUsedSize != estimatedSize)) {
+    CODE_COVERAGE(508); // Hit
+    /*
+    Note: The most efficient way to calculate the exact size needed for the heap
+    is actually to run a collection twice. The collection algorithm itself is
+    almost as efficient as any size-counting algorithm in terms of running time
+    since it needs to iterate the whole reachability graph and all the pointers
+    contained therein. But having a distinct size-counting algorithm is less
+    efficient in terms of the amount of code-space (ROM) used, since it must
+    duplicate much of the logic to parse the heap. It also needs to keep
+    separate flags to know what it's already counted or not, and these flags
+    would presumably take up space in the headers that isn't otherwise needed.
+
+    Furthermore, it's suspected that a common case is where the VM is repeatedly
+    used to perform the same calculation, such as a "tick" or "check" function,
+    that does basically the same thing every time and so lands up in the same
+    equilibrium size each time. With this squeeze implementation we would only
+    run the GC once each time, since the estimated size would be correct most of
+    the time.
+
+    In conclusion, I decided that the best way to "squeeze" the heap is to just
+    run the collection twice. The first time will tell us the exact size, and
+    then if that's different to what we estimated then we perform the collection
+    again, now with the exact target size, so that there is no unused space
+    malloc'd from the host, and no unnecessary mallocs from the host.
+
+    Note: especially for small programs, the squeeze could make a significant
+    difference to the idle memory usage. A program that goes from 18 bytes to 20
+    bytes will cause a whole new bucket to be allocated for the additional 2B,
+    leaving 254B unused (if the bucket size is 256B). The "squeeze" pass will
+    compact everything into a single 20B allocation.
+    */
+    mvm_runGC(vm, false);
+  } else {
+    CODE_COVERAGE(509); // Hit
+  }
+}
+
+/**
+ * Create the call VM call stack and registers
+ */
+TeError vm_createStackAndRegisters(VM* vm) {
+  CODE_COVERAGE(225); // Hit
+  // This is freed again at the end of mvm_call. Note: the allocated
+  // memory includes the registers, which are part of the vm_TsStack
+  // structure
+  vm_TsStack* stack = vm_malloc(vm, sizeof (vm_TsStack) + MVM_STACK_SIZE);
+  if (!stack) {
+    CODE_COVERAGE_ERROR_PATH(231); // Not hit
+    return vm_newError(vm, MVM_E_MALLOC_FAIL);
+  }
+  vm->stack = stack;
+  vm_TsRegisters* reg = &stack->reg;
+  memset(reg, 0, sizeof *reg);
+  // The stack grows upward. The bottom is the lowest address.
+  uint16_t* bottomOfStack = getBottomOfStack(stack);
+  reg->pFrameBase = bottomOfStack;
+  reg->pStackPointer = bottomOfStack;
+  reg->lpProgramCounter = vm->lpBytecode; // This is essentially treated as a null value
+  reg->argCountAndFlags = 0;
+  reg->closure = VM_VALUE_UNDEFINED;
+  reg->pCatchTarget = NULL;
+  reg->cpsCallback = VM_VALUE_DELETED;
+  reg->jobQueue = VM_VALUE_UNDEFINED;
+  VM_ASSERT(vm, reg->pArgs == 0);
+
+  return MVM_E_SUCCESS;
+}
+
+// Lowest address on stack
+static inline uint16_t* getBottomOfStack(vm_TsStack* stack) {
+  CODE_COVERAGE(510); // Hit
+  return (uint16_t*)(stack + 1);
+}
+
+// Highest possible address on stack (+1) before overflow
+static inline uint16_t* getTopOfStackSpace(vm_TsStack* stack) {
+  CODE_COVERAGE(511); // Hit
+  return getBottomOfStack(stack) + MVM_STACK_SIZE / 2;
+}
+
+#if MVM_DEBUG
+// Some utility functions, mainly to execute in the debugger (could also be copy-pasted as expressions in some cases)
+uint16_t dbgStackDepth(VM* vm) {
+  return (uint16_t)((uint16_t*)vm->stack->reg.pStackPointer - (uint16_t*)(vm->stack + 1));
+}
+uint16_t* dbgStack(VM* vm) {
+  return (uint16_t*)(vm->stack + 1);
+}
+uint16_t dbgPC(VM* vm) {
+  return (uint16_t)((intptr_t)vm->stack->reg.lpProgramCounter - (intptr_t)vm->lpBytecode);
+}
+#endif // MVM_DEBUG
+
+/**
+ * Checks that we have enough stack space for the given size, and updates the
+ * high water mark.
+ */
+static TeError vm_requireStackSpace(VM* vm, uint16_t* pStackPointer, uint16_t sizeRequiredInWords) {
+  uint16_t* pStackHighWaterMark = pStackPointer + ((intptr_t)sizeRequiredInWords);
+  if (pStackHighWaterMark > getTopOfStackSpace(vm->stack)) {
+    CODE_COVERAGE_ERROR_PATH(233); // Not hit
+
+    // TODO(low): Since we know the max stack depth for the function, we could
+    // actually grow the stack dynamically rather than allocate it fixed size.
+    // Actually, it seems likely that we could allocate the VM stack on the C
+    // stack, since it's a fixed-size structure anyway.
+    //
+    // (A way to do the allocation on the stack would be to perform a nested
+    // call to mvm_call, and the allocation can be at the beginning of
+    // mvm_call). Otherwise we could just malloc, which has the advantage of
+    // simplicity and we can grow the stack at any time.
+    //
+    // Rather than a segmented stack, it might also be simpler to just grow the
+    // stack size and copy across old data. This has the advantage of keeping
+    // the GC simple.
+    return vm_newError(vm, MVM_E_STACK_OVERFLOW);
+  }
+
+  // Stack high-water mark
+  uint16_t stackHighWaterMark = (uint16_t)((intptr_t)pStackHighWaterMark - (intptr_t)getBottomOfStack(vm->stack));
+  if (stackHighWaterMark > vm->stackHighWaterMark) {
+    vm->stackHighWaterMark = stackHighWaterMark;
+  }
+
+  return MVM_E_SUCCESS;
+}
+
+TeError vm_resolveExport(VM* vm, mvm_VMExportID id, Value* result) {
+  CODE_COVERAGE(17); // Hit
+
+  LongPtr exportTableEnd;
+  LongPtr exportTable = getBytecodeSection(vm, BCS_EXPORT_TABLE, &exportTableEnd);
+
+  // See vm_TsExportTableEntry
+  LongPtr exportTableEntry = exportTable;
+  while (exportTableEntry < exportTableEnd) {
+    CODE_COVERAGE(234); // Hit
+    mvm_VMExportID exportID = LongPtr_read2_aligned(exportTableEntry);
+    if (exportID == id) {
+      CODE_COVERAGE(235); // Hit
+      LongPtr pExportValue = LongPtr_add(exportTableEntry, 2);
+      mvm_VMExportID exportValue = LongPtr_read2_aligned(pExportValue);
+      *result = exportValue;
+      return MVM_E_SUCCESS;
+    } else {
+      CODE_COVERAGE(236); // Hit
+    }
+    exportTableEntry = LongPtr_add(exportTableEntry, sizeof (vm_TsExportTableEntry));
+  }
+
+  *result = VM_VALUE_UNDEFINED;
+  return vm_newError(vm, MVM_E_UNRESOLVED_EXPORT);
+}
+
+TeError mvm_resolveExports(VM* vm, const mvm_VMExportID* idTable, Value* resultTable, uint8_t count) {
+  CODE_COVERAGE(18); // Hit
+  TeError err = MVM_E_SUCCESS;
+  while (count--) {
+    CODE_COVERAGE(237); // Hit
+    TeError tempErr = vm_resolveExport(vm, *idTable++, resultTable++);
+    if (tempErr != MVM_E_SUCCESS) {
+      CODE_COVERAGE_ERROR_PATH(238); // Not hit
+      err = tempErr;
+    } else {
+      CODE_COVERAGE(239); // Hit
+    }
+  }
+  return err;
+}
+
+#if MVM_SAFE_MODE
+static bool vm_isHandleInitialized(VM* vm, const mvm_Handle* handle) {
+  CODE_COVERAGE(22); // Hit
+  mvm_Handle* h = vm->gc_handles;
+  while (h) {
+    CODE_COVERAGE(243); // Hit
+    if (h == handle) {
+      CODE_COVERAGE_UNTESTED(244); // Not hit
+      return true;
+    }
+    else {
+      CODE_COVERAGE(245); // Hit
+    }
+    h = h->_next;
+  }
+  return false;
+}
+#endif // MVM_SAFE_MODE
+
+void mvm_initializeHandle(VM* vm, mvm_Handle* handle) {
+  CODE_COVERAGE(19); // Hit
+  VM_ASSERT(vm, !vm_isHandleInitialized(vm, handle));
+  handle->_next = vm->gc_handles;
+  vm->gc_handles = handle;
+  handle->_value = VM_VALUE_UNDEFINED;
+}
+
+void vm_cloneHandle(VM* vm, mvm_Handle* target, const mvm_Handle* source) {
+  CODE_COVERAGE_UNTESTED(20); // Not hit
+  VM_ASSERT(vm, !vm_isHandleInitialized(vm, source));
+  mvm_initializeHandle(vm, target);
+  target->_value = source->_value;
+}
+
+TeError mvm_releaseHandle(VM* vm, mvm_Handle* handle) {
+  // This function doesn't contain coverage markers because node hits this path
+  // non-deterministically.
+  mvm_Handle** h = &vm->gc_handles;
+  while (*h) {
+    if (*h == handle) {
+      *h = handle->_next;
+      handle->_value = VM_VALUE_UNDEFINED;
+      handle->_next = NULL;
+      return MVM_E_SUCCESS;
+    }
+    h = &((*h)->_next);
+  }
+  handle->_value = VM_VALUE_UNDEFINED;
+  handle->_next = NULL;
+  return vm_newError(vm, MVM_E_INVALID_HANDLE);
+}
+
+#if MVM_SUPPORT_FLOAT
+static Value vm_float64ToStr(VM* vm, Value value) {
+  CODE_COVERAGE(619); // Hit
+
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm); // Because we allocate a new string
+
+  // I don't think this is 100% compliant, but it's probably fine for most use
+  // cases, and most importantly it's small.
+
+  double x = mvm_toFloat64(vm, value);
+
+  char buf[64];
+  char* p = buf;
+
+  // NaN should be represented as VM_VALUE_NAN not a float with NaN
+  VM_ASSERT(vm, !isnan(x));
+
+  if (isinf(x)) {
+    CODE_COVERAGE(621); // Hit
+    if (x < 0) {
+      CODE_COVERAGE(622); // Hit
+      *p++ = '-';
+    }
+    memcpy(p, "Infinity", 9);
+    p += 8;
+  } else {
+    CODE_COVERAGE(657); // Hit
+    p += MVM_SNPRINTF(p, sizeof buf, "%.15g", x);
+    VM_ASSERT(vm, p < buf + sizeof buf);
+  }
+
+  return mvm_newString(vm, buf, p - buf);
+}
+#endif //  MVM_SUPPORT_FLOAT
+
+static Value vm_intToStr(VM* vm, int32_t i) {
+  CODE_COVERAGE(618); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  char buf[32];
+  size_t size;
+
+  size = MVM_SNPRINTF(buf, sizeof buf, "%" PRId32, i);
+  VM_ASSERT(vm, size < sizeof buf);
+
+  return mvm_newString(vm, buf, size);
+}
+
+static Value vm_convertToString(VM* vm, Value value) {
+  CODE_COVERAGE(23); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  TeTypeCode type = deepTypeOf(vm, value);
+  const char* constStr;
+
+  switch (type) {
+    case TC_VAL_INT14:
+    case TC_REF_INT32: {
+      CODE_COVERAGE(246); // Hit
+      int32_t i = vm_readInt32(vm, type, value);
+      return vm_intToStr(vm, i);
+    }
+    case TC_REF_FLOAT64: {
+      CODE_COVERAGE(248); // Not hit
+      #if MVM_SUPPORT_FLOAT
+      return vm_float64ToStr(vm, value);
+      #else
+      constStr = "";
+      #endif
+      break;
+    }
+    case TC_REF_STRING: {
+      CODE_COVERAGE(249); // Hit
+      return value;
+    }
+    case TC_REF_INTERNED_STRING: {
+      CODE_COVERAGE(250); // Hit
+      return value;
+    }
+    case TC_REF_PROPERTY_LIST: {
+      CODE_COVERAGE_UNTESTED(251); // Not hit
+      constStr = "[Object]";
+      break;
+    }
+    case TC_REF_CLOSURE: {
+      CODE_COVERAGE_UNTESTED(365); // Not hit
+      constStr = "[Function]";
+      break;
+    }
+    case TC_REF_FIXED_LENGTH_ARRAY:
+    case TC_REF_ARRAY: {
+      CODE_COVERAGE_UNTESTED(252); // Not hit
+      constStr = "[Object]";
+      break;
+    }
+    case TC_REF_FUNCTION: {
+      CODE_COVERAGE_UNTESTED(254); // Not hit
+      constStr = "[Function]";
+      break;
+    }
+    case TC_REF_HOST_FUNC: {
+      CODE_COVERAGE_UNTESTED(255); // Not hit
+      constStr = "[Function]";
+      break;
+    }
+    case TC_REF_UINT8_ARRAY: {
+      CODE_COVERAGE_UNTESTED(256); // Not hit
+      constStr = "[Object]";
+      break;
+    }
+    case TC_REF_CLASS: {
+      CODE_COVERAGE_UNTESTED(596); // Not hit
+      constStr = "[Function]";
+      break;
+    }
+    case TC_REF_VIRTUAL: {
+      CODE_COVERAGE_UNTESTED(597); // Not hit
+      VM_NOT_IMPLEMENTED(vm);
+      return MVM_E_FATAL_ERROR_MUST_KILL_VM;
+    }
+    case TC_REF_SYMBOL: {
+      CODE_COVERAGE_UNTESTED(257); // Not hit
+      VM_NOT_IMPLEMENTED(vm);
+      return MVM_E_FATAL_ERROR_MUST_KILL_VM;
+    }
+    case TC_VAL_UNDEFINED: {
+      CODE_COVERAGE(258); // Hit
+      constStr = "undefined";
+      break;
+    }
+    case TC_VAL_NULL: {
+      CODE_COVERAGE(259); // Hit
+      constStr = "null";
+      break;
+    }
+    case TC_VAL_TRUE: {
+      CODE_COVERAGE(260); // Hit
+      constStr = "true";
+      break;
+    }
+    case TC_VAL_FALSE: {
+      CODE_COVERAGE(261); // Hit
+      constStr = "false";
+      break;
+    }
+    case TC_VAL_NAN: {
+      CODE_COVERAGE(262); // Not hit
+      constStr = "NaN";
+      break;
+    }
+    case TC_VAL_NEG_ZERO: {
+      CODE_COVERAGE(263); // Hit
+      constStr = "0";
+      break;
+    }
+    case TC_VAL_STR_LENGTH: {
+      CODE_COVERAGE(266); // Hit
+      return value;
+    }
+    case TC_VAL_STR_PROTO: {
+      CODE_COVERAGE_UNTESTED(267); // Not hit
+      return value;
+    }
+    case TC_VAL_NO_OP_FUNC: {
+      CODE_COVERAGE(654); // Hit
+      constStr = "[Function]";
+      break;
+    }
+    case TC_VAL_DELETED: {
+      return VM_UNEXPECTED_INTERNAL_ERROR(vm);
+    }
+    default: return VM_UNEXPECTED_INTERNAL_ERROR(vm);
+  }
+
+  return vm_newStringFromCStrNT(vm, constStr);
+}
+
+static Value vm_concat(VM* vm, Value* left, Value* right) {
+  CODE_COVERAGE(553); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  uint16_t leftSize = vm_stringSizeUtf8(vm, *left);
+  uint16_t rightSize = vm_stringSizeUtf8(vm, *right);
+
+  uint8_t* data;
+  // Note: this allocation can cause a GC collection which could cause the
+  // strings to move in memory
+  Value value = vm_allocString(vm, leftSize + rightSize, (void**)&data);
+
+  LongPtr lpLeftStr = vm_getStringData(vm, *left);
+  LongPtr lpRightStr = vm_getStringData(vm, *right);
+  memcpy_long(data, lpLeftStr, leftSize);
+  memcpy_long(data + leftSize, lpRightStr, rightSize);
+  return value;
+}
+
+/* Returns the deep type code of the value, looking through pointers and boxing */
+static TeTypeCode deepTypeOf(VM* vm, Value value) {
+  CODE_COVERAGE(27); // Hit
+
+  if (Value_isShortPtr(value)) {
+    CODE_COVERAGE(0); // Hit
+    void* p = ShortPtr_decode(vm, value);
+    uint16_t headerWord = readAllocationHeaderWord(p);
+    TeTypeCode typeCode = vm_getTypeCodeFromHeaderWord(headerWord);
+    return typeCode;
+  } else {
+    CODE_COVERAGE(515); // Hit
+  }
+
+  if (Value_isVirtualInt14(value)) {
+    CODE_COVERAGE(295); // Hit
+    return TC_VAL_INT14;
+  } else {
+    CODE_COVERAGE(516); // Hit
+  }
+
+  VM_ASSERT(vm, Value_isBytecodeMappedPtrOrWellKnown(value));
+
+  // Check for "well known" values such as TC_VAL_UNDEFINED
+  if (value < VM_VALUE_WELLKNOWN_END) {
+    CODE_COVERAGE(296); // Hit
+    return (TeTypeCode)((value >> 2) + 0x11);
+  } else {
+    CODE_COVERAGE(297); // Hit
+  }
+
+  LongPtr p = DynamicPtr_decode_long(vm, value);
+  uint16_t headerWord = readAllocationHeaderWord_long(p);
+  TeTypeCode typeCode = vm_getTypeCodeFromHeaderWord(headerWord);
+
+  return typeCode;
+}
+
+#if MVM_SUPPORT_FLOAT
+int32_t mvm_float64ToInt32(MVM_FLOAT64 value) {
+  CODE_COVERAGE(486); // Hit
+  if (MVM_FLOAT_IS_FINITE(value)) {
+    CODE_COVERAGE(487); // Hit
+    return (int32_t)value;
+  } else {
+    CODE_COVERAGE(488); // Hit
+    return 0;
+  }
+}
+
+Value mvm_newNumber(VM* vm, MVM_FLOAT64 value) {
+  CODE_COVERAGE(28); // Hit
+  if (MVM_FLOAT_IS_NAN(value)) {
+    CODE_COVERAGE(298); // Hit
+    return VM_VALUE_NAN;
+  } else {
+    CODE_COVERAGE(517); // Hit
+  }
+
+  if (MVM_FLOAT_IS_NEG_ZERO(value)) {
+    CODE_COVERAGE(299); // Hit
+    return VM_VALUE_NEG_ZERO;
+  } else {
+    CODE_COVERAGE(518); // Hit
+  }
+
+  // Doubles are very expensive to compute, so at every opportunity, we'll check
+  // if we can coerce back to an integer
+  int32_t valueAsInt = mvm_float64ToInt32(value);
+  if (value == (MVM_FLOAT64)valueAsInt) {
+    CODE_COVERAGE(300); // Hit
+    return mvm_newInt32(vm, valueAsInt);
+  } else {
+    CODE_COVERAGE(301); // Hit
+  }
+
+  MVM_FLOAT64* pResult = GC_ALLOCATE_TYPE(vm, MVM_FLOAT64, TC_REF_FLOAT64);
+  *pResult = value;
+
+  return ShortPtr_encode(vm, pResult);
+}
+#endif // MVM_SUPPORT_FLOAT
+
+Value mvm_newInt32(VM* vm, int32_t value) {
+  CODE_COVERAGE(29); // Hit
+  if ((value >= VM_MIN_INT14) && (value <= VM_MAX_INT14)) {
+    CODE_COVERAGE(302); // Hit
+    return VirtualInt14_encode(vm, value);
+  } else {
+    CODE_COVERAGE(303); // Hit
+  }
+
+  // Int32
+
+  int32_t* pResult = GC_ALLOCATE_TYPE(vm, int32_t, TC_REF_INT32);
+  *pResult = value;
+
+  return ShortPtr_encode(vm, pResult);
+}
+
+bool mvm_toBool(VM* vm, Value value) {
+  CODE_COVERAGE(30); // Hit
+
+  TeTypeCode type = deepTypeOf(vm, value);
+  switch (type) {
+    case TC_VAL_INT14: {
+      CODE_COVERAGE(304); // Hit
+      return value != VirtualInt14_encode(vm, 0);
+    }
+    case TC_REF_INT32: {
+      CODE_COVERAGE_UNTESTED(305); // Not hit
+      // Int32 can't be zero, otherwise it would be encoded as an int14
+      VM_ASSERT(vm, vm_readInt32(vm, type, value) != 0);
+      return false;
+    }
+    case TC_REF_FLOAT64: {
+      CODE_COVERAGE_UNTESTED(306); // Not hit
+      #if MVM_SUPPORT_FLOAT
+        // Double can't be zero, otherwise it would be encoded as an int14
+        VM_ASSERT(vm, mvm_toFloat64(vm, value) != 0);
+      #endif
+      return false;
+    }
+    case TC_REF_INTERNED_STRING:
+    case TC_REF_STRING: {
+      CODE_COVERAGE(307); // Hit
+      return vm_stringSizeUtf8(vm, value) != 0;
+    }
+    case TC_REF_PROPERTY_LIST: {
+      CODE_COVERAGE(308); // Hit
+      return true;
+    }
+    case TC_REF_CLOSURE: {
+      CODE_COVERAGE_UNTESTED(372); // Not hit
+      return true;
+    }
+    case TC_REF_ARRAY: {
+      CODE_COVERAGE(309); // Hit
+      return true;
+    }
+    case TC_REF_FUNCTION: {
+      CODE_COVERAGE_UNTESTED(311); // Not hit
+      return true;
+    }
+    case TC_REF_HOST_FUNC: {
+      CODE_COVERAGE_UNTESTED(312); // Not hit
+      return true;
+    }
+    case TC_REF_UINT8_ARRAY: {
+      CODE_COVERAGE_UNTESTED(313); // Not hit
+      return true;
+    }
+    case TC_REF_SYMBOL: {
+      CODE_COVERAGE_UNTESTED(314); // Not hit
+      return true;
+    }
+    case TC_REF_CLASS: {
+      CODE_COVERAGE(604); // Hit
+      return true;
+    }
+    case TC_REF_VIRTUAL: {
+      CODE_COVERAGE_UNTESTED(609); // Not hit
+      VM_RESERVED(vm);
+      return MVM_E_FATAL_ERROR_MUST_KILL_VM;
+
+    }
+    case TC_REF_RESERVED_1: {
+      CODE_COVERAGE_UNTESTED(610); // Not hit
+      VM_RESERVED(vm);
+      return MVM_E_FATAL_ERROR_MUST_KILL_VM;
+
+    }
+    case TC_VAL_UNDEFINED: {
+      CODE_COVERAGE(315); // Hit
+      return false;
+    }
+    case TC_VAL_NULL: {
+      CODE_COVERAGE(316); // Hit
+      return false;
+    }
+    case TC_VAL_TRUE: {
+      CODE_COVERAGE(317); // Hit
+      return true;
+    }
+    case TC_VAL_FALSE: {
+      CODE_COVERAGE(318); // Hit
+      return false;
+    }
+    case TC_VAL_NAN: {
+      CODE_COVERAGE_UNTESTED(319); // Not hit
+      return false;
+    }
+    case TC_VAL_NEG_ZERO: {
+      CODE_COVERAGE_UNTESTED(320); // Not hit
+      return false;
+    }
+    case TC_VAL_DELETED: {
+      CODE_COVERAGE_UNTESTED(321); // Not hit
+      return false;
+    }
+    case TC_VAL_STR_LENGTH: {
+      CODE_COVERAGE_UNTESTED(268); // Not hit
+      return true;
+    }
+    case TC_VAL_STR_PROTO: {
+      CODE_COVERAGE_UNTESTED(269); // Not hit
+      return true;
+    }
+    case TC_VAL_NO_OP_FUNC: {
+      CODE_COVERAGE_UNTESTED(655); // Not hit
+      return true;
+    }
+    default: return VM_UNEXPECTED_INTERNAL_ERROR(vm);
+  }
+}
+
+static bool vm_isString(VM* vm, Value value) {
+  CODE_COVERAGE(31); // Hit
+  return mvm_typeOf(vm, value) == VM_T_STRING;
+}
+
+/** Reads a numeric value that is a subset of a 32-bit integer */
+static int32_t vm_readInt32(VM* vm, TeTypeCode type, Value value) {
+  CODE_COVERAGE(33); // Hit
+  if (type == TC_VAL_INT14) {
+    CODE_COVERAGE(330); // Hit
+    return VirtualInt14_decode(vm, value);
+  } else if (type == TC_REF_INT32) {
+    CODE_COVERAGE(331); // Hit
+    LongPtr target = DynamicPtr_decode_long(vm, value);
+    int32_t result = (int32_t)LongPtr_read4(target);
+    return result;
+  } else {
+    return VM_UNEXPECTED_INTERNAL_ERROR(vm);
+  }
+}
+
+static inline uint16_t readAllocationHeaderWord_long(LongPtr pAllocation) {
+  CODE_COVERAGE(519); // Hit
+  return LongPtr_read2_aligned(LongPtr_add(pAllocation, -2));
+}
+
+static inline uint16_t readAllocationHeaderWord(void* pAllocation) {
+  CODE_COVERAGE(520); // Hit
+  return ((uint16_t*)pAllocation)[-1];
+}
+
+static inline mvm_TfHostFunction* vm_getResolvedImports(VM* vm) {
+  CODE_COVERAGE(40); // Hit
+  return (mvm_TfHostFunction*)(vm + 1); // Starts right after the header
+}
+
+static inline mvm_HostFunctionID vm_getHostFunctionId(VM* vm, uint16_t hostFunctionIndex) {
+  LongPtr lpImportTable = getBytecodeSection(vm, BCS_IMPORT_TABLE, NULL);
+  LongPtr lpImportTableEntry = LongPtr_add(lpImportTable, hostFunctionIndex * sizeof (vm_TsImportTableEntry));
+  return LongPtr_read2_aligned(lpImportTableEntry);
+}
+
+mvm_TeType mvm_typeOf(VM* vm, Value value) {
+  TeTypeCode tc = deepTypeOf(vm, value);
+  VM_ASSERT(vm, tc < sizeof typeByTC);
+  TABLE_COVERAGE(tc, TC_END, 42); // Hit 17/27
+  return (mvm_TeType)typeByTC[tc];
+}
+
+LongPtr vm_toStringUtf8_long(VM* vm, Value value, size_t* out_sizeBytes) {
+  CODE_COVERAGE(43); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  value = vm_convertToString(vm, value);
+
+  TeTypeCode typeCode = deepTypeOf(vm, value);
+
+  if (typeCode == TC_VAL_STR_PROTO) {
+    CODE_COVERAGE_UNTESTED(521); // Not hit
+    *out_sizeBytes = sizeof PROTO_STR - 1;
+    return LongPtr_new((void*)&PROTO_STR);
+  } else {
+    CODE_COVERAGE(522); // Hit
+  }
+
+  if (typeCode == TC_VAL_STR_LENGTH) {
+    CODE_COVERAGE_UNTESTED(523); // Not hit
+    *out_sizeBytes = sizeof LENGTH_STR - 1;
+    return LongPtr_new((void*)&LENGTH_STR);
+  } else {
+    CODE_COVERAGE(524); // Hit
+  }
+
+  VM_ASSERT(vm, (typeCode == TC_REF_STRING) || (typeCode == TC_REF_INTERNED_STRING));
+
+  LongPtr lpTarget = DynamicPtr_decode_long(vm, value);
+  uint16_t headerWord = readAllocationHeaderWord_long(lpTarget);
+  uint16_t sourceSize = vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+
+  if (out_sizeBytes) {
+    CODE_COVERAGE(349); // Hit
+    *out_sizeBytes = sourceSize - 1; // Without the extra safety null-terminator
+  } else {
+    CODE_COVERAGE_UNTESTED(350); // Not hit
+  }
+
+  return lpTarget;
+}
+
+/**
+ * Gets a pointer to the string bytes of the string represented by `value`.
+ *
+ * `value` must be a string
+ *
+ * Warning: the result is a native pointer and becomes invalid if a GC
+ * collection occurs.
+ */
+LongPtr vm_getStringData(VM* vm, Value value) {
+  CODE_COVERAGE(228); // Hit
+  TeTypeCode typeCode = deepTypeOf(vm, value);
+  switch (typeCode) {
+    case TC_VAL_STR_PROTO:
+      CODE_COVERAGE_UNTESTED(229); // Not hit
+      return LongPtr_new((void*)&PROTO_STR);
+    case TC_VAL_STR_LENGTH:
+      CODE_COVERAGE(512); // Hit
+      return LongPtr_new((void*)&LENGTH_STR);
+    case TC_REF_STRING:
+    case TC_REF_INTERNED_STRING:
+      return DynamicPtr_decode_long(vm, value);
+    default:
+      VM_ASSERT_UNREACHABLE(vm);
+      return LongPtr_new(0);
+  }
+}
+
+const char* mvm_toStringUtf8(VM* vm, Value value, size_t* out_sizeBytes) {
+  CODE_COVERAGE(623); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+  /*
+   * Note: I previously had this function returning a long pointer, but this
+   * tripped someone up because they passed the result directly to printf, which
+   * on MSP430 apparently doesn't support arbitrary long pointers (data20
+   * pointers). Now I just copy it locally.
+   */
+
+  size_t size; // Size excluding a null terminator
+  LongPtr lpTarget = vm_toStringUtf8_long(vm, value, &size);
+  if (out_sizeBytes)
+    *out_sizeBytes = size;
+
+  void* pTarget = LongPtr_truncate(vm, lpTarget);
+  // Is the string in RAM? (i.e. the truncated pointer is the same as the full pointer)
+  if (LongPtr_new(pTarget) == lpTarget) {
+    CODE_COVERAGE(624); // Hit
+    return (const char*)pTarget;
+  } else {
+    CODE_COVERAGE_UNTESTED(625); // Not hit
+    // Allocate a new string in local memory (with additional null terminator)
+    vm_allocString(vm, size, &pTarget);
+    memcpy_long(pTarget, lpTarget, size);
+
+    return (const char*)pTarget;
+  }
+}
+
+size_t mvm_stringSizeUtf8(mvm_VM* vm, mvm_Value value) {
+  CODE_COVERAGE_UNTESTED(620); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+  size_t size;
+  vm_toStringUtf8_long(vm, value, &size);
+  return size;
+}
+
+Value mvm_newBoolean(bool source) {
+  CODE_COVERAGE(44); // Hit
+  return source ? VM_VALUE_TRUE : VM_VALUE_FALSE;
+}
+
+Value vm_allocString(VM* vm, size_t sizeBytes, void** out_pData) {
+  CODE_COVERAGE(45); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+  if (sizeBytes < 3) {
+    TABLE_COVERAGE(sizeBytes, 3, 525); // Hit 2/3
+  }
+
+  // Note: allocating 1 extra byte for the extra null terminator
+  char* pData = mvm_allocate(vm, (uint16_t)sizeBytes + 1, TC_REF_STRING);
+  *out_pData = pData;
+  // Null terminator
+  pData[sizeBytes] = '\0';
+  return ShortPtr_encode(vm, pData);
+}
+
+// New string from null-terminated
+static Value vm_newStringFromCStrNT(VM* vm, const char* s) {
+  size_t len = strlen(s);
+  return mvm_newString(vm, s, len);
+}
+
+Value mvm_newString(VM* vm, const char* sourceUtf8, size_t sizeBytes) {
+  CODE_COVERAGE(46); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+  void* data;
+  Value value = vm_allocString(vm, sizeBytes, &data);
+  memcpy(data, sourceUtf8, sizeBytes);
+  return value;
+}
+
+static Value getBuiltin(VM* vm, mvm_TeBuiltins builtinID) {
+  CODE_COVERAGE(526); // Hit
+  LongPtr lpBuiltins = getBytecodeSection(vm, BCS_BUILTINS, NULL);
+  LongPtr lpBuiltin = LongPtr_add(lpBuiltins, (int16_t)(builtinID * sizeof (Value)));
+  Value value = LongPtr_read2_aligned(lpBuiltin);
+
+  // Check if the builtin accesses a RAM value via a handle
+  Value* target = vm_getHandleTargetOrNull(vm, value);
+  if (target) {
+    CODE_COVERAGE(212); // Hit
+    return *target;
+  } else {
+    CODE_COVERAGE(213); // Hit
+    return value;
+  }
+}
+
+/**
+ * If the value is a handle, this returns a pointer to the global variable
+ * referenced by the handle. Otherwise, this returns NULL.
+ *
+ * See also vm_resolveIndirections if you're only reading the value.
+ */
+static Value* vm_getHandleTargetOrNull(VM* vm, Value value) {
+  CODE_COVERAGE(527); // Hit
+
+  // Check low bits
+  if (!Value_isBytecodeMappedPtrOrWellKnown(value)) {
+    CODE_COVERAGE(528); // Hit
+    return NULL;
+  }
+
+  if (value < VM_VALUE_WELLKNOWN_END) {
+    CODE_COVERAGE(529); // Hit
+    return NULL;
+  }
+
+  // See if it points earlier than the globals section, then it's pointing to a
+  // ROM allocation, which is not a handle.
+  uint16_t globalsOffset = getSectionOffset(vm->lpBytecode, BCS_GLOBALS);
+  if (value < globalsOffset) {
+    CODE_COVERAGE(530); // Hit
+    VM_ASSERT(vm, value >= getSectionOffset(vm->lpBytecode, BCS_ROM));
+    return NULL;
+  }
+
+  CODE_COVERAGE(531); // Hit
+
+  // The globals section should be the last addressable section in the ROM, so a
+  // pointer should not pointer later than the end of the globals section.
+  VM_ASSERT(vm, value < getSectionOffset(vm->lpBytecode, vm_sectionAfter(vm, BCS_GLOBALS)));
+
+  uint16_t globalIndex = (value - globalsOffset) / 2;
+  return &vm->globals[globalIndex];
+}
+
+/**
+ * If `value` points to a handle, this returns the value in the handle.
+ *
+ * See also `vm_getHandleTargetOrNull` if you want to write to the value.
+ */
+static Value vm_resolveIndirections(VM* vm, Value value) {
+  CODE_COVERAGE(729); // Hit
+  Value* target = vm_getHandleTargetOrNull(vm, value);
+  if (!target) {
+    CODE_COVERAGE(730); // Hit
+    return value;
+  }
+  CODE_COVERAGE(731); // Hit
+  return *target;
+}
+
+
+/**
+ * Assigns to the slot pointed to by lpTarget
+ *
+ * If lpTarget points to a handle, then the corresponding global variable is
+ * mutated. Otherwise, the target is directly mutated.
+ *
+ * This is used to synthesize mutation of slots in ROM, such as exports,
+ * builtins, and properties of ROM objects. Such logically-mutable slots *must*
+ * hold a value that is a BytecodeMappedPtr to a global variable that holds the
+ * mutable reference.
+ *
+ * The function works transparently on RAM or ROM slots.
+ */
+// TODO: probably SetProperty should use this, so it works on ROM-allocated
+// objects/arrays. Probably a good candidate for TDD.
+static void setSlot_long(VM* vm, LongPtr lpSlot, Value value) {
+  CODE_COVERAGE(532); // Hit
+  Value slotContents = LongPtr_read2_aligned(lpSlot);
+  // Work out if the target slot is actually a handle.
+  Value* handleTarget = vm_getHandleTargetOrNull(vm, slotContents);
+  if (handleTarget) {
+    CODE_COVERAGE(533); // Hit
+    // Set the corresponding global variable
+    *handleTarget = value;
+    return;
+  } else {
+    CODE_COVERAGE_UNTESTED(534); // Not hit
+  }
+  // Otherwise, for the mutation must be valid, the slot must be in RAM.
+
+  // We never mutate through a long pointer, because anything mutable must be in
+  // RAM and anything in RAM must be addressable by a short pointer
+  Value* pSlot = LongPtr_truncate(vm, lpSlot);
+
+  // Check the truncation hasn't lost anything. If this fails, the slot could be
+  // in ROM. If this passes, the slot
+  VM_ASSERT(vm, LongPtr_new(pSlot) == lpSlot);
+
+  // The compiler must never produce bytecode that is able to attempt to write
+  // to the bytecode image itself, but just to catch mistakes, here's an
+  // assertion to make sure it doesn't write to bytecode. In a properly working
+  // system (compiler + engine), this assertion isn't needed
+  VM_ASSERT(vm, (lpSlot < vm->lpBytecode) ||
+    (lpSlot >= LongPtr_add(vm->lpBytecode, getBytecodeSize(vm))));
+
+  *pSlot = value;
+}
+
+static void setBuiltin(VM* vm, mvm_TeBuiltins builtinID, Value value) {
+  CODE_COVERAGE(535); // Hit
+  LongPtr lpBuiltins = getBytecodeSection(vm, BCS_BUILTINS, NULL);
+  LongPtr lpBuiltin = LongPtr_add(lpBuiltins, (int16_t)(builtinID * sizeof (Value)));
+  setSlot_long(vm, lpBuiltin, value);
+}
+
+// Warning: this function trashes the word at pObjectValue, which happens when
+// traversing the prototype chain.
+//
+// Warning: this function will convert the value at pPropertyName to an interned
+// string if it isn't already.
+//
+// Note: out_propertyValue is allowed point to the same address as pObjectValue
+MVM_HIDDEN TeError getProperty(VM* vm, Value* pObjectValue, Value* pPropertyName, Value* out_propertyValue) {
+  CODE_COVERAGE(48); // Hit
+
+  mvm_TeError err;
+  LongPtr lpArr;
+  LongPtr lpClass;
+  uint16_t length;
+  TeTypeCode type;
+  Value objectValue;
+  Value propertyName;
+
+  // This function may trigger a GC cycle because it may add a cell to the string intern table
+  VM_ASSERT(vm, !vm->stack || !vm->stack->reg.usingCachedRegisters);
+
+  // Note: toPropertyName can trigger a GC cycle
+  err = toPropertyName(vm, pPropertyName);
+  if (err != MVM_E_SUCCESS) return err;
+
+SUB_GET_PROPERTY:
+
+  propertyName = *pPropertyName;
+  objectValue = *pObjectValue;
+  type = deepTypeOf(vm, objectValue);
+  switch (type) {
+    case TC_REF_UINT8_ARRAY: {
+      CODE_COVERAGE(339); // Hit
+      lpArr = DynamicPtr_decode_long(vm, objectValue);
+      uint16_t header = readAllocationHeaderWord_long(lpArr);
+      length = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+      if (propertyName == VM_VALUE_STR_LENGTH) {
+        CODE_COVERAGE(340); // Hit
+        VM_EXEC_SAFE_MODE(*pObjectValue = VM_VALUE_NULL);
+        *out_propertyValue = VirtualInt14_encode(vm, length);
+        return MVM_E_SUCCESS;
+      } else {
+        CODE_COVERAGE(341); // Hit
+      }
+
+      if (!Value_isVirtualInt14(propertyName)) {
+        CODE_COVERAGE_ERROR_PATH(342); // Not hit
+        return MVM_E_INVALID_ARRAY_INDEX;
+      }
+      int16_t index = VirtualInt14_decode(vm, propertyName);
+
+      if ((index < 0) || (index >= length)) {
+        CODE_COVERAGE(343); // Not hit
+        *out_propertyValue = VM_VALUE_UNDEFINED;
+        return MVM_E_SUCCESS;
+      }
+
+      uint8_t byteValue = LongPtr_read1(LongPtr_add(lpArr, (uint16_t)index));
+      VM_EXEC_SAFE_MODE(*pObjectValue = VM_VALUE_NULL);
+      *out_propertyValue = VirtualInt14_encode(vm, byteValue);
+      return MVM_E_SUCCESS;
+    }
+
+    case TC_REF_PROPERTY_LIST: {
+      CODE_COVERAGE(359); // Hit
+
+      LongPtr lpPropertyList = DynamicPtr_decode_long(vm, objectValue);
+      DynamicPtr dpProto = READ_FIELD_2(lpPropertyList, TsPropertyList, dpProto);
+
+      if (propertyName == VM_VALUE_STR_PROTO) {
+        CODE_COVERAGE_UNIMPLEMENTED(326); // Hit
+        *out_propertyValue = dpProto;
+        return MVM_E_SUCCESS;
+      }
+
+      while (lpPropertyList) {
+        uint16_t headerWord = readAllocationHeaderWord_long(lpPropertyList);
+        uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+        uint16_t propCount = (size - sizeof (TsPropertyList)) / 4;
+
+        LongPtr p = LongPtr_add(lpPropertyList, sizeof (TsPropertyList));
+        while (propCount--) {
+          Value key = LongPtr_read2_aligned(p);
+          p = LongPtr_add(p, 2);
+          Value value = LongPtr_read2_aligned(p);
+          p = LongPtr_add(p, 2);
+
+          if (key == propertyName) {
+            CODE_COVERAGE(361); // Hit
+            VM_EXEC_SAFE_MODE(*pObjectValue = VM_VALUE_NULL);
+            *out_propertyValue = value;
+            return MVM_E_SUCCESS;
+          } else {
+            CODE_COVERAGE(362); // Hit
+          }
+        }
+
+        DynamicPtr dpNext = READ_FIELD_2(lpPropertyList, TsPropertyList, dpNext);
+         // Move to next group, if there is one
+        if (dpNext != VM_VALUE_NULL) {
+          CODE_COVERAGE(536); // Hit
+          lpPropertyList = DynamicPtr_decode_long(vm, dpNext);
+        } else { // Otherwise try read from the prototype
+          CODE_COVERAGE(537); // Hit
+          lpPropertyList = DynamicPtr_decode_long(vm, dpProto);
+          if (lpPropertyList) {
+            CODE_COVERAGE(538); // Hit
+            dpProto = READ_FIELD_2(lpPropertyList, TsPropertyList, dpProto);
+          } else {
+            CODE_COVERAGE(539); // Hit
+          }
+        }
+      }
+
+      VM_EXEC_SAFE_MODE(*pObjectValue = VM_VALUE_NULL);
+      *out_propertyValue = VM_VALUE_UNDEFINED;
+      return MVM_E_SUCCESS;
+    }
+
+    case TC_REF_ARRAY: {
+      CODE_COVERAGE(363); // Hit
+
+      lpArr = DynamicPtr_decode_long(vm, objectValue);
+      Value viLength = READ_FIELD_2(lpArr, TsArray, viLength);
+      length = VirtualInt14_decode(vm, viLength);
+
+      // Drill in to fixed-length array inside the array
+      DynamicPtr dpData = READ_FIELD_2(lpArr, TsArray, dpData);
+      lpArr = DynamicPtr_decode_long(vm, dpData);
+
+      goto SUB_GET_PROP_FIXED_LENGTH_ARRAY;
+    }
+
+    case TC_REF_FIXED_LENGTH_ARRAY: {
+      CODE_COVERAGE(286); // Hit
+
+      lpArr = DynamicPtr_decode_long(vm, objectValue);
+
+      uint16_t header = readAllocationHeaderWord_long(lpArr);
+      uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+      length = size >> 1;
+
+      goto SUB_GET_PROP_FIXED_LENGTH_ARRAY;
+    }
+
+    case TC_REF_CLASS: {
+      CODE_COVERAGE(615); // Hit
+      lpClass = DynamicPtr_decode_long(vm, objectValue);
+      // Delegate to the `staticProps` of the class
+      *pObjectValue = READ_FIELD_2(lpClass, TsClass, staticProps);
+      goto SUB_GET_PROPERTY;
+    }
+
+    default: return vm_newError(vm, MVM_E_TYPE_ERROR);
+  }
+
+SUB_GET_PROP_FIXED_LENGTH_ARRAY:
+  CODE_COVERAGE(323); // Hit
+
+  if (propertyName == VM_VALUE_STR_LENGTH) {
+    CODE_COVERAGE(274); // Hit
+    VM_EXEC_SAFE_MODE(*pObjectValue = VM_VALUE_NULL);
+    *out_propertyValue = VirtualInt14_encode(vm, length);
+    return MVM_E_SUCCESS;
+  } else if (propertyName == VM_VALUE_STR_PROTO) {
+    CODE_COVERAGE(275); // Hit
+    VM_EXEC_SAFE_MODE(*pObjectValue = VM_VALUE_NULL);
+    *out_propertyValue = getBuiltin(vm, BIN_ARRAY_PROTO);
+    return MVM_E_SUCCESS;
+  } else {
+    CODE_COVERAGE(276); // Hit
+  }
+
+  // Array index
+  if (Value_isVirtualInt14(propertyName)) {
+    CODE_COVERAGE(277); // Hit
+    int16_t index = VirtualInt14_decode(vm, propertyName);
+    if (index < 0) {
+      CODE_COVERAGE_ERROR_PATH(144); // Not hit
+      return vm_newError(vm, MVM_E_INVALID_ARRAY_INDEX);
+    }
+
+    if ((uint16_t)index >= length) {
+      CODE_COVERAGE(283); // Hit
+      VM_EXEC_SAFE_MODE(*pObjectValue = VM_VALUE_NULL);
+      *out_propertyValue = VM_VALUE_UNDEFINED;
+      return MVM_E_SUCCESS;
+    } else {
+      CODE_COVERAGE(328); // Hit
+    }
+    // We've already checked if the value exceeds the length, so lpData
+    // cannot be null and the capacity must be at least as large as the
+    // length of the array.
+    VM_ASSERT(vm, lpArr);
+    VM_ASSERT(vm, length * 2 <= vm_getAllocationSizeExcludingHeaderFromHeaderWord(readAllocationHeaderWord_long(lpArr)));
+    Value value = LongPtr_read2_aligned(LongPtr_add(lpArr, (uint16_t)index * 2));
+    if (value == VM_VALUE_DELETED) {
+      CODE_COVERAGE(329); // Hit
+      value = VM_VALUE_UNDEFINED;
+    } else {
+      CODE_COVERAGE(364); // Hit
+    }
+    VM_EXEC_SAFE_MODE(*pObjectValue = VM_VALUE_NULL);
+    *out_propertyValue = value;
+    return MVM_E_SUCCESS;
+  }
+  CODE_COVERAGE(278); // Hit
+
+  *pObjectValue = getBuiltin(vm, BIN_ARRAY_PROTO);
+  if (*pObjectValue != VM_VALUE_NULL) {
+    CODE_COVERAGE(396); // Hit
+    goto SUB_GET_PROPERTY;
+  } else {
+    CODE_COVERAGE_UNTESTED(397); // Not hit
+    VM_EXEC_SAFE_MODE(*pObjectValue = VM_VALUE_NULL);
+    *out_propertyValue = VM_VALUE_UNDEFINED;
+    return MVM_E_SUCCESS;
+  }
+}
+
+// Note: the array is passed by pointer (pvArr) because this function can
+// trigger a GC cycle, not because `*pvArr` is mutated by this function.
+static void growArray(VM* vm, Value* pvArr, uint16_t newLength, uint16_t newCapacity) {
+  CODE_COVERAGE(293); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  VM_ASSERT(vm, newCapacity >= newLength);
+  if (newCapacity > MAX_ALLOCATION_SIZE / 2) {
+    CODE_COVERAGE_ERROR_PATH(540); // Not hit
+    MVM_FATAL_ERROR(vm, MVM_E_ARRAY_TOO_LONG);
+  }
+  VM_ASSERT(vm, newCapacity != 0);
+
+  uint16_t* pNewData = mvm_allocate(vm, newCapacity * 2, TC_REF_FIXED_LENGTH_ARRAY);
+  // Copy values from the old array. Note that the above allocation can trigger
+  // a GC collection which moves the array, so we need to decode the value again
+  TsArray* arr = DynamicPtr_decode_native(vm, *pvArr);
+  DynamicPtr dpOldData = arr->dpData;
+  uint16_t oldCapacity = 0;
+  if (dpOldData != VM_VALUE_NULL) {
+    CODE_COVERAGE(294); // Hit
+    LongPtr lpOldData = DynamicPtr_decode_long(vm, dpOldData);
+
+    uint16_t oldDataHeader = readAllocationHeaderWord_long(lpOldData);
+    uint16_t oldSize = vm_getAllocationSizeExcludingHeaderFromHeaderWord(oldDataHeader);
+    VM_ASSERT(vm, (oldSize & 1) == 0);
+    oldCapacity = oldSize / 2;
+
+    memcpy_long(pNewData, lpOldData, oldSize);
+  } else {
+    CODE_COVERAGE(310); // Hit
+  }
+  CODE_COVERAGE(325); // Hit
+  VM_ASSERT(vm, newCapacity >= oldCapacity);
+  // Fill in the rest of the memory as holes
+  uint16_t* p = &pNewData[oldCapacity];
+  uint16_t* end = &pNewData[newCapacity];
+  while (p != end) {
+    *p++ = VM_VALUE_DELETED;
+  }
+  arr->dpData = ShortPtr_encode(vm, pNewData);
+  arr->viLength = VirtualInt14_encode(vm, newLength);
+}
+
+/**
+ * Gets an array of the keys of an object. Does not enumerate internal
+ * properties, which are identified as any property with a key that is a
+ * negative int14.
+ */
+MVM_HIDDEN TeError vm_objectKeys(VM* vm, Value* inout_slot) {
+  CODE_COVERAGE(636); // Hit
+  Value obj;
+  LongPtr lpClass;
+
+SUB_OBJECT_KEYS:
+  obj = *inout_slot;
+
+  TeTypeCode tc = deepTypeOf(vm, obj);
+  if (tc == TC_REF_CLASS) {
+    CODE_COVERAGE_UNTESTED(637); // Not hit
+    lpClass = DynamicPtr_decode_long(vm, obj);
+    // Delegate to the `staticProps` of the class
+    *inout_slot = READ_FIELD_2(lpClass, TsClass, staticProps);
+    goto SUB_OBJECT_KEYS;
+  }
+  CODE_COVERAGE(638); // Hit
+
+  if (tc != TC_REF_PROPERTY_LIST) {
+    CODE_COVERAGE_ERROR_PATH(639); // Not hit
+    return MVM_E_OBJECT_KEYS_ON_NON_OBJECT;
+  }
+
+  // Count the number of properties (first add up the sizes)
+
+  uint16_t propsSize = 0;
+  Value propList = obj;
+  // Note: the GC packs an object into a single allocation, so this should
+  // frequently be O(1) and only loop once
+  do {
+    LongPtr lpPropList = DynamicPtr_decode_long(vm, propList);
+    uint16_t segmentSize = vm_getAllocationSize_long(lpPropList) - sizeof(TsPropertyList);
+
+    // Skip internal properties
+    LongPtr lpProp = LongPtr_add(lpPropList, sizeof(TsPropertyList));
+    while (segmentSize) {
+      Value propKey = LongPtr_read2_aligned(lpProp);
+      // Internal slots are always the first slots, so when we find the first non-internal slot then we've reached the end of the internal slots
+      if ((propKey & 0x8003) != 0x8003) break;
+      VM_ASSERT(vm, segmentSize >= 4); // Internal slots must always come in pairs
+      segmentSize -= 4;
+      lpProp = LongPtr_add(lpProp, 4);
+    }
+
+    propsSize += segmentSize;
+    propList = LongPtr_read2_aligned(lpPropList) /* dpNext */;
+    TABLE_COVERAGE(propList != VM_VALUE_NULL ? 1 : 0, 2, 640); // Hit 2/2
+  } while (propList != VM_VALUE_NULL);
+
+  // Each prop is 4 bytes, and each entry in the key array is 2 bytes
+  uint16_t arrSize = propsSize >> 1;
+
+  // If the array is empty, an empty allocation is illegal because allocations
+  // need to be big enough to hold the tombstone. A 1-byte allocation will be
+  // rounded down when asking the size, but rounded up in the allocation unit.
+  if (!arrSize) {
+    CODE_COVERAGE(641); // Hit
+    arrSize = 1;
+  } else {
+    CODE_COVERAGE(691); // Hit
+  }
+
+  // Allocate the new array.
+  Value* pArr = mvm_allocate(vm, arrSize, TC_REF_FIXED_LENGTH_ARRAY);
+  obj = *inout_slot; // Invalidated by potential GC collection
+
+  // Populate the array
+
+  propList = obj;
+  *inout_slot = ShortPtr_encode(vm, pArr);
+  Value* p = pArr;
+  do {
+    LongPtr lpPropList = DynamicPtr_decode_long(vm, propList);
+    propList = LongPtr_read2_aligned(lpPropList) /* dpNext */;
+
+    uint16_t propsSize = vm_getAllocationSize_long(lpPropList) - sizeof(TsPropertyList);
+    LongPtr lpProp = LongPtr_add(lpPropList, sizeof(TsPropertyList));
+    TABLE_COVERAGE(propsSize != 0 ? 1 : 0, 2, 642); // Hit 2/2
+    while (propsSize) {
+      Value value = LongPtr_read2_aligned(lpProp);
+      // Skip internal properties, which are negative int14. A negative int14
+      // will have the low 2 bits set to say that it's an int14 and teh high bit
+      // set to say that it's negative.
+      if ((value & 0x8003) != 0x8003) {
+        CODE_COVERAGE(692); // Hit
+        *p = value;
+        p++; // Move to next entry in array
+      } else {
+        CODE_COVERAGE(693); // Hit
+      }
+      // Each property cell is 4 bytes
+      lpProp /* prop */ = LongPtr_add(lpProp /* prop */, 4);
+      propsSize -= 4;
+    }
+    TABLE_COVERAGE(propList != VM_VALUE_NULL ? 1 : 0, 2, 643); // Hit 2/2
+  } while (propList != VM_VALUE_NULL);
+
+  VM_ASSERT(vm, (p - pArr) * 4 == propsSize);
+
+  return MVM_E_SUCCESS;
+}
+
+/**
+ * Note: the operands are passed by pointer because they can move during
+ * setProperty if a GC cycle is triggered (the pointers should point to
+ * GC-reachable slots).
+ *
+ * Warning: `pObject` is trashed. In particular, when setting a property on a
+ * class, the `pObject` slot is reused for the `staticProps` of the class.
+ *
+ * Warning: this function will convert the value at pPropertyName to an interned
+ * string if it isn't already.
+ */
+MVM_HIDDEN TeError setProperty(VM* vm, Value* pObject, Value* pPropertyName, Value* pPropertyValue) {
+  CODE_COVERAGE(49); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  mvm_TeError err;
+  LongPtr lpClass;
+  TeTypeCode type;
+
+  // This function may trigger a GC cycle because it may add a cell to the string intern table
+  VM_ASSERT(vm, !vm->stack || !vm->stack->reg.usingCachedRegisters);
+
+  err = toPropertyName(vm, pPropertyName);
+  if (err != MVM_E_SUCCESS) return err;
+
+  MVM_LOCAL(Value, vObjectValue, 0);
+  MVM_LOCAL(Value, vPropertyName, *pPropertyName);
+  MVM_LOCAL(Value, vPropertyValue, *pPropertyValue);
+
+SUB_SET_PROPERTY:
+
+  MVM_SET_LOCAL(vObjectValue, *pObject);
+  type = deepTypeOf(vm, MVM_GET_LOCAL(vObjectValue));
+  switch (type) {
+    case TC_REF_UINT8_ARRAY: {
+      CODE_COVERAGE(594); // Hit
+      // It's not valid for the optimizer to move a buffer into ROM if it's
+      // ever written to, so it must be in RAM.
+      VM_ASSERT(vm, Value_isShortPtr(MVM_GET_LOCAL(vObjectValue)));
+      uint8_t* p = ShortPtr_decode(vm, MVM_GET_LOCAL(vObjectValue));
+      uint16_t header = readAllocationHeaderWord(p);
+      uint16_t length = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+
+      if (!Value_isVirtualInt14(MVM_GET_LOCAL(vPropertyName))) {
+        CODE_COVERAGE_ERROR_PATH(595); // Not hit
+        return MVM_E_INVALID_ARRAY_INDEX;
+      }
+      int16_t index = VirtualInt14_decode(vm, MVM_GET_LOCAL(vPropertyName));
+      if ((index < 0) || (index >= length)) {
+        CODE_COVERAGE_ERROR_PATH(612); // Not hit
+        return MVM_E_INVALID_ARRAY_INDEX;
+      }
+
+      Value byteValue = MVM_GET_LOCAL(vPropertyValue);
+      if (!Value_isVirtualUInt8(byteValue)) {
+        // For performance reasons, Microvium does not automatically coerce
+        // values to bytes.
+        CODE_COVERAGE_ERROR_PATH(613); // Not hit
+        return MVM_E_CAN_ONLY_ASSIGN_BYTES_TO_UINT8_ARRAY;
+      }
+
+      p[index] = (uint8_t)VirtualInt14_decode(vm, byteValue);
+      VM_EXEC_SAFE_MODE(*pObject = VM_VALUE_NULL);
+      return MVM_E_SUCCESS;
+    }
+
+    case TC_REF_PROPERTY_LIST: {
+      CODE_COVERAGE(366); // Hit
+      if (MVM_GET_LOCAL(vPropertyName) == VM_VALUE_STR_PROTO) {
+        CODE_COVERAGE_UNIMPLEMENTED(327); // Not hit
+        VM_NOT_IMPLEMENTED(vm);
+        return MVM_E_FATAL_ERROR_MUST_KILL_VM;
+      } else {
+        CODE_COVERAGE(541); // Hit
+      }
+
+      // Note: while objects in general can be in ROM, objects which are
+      // writable must always be in RAM.
+
+      MVM_LOCAL(TsPropertyList*, pPropertyList, DynamicPtr_decode_native(vm, MVM_GET_LOCAL(vObjectValue)));
+
+      while (true) {
+        CODE_COVERAGE(367); // Hit
+        uint16_t headerWord = readAllocationHeaderWord(MVM_GET_LOCAL(pPropertyList));
+        uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+        uint16_t propCount = (size - sizeof (TsPropertyList)) / 4;
+
+        uint16_t* p = (uint16_t*)(MVM_GET_LOCAL(pPropertyList) + 1);
+        while (propCount--) {
+          Value key = *p++;
+
+          // We can do direct comparison because the strings have been interned,
+          // and numbers are represented in a normalized way.
+          if (key == MVM_GET_LOCAL(vPropertyName)) {
+            CODE_COVERAGE(368); // Hit
+            *p = MVM_GET_LOCAL(vPropertyValue);
+            VM_EXEC_SAFE_MODE(*pObject = VM_VALUE_NULL);
+            return MVM_E_SUCCESS;
+          } else {
+            // Skip to next property
+            p++;
+            CODE_COVERAGE(369); // Hit
+          }
+        }
+
+        DynamicPtr dpNext = MVM_GET_LOCAL(pPropertyList)->dpNext;
+        // Move to next group, if there is one
+        if (dpNext != VM_VALUE_NULL) {
+          CODE_COVERAGE(542); // Hit
+          MVM_SET_LOCAL(pPropertyList, DynamicPtr_decode_native(vm, dpNext));
+        } else {
+          CODE_COVERAGE(543); // Hit
+          break;
+        }
+      }
+
+      // If we reach the end, then this is a new property. We add new properties
+      // by just appending a new TsPropertyList onto the linked list. The GC
+      // will compact these into the head later.
+
+      TsPropertyCell* pNewCell = GC_ALLOCATE_TYPE(vm, TsPropertyCell, TC_REF_PROPERTY_LIST);
+
+      // GC collection invalidates the following values so we need to refresh
+      // them from the stack slots.
+      MVM_SET_LOCAL(vPropertyName, *pPropertyName);
+      MVM_SET_LOCAL(vPropertyValue, *pPropertyValue);
+      MVM_SET_LOCAL(pPropertyList, DynamicPtr_decode_native(vm, *pObject));
+
+      /*
+      Note: This is a bit of a pain. When we allocate the new cell, it may or
+      may not trigger a GC collection cycle. If it does, then the object may be
+      moved AND COMPACTED, so the linked list chain of properties is different
+      to before (or may not be different, if there was no GC cycle), so we need
+      to re-iterate the linked list to find the last node, where we append the
+      property.
+      */
+      while (true) {
+        DynamicPtr dpNext = MVM_GET_LOCAL(pPropertyList)->dpNext;
+        if (dpNext != VM_VALUE_NULL) {
+          MVM_SET_LOCAL(pPropertyList, DynamicPtr_decode_native(vm, dpNext));
+        } else {
+          break;
+        }
+      }
+
+      ShortPtr spNewCell = ShortPtr_encode(vm, pNewCell);
+      pNewCell->base.dpNext = VM_VALUE_NULL;
+      pNewCell->base.dpProto = VM_VALUE_NULL; // Not used because this is a child cell, but still needs a value because the GC sees it.
+      pNewCell->key = MVM_GET_LOCAL(vPropertyName);
+      pNewCell->value = MVM_GET_LOCAL(vPropertyValue);
+
+      // Attach to linked list. This needs to be a long-pointer write because we
+      // don't know if the original property list was in data memory.
+      //
+      // Note: `pPropertyList` currently points to the last property list in
+      // the chain.
+      MVM_GET_LOCAL(pPropertyList)->dpNext = spNewCell;
+      VM_EXEC_SAFE_MODE(*pObject = VM_VALUE_NULL);
+      return MVM_E_SUCCESS;
+    }
+    case TC_REF_ARRAY: {
+      CODE_COVERAGE(370); // Hit
+
+      // Note: while objects in general can be in ROM, objects which are
+      // writable must always be in RAM.
+
+      MVM_LOCAL(TsArray*, arr, DynamicPtr_decode_native(vm, MVM_GET_LOCAL(vObjectValue)));
+      VirtualInt14 viLength = MVM_GET_LOCAL(arr)->viLength;
+      VM_ASSERT(vm, Value_isVirtualInt14(viLength));
+      uint16_t oldLength = VirtualInt14_decode(vm, viLength);
+      MVM_LOCAL(DynamicPtr, dpData, MVM_GET_LOCAL(arr)->dpData);
+      MVM_LOCAL(uint16_t*, pData, NULL);
+      uint16_t oldCapacity = 0;
+      if (MVM_GET_LOCAL(dpData) != VM_VALUE_NULL) {
+        CODE_COVERAGE(544); // Hit
+        VM_ASSERT(vm, Value_isShortPtr(MVM_GET_LOCAL(dpData)));
+        MVM_SET_LOCAL(pData, DynamicPtr_decode_native(vm, MVM_GET_LOCAL(dpData)));
+        uint16_t dataSize = vm_getAllocationSize(MVM_GET_LOCAL(pData));
+        oldCapacity = dataSize / 2;
+      } else {
+        CODE_COVERAGE(545); // Hit
+      }
+
+      // If the property name is "length" then we'll be changing the length
+      if (MVM_GET_LOCAL(vPropertyName) == VM_VALUE_STR_LENGTH) {
+        CODE_COVERAGE(282); // Hit
+
+        if (!Value_isVirtualInt14(MVM_GET_LOCAL(vPropertyValue)))
+          MVM_FATAL_ERROR(vm, MVM_E_TYPE_ERROR);
+        uint16_t newLength = VirtualInt14_decode(vm, MVM_GET_LOCAL(vPropertyValue));
+
+        if (newLength < oldLength) { // Making array smaller
+          CODE_COVERAGE(176); // Hit
+          // pData will not be null because oldLength must be more than 1 for it to get here
+          VM_ASSERT(vm, MVM_GET_LOCAL(pData));
+          // Wipe array items that aren't reachable
+          uint16_t count = oldLength - newLength;
+          uint16_t* p = &MVM_GET_LOCAL(pData)[newLength];
+          while (count--)
+            *p++ = VM_VALUE_DELETED;
+
+          MVM_GET_LOCAL(arr)->viLength = VirtualInt14_encode(vm, newLength);
+          VM_EXEC_SAFE_MODE(*pObject = VM_VALUE_NULL);
+          return MVM_E_SUCCESS;
+        } else if (newLength == oldLength) {
+          CODE_COVERAGE_UNTESTED(546); // Not hit
+          /* Do nothing */
+        } else if (newLength <= oldCapacity) { // Array is getting bigger, but still less than capacity
+          CODE_COVERAGE(287); // Hit
+
+          // We can just overwrite the length field. Note that the newly
+          // uncovered memory is already filled with VM_VALUE_DELETED
+          MVM_GET_LOCAL(arr)->viLength = VirtualInt14_encode(vm, newLength);
+          VM_EXEC_SAFE_MODE(*pObject = VM_VALUE_NULL);
+          return MVM_E_SUCCESS;
+        } else { // Make array bigger
+          CODE_COVERAGE(288); // Hit
+          // I'll assume that direct assignments to the length mean that people
+          // know exactly how big the array should be, so we don't add any
+          // extra capacity
+          uint16_t newCapacity = newLength;
+          growArray(vm, &*pObject, newLength, newCapacity);
+          VM_EXEC_SAFE_MODE(*pObject = VM_VALUE_NULL);
+          return MVM_E_SUCCESS;
+        }
+      } else if (MVM_GET_LOCAL(vPropertyName) == VM_VALUE_STR_PROTO) { // Writing to the __proto__ property
+        CODE_COVERAGE_UNTESTED(289); // Not hit
+        // We could make this read/write in future
+        return vm_newError(vm, MVM_E_PROTO_IS_READONLY);
+      } else if (Value_isVirtualInt14(MVM_GET_LOCAL(vPropertyName))) { // Array index
+        CODE_COVERAGE(285); // Hit
+        int16_t index = VirtualInt14_decode(vm, MVM_GET_LOCAL(vPropertyName) );
+        if (index < 0) {
+          CODE_COVERAGE_ERROR_PATH(24); // Not hit
+          return vm_newError(vm, MVM_E_INVALID_ARRAY_INDEX);
+        }
+
+        // Need to expand the array?
+        if ((uint16_t)index >= oldLength) {
+          CODE_COVERAGE(290); // Hit
+          uint16_t newLength = (uint16_t)index + 1;
+          if ((uint16_t)index < oldCapacity) {
+            CODE_COVERAGE(291); // Hit
+            // The length changes to include the value. The extra slots are
+            // already filled in with holes from the original allocation.
+            MVM_GET_LOCAL(arr)->viLength = VirtualInt14_encode(vm, newLength);
+          } else {
+            CODE_COVERAGE(292); // Hit
+            // We expand the capacity more aggressively here because this is the
+            // path used when we push into arrays or just assign values to an
+            // array in a loop.
+            uint16_t newCapacity = oldCapacity * 2;
+            if (newCapacity < VM_ARRAY_INITIAL_CAPACITY) newCapacity = VM_ARRAY_INITIAL_CAPACITY;
+            if (newCapacity < newLength) newCapacity = newLength;
+            growArray(vm, &*pObject, newLength, newCapacity);
+            MVM_SET_LOCAL(vPropertyValue, *pPropertyValue); // Value could have changed due to GC collection
+            MVM_SET_LOCAL(vObjectValue, *pObject); // Value could have changed due to GC collection
+            MVM_SET_LOCAL(arr, DynamicPtr_decode_native(vm, MVM_GET_LOCAL(vObjectValue))); // Value could have changed due to GC collection
+          }
+        } // End of array expansion
+
+        // By this point, the array should have expanded as necessary
+        MVM_SET_LOCAL(dpData, MVM_GET_LOCAL(arr)->dpData);
+        VM_ASSERT(vm, MVM_GET_LOCAL(dpData) != VM_VALUE_NULL);
+        VM_ASSERT(vm, Value_isShortPtr(MVM_GET_LOCAL(dpData)));
+        MVM_SET_LOCAL(pData, DynamicPtr_decode_native(vm, MVM_GET_LOCAL(dpData)));
+        VM_ASSERT(vm, !!MVM_GET_LOCAL(pData));
+
+        // Write the item to memory
+        MVM_GET_LOCAL(pData)[(uint16_t)index] = MVM_GET_LOCAL(vPropertyValue);
+
+        VM_EXEC_SAFE_MODE(*pObject = VM_VALUE_NULL);
+        return MVM_E_SUCCESS;
+      }
+
+      // Else not a valid array index
+      CODE_COVERAGE_ERROR_PATH(140); // Not hit
+      return vm_newError(vm, MVM_E_INVALID_ARRAY_INDEX);
+    }
+
+    case TC_REF_CLASS: {
+      CODE_COVERAGE(630); // Hit
+      lpClass = DynamicPtr_decode_long(vm, MVM_GET_LOCAL(vObjectValue));
+      // Delegate to the `staticProps` of the class
+      *pObject = READ_FIELD_2(lpClass, TsClass, staticProps);
+      goto SUB_SET_PROPERTY;
+    }
+
+    default: return vm_newError(vm, MVM_E_TYPE_ERROR);
+  }
+}
+
+/** Converts the argument to either an TC_VAL_INT14 or a TC_REF_INTERNED_STRING, or gives an error */
+static TeError toPropertyName(VM* vm, Value* value) {
+  CODE_COVERAGE(50); // Hit
+
+  // This function may trigger a GC cycle because it may add a cell to the string intern table
+  VM_ASSERT(vm, !vm->stack || !vm->stack->reg.usingCachedRegisters);
+  VM_POTENTIAL_GC_POINT(vm);
+
+  // Property names in microvium are either integer indexes or non-integer interned strings
+  TeTypeCode type = deepTypeOf(vm, *value);
+  switch (type) {
+    // These are already valid property names
+    case TC_VAL_INT14: {
+      CODE_COVERAGE(279); // Hit
+      if (VirtualInt14_decode(vm, *value) < 0) {
+        CODE_COVERAGE_UNTESTED(280); // Not hit
+        return vm_newError(vm, MVM_E_RANGE_ERROR);
+      }
+      CODE_COVERAGE(281); // Hit
+      return MVM_E_SUCCESS;
+    }
+    case TC_REF_INTERNED_STRING: {
+      CODE_COVERAGE(373); // Hit
+      return MVM_E_SUCCESS;
+    }
+
+    case TC_REF_INT32: {
+      CODE_COVERAGE_ERROR_PATH(374); // Not hit
+      // 32-bit numbers are out of the range of supported array indexes
+      return vm_newError(vm, MVM_E_RANGE_ERROR);
+    }
+
+    case TC_REF_STRING: {
+      CODE_COVERAGE(375); // Hit
+
+      // Note: In Microvium at the moment, it's illegal to use an integer-valued
+      // string as a property name. If the string is in bytecode, it will only
+      // have the type TC_REF_STRING if it's a number and is illegal.
+      if (!Value_isShortPtr(*value)) {
+        return vm_newError(vm, MVM_E_TYPE_ERROR);
+      }
+
+      if (vm_ramStringIsNonNegativeInteger(vm, *value)) {
+        CODE_COVERAGE_ERROR_PATH(378); // Not hit
+        return vm_newError(vm, MVM_E_TYPE_ERROR);
+      } else {
+        CODE_COVERAGE(379); // Hit
+      }
+
+      // Strings need to be converted to interned strings in order to be valid
+      // property names. This is because properties are searched by reference
+      // equality.
+      toInternedString(vm, value);
+      return MVM_E_SUCCESS;
+    }
+
+    case TC_VAL_STR_LENGTH: {
+      CODE_COVERAGE(272); // Hit
+      return MVM_E_SUCCESS;
+    }
+
+    case TC_VAL_STR_PROTO: {
+      CODE_COVERAGE(273); // Hit
+      return MVM_E_SUCCESS;
+    }
+    default: {
+      CODE_COVERAGE_ERROR_PATH(380); // Not hit
+      return vm_newError(vm, MVM_E_TYPE_ERROR);
+    }
+  }
+}
+
+// Converts a TC_REF_STRING to a TC_REF_INTERNED_STRING
+// TODO: Test cases for this function
+static void toInternedString(VM* vm, Value* pValue) {
+  CODE_COVERAGE(51); // Hit
+  Value value = *pValue;
+  VM_ASSERT(vm, deepTypeOf(vm, value) == TC_REF_STRING);
+
+  // This function may trigger a GC cycle because it may add a cell to the intern table
+  VM_ASSERT(vm, !vm->stack || !vm->stack->reg.usingCachedRegisters);
+
+  // TC_REF_STRING values are always in GC memory. If they were in flash, they'd
+  // already be TC_REF_INTERNED_STRING.
+  char* pStr1 = DynamicPtr_decode_native(vm, value);
+  uint16_t str1Size = vm_getAllocationSize(pStr1);
+
+  LongPtr lpStr1 = LongPtr_new(pStr1);
+  // Note: the sizes here include the null terminator
+  if ((str1Size == sizeof PROTO_STR) && (memcmp_long(lpStr1, LongPtr_new((void*)&PROTO_STR), sizeof PROTO_STR) == 0)) {
+    CODE_COVERAGE_UNTESTED(547); // Not hit
+    *pValue = VM_VALUE_STR_PROTO;
+  } else if ((str1Size == sizeof LENGTH_STR) && (memcmp_long(lpStr1, LongPtr_new((void*)&LENGTH_STR), sizeof LENGTH_STR) == 0)) {
+    CODE_COVERAGE(548); // Hit
+    *pValue = VM_VALUE_STR_LENGTH;
+  } else {
+    CODE_COVERAGE(549); // Hit
+  }
+
+  LongPtr lpBytecode = vm->lpBytecode;
+
+  // We start by searching the string table for interned strings that are baked
+  // into the ROM. These are stored alphabetically, so we can perform a binary
+  // search.
+
+  uint16_t stringTableOffset = getSectionOffset(vm->lpBytecode, BCS_STRING_TABLE);
+  uint16_t stringTableSize = getSectionOffset(vm->lpBytecode, vm_sectionAfter(vm, BCS_STRING_TABLE)) - stringTableOffset;
+  int strCount = stringTableSize / sizeof (Value);
+
+  int first = 0;
+  int last = strCount - 1;
+
+  while (first <= last) {
+    CODE_COVERAGE(381); // Hit
+    int middle = (first + last) / 2;
+    uint16_t str2Offset = stringTableOffset + middle * 2;
+    Value vStr2 = LongPtr_read2_aligned(LongPtr_add(lpBytecode, str2Offset));
+    LongPtr lpStr2 = DynamicPtr_decode_long(vm, vStr2);
+    uint16_t header = readAllocationHeaderWord_long(lpStr2);
+    VM_ASSERT(vm, vm_getTypeCodeFromHeaderWord(header) == TC_REF_INTERNED_STRING);
+    uint16_t str2Size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+    int compareSize = str1Size < str2Size ? str1Size : str2Size;
+    int c = memcmp_long(lpStr1, lpStr2, compareSize);
+
+    // If they compare equal for the range that they have in common, we check the length
+    if (c == 0) {
+      CODE_COVERAGE(382); // Hit
+      if (str1Size < str2Size) {
+        CODE_COVERAGE_UNTESTED(383); // Not hit
+        c = -1;
+      } else if (str1Size > str2Size) {
+        CODE_COVERAGE_UNTESTED(384); // Not hit
+        c = 1;
+      } else {
+        CODE_COVERAGE(385); // Hit
+        // Exact match
+        *pValue = vStr2;
+        return;
+      }
+    }
+
+    // c is > 0 if the string we're searching for comes after the middle point
+    if (c > 0) {
+      CODE_COVERAGE(386); // Hit
+      first = middle + 1;
+    } else {
+      CODE_COVERAGE(387); // Hit
+      last = middle - 1;
+    }
+  }
+
+  // At this point, we haven't found the interned string in the bytecode. We
+  // need to check in RAM. Now we're comparing an in-RAM string against other
+  // in-RAM strings. We're looking for an exact match, not performing a binary
+  // search with inequality comparison, since the linked list of interned
+  // strings in RAM is not sorted.
+  Value vInternedStrings = getBuiltin(vm, BIN_INTERNED_STRINGS);
+  Value spCell = vInternedStrings;
+  while (spCell != VM_VALUE_UNDEFINED) {
+    CODE_COVERAGE(388); // Hit
+    VM_ASSERT(vm, Value_isShortPtr(spCell));
+    TsInternedStringCell* pCell = ShortPtr_decode(vm, spCell);
+    Value vStr2 = pCell->str;
+    char* pStr2 = ShortPtr_decode(vm, vStr2);
+    uint16_t str2Header = readAllocationHeaderWord(pStr2);
+    uint16_t str2Size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(str2Header);
+
+    // The sizes have to match for the strings to be equal
+    if (str2Size == str1Size) {
+      CODE_COVERAGE(389); // Hit
+      // Note: we use memcmp instead of strcmp because strings are allowed to
+      // have embedded null terminators.
+      int c = memcmp(pStr1, pStr2, str1Size);
+      // Equal?
+      if (c == 0) {
+        CODE_COVERAGE(390); // Hit
+        *pValue = vStr2;
+        return;
+      } else {
+        CODE_COVERAGE(391); // Hit
+      }
+    } else {
+      CODE_COVERAGE(550); // Hit
+    }
+    spCell = pCell->spNext;
+    TABLE_COVERAGE(spCell ? 1 : 0, 2, 551); // Hit 1/2
+  }
+
+  CODE_COVERAGE(616); // Hit
+
+  // If we get here, it means there was no matching interned string already
+  // existing in ROM or RAM. We upgrade the current string to a
+  // TC_REF_INTERNED_STRING, since we now know it doesn't conflict with any existing
+  // existing interned strings.
+  setHeaderWord(vm, pStr1, TC_REF_INTERNED_STRING, str1Size);
+
+  // Add the string to the linked list of interned strings
+  TsInternedStringCell* pCell = GC_ALLOCATE_TYPE(vm, TsInternedStringCell, TC_REF_FIXED_LENGTH_ARRAY);
+  value = *pValue; // Invalidated by potential GC collection
+  vInternedStrings = getBuiltin(vm, BIN_INTERNED_STRINGS);  // Invalidated by potential GC collection
+  // Push onto linked list2
+  pCell->spNext = vInternedStrings;
+  pCell->str = value;
+  setBuiltin(vm, BIN_INTERNED_STRINGS, ShortPtr_encode(vm, pCell));
+}
+
+static int memcmp_long(LongPtr p1, LongPtr p2, size_t size) {
+  CODE_COVERAGE(471); // Hit
+  return MVM_LONG_MEM_CMP(p1, p2, size);
+}
+
+static void memcpy_long(void* target, LongPtr source, size_t size) {
+  CODE_COVERAGE(9); // Hit
+  MVM_LONG_MEM_CPY(target, source, size);
+}
+
+/** Size of string excluding bonus null terminator */
+static uint16_t vm_stringSizeUtf8(VM* vm, Value value) {
+  CODE_COVERAGE(53); // Hit
+  TeTypeCode typeCode = deepTypeOf(vm, value);
+  switch (typeCode) {
+    case TC_REF_STRING:
+    case TC_REF_INTERNED_STRING: {
+      LongPtr lpStr = DynamicPtr_decode_long(vm, value);
+      uint16_t headerWord = readAllocationHeaderWord_long(lpStr);
+      // Less 1 because of the bonus null terminator
+      return vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord) - 1;
+    }
+    case TC_VAL_STR_PROTO: {
+      CODE_COVERAGE_UNTESTED(552); // Not hit
+      return sizeof PROTO_STR - 1;
+    }
+    case TC_VAL_STR_LENGTH: {
+      CODE_COVERAGE(608); // Hit
+      return sizeof LENGTH_STR - 1;
+    }
+    default:
+      VM_ASSERT_UNREACHABLE(vm);
+      return 0;
+  }
+}
+
+/**
+ * Checks if a string contains only decimal digits (and is not empty). May only
+ * be called on TC_REF_STRING and only those in GC memory.
+ */
+static bool vm_ramStringIsNonNegativeInteger(VM* vm, Value str) {
+  CODE_COVERAGE(55); // Hit
+  VM_ASSERT(vm, deepTypeOf(vm, str) == TC_REF_STRING);
+
+  char* pStr = ShortPtr_decode(vm, str);
+
+  // Length excluding bonus null terminator
+  uint16_t len = vm_getAllocationSize(pStr) - 1;
+  char* p = pStr;
+  if (!len) {
+    CODE_COVERAGE_UNTESTED(554); // Not hit
+    return false;
+  } else {
+    CODE_COVERAGE(555); // Hit
+  }
+  while (len--) {
+    CODE_COVERAGE(398); // Hit
+    if (!isdigit(*p++)) {
+      CODE_COVERAGE(399); // Hit
+      return false;
+    } else {
+      CODE_COVERAGE_UNTESTED(400); // Not hit
+    }
+  }
+  return true;
+}
+
+// Convert a string to an integer
+TeError strToInt32(mvm_VM* vm, mvm_Value value, int32_t* out_result) {
+  CODE_COVERAGE(404); // Not hit
+
+  TeTypeCode type = deepTypeOf(vm, value);
+  VM_ASSERT(vm, type == TC_REF_STRING || type == TC_REF_INTERNED_STRING);
+
+  bool isFloat = false;
+
+  // Note: this function is implemented to use long pointers to access ROM
+  // memory. This is because the string may be in ROM and we don't want to copy
+  // the string to RAM. Copying to RAM involves allocating the available memory,
+  // which requires that the VM register cache be in a flushed state, which they
+  // aren't necessarily at this point in the code.
+
+  LongPtr start = DynamicPtr_decode_long(vm, value);
+  LongPtr s = start;
+  uint16_t size = vm_getAllocationSize_long(s);
+  uint16_t len = size - 1; // Excluding null terminator
+
+  // Skip leading whitespace
+  while (isspace(LongPtr_read1(s))) {
+    s = LongPtr_add(s, 1);
+  }
+
+  int sign = (LongPtr_read1(s) == '-') ? -1 : 1;
+  if (LongPtr_read1(s) == '+' || LongPtr_read1(s) == '-') {
+    s = LongPtr_add(s, 1);
+  }
+
+  // Find end of digits
+  int32_t n = 0;
+  while (isdigit(LongPtr_read1(s))) {
+    int32_t n2 = n * 10 + (LongPtr_read1(s) - '0');
+    s = LongPtr_add(s, 1);
+    // Overflow Int32
+    if (n2 < n) isFloat = true;
+    n = n2;
+  }
+
+  // Decimal point
+  if ((LongPtr_read1(s) == ',') || (LongPtr_read1(s) == '.')) {
+    CODE_COVERAGE(739); // Not hit
+    isFloat = true;
+    s = LongPtr_add(s, 1);
+  }
+
+  // Digits after decimal point
+  while (isdigit(LongPtr_read1(s))) s = LongPtr_add(s, 1);
+
+  // Skip trailing whitespace
+  while (isspace(LongPtr_read1(s))) s = LongPtr_add(s, 1);
+
+  // Check if we reached the end of the string. If we haven't reached the end of
+  // the string then there is a non-digit character in the string.
+  if (LongPtr_sub(s, start) != len) {
+    CODE_COVERAGE(740); // Not hit
+    return MVM_E_NAN;
+  }
+
+  // This function cannot handle floating point numbers
+  if (isFloat) {
+    CODE_COVERAGE_UNTESTED(741); // Not hit
+    return MVM_E_FLOAT64;
+  }
+
+  CODE_COVERAGE(656); // Hit
+
+  *out_result = sign * n;
+
+  return MVM_E_SUCCESS;
+}
+
+TeError toInt32Internal(mvm_VM* vm, mvm_Value value, int32_t* out_result) {
+  CODE_COVERAGE(56); // Hit
+  // TODO: when the type codes are more stable, we should convert these to a table.
+  *out_result = 0;
+  TeTypeCode type = deepTypeOf(vm, value);
+  MVM_SWITCH(type, TC_END - 1) {
+    MVM_CASE(TC_VAL_INT14):
+    MVM_CASE(TC_REF_INT32): {
+      CODE_COVERAGE(401); // Hit
+      *out_result = vm_readInt32(vm, type, value);
+      return MVM_E_SUCCESS;
+    }
+    MVM_CASE(TC_REF_FLOAT64): {
+      CODE_COVERAGE(402); // Hit
+      return MVM_E_FLOAT64;
+    }
+    MVM_CASE(TC_REF_STRING):
+    MVM_CASE(TC_REF_INTERNED_STRING): {
+      CODE_COVERAGE(403); // Not hit
+      return strToInt32(vm, value, out_result);
+    }
+    MVM_CASE(TC_VAL_STR_LENGTH): {
+      CODE_COVERAGE(270); // Not hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_VAL_STR_PROTO): {
+      CODE_COVERAGE(271); // Not hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_REF_PROPERTY_LIST): {
+      CODE_COVERAGE(405); // Hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_REF_ARRAY): {
+      CODE_COVERAGE_UNTESTED(406); // Not hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_REF_FUNCTION): {
+      CODE_COVERAGE(408); // Hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_REF_HOST_FUNC): {
+      CODE_COVERAGE_UNTESTED(409); // Not hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_REF_CLOSURE): {
+      CODE_COVERAGE_UNTESTED(410); // Not hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_REF_UINT8_ARRAY): {
+      CODE_COVERAGE_UNTESTED(411); // Not hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_REF_VIRTUAL): {
+      CODE_COVERAGE_UNTESTED(632); // Not hit
+      VM_RESERVED(vm);
+      return MVM_E_FATAL_ERROR_MUST_KILL_VM;
+    }
+    MVM_CASE(TC_REF_CLASS): {
+      CODE_COVERAGE(633); // Hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_REF_SYMBOL): {
+      CODE_COVERAGE_UNTESTED(412); // Not hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_VAL_UNDEFINED): {
+      CODE_COVERAGE(413); // Hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_VAL_NULL): {
+      CODE_COVERAGE(414); // Hit
+      break;
+    }
+    MVM_CASE(TC_VAL_TRUE): {
+      CODE_COVERAGE_UNTESTED(415); // Not hit
+      *out_result = 1; break;
+    }
+    MVM_CASE(TC_VAL_FALSE): {
+      CODE_COVERAGE_UNTESTED(416); // Not hit
+      break;
+    }
+    MVM_CASE(TC_VAL_NAN): {
+      CODE_COVERAGE(417); // Hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_VAL_NEG_ZERO): {
+      CODE_COVERAGE(418); // Hit
+      return MVM_E_NEG_ZERO;
+    }
+    MVM_CASE(TC_VAL_DELETED): {
+      CODE_COVERAGE_UNTESTED(419); // Not hit
+      return MVM_E_NAN;
+    }
+    MVM_CASE(TC_VAL_NO_OP_FUNC): {
+      CODE_COVERAGE(742); // Not hit
+      return MVM_E_NAN;
+    }
+    default:
+      VM_ASSERT_UNREACHABLE(vm);
+  }
+  return MVM_E_SUCCESS;
+}
+
+int32_t mvm_toInt32(mvm_VM* vm, mvm_Value value) {
+  CODE_COVERAGE(57); // Hit
+  int32_t result;
+  TeError err = toInt32Internal(vm, value, &result);
+  if (err == MVM_E_SUCCESS) {
+    CODE_COVERAGE(420); // Hit
+    return result;
+  } else if (err == MVM_E_NAN) {
+    CODE_COVERAGE(421); // Hit
+    return 0;
+  } else if (err == MVM_E_NEG_ZERO) {
+    CODE_COVERAGE_UNTESTED(422); // Not hit
+    return 0;
+  } else {
+    CODE_COVERAGE_UNTESTED(423); // Not hit
+  }
+
+  VM_ASSERT(vm, deepTypeOf(vm, value) == TC_REF_FLOAT64);
+  #if MVM_SUPPORT_FLOAT
+    return (int32_t)mvm_toFloat64(vm, value);
+  #else // !MVM_SUPPORT_FLOAT
+    // If things were compiled correctly, there shouldn't be any floats in the
+    // system at all
+    return 0;
+  #endif
+}
+
+#if MVM_SUPPORT_FLOAT
+MVM_FLOAT64 mvm_toFloat64(mvm_VM* vm, mvm_Value value) {
+  CODE_COVERAGE(58); // Hit
+  int32_t result;
+  TeError err = toInt32Internal(vm, value, &result);
+  if (err == MVM_E_SUCCESS) {
+    CODE_COVERAGE(424); // Hit
+    return result;
+  } else if (err == MVM_E_NAN) {
+    CODE_COVERAGE(425); // Hit
+    return MVM_FLOAT64_NAN;
+  } else if (err == MVM_E_NEG_ZERO) {
+    CODE_COVERAGE(426); // Hit
+    return MVM_FLOAT_NEG_ZERO;
+  } else {
+    CODE_COVERAGE(427); // Hit
+  }
+
+  VM_ASSERT(vm, deepTypeOf(vm, value) == TC_REF_FLOAT64);
+  LongPtr lpFloat = DynamicPtr_decode_long(vm, value);
+  MVM_FLOAT64 f;
+  memcpy_long(&f, lpFloat, sizeof f);
+  return f;
+}
+#endif // MVM_SUPPORT_FLOAT
+
+// See implementation of mvm_equal for the meaning of each
+typedef enum TeEqualityAlgorithm {
+  EA_NONE,
+  EA_COMPARE_PTR_VALUE_AND_TYPE,
+  EA_COMPARE_NON_PTR_TYPE,
+  EA_COMPARE_REFERENCE,
+  EA_NOT_EQUAL,
+  EA_COMPARE_STRING,
+} TeEqualityAlgorithm;
+
+static const TeEqualityAlgorithm equalityAlgorithmByTypeCode[TC_END] = {
+  EA_NONE,                       // TC_REF_TOMBSTONE          = 0x0
+  EA_COMPARE_PTR_VALUE_AND_TYPE, // TC_REF_INT32              = 0x1
+  EA_COMPARE_PTR_VALUE_AND_TYPE, // TC_REF_FLOAT64            = 0x2
+  EA_COMPARE_STRING,             // TC_REF_STRING             = 0x3
+  EA_COMPARE_STRING,             // TC_REF_INTERNED_STRING    = 0x4
+  EA_COMPARE_REFERENCE,          // TC_REF_FUNCTION           = 0x5
+  EA_COMPARE_PTR_VALUE_AND_TYPE, // TC_REF_HOST_FUNC          = 0x6
+  EA_COMPARE_PTR_VALUE_AND_TYPE, // TC_REF_BIG_INT            = 0x7
+  EA_COMPARE_REFERENCE,          // TC_REF_SYMBOL             = 0x8
+  EA_NONE,                       // TC_REF_CLASS              = 0x9
+  EA_NONE,                       // TC_REF_VIRTUAL            = 0xA
+  EA_NONE,                       // TC_REF_RESERVED_1         = 0xB
+  EA_COMPARE_REFERENCE,          // TC_REF_PROPERTY_LIST      = 0xC
+  EA_COMPARE_REFERENCE,          // TC_REF_ARRAY              = 0xD
+  EA_COMPARE_REFERENCE,          // TC_REF_FIXED_LENGTH_ARRAY = 0xE
+  EA_COMPARE_REFERENCE,          // TC_REF_CLOSURE            = 0xF
+  EA_COMPARE_NON_PTR_TYPE,       // TC_VAL_INT14              = 0x10
+  EA_COMPARE_NON_PTR_TYPE,       // TC_VAL_UNDEFINED          = 0x11
+  EA_COMPARE_NON_PTR_TYPE,       // TC_VAL_NULL               = 0x12
+  EA_COMPARE_NON_PTR_TYPE,       // TC_VAL_TRUE               = 0x13
+  EA_COMPARE_NON_PTR_TYPE,       // TC_VAL_FALSE              = 0x14
+  EA_NOT_EQUAL,                  // TC_VAL_NAN                = 0x15
+  EA_COMPARE_NON_PTR_TYPE,       // TC_VAL_NEG_ZERO           = 0x16
+  EA_NONE,                       // TC_VAL_DELETED            = 0x17
+  EA_COMPARE_STRING,             // TC_VAL_STR_LENGTH         = 0x18
+  EA_COMPARE_STRING,             // TC_VAL_STR_PROTO          = 0x19
+  EA_COMPARE_NON_PTR_TYPE,       // TC_VAL_NO_OP_FUNC         = 0x1A
+};
+
+bool mvm_equal(mvm_VM* vm, mvm_Value a, mvm_Value b) {
+  CODE_COVERAGE(462); // Hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  TeTypeCode aType = deepTypeOf(vm, a);
+  TeTypeCode bType = deepTypeOf(vm, b);
+  TeEqualityAlgorithm algorithmA = equalityAlgorithmByTypeCode[aType];
+  TeEqualityAlgorithm algorithmB = equalityAlgorithmByTypeCode[bType];
+
+  TABLE_COVERAGE(algorithmA, 6, 556); // Hit 4/6
+  TABLE_COVERAGE(algorithmB, 6, 557); // Hit 4/6
+  TABLE_COVERAGE(aType, TC_END, 558); // Hit 7/27
+  TABLE_COVERAGE(bType, TC_END, 559); // Hit 9/27
+
+  // If the values aren't even in the same class of comparison, they're not
+  // equal. In particular, strings will not be equal to non-strings.
+  if (algorithmA != algorithmB) {
+    CODE_COVERAGE(560); // Hit
+    return false;
+  } else {
+    CODE_COVERAGE(561); // Hit
+  }
+
+  if (algorithmA == EA_NOT_EQUAL) {
+    CODE_COVERAGE(562); // Hit
+    return false; // E.g. comparing NaN
+  } else {
+    CODE_COVERAGE(563); // Hit
+  }
+
+  if (a == b) {
+    CODE_COVERAGE(564); // Hit
+    return true;
+  } else {
+    CODE_COVERAGE(565); // Hit
+  }
+
+  switch (algorithmA) {
+    case EA_COMPARE_REFERENCE: {
+      // Reference equality comparison assumes that two values with different
+      // locations in memory must be different values, since their identity is
+      // their address. Since we've already checked `a == b`, this must be false.
+      return false;
+    }
+    case EA_COMPARE_NON_PTR_TYPE: {
+      // Non-pointer types are those like Int14 and the well-known values
+      // (except NaN). These can just be compared with `a == b`, which we've
+      // already done.
+      return false;
+    }
+
+    case EA_COMPARE_STRING: {
+      // Strings are a pain to compare because there are edge cases like the
+      // fact that the string "length" _may_ be represented by
+      // VM_VALUE_STR_LENGTH rather than a pointer to a string (or it may be a
+      // TC_REF_STRING). To keep the code concise, I'm fetching a pointer to the
+      // string data itself and then comparing that. This is the only equality
+      // algorithm that doesn't check the type. It makes use of the check for
+      // `algorithmA != algorithmB` from earlier and the fact that only strings
+      // compare with this algorithm, which means we won't get to this point
+      // unless both `a` and `b` are strings.
+      if (a == b) {
+        CODE_COVERAGE_UNTESTED(566); // Not hit
+        return true;
+      } else {
+        CODE_COVERAGE(567); // Hit
+      }
+      size_t sizeA;
+      size_t sizeB;
+      LongPtr lpStrA = vm_toStringUtf8_long(vm, a, &sizeA);
+      LongPtr lpStrB = vm_toStringUtf8_long(vm, b, &sizeB);
+      bool result = (sizeA == sizeB) && (memcmp_long(lpStrA, lpStrB, (uint16_t)sizeA) == 0);
+      TABLE_COVERAGE(result ? 1 : 0, 2, 568); // Hit 2/2
+      return result;
+    }
+
+    /*
+    Compares two values that are both pointer values that point to non-reference
+    types (e.g. int32). These will be equal if the value pointed to has the same
+    type, the same size, and the raw data pointed to is the same.
+    */
+    case EA_COMPARE_PTR_VALUE_AND_TYPE: {
+      CODE_COVERAGE_UNTESTED(475); // Not hit
+
+      if (a == b) {
+        CODE_COVERAGE_UNTESTED(569); // Not hit
+        return true;
+      } else {
+        CODE_COVERAGE_UNTESTED(570); // Not hit
+      }
+      if (aType != bType) {
+        CODE_COVERAGE_UNTESTED(571); // Not hit
+        return false;
+      } else {
+        CODE_COVERAGE_UNTESTED(572); // Not hit
+        }
+
+      LongPtr lpA = DynamicPtr_decode_long(vm, a);
+      LongPtr lpB = DynamicPtr_decode_long(vm, b);
+      uint16_t aHeaderWord = readAllocationHeaderWord_long(lpA);
+      uint16_t bHeaderWord = readAllocationHeaderWord_long(lpB);
+      // If the header words are different, the sizes or types are different
+      if (aHeaderWord != bHeaderWord) {
+        CODE_COVERAGE_UNTESTED(476); // Not hit
+        return false;
+      } else {
+        CODE_COVERAGE_UNTESTED(477); // Not hit
+      }
+      uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(aHeaderWord);
+      if (memcmp_long(lpA, lpB, size) == 0) {
+        CODE_COVERAGE_UNTESTED(481); // Not hit
+        return true;
+      } else {
+        CODE_COVERAGE_UNTESTED(482); // Not hit
+        return false;
+      }
+    }
+
+    default: {
+      VM_ASSERT_UNREACHABLE(vm);
+      return false;
+    }
+  }
+}
+
+bool mvm_isNaN(mvm_Value value) {
+  CODE_COVERAGE_UNTESTED(573); // Not hit
+  return value == VM_VALUE_NAN;
+}
+
+#if MVM_INCLUDE_SNAPSHOT_CAPABILITY
+
+// Called during snapshotting to convert native pointers to their position-independent form
+static void serializePtr(VM* vm, Value* pv) {
+  CODE_COVERAGE(576); // Hit
+  Value v = *pv;
+  if (!Value_isShortPtr(v)) {
+    CODE_COVERAGE(577); // Hit
+    return;
+  } else {
+    CODE_COVERAGE(578); // Hit
+  }
+  void* p = ShortPtr_decode(vm, v);
+
+  // Pointers are encoded as an offset in the heap
+  uint16_t offsetInHeap = pointerOffsetInHeap(vm, vm->pLastBucket, p);
+
+  // The lowest bit must be zero so that this is tagged as a "ShortPtr".
+  VM_ASSERT(vm, (offsetInHeap & 1) == 0);
+
+  *pv = offsetInHeap;
+}
+
+// The opposite of `loadPointers`
+static void serializePointers(VM* vm, mvm_TsBytecodeHeader* bc) {
+  CODE_COVERAGE(579); // Hit
+  // CAREFUL! This function mutates `bc`, not `vm`.
+
+  uint16_t n;
+  uint16_t* p;
+
+  uint16_t heapOffset = bc->sectionOffsets[BCS_HEAP];
+  uint16_t heapSize = bc->bytecodeSize - heapOffset;
+
+  uint16_t* pGlobals = (uint16_t*)((uint8_t*)bc + bc->sectionOffsets[BCS_GLOBALS]);
+  uint16_t* heapMemory = (uint16_t*)((uint8_t*)bc + heapOffset);
+
+  // Roots in global variables
+  uint16_t globalsSize = bc->sectionOffsets[BCS_GLOBALS + 1] - bc->sectionOffsets[BCS_GLOBALS];
+  p = pGlobals;
+  n = globalsSize / 2;
+  TABLE_COVERAGE(n ? 1 : 0, 2, 580); // Hit 1/2
+  while (n--) {
+    serializePtr(vm, p++);
+  }
+
+  // Pointers in heap memory
+  p = heapMemory;
+  uint16_t* heapEnd = (uint16_t*)((uint8_t*)heapMemory + heapSize);
+  while (p < heapEnd) {
+    CODE_COVERAGE(581); // Hit
+    uint16_t header = *p++;
+    uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+    int words = size / 2; // Note: round **down** to nearest word
+    uint16_t* next = p + (size + 1) / 2; // Note: round **up** to nearest word
+    TABLE_COVERAGE(next == p + words ? 1 : 0, 2, 694); // Hit 2/2
+    TeTypeCode tc = vm_getTypeCodeFromHeaderWord(header);
+
+    if (tc < TC_REF_DIVIDER_CONTAINER_TYPES) { // Non-container types
+      CODE_COVERAGE(582); // Hit
+      p = next;
+      continue;
+    } else {
+      // Else, container types
+      CODE_COVERAGE(583); // Hit
+    }
+
+    while (words--) {
+      if (Value_isShortPtr(*p))
+        serializePtr(vm, p);
+      p++;
+    }
+    p = next;
+  }
+}
+
+void* mvm_createSnapshot(mvm_VM* vm, size_t* out_size) {
+  CODE_COVERAGE(503); // Hit
+  if (out_size)
+    *out_size = 0;
+
+  uint16_t heapOffset = getSectionOffset(vm->lpBytecode, BCS_HEAP);
+  uint16_t heapSize = getHeapSize(vm);
+
+  // This assumes that the heap is the last section in the bytecode. Since the
+  // heap is the only part of the bytecode image that changes size, we can just
+  // calculate the new bytecode size as follows
+  VM_ASSERT(vm, BCS_HEAP == BCS_SECTION_COUNT - 1);
+  uint32_t bytecodeSize = (uint32_t)heapOffset + heapSize;
+
+  if (bytecodeSize > 0xFFFF) {
+    CODE_COVERAGE_ERROR_PATH(584); // Not hit
+    MVM_FATAL_ERROR(vm, MVM_E_SNAPSHOT_TOO_LARGE);
+  } else {
+    CODE_COVERAGE(585); // Hit
+  }
+
+  mvm_TsBytecodeHeader* pNewBytecode = vm_malloc(vm, bytecodeSize);
+  if (!pNewBytecode) return NULL;
+
+  // The globals and heap are the last parts of the image because they're the
+  // only mutable sections
+  VM_ASSERT(vm, BCS_GLOBALS == BCS_SECTION_COUNT - 2);
+  uint16_t sizeOfConstantPart = getSectionOffset(vm->lpBytecode, BCS_GLOBALS);
+
+  // The first part of the snapshot doesn't change between executions (except
+  // some header fields, which we'll update later).
+  memcpy_long(pNewBytecode, vm->lpBytecode, sizeOfConstantPart);
+
+  // Snapshot the globals memory
+  uint16_t sizeOfGlobals = getSectionSize(vm, BCS_GLOBALS);
+  memcpy((uint8_t*)pNewBytecode + pNewBytecode->sectionOffsets[BCS_GLOBALS], vm->globals, sizeOfGlobals);
+
+  // Snapshot heap memory
+
+  TsBucket* pBucket = vm->pLastBucket;
+  // Start at the end of the heap and work backwards, because buckets are linked
+  // in reverse order. (Edit: actually, they're also linked forwards now, but I
+  // might retract that at some point so I'll leave this with the backwards
+  // iteration).
+  uint8_t* pHeapStart = (uint8_t*)pNewBytecode + pNewBytecode->sectionOffsets[BCS_HEAP];
+  uint8_t* pTarget = pHeapStart + heapSize;
+  uint16_t cursor = heapSize;
+  TABLE_COVERAGE(pBucket ? 1 : 0, 2, 586); // Hit 2/2
+  while (pBucket) {
+    CODE_COVERAGE(504); // Hit
+    uint16_t offsetStart = pBucket->offsetStart;
+    uint16_t bucketSize = cursor - offsetStart;
+    uint8_t* pBucketData = getBucketDataBegin(pBucket);
+
+    pTarget -= bucketSize;
+    memcpy(pTarget, pBucketData, bucketSize);
+
+    cursor = offsetStart;
+    pBucket = pBucket->prev;
+  }
+
+  // Update header fields
+  pNewBytecode->bytecodeSize = bytecodeSize;
+
+  // Convert pointers-to-RAM into their corresponding serialized form
+  serializePointers(vm, pNewBytecode);
+
+  uint16_t crcStartOffset = OFFSETOF(mvm_TsBytecodeHeader, crc) + sizeof pNewBytecode->crc;
+  uint16_t crcSize = bytecodeSize - crcStartOffset;
+  void* pCrcStart = (uint8_t*)pNewBytecode + crcStartOffset;
+  pNewBytecode->crc = MVM_CALC_CRC16_CCITT(pCrcStart, crcSize);
+
+  if (out_size) {
+    CODE_COVERAGE(587); // Hit
+    *out_size = bytecodeSize;
+  }
+  return (void*)pNewBytecode;
+}
+#endif // MVM_INCLUDE_SNAPSHOT_CAPABILITY
+
+#if MVM_INCLUDE_DEBUG_CAPABILITY
+
+void mvm_dbg_setBreakpoint(VM* vm, int bytecodeAddress) {
+  CODE_COVERAGE_UNTESTED(588); // Not hit
+
+  // These checks on the bytecode address are assertions rather than user faults
+  // because the address is probably not manually computed by a user, it's
+  // derived from some kind of debug symbol file. In a production environment,
+  // setting a breakpoint on an address that's never executed (e.g. because it's
+  // not executable) is not a VM failure.
+  VM_ASSERT(vm, (bytecodeAddress == - 1) || (bytecodeAddress >= getSectionOffset(vm->lpBytecode, BCS_ROM)));
+  VM_ASSERT(vm, (bytecodeAddress == -1) || (bytecodeAddress < getSectionOffset(vm->lpBytecode, vm_sectionAfter(vm, BCS_ROM))));
+
+  mvm_dbg_removeBreakpoint(vm, bytecodeAddress);
+  TsBreakpoint* breakpoint = vm_malloc(vm, sizeof (TsBreakpoint));
+  if (!breakpoint) {
+    MVM_FATAL_ERROR(vm, MVM_E_MALLOC_FAIL);
+    return;
+  }
+  breakpoint->bytecodeAddress = bytecodeAddress;
+  // Add to linked-list
+  breakpoint->next = vm->pBreakpoints;
+  vm->pBreakpoints = breakpoint;
+}
+
+void mvm_dbg_removeBreakpoint(VM* vm, uint16_t bytecodeAddress) {
+  CODE_COVERAGE_UNTESTED(589); // Not hit
+
+  TsBreakpoint** ppBreakpoint = &vm->pBreakpoints;
+  TsBreakpoint* pBreakpoint = *ppBreakpoint;
+  while (pBreakpoint) {
+    if (pBreakpoint->bytecodeAddress == bytecodeAddress) {
+      CODE_COVERAGE_UNTESTED(590); // Not hit
+      // Remove from linked list
+      *ppBreakpoint = pBreakpoint->next;
+      vm_free(vm, pBreakpoint);
+      pBreakpoint = *ppBreakpoint;
+    } else {
+      CODE_COVERAGE_UNTESTED(591); // Not hit
+      ppBreakpoint = &pBreakpoint->next;
+      pBreakpoint = *ppBreakpoint;
+    }
+  }
+}
+
+void mvm_dbg_setBreakpointCallback(mvm_VM* vm, mvm_TfBreakpointCallback cb) {
+  CODE_COVERAGE_UNTESTED(592); // Not hit
+  // It doesn't strictly need to be null, but is probably a mistake if it's not.
+  VM_ASSERT(vm, vm->breakpointCallback == NULL);
+  vm->breakpointCallback = cb;
+}
+
+#endif // MVM_INCLUDE_DEBUG_CAPABILITY
+
+/**
+ * Test out the LONG_PTR macros provided in the port file. lpBytecode should
+ * point to actual bytecode, whereas pHeader should point to a local copy that's
+ * been validated.
+ */
+static TeError vm_validatePortFileMacros(MVM_LONG_PTR_TYPE lpBytecode, mvm_TsBytecodeHeader* pHeader, void* context) {
+  uint32_t x1 = 0x12345678;
+  uint32_t x2 = 0x12345678;
+  uint32_t x3 = 0x87654321;
+  uint32_t x4 = 0x99999999;
+  uint32_t* px1 = &x1;
+  uint32_t* px2 = &x2;
+  uint32_t* px3 = &x3;
+  uint32_t* px4 = &x4;
+  MVM_LONG_PTR_TYPE lpx1 = MVM_LONG_PTR_NEW(px1);
+  MVM_LONG_PTR_TYPE lpx2 = MVM_LONG_PTR_NEW(px2);
+  MVM_LONG_PTR_TYPE lpx3 = MVM_LONG_PTR_NEW(px3);
+  MVM_LONG_PTR_TYPE lpx4 = MVM_LONG_PTR_NEW(px4);
+
+  if (!((MVM_LONG_PTR_TRUNCATE(lpx1)) == px1)) goto SUB_FAIL;
+  if (!((MVM_READ_LONG_PTR_1(lpx1)) == 0x78)) goto SUB_FAIL;
+  if (!((MVM_READ_LONG_PTR_2(lpx1)) == 0x5678)) goto SUB_FAIL;
+  if (!((MVM_READ_LONG_PTR_1((MVM_LONG_PTR_ADD(lpx1, 1)))) == 0x56)) goto SUB_FAIL;
+  if (!((MVM_LONG_PTR_SUB((MVM_LONG_PTR_ADD(lpx1, 3)), lpx1)) == 3)) goto SUB_FAIL;
+  if (!((MVM_LONG_PTR_SUB(lpx1, (MVM_LONG_PTR_ADD(lpx1, 3)))) == -3)) goto SUB_FAIL;
+  if (!((MVM_LONG_MEM_CMP(lpx1, lpx2, 4)) == 0)) goto SUB_FAIL;
+  if (!((MVM_LONG_MEM_CMP(lpx1, lpx3, 4)) > 0)) goto SUB_FAIL;
+  if (!((MVM_LONG_MEM_CMP(lpx1, lpx4, 4)) < 0)) goto SUB_FAIL;
+
+  MVM_LONG_MEM_CPY(px4, lpx3, 4);
+  if (!(x4 == 0x87654321)) goto SUB_FAIL;
+  x4 = 0x99999999;
+
+  // The above tests were testing the case of using a long pointer to point to
+  // local RAM. We need to also test that everything works when point to the
+  // actual bytecode. lpBytecode and pHeader should point to data of the same
+  // value but in different address spaces (ROM and RAM respectively).
+
+  if (!((MVM_READ_LONG_PTR_1(lpBytecode)) == pHeader->bytecodeVersion)) goto SUB_FAIL;
+  if (!((MVM_READ_LONG_PTR_2(lpBytecode)) == *((uint16_t*)pHeader))) goto SUB_FAIL;
+  if (!((MVM_READ_LONG_PTR_1((MVM_LONG_PTR_ADD(lpBytecode, 2)))) == pHeader->requiredEngineVersion)) goto SUB_FAIL;
+  if (!((MVM_LONG_PTR_SUB((MVM_LONG_PTR_ADD(lpBytecode, 3)), lpBytecode)) == 3)) goto SUB_FAIL;
+  if (!((MVM_LONG_PTR_SUB(lpBytecode, (MVM_LONG_PTR_ADD(lpBytecode, 3)))) == -3)) goto SUB_FAIL;
+  if (!((MVM_LONG_MEM_CMP(lpBytecode, (MVM_LONG_PTR_NEW(pHeader)), 8)) == 0)) goto SUB_FAIL;
+
+  if (MVM_NATIVE_POINTER_IS_16_BIT && (sizeof(void*) != 2)) return MVM_E_EXPECTED_POINTER_SIZE_TO_BE_16_BIT;
+  if ((!MVM_NATIVE_POINTER_IS_16_BIT) && (sizeof(void*) == 2)) return MVM_E_EXPECTED_POINTER_SIZE_NOT_TO_BE_16_BIT;
+
+  #if MVM_USE_SINGLE_RAM_PAGE
+    void* ptr = MVM_CONTEXTUAL_MALLOC(2, context);
+    MVM_CONTEXTUAL_FREE(ptr, context);
+    if ((intptr_t)ptr - (intptr_t)MVM_RAM_PAGE_ADDR > 0xffff) return MVM_E_MALLOC_NOT_WITHIN_RAM_PAGE;
+  #endif // MVM_USE_SINGLE_RAM_PAGE
+
+  return MVM_E_SUCCESS;
+
+SUB_FAIL:
+  return MVM_E_PORT_FILE_MACRO_TEST_FAILURE;
+}
+
+uint16_t mvm_getCurrentAddress(VM* vm) {
+  vm_TsStack* stack = vm->stack;
+  if (!stack) return 0; // Not currently running
+  LongPtr lpProgramCounter = stack->reg.lpProgramCounter;
+  LongPtr lpBytecode = vm->lpBytecode;
+  uint16_t address = (uint16_t)MVM_LONG_PTR_SUB(lpProgramCounter, lpBytecode);
+  return address;
+}
+
+// Clone a fixed length array or other container type
+static Value vm_cloneContainer(VM* vm, Value* pArr) {
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  LongPtr* lpSource = DynamicPtr_decode_long(vm, *pArr);
+  uint16_t headerWord = readAllocationHeaderWord_long(lpSource);
+  uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+  uint16_t* newArray = mvm_allocate(vm, size, vm_getTypeCodeFromHeaderWord(headerWord));
+
+  // May have moved during allocation
+  lpSource = DynamicPtr_decode_long(vm, *pArr);
+
+  uint16_t* pTarget = newArray;
+  while (size) {
+    *pTarget++ = LongPtr_read2_aligned(lpSource);
+    lpSource = LongPtr_add(lpSource, 2);
+    size -= 2;
+  }
+
+  return ShortPtr_encode(vm, newArray);
+}
+
+static Value vm_safePop(VM* vm, Value* pStackPointerAfterDecr) {
+  // This is only called in the run-loop, so the registers should be cached
+  VM_ASSERT(vm, vm->stack->reg.usingCachedRegisters);
+  if (pStackPointerAfterDecr < getBottomOfStack(vm->stack)) {
+    MVM_FATAL_ERROR(vm, MVM_E_ASSERTION_FAILED);
+  }
+  return *pStackPointerAfterDecr;
+}
+
+static inline void mvm_checkValueAccess(VM* vm, uint8_t potentialCycleNumber) {
+  VM_ASSERT(vm, vm->gc_potentialCycleNumber == potentialCycleNumber);
+}
+
+static TeError vm_newError(VM* vm, TeError err) {
+  #if MVM_ALL_ERRORS_FATAL
+  MVM_FATAL_ERROR(vm, err);
+  #endif
+  return err;
+}
+
+static void* vm_malloc(VM* vm, size_t size) {
+  void* result = MVM_CONTEXTUAL_MALLOC(size, vm->context);
+
+  #if MVM_SAFE_MODE && MVM_USE_SINGLE_RAM_PAGE
+    // See comment on MVM_RAM_PAGE_ADDR in microvium_port_example.h
+    VM_ASSERT(vm, (intptr_t)result - (intptr_t)MVM_RAM_PAGE_ADDR <= 0xFFFF);
+  #endif
+  return result;
+}
+
+// This is because we get an unused warning on the `context` variable if the
+// MVM_CONTEXTUAL_FREE macro doesn't actually use the context.
+#ifdef __GNUC__
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wunused-variable"
+#endif
+// Note: mvm_free frees the VM, while vm_free is the counterpart to vm_malloc
+static void vm_free(VM* vm, void* ptr) {
+  // Capture the context before freeing the ptr, since the pointer could be the vm
+  void* context = vm->context;
+
+  #if MVM_SAFE_MODE && MVM_USE_SINGLE_RAM_PAGE
+    // See comment on MVM_RAM_PAGE_ADDR in microvium_port_example.h
+    VM_ASSERT(vm, !ptr || ((intptr_t)ptr - (intptr_t)MVM_RAM_PAGE_ADDR <= 0xFFFF));
+  #endif
+
+  MVM_CONTEXTUAL_FREE(ptr, context);
+}
+#ifdef __GNUC__
+#pragma GCC diagnostic pop
+#endif
+
+static mvm_TeError vm_uint8ArrayNew(VM* vm, Value* slot) {
+  CODE_COVERAGE(344); // Hit
+
+  uint16_t size = *slot;
+  if (!Value_isVirtualUInt12(size)) {
+    CODE_COVERAGE_ERROR_PATH(345); // Not hit
+    return vm_newError(vm, MVM_E_INVALID_UINT8_ARRAY_LENGTH);
+  }
+  size = VirtualInt14_decode(vm, size);
+
+  uint8_t* p = mvm_allocate(vm, size, TC_REF_UINT8_ARRAY);
+  *slot = ShortPtr_encode(vm, p);
+  memset(p, 0, size);
+
+  return MVM_E_SUCCESS;
+}
+
+mvm_Value mvm_uint8ArrayFromBytes(mvm_VM* vm, const uint8_t* data, size_t sizeBytes) {
+  CODE_COVERAGE(346); // Hit
+  if (sizeBytes >= (MAX_ALLOCATION_SIZE + 1)) {
+    MVM_FATAL_ERROR(vm, MVM_E_ALLOCATION_TOO_LARGE);
+    return VM_VALUE_UNDEFINED;
+  }
+  // Note: mvm_allocate will also check the size
+  uint8_t* p = mvm_allocate(vm, (uint16_t)sizeBytes, TC_REF_UINT8_ARRAY);
+  Value result = ShortPtr_encode(vm, p);
+  memcpy(p, data, sizeBytes);
+  return result;
+}
+
+mvm_TeError mvm_uint8ArrayToBytes(mvm_VM* vm, mvm_Value uint8ArrayValue, uint8_t** out_data, size_t* out_size) {
+  CODE_COVERAGE(348); // Hit
+
+  // Note: while it makes sense to allow Uint8Arrays in general to live in ROM,
+  // I think we can require that those that hit the FFI boundary are never
+  // optimized into ROM. For efficiency and because I imagine that it's a very
+  // limited use case to have constant data accessed through this API.
+
+  if (!Value_isShortPtr(uint8ArrayValue)) {
+    CODE_COVERAGE_ERROR_PATH(574); // Not hit
+    return vm_newError(vm, MVM_E_TYPE_ERROR);
+  }
+
+  void* p = ShortPtr_decode(vm, uint8ArrayValue);
+  uint16_t headerWord = readAllocationHeaderWord(p);
+  TeTypeCode typeCode = vm_getTypeCodeFromHeaderWord(headerWord);
+  if (typeCode != TC_REF_UINT8_ARRAY) {
+    CODE_COVERAGE_ERROR_PATH(575); // Not hit
+    return vm_newError(vm, MVM_E_TYPE_ERROR);
+  }
+
+  *out_size = (size_t)vm_getAllocationSizeExcludingHeaderFromHeaderWord(headerWord);
+  *out_data = p;
+  return MVM_E_SUCCESS;
+}
+
+/**
+ * The internal version of asyncStart.
+ *
+ * Differs in the following ways:
+ *
+ * - `vm_asyncStartUnsafe` doesn't add an additional wrapper to the callback.
+ * - `vm_asyncStartUnsafe` may return a promise value, whereas `mvm_asyncStart`
+ *   will wrap that promise in a callback.
+ */
+static mvm_Value vm_asyncStartUnsafe(mvm_VM* vm, mvm_Value* out_result) {
+  CODE_COVERAGE(743); // Not hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  #if MVM_SAFE_MODE
+    if (!vm || !vm->stack) MVM_FATAL_ERROR(vm, MVM_E_REQUIRES_ACTIVE_VM);
+  #endif
+  vm_TsRegisters* reg = &vm->stack->reg;
+
+
+  Value cpsCallback = reg->cpsCallback;
+  // Mark that the callback has been "consumed". This is not strictly
+  // necessary but adds a layer of safety because it could indicate a mistake
+  // if `mvm_asyncStart` is called multiple times (especially since the
+  // callback should only be called exactly once).
+  reg->cpsCallback = VM_VALUE_DELETED;
+
+  mvm_TeType tc = mvm_typeOf(vm, cpsCallback);
+
+  // The callback is a continuation function (optimized hot-path)
+  if (tc == VM_T_FUNCTION) {
+    CODE_COVERAGE(744); // Not hit
+    // Else, the callback will be a function. This path indicates the situation
+    // where the caller supports CPS and has given the callee the callback via
+    // the `cpsCallback` register.
+    VM_ASSERT(vm, mvm_typeOf(vm, cpsCallback) == VM_T_FUNCTION);
+    // The synchronous result (the promise) is elided because the caller
+    // communicated that they support CPS
+    *out_result = VM_VALUE_DELETED;
+
+    return cpsCallback;
+  }
+
+  // Void call - no callback so we return `VM_VALUE_NO_OP_FUNC`
+  if (reg->argCountAndFlags & AF_VOID_CALLED) {
+    // The callback is undefined if the caller is void-calling
+    VM_ASSERT(vm, cpsCallback == VM_VALUE_UNDEFINED);
+
+    // This path indicates the situation where the caller is a void call and
+    // does not need the promise result.
+    CODE_COVERAGE(658); // Hit
+
+    // This is not strictly necessary because the synchronous result is not used
+    // in a void call, but it's consistent.
+    *out_result = VM_VALUE_DELETED;
+
+    // In this situation, there's nothing actually waiting to be called back
+    // (the JS code is not awaiting the result of the host call), but we return
+    // a dummy function so that the API is consistent.
+    return VM_VALUE_NO_OP_FUNC;
+  }
+
+  if (cpsCallback == VM_VALUE_DELETED) {
+    // This path indicates the situation where the callback for the current
+    // activation record is no longer accessible, either because of a nested
+    // function call or because the host already called `mvm_asyncStart`.
+    CODE_COVERAGE_ERROR_PATH(745); // Not hit
+    MVM_FATAL_ERROR(vm, MVM_E_ASYNC_START_ERROR);
+    return 0;
+  }
+
+  // Otherwise, the caller does not support CPS (the caller is not a void call
+  // and not an await-call) and so is expecting a promise result. We need to
+  // instantiate a promise and then create a closure callback that resolves the
+  // promise.
+  CODE_COVERAGE(746); // Not hit
+
+  VM_ASSERT(vm, cpsCallback == VM_VALUE_UNDEFINED);
+  Value promiseProto = getBuiltin(vm, BIN_PROMISE_PROTOTYPE);
+  if (promiseProto == VM_VALUE_UNDEFINED) {
+    MVM_FATAL_ERROR(vm, MVM_E_ASYNC_WITHOUT_AWAIT);
+  }
+  VM_ASSERT(vm, deepTypeOf(vm, promiseProto) == TC_REF_PROPERTY_LIST);
+  Value promise = vm_objectCreate(vm, promiseProto, 2);
+  Value* pPromise = (Value*)ShortPtr_decode(vm, promise);
+  // Internal slots
+  pPromise[VM_OIS_PROMISE_STATUS] = VM_PROMISE_STATUS_PENDING;
+  pPromise[VM_OIS_PROMISE_OUT] = VM_VALUE_UNDEFINED; // No subscribers yet
+
+  // Note: both the synchronous result and callback are represented by the
+  // promise in this scenario. As the "callback", the promise essentially
+  // represents the group of subscribers to invoke.
+  *out_result = promise; // The promise to put in var[0] to return to the caller of the async function
+  return promise; // The promise to put in slot[1] of the closure to invoke when the async operation completes
+}
+
+/**
+ * Create a new object with the given prototype and number of internal slots.
+ *
+ * internalSlotCount must be a multiple of 2 because it's overloading the
+ * key/value pairs of the object.
+ *
+ * @warning It does NOT initialize the internal slots.
+ */
+static Value vm_objectCreate(VM* vm, Value prototype, int internalSlotCount) {
+  VM_ASSERT(vm, (internalSlotCount % 2) == 0);
+  size_t size = sizeof(TsPropertyList) + internalSlotCount * sizeof(Value);
+  vm_push(vm, prototype); // GC reachable
+  TsPropertyList* pObject = mvm_allocate(vm, (uint16_t)size, TC_REF_PROPERTY_LIST);
+  pObject->dpProto = vm_pop(vm); // prototype
+  pObject->dpNext = VM_VALUE_NULL;
+
+  return ShortPtr_encode(vm, pObject);
+}
+
+// Same as vm_asyncStartUnsafe but adds an additional wrapper closure
+mvm_Value mvm_asyncStart(mvm_VM* vm, mvm_Value* out_result) {
+  mvm_Value callbackOrPromise = vm_asyncStartUnsafe(vm, out_result);
+
+  if (callbackOrPromise == VM_VALUE_NO_OP_FUNC) {
+    CODE_COVERAGE(702); // Hit
+    return VM_VALUE_NO_OP_FUNC;
+  }
+
+  mvm_Value asyncHostCallback = getBuiltin(vm, BIN_ASYNC_HOST_CALLBACK);
+  CODE_COVERAGE(695); // Hit
+  if (asyncHostCallback == VM_VALUE_UNDEFINED) {
+    CODE_COVERAGE_UNTESTED(703); // Not hit
+
+    // If the builtin is missing, it means the compiler found no await points
+    // in the program and assumed that the async machinery was not needed.
+    MVM_FATAL_ERROR(vm, MVM_E_ASYNC_WITHOUT_AWAIT);
+    return VM_VALUE_NO_OP_FUNC;
+  }
+
+  CODE_COVERAGE(704); // Hit
+
+  // Anchor on stack
+  vm_push(vm, callbackOrPromise);
+
+  uint16_t* pClosure = mvm_allocate(vm, 4, TC_REF_CLOSURE);
+  pClosure[0] = asyncHostCallback;
+  pClosure[1] = vm_pop(vm); // callbackOrPromise
+  mvm_Value closureValue = ShortPtr_encode(vm, pClosure);
+
+  return closureValue;
+}
+
+static mvm_Value* vm_push(mvm_VM* vm, mvm_Value value) {
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+  VM_ASSERT(vm, vm && vm->stack);
+  vm_TsRegisters* reg = &vm->stack->reg;
+  VM_ASSERT(vm, reg->pStackPointer < getTopOfStackSpace(vm->stack));
+  mvm_Value* result = reg->pStackPointer++;
+  *result = value;
+  return result;
+}
+
+static mvm_Value vm_pop(mvm_VM* vm) {
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+  VM_ASSERT(vm, vm && vm->stack);
+  vm_TsRegisters* reg = &vm->stack->reg;
+  VM_ASSERT(vm, reg->pStackPointer > getBottomOfStack(vm->stack));
+  return *--reg->pStackPointer;
+}
+
+/**
+ * Enqueue the given job to the job queue (for the moment there is only one job
+ * queue, for executing async callbacks). The job must be of type TC_REF_CLOSURE
+ */
+static void vm_enqueueJob(VM* vm, Value jobClosure) {
+  Value* firstNode;
+  Value firstNodeRef;
+
+  CODE_COVERAGE(672); // Hit
+
+  // The job queue exists in the ephemeral registers. There is no way to enqueue
+  // job while the VM is idle (no stack). But obviously you can call a VM
+  // function that triggers a job to be enqueued
+  VM_ASSERT(vm, vm->stack);
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+  vm_TsRegisters* reg = &vm->stack->reg;
+  Value jobQueue = reg->jobQueue;
+
+  // Note: jobs are always closures
+  VM_ASSERT(vm, deepTypeOf(vm, jobClosure) == TC_REF_CLOSURE);
+
+  TeTypeCode type = deepTypeOf(vm, jobQueue);
+
+  // Hot path (I think)
+  if (type == TC_VAL_UNDEFINED) {
+    CODE_COVERAGE(673); // Hit
+    // No jobs yet - the new job is the only job
+    reg->jobQueue = jobClosure;
+    return;
+  }
+
+  vm_push(vm, jobClosure); // GC-reachable
+
+  // Note: jobs are always closures
+  if (type == TC_REF_CLOSURE) {
+    CODE_COVERAGE(674); // Hit
+
+    // There is already one job. We need to promote the queue to a linked list
+    // (cycle). Each element in the linked cycle is a triple with [prev, job,
+    // next]. Here there is only one node in the cycle, so the next and prev are
+    // itself.
+    firstNode = (Value*)mvm_allocate(vm, 2 * 3, TC_REF_FIXED_LENGTH_ARRAY);
+    firstNodeRef = ShortPtr_encode(vm, firstNode);
+    firstNode[0] = firstNodeRef; // prev
+    firstNode[1] = reg->jobQueue; // job
+    firstNode[2] = firstNodeRef; // next
+    reg->jobQueue = firstNodeRef;
+    VM_EXEC_SAFE_MODE(jobQueue = VM_VALUE_DELETED); // Invalidated
+    VM_EXEC_SAFE_MODE(type = 0); // Invalidated
+    /* no return */
+  } else {
+    CODE_COVERAGE(675); // Hit
+  }
+
+  // If it's not undefined or a closure, it must be a linked list (linked cycle)
+  // of jobs.
+  VM_ASSERT(vm, deepTypeOf(vm, reg->jobQueue) == TC_REF_FIXED_LENGTH_ARRAY);
+
+  // Create a new node in the linked cycle
+  Value* newNode = mvm_allocate(vm, 2 * 3, TC_REF_FIXED_LENGTH_ARRAY);
+  VM_EXEC_SAFE_MODE(firstNodeRef = VM_VALUE_DELETED); // Invalidated
+  VM_EXEC_SAFE_MODE(firstNode = 0); // Invalidated
+  VM_EXEC_SAFE_MODE(jobClosure = VM_VALUE_DELETED); // Invalidated
+
+  // Note: the job queue is always in RAM.
+  firstNodeRef = reg->jobQueue;
+  firstNode = ShortPtr_decode(vm, firstNodeRef);
+
+  // We insert the new job at the "end" of the list. Since the list is actually
+  // a cycle, this means inserting it before the first node. This is the main
+  // reason we store this as a cycle rather than a flat list -- it gives us
+  // access to the last node of the list without using another register.
+  Value lastNodeRef = firstNode[0] /* prev */;
+  Value* lastNode = ShortPtr_decode(vm, lastNodeRef);
+
+  Value newNodeRef = ShortPtr_encode(vm, newNode);
+  newNode[0] = lastNodeRef;  // prev
+  newNode[1] = vm_pop(vm) /* jobClosure */; // job
+  newNode[2] = firstNodeRef; // next
+  lastNode[2] = newNodeRef;  // last.next
+  firstNode[0] = newNodeRef; // first.prev
+}
+
+/**
+ * Dequeues the first job from the job queue and returns it.
+ *
+ * WARNING: the result is not otherwise GC reachable, so don't run a GC cycle
+ * until it's anchored to the reachability graph.
+ *
+ * WARNING: this should only be called if there is an actual job (i.e. the queue
+ * register is not VM_VALUE_UNDEFINED). This function doesn't handle that case
+ * because it's expected to be the hot case.
+ */
+static Value vm_dequeueJob(VM* vm) {
+  CODE_COVERAGE(676); // Hit
+
+  // The job queue exists in the ephemeral registers. There is no way to enqueue
+  // job while the VM is idle (no stack). But obviously you can call a VM
+  // function that triggers a job to be enqueued
+  VM_ASSERT(vm, vm->stack);
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  vm_TsRegisters* reg = &vm->stack->reg;
+  Value jobQueue = reg->jobQueue;
+
+  // Caller should check if there isn't a job first (hot path)
+  VM_ASSERT(vm, reg->jobQueue != VM_VALUE_UNDEFINED);
+
+  TeTypeCode tc = deepTypeOf(vm, jobQueue);
+
+  // Note: jobs are only closures (not other callable types)
+  if (tc == TC_REF_CLOSURE) {
+    CODE_COVERAGE(677); // Hit
+    reg->jobQueue = VM_VALUE_UNDEFINED;
+    return jobQueue;
+  }
+
+  // Otherwise the queue is a linked cycle (see vm_enqueueJob). Each node in the
+  // cycle is a triple of [prev, job, next]
+  VM_ASSERT(vm, tc == TC_REF_FIXED_LENGTH_ARRAY);
+  Value* first = ShortPtr_decode(vm, jobQueue);
+
+  // First job in the queue
+  Value result = first[1] /* job */;
+
+  // Cycle of 1? Then this dequeue empties the queue
+  if (ShortPtr_decode(vm, first[0] /* prev */) == first) {
+    CODE_COVERAGE(678); // Hit
+    VM_ASSERT(vm, first[0] == jobQueue);
+    reg->jobQueue = VM_VALUE_UNDEFINED; // Job queue is empty
+    VM_ASSERT(vm, deepTypeOf(vm, first[1]) == TC_REF_CLOSURE);
+    return result;
+  } else {
+    CODE_COVERAGE(679); // Hit
+    // Warning: `second` might be the same as `last` if there are only 2 cells in the cycle
+    Value* last = ShortPtr_decode(vm, first[0]);
+    Value* second = ShortPtr_decode(vm, first[2]);
+    last[2] /* next */ = first[2] /* next */;
+    second[0] /* prev */ = first[0] /* prev */;
+    reg->jobQueue = first[2];
+    return result;
+  }
+}
+
+/**
+ * Checks the Microvium heap for corruption.
+ *
+ * This function walks the heap and checks that all RAM pointer values point to
+ * legitimate allocations.
+ */
+#if MVM_DEBUG_UTILS
+void mvm_checkHeap(mvm_VM* vm) {
+  // Allocation map is 1 bit per word. The bit is set if the word is the
+  // beginning of an allocation.
+  uint16_t heapSize = getHeapSize(vm);
+  uint8_t* allocationMap = (uint8_t*)malloc((heapSize + 15) / 16);
+  memset(allocationMap, 0, (heapSize + 15) / 16);
+  #define setAllocationMapBit(address) (allocationMap[(address) / 16] |=  (1 << ((address) % 16 / 2)))
+  #define getAllocationMapBit(address) ((allocationMap[(address) / 16] & (1 << ((address) % 16 / 2))) != 0)
+
+  TsBucket* firstBucket = vm->pLastBucket;
+  while (firstBucket && firstBucket->prev) {
+    firstBucket = firstBucket->prev;
+  }
+
+  // 1st pass to find allocations
+  TsBucket* bucket = firstBucket;
+  while (bucket) {
+    uint8_t* bucketBegin = (uint8_t*)getBucketDataBegin(bucket);
+    Value* p = (Value*)bucketBegin;
+    Value* bucketEnd = bucket->pEndOfUsedSpace;
+    uint16_t offsetStart = bucket->offsetStart;
+    while (p < bucketEnd) {
+      uint16_t header = *p++;
+      uint16_t offset = (uint16_t)((uint8_t*)p - bucketBegin) + offsetStart;
+      setAllocationMapBit(offset);
+      uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+      Value* next = p + ((size + 1) / 2);
+      if (next > bucketEnd) {
+        MVM_FATAL_ERROR(vm, MVM_E_HEAP_CORRUPT);
+        goto cleanup;
+      }
+      p = next;
+    }
+    bucket = bucket->next;
+  }
+
+  // 2st pass to check pointers
+  bucket = firstBucket;
+  while (bucket) {
+    uint8_t* bucketBegin = (uint8_t*)getBucketDataBegin(bucket);
+    Value* p = (Value*)bucketBegin;
+    Value* bucketEnd = bucket->pEndOfUsedSpace;
+    while (p < bucketEnd) {
+      uint16_t header = *p++;
+      Value* pAlloc = p;
+      uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+      TeTypeCode tc = vm_getTypeCodeFromHeaderWord(header);
+      Value* next = pAlloc + ((size + 1) / 2); // Round up
+      if (next > bucketEnd) {
+        MVM_FATAL_ERROR(vm, MVM_E_HEAP_CORRUPT);
+      }
+      if (tc >= TC_REF_DIVIDER_CONTAINER_TYPES) {
+        uint16_t words = size / 2; // Round down
+        for (uint16_t slotNumber = 0; slotNumber < words; slotNumber++) {
+          Value value = pAlloc[slotNumber];
+          if (Value_isShortPtr(value)) {
+            Value* target = ShortPtr_decode(vm, value);
+            uint16_t offset = pointerOffsetInHeap(vm, vm->pLastBucket, target);
+            if (!getAllocationMapBit(offset)) {
+              MVM_FATAL_ERROR(vm, MVM_E_HEAP_CORRUPT);
+              goto cleanup;
+            }
+          }
+        }
+        p = next;
+      }
+      p = next;
+    }
+    bucket = bucket->next;
+  }
+
+cleanup:
+  free(allocationMap);
+}
+#endif // MVM_DEBUG_UTILS
+
+/**
+ * If the value is a RAM pointer, validates that it points to a valid heap
+ * allocation.
+ *
+ * WARNING: Very expensive, since it walks the whole heap to find the
+ * allocation.
+ */
+#if MVM_DEBUG_UTILS
+void mvm_checkValue(mvm_VM* vm, mvm_Value value) {
+  if (!Value_isShortPtr(value)) {
+    return;
+  }
+
+  TsBucket* firstBucket = vm->pLastBucket;
+  while (firstBucket && firstBucket->prev) {
+    firstBucket = firstBucket->prev;
+  }
+
+  TsBucket* bucket = firstBucket;
+  while (bucket) {
+    uint8_t* bucketBegin = (uint8_t*)getBucketDataBegin(bucket);
+    Value* p = (Value*)bucketBegin;
+    Value* bucketEnd = bucket->pEndOfUsedSpace;
+    while (p < bucketEnd) {
+      uint16_t header = *p++;
+      if (ShortPtr_encode(vm, p) == value) {
+        return; // Found
+      }
+      uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+      Value* next = p + ((size + 1) / 2);
+      if (next > bucketEnd) {
+        MVM_FATAL_ERROR(vm, MVM_E_HEAP_CORRUPT);
+        return;
+      }
+      p = next;
+    }
+    bucket = bucket->next;
+  }
+
+  // Not found. Therefore corrupt.
+  MVM_FATAL_ERROR(vm, MVM_E_HEAP_CORRUPT);
+}
+#endif // MVM_DEBUG_UTILS
+
+/**
+ * Parses the call stack and returns a pointer to the set of frame.
+ *
+ * The first returned frame is the active frame, and the rest proceed in
+ * descending order.
+ *
+ * If the registers are currently cached, the first frame will be rubbish.
+ *
+ * The memory is malloced but re-used on each call to `mvm_readCallStack`,
+ * so the caller should not free it.
+ *
+ * A zero programCounter indicates the end of the call stack, which can be
+ * useful for using this in debug watch. Also the `out_frameCount` is optional.
+ *
+ * If there are no frames, returns NULL.
+ */
+#if MVM_DEBUG_UTILS
+mvm_TsCallStackFrame* mvm_readCallStack(VM* vm, int* out_frameCount) {
+  static mvm_TsCallStackFrame* frames = 0;
+  static int allocatedFramesCount = 0;
+
+  vm_TsStack* stack = vm->stack;
+  if (!stack) {
+    if (out_frameCount) {
+      *out_frameCount = 0;
+    }
+    return NULL;
+  }
+  vm_TsRegisters* reg = &stack->reg;
+  uint16_t* beginningOfStack = getBottomOfStack(stack);
+
+  // Count number of required frames
+  int frameCount = 1;
+  uint16_t* pFrameBase = reg->pFrameBase;
+  while (pFrameBase > beginningOfStack) {
+    frameCount++;
+    pFrameBase -= 4;
+    pFrameBase = pFrameBase - (*pFrameBase) / 2;
+  }
+
+  if (out_frameCount) {
+    *out_frameCount = frameCount;
+  }
+
+  // Allocate enough space. One extra as a kind of "null terminator" for debug purposes
+  if (allocatedFramesCount < frameCount + 1) {
+    free(frames);
+    allocatedFramesCount = frameCount + 1;
+    frames = (mvm_TsCallStackFrame*)malloc(sizeof(mvm_TsCallStackFrame) * allocatedFramesCount);
+  }
+
+  memset(frames, 0, sizeof(mvm_TsCallStackFrame) * allocatedFramesCount);
+
+  mvm_TsCallStackFrame* pFrame = frames;
+  VM_ASSERT(vm, pFrame >= frames);
+
+  Value* pStackPointer = reg->pStackPointer;
+  uint16_t programCounter = (uint16_t)LongPtr_sub(reg->lpProgramCounter, vm->lpBytecode);
+  Value* pArgs = reg->pArgs;
+  uint16_t argCountAndFlags = reg->argCountAndFlags;
+  uint16_t argCount = (argCountAndFlags & AF_ARG_COUNT_MASK);
+  Value closure = reg->closure;
+
+  pFrameBase = reg->pFrameBase;
+  while (true) {
+    VM_ASSERT(vm, pFrame >= frames);
+    VM_ASSERT(vm, pFrame < frames + frameCount);
+
+    pFrame->programCounter = programCounter;
+    pFrame->frameBase = pFrameBase;
+    pFrame->frameDepth = (int)(pStackPointer - pFrameBase);
+    pFrame->argCount = argCount;
+    pFrame->args = pArgs;
+    pFrame->closure = closure == VM_VALUE_UNDEFINED ? NULL : ShortPtr_decode(vm, closure);
+    pFrame->closureSlotCount = closure == VM_VALUE_UNDEFINED ? 0 : vm_getAllocationSize(ShortPtr_decode(vm, closure)) / 2;
+
+    pFrame++;
+
+    // Unwind frame
+    VM_ASSERT(vm, VM_FRAME_BOUNDARY_VERSION == 2);
+    pStackPointer = pFrameBase;
+    if (pStackPointer <= beginningOfStack) {
+      break;
+    }
+
+    programCounter = *(--pStackPointer);
+    argCountAndFlags = *(--pStackPointer);
+    argCount = (argCountAndFlags & AF_ARG_COUNT_MASK);
+    closure = *(--pStackPointer);
+    pStackPointer--;
+    pFrameBase = (uint16_t*)((uint8_t*)pStackPointer - *pStackPointer);
+    pArgs = pFrameBase - VM_FRAME_BOUNDARY_SAVE_SIZE_WORDS - argCount;
+
+  }
+
+  VM_ASSERT(vm, pFrameBase == beginningOfStack);
+  VM_ASSERT(vm, pFrame == frames + frameCount);
+
+  return frames;
+}
+#endif // MVM_DEBUG_UTILS
+
+/**
+ * Counts the number of allocations in the heap
+ *
+ * WARNING: Expensive, since it walks the whole heap.
+ */
+#if MVM_DEBUG_UTILS
+int mvm_readHeapCount(VM* vm) {
+  TsBucket* firstBucket = vm->pLastBucket;
+  while (firstBucket && firstBucket->prev) {
+    firstBucket = firstBucket->prev;
+  }
+
+  TsBucket* bucket = firstBucket;
+  int count = 0;
+  while (bucket) {
+    uint8_t* bucketBegin = (uint8_t*)getBucketDataBegin(bucket);
+    Value* p = (Value*)bucketBegin;
+    Value* bucketEnd = bucket->pEndOfUsedSpace;
+    while (p < bucketEnd) {
+      uint16_t header = *p++;
+      uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+      count++;
+
+      Value* next = p + ((size + 1) / 2);
+      if (next > bucketEnd) {
+        MVM_FATAL_ERROR(vm, MVM_E_HEAP_CORRUPT);
+        return 0;
+      }
+      p = next;
+    }
+    bucket = bucket->next;
+  }
+
+  return count;
+}
+#endif // MVM_DEBUG_UTILS
+
+/**
+ * Parse the whole heap. Will return a pointer to the heap allocation info
+ * array, which is malloced and re-used on each call to `mvm_readHeap`, so the
+ * caller should not free it.
+ *
+ * The `out_count` is optional. The number of returned heap allocations is N+1
+ * with the last one being a sentinel with all fields zeroed.
+ */
+#if MVM_DEBUG_UTILS
+mvm_TsHeapAllocationInfo* mvm_readHeap(VM* vm, int* out_count) {
+  static mvm_TsHeapAllocationInfo* heap = 0;
+  static int allocatedHeapCount = 0;
+
+  if (out_count) {
+    *out_count = -1; // Initial value. Will be overridden.
+  }
+
+  if (heap) {
+    free(heap);
+    heap = 0;
+    allocatedHeapCount = 0;
+  }
+
+  int count = mvm_readHeapCount(vm);
+  allocatedHeapCount = count + 1;
+  heap = (mvm_TsHeapAllocationInfo*)malloc(sizeof(mvm_TsHeapAllocationInfo) * allocatedHeapCount);
+  if (!heap) return heap;
+  memset(heap, 0, sizeof(mvm_TsHeapAllocationInfo) * allocatedHeapCount);
+
+  TsBucket* firstBucket = vm->pLastBucket;
+  while (firstBucket && firstBucket->prev) {
+    firstBucket = firstBucket->prev;
+  }
+
+  TsBucket* bucket = firstBucket;
+  int i = 0;
+  while (bucket) {
+    uint8_t* bucketBegin = (uint8_t*)getBucketDataBegin(bucket);
+    Value* p = (Value*)bucketBegin;
+    Value* bucketEnd = bucket->pEndOfUsedSpace;
+    uint16_t offsetStart = bucket->offsetStart;
+    while (p < bucketEnd) {
+      uint16_t header = *p++;
+      uint16_t offset = (uint16_t)((uint8_t*)p - bucketBegin) + offsetStart;
+      uint16_t size = vm_getAllocationSizeExcludingHeaderFromHeaderWord(header);
+      TeTypeCode tc = vm_getTypeCodeFromHeaderWord(header);
+
+      VM_ASSERT(vm, i < allocatedHeapCount - 1);
+      heap[i++] = (mvm_TsHeapAllocationInfo){
+        .a = (uint16_t)((intptr_t)p),
+        .t = tc,
+        .s = size,
+        .offset = offset,
+        .address = p,
+      };
+
+      Value* next = p + ((size + 1) / 2);
+      if (next > bucketEnd) {
+        MVM_FATAL_ERROR(vm, MVM_E_HEAP_CORRUPT);
+        return NULL;
+      }
+      p = next;
+    }
+    bucket = bucket->next;
+  }
+
+  if (out_count) {
+    *out_count = count;
+  }
+
+  return heap;
+}
+#endif // MVM_DEBUG_UTILS
+
+#ifdef MVM_GAS_COUNTER
+void mvm_stopAfterNInstructions(mvm_VM* vm, int32_t n) {
+  vm->stopAfterNInstructions = n;
+}
+
+int32_t mvm_getInstructionCountRemaining(mvm_VM* vm) {
+  return vm->stopAfterNInstructions;
+}
+#endif // MVM_GAS_COUNTER
+
+/**
+ * Subscribe a callback to a promise.
+ */
+MVM_HIDDEN void mvm_subscribeToPromise(VM* vm, Value vPromise, Value vCallback) {
+  CODE_COVERAGE_UNTESTED(747); // Not hit
+  VM_ASSERT_NOT_USING_CACHED_REGISTERS(vm);
+
+  VM_ASSERT(vm, deepTypeOf(vm, vPromise) == TC_REF_PROPERTY_LIST);
+  VM_ASSERT(vm, mvm_typeOf(vm, vCallback) == VM_T_FUNCTION);
+
+  Value* pPromise = ShortPtr_decode(vm, vPromise);
+  // Check that the promise is a promise
+  VM_ASSERT(vm, pPromise[VM_OIS_PROTO] == getBuiltin(vm, BIN_PROMISE_PROTOTYPE));
+
+  vm_TePromiseStatus promiseStatus = pPromise[VM_OIS_PROMISE_STATUS];
+
+  if (promiseStatus == VM_PROMISE_STATUS_PENDING) {
+    CODE_COVERAGE(707); // Hit
+
+    // Subscribe to the promise
+    Value vSubscribers = pPromise[VM_OIS_PROMISE_OUT];
+    if (vSubscribers == VM_VALUE_UNDEFINED) {
+      CODE_COVERAGE(715); // Hit
+      // No subscribers yet (hot path)
+      pPromise[VM_OIS_PROMISE_OUT] = vCallback;
+    } else {
+      CODE_COVERAGE(716); // Hit
+
+      // Warning: the stack work here is a bit awkward but required because when
+      // we call `vm_newArray` or `vm_arrayPush` it may trigger a garbage
+      // collection which may move the promise object or the array itself. So we
+      // need these to be GC-reachable in slots that have stable addresses (i.e.
+      // stack slots).
+
+      Value* pvCallback = vm_push(vm, vCallback);
+      Value* pvPromise = vm_push(vm, vPromise);
+      Value* pvSubscribers = vm_push(vm, vSubscribers);
+
+      TeTypeCode tc = deepTypeOf(vm, vSubscribers);
+      if (tc == TC_REF_CLOSURE) { // Single subscriber. Upgrade to array
+        CODE_COVERAGE(717); // Hit
+        Value vNewArray = vm_newArray(vm, 2); // capacity = 2; [old subscriber, new subscriber]
+        Value* pvNewArray = vm_push(vm, vNewArray);
+        // Put the single subscriber into the array
+        vm_arrayPush(vm, pvNewArray, pvSubscribers);
+        vNewArray = vm_pop(vm);
+        pPromise = ShortPtr_decode(vm, *pvPromise); // May have moved
+        pPromise[VM_OIS_PROMISE_OUT] = vNewArray;
+        *pvSubscribers = vNewArray;
+      } else { // Already an array -- nothing to do
+        CODE_COVERAGE(718); // Hit
+        VM_ASSERT(vm, tc == TC_REF_ARRAY);
+      }
+      vm_arrayPush(vm, pvSubscribers, pvCallback);
+      vm_pop(vm); // vSubscribers
+      vm_pop(vm); // vPromise
+      vm_pop(vm); // vCallback
+    }
+  } else { // Resolved or rejected
+    CODE_COVERAGE(708); // Hit
+    VM_ASSERT(vm, (promiseStatus == VM_PROMISE_STATUS_RESOLVED) || (promiseStatus == VM_PROMISE_STATUS_REJECTED));
+    TABLE_COVERAGE(promiseStatus == VM_PROMISE_STATUS_RESOLVED ? 1 : 0, 2, 709); // Hit 2/2
+
+    Value resultOrError = pPromise[VM_OIS_PROMISE_OUT];
+    Value isSuccess = (promiseStatus == VM_PROMISE_STATUS_RESOLVED) ? VM_VALUE_TRUE : VM_VALUE_FALSE;
+
+    // Immediately schedule this async function to resume on the job queue
+    vm_scheduleContinuation(vm, vCallback, isSuccess, resultOrError);
+  }
+
+  // The whole function is a potential GC point because it may allocate a new
+  // array for the subscription.
+  VM_POTENTIAL_GC_POINT(vm);
+}

+ 560 - 0
non_catalog_apps/js_f0/lib/microvium/microvium.h

@@ -0,0 +1,560 @@
+// Copyright 2020 Michael Hunter. Part of the Microvium project. Links to full code at https://microvium.com for license details.
+
+/*
+ * Microvium Bytecode Interpreter
+ *
+ * Version: 8.0.0
+ *
+ * This is the main header for the Microvium bytecode interpreter. Latest source
+ * available at https://microvium.com. Raise issues at
+ * https://github.com/coder-mike/microvium/issues.
+ */
+#pragma once
+
+#include "microvium_port.h"
+#include <stdbool.h>
+#include <stdint.h>
+
+#define MVM_ENGINE_MAJOR_VERSION 8  /* aka MVM_BYTECODE_VERSION */
+#define MVM_ENGINE_MINOR_VERSION 0  /* aka MVM_ENGINE_VERSION */
+
+typedef uint16_t mvm_Value;
+typedef uint16_t mvm_VMExportID;
+typedef uint16_t mvm_HostFunctionID;
+
+typedef enum mvm_TeError {
+  /*  0 */ MVM_E_SUCCESS,
+  /*  1 */ MVM_E_UNEXPECTED,
+  /*  2 */ MVM_E_MALLOC_FAIL,
+  /*  3 */ MVM_E_ALLOCATION_TOO_LARGE,
+  /*  4 */ MVM_E_INVALID_ADDRESS,
+  /*  5 */ MVM_E_COPY_ACROSS_BUCKET_BOUNDARY,
+  /*  6 */ MVM_E_FUNCTION_NOT_FOUND,
+  /*  7 */ MVM_E_INVALID_HANDLE,
+  /*  8 */ MVM_E_STACK_OVERFLOW,
+  /*  9 */ MVM_E_UNRESOLVED_IMPORT,
+  /* 10 */ MVM_E_ATTEMPT_TO_WRITE_TO_ROM,
+  /* 11 */ MVM_E_INVALID_ARGUMENTS,
+  /* 12 */ MVM_E_TYPE_ERROR,
+  /* 13 */ MVM_E_TARGET_NOT_CALLABLE,
+  /* 14 */ MVM_E_HOST_ERROR,
+  /* 15 */ MVM_E_NOT_IMPLEMENTED,
+  /* 16 */ MVM_E_HOST_RETURNED_INVALID_VALUE,
+  /* 17 */ MVM_E_ASSERTION_FAILED,
+  /* 18 */ MVM_E_INVALID_BYTECODE,
+  /* 19 */ MVM_E_UNRESOLVED_EXPORT,
+  /* 20 */ MVM_E_RANGE_ERROR,
+  /* 21 */ MVM_E_DETACHED_EPHEMERAL,
+  /* 22 */ MVM_E_TARGET_IS_NOT_A_VM_FUNCTION,
+  /* 23 */ MVM_E_FLOAT64,
+  /* 24 */ MVM_E_NAN,
+  /* 25 */ MVM_E_NEG_ZERO,
+  /* 26 */ MVM_E_OPERATION_REQUIRES_FLOAT_SUPPORT,
+  /* 27 */ MVM_E_BYTECODE_CRC_FAIL,
+  /* 28 */ MVM_E_BYTECODE_REQUIRES_FLOAT_SUPPORT,
+  /* 29 */ MVM_E_PROTO_IS_READONLY, // The __proto__ property of objects and arrays is not mutable
+  /* 30 */ MVM_E_SNAPSHOT_TOO_LARGE, // The resulting snapshot does not fit in the 64kB boundary
+  /* 31 */ MVM_E_MALLOC_MUST_RETURN_POINTER_TO_EVEN_BOUNDARY,
+  /* 32 */ MVM_E_ARRAY_TOO_LONG,
+  /* 33 */ MVM_E_OUT_OF_MEMORY, // Allocating a new block of memory from the host causes it to exceed MVM_MAX_HEAP_SIZE
+  /* 34 */ MVM_E_TOO_MANY_ARGUMENTS, // Exceeded the maximum number of arguments for a function (255)
+  /* 35 */ MVM_E_REQUIRES_LATER_ENGINE, // Please update your microvium.h and microvium.c files
+  /* 36 */ MVM_E_PORT_FILE_VERSION_MISMATCH, // Please migrate your port file to the required version
+  /* 37 */ MVM_E_PORT_FILE_MACRO_TEST_FAILURE, // Something in microvium_port.h doesn't behave as expected
+  /* 38 */ MVM_E_EXPECTED_POINTER_SIZE_TO_BE_16_BIT, // MVM_NATIVE_POINTER_IS_16_BIT is 1 but pointer size is not 16-bit
+  /* 39 */ MVM_E_EXPECTED_POINTER_SIZE_NOT_TO_BE_16_BIT, // MVM_NATIVE_POINTER_IS_16_BIT is 0 but pointer size is 16-bit
+  /* 40 */ MVM_E_TYPE_ERROR_TARGET_IS_NOT_CALLABLE, // The script tried to call something that wasn't a function
+  /* 41 */ MVM_E_TDZ_ERROR, // The script tried to access a local variable before its declaration
+  /* 42 */ MVM_E_MALLOC_NOT_WITHIN_RAM_PAGE, // See instructions in example port file at the defitions MVM_USE_SINGLE_RAM_PAGE and MVM_RAM_PAGE_ADDR
+  /* 43 */ MVM_E_INVALID_ARRAY_INDEX, // Array indexes must be integers in the range 0 to 8191
+  /* 44 */ MVM_E_UNCAUGHT_EXCEPTION, // The script threw an exception with `throw` that was wasn't caught before returning to the host
+  /* 45 */ MVM_E_FATAL_ERROR_MUST_KILL_VM, // Please make sure that MVM_FATAL_ERROR does not return, or bad things can happen. (Kill the process, the thread, or use longjmp)
+  /* 46 */ MVM_E_OBJECT_KEYS_ON_NON_OBJECT, // Can only use Reflect.ownKeys on plain objects (not functions, arrays, or other values)
+  /* 47 */ MVM_E_INVALID_UINT8_ARRAY_LENGTH, // Either non-numeric or out-of-range argument for creating a Uint8Array
+  /* 48 */ MVM_E_CAN_ONLY_ASSIGN_BYTES_TO_UINT8_ARRAY, // Value assigned to index of Uint8Array must be an integer in the range 0 to 255
+  /* 49 */ MVM_E_WRONG_BYTECODE_VERSION, // The version of bytecode is different to what the engine supports
+  /* 50 */ MVM_E_USING_NEW_ON_NON_CLASS, // The `new` operator can only be used on classes
+  /* 51 */ MVM_E_INSTRUCTION_COUNT_REACHED, // The instruction count set by `mvm_stopAfterNInstructions` has been reached
+  /* 52 */ MVM_E_REQUIRES_ACTIVE_VM, // The given operation requires that the VM has active calls on the stack
+  /* 53 */ MVM_E_ASYNC_START_ERROR, // mvm_asyncStart must be called exactly once at the beginning of a host function that is called from JS
+  /* 54 */ MVM_E_ASYNC_WITHOUT_AWAIT, // mvm_asyncStart can only be used with a script that has await points. Add at least one (reachable) await point to the script.
+  /* 55 */ MVM_E_TYPE_ERROR_AWAIT_NON_PROMISE, // Can only await a promise in Microvium
+  /* 56 */ MVM_E_HEAP_CORRUPT, // Microvium's internal heap is not in a consistent state
+  /* 57 */ MVM_E_CLASS_PROTOTYPE_MUST_BE_NULL_OR_OBJECT, // The prototype property of a class must be null or a plain object
+} mvm_TeError;
+
+typedef enum mvm_TeType {
+  VM_T_UNDEFINED   = 0,
+  VM_T_NULL        = 1,
+  VM_T_BOOLEAN     = 2,
+  VM_T_NUMBER      = 3,
+  VM_T_STRING      = 4,
+  VM_T_FUNCTION    = 5,
+  VM_T_OBJECT      = 6,
+  VM_T_ARRAY       = 7,
+  VM_T_UINT8_ARRAY = 8,
+  VM_T_CLASS       = 9,
+  VM_T_SYMBOL      = 10, // Reserved
+  VM_T_BIG_INT     = 11, // Reserved
+
+  VM_T_END,
+} mvm_TeType;
+
+// Prefix to attach to exported microvium API functions. If a user doesn't
+// specify this, we just set it up as the empty macro.
+#ifndef MVM_EXPORT
+#define MVM_EXPORT
+#endif
+
+#ifndef MVM_SUPPORT_FLOAT
+#define MVM_SUPPORT_FLOAT 1
+#endif
+
+#ifndef MVM_FLOAT64
+#define MVM_FLOAT64 double
+#endif
+
+#ifndef MVM_INCLUDE_DEBUG_CAPABILITY
+#define MVM_INCLUDE_DEBUG_CAPABILITY 1
+#endif
+
+typedef struct mvm_VM mvm_VM;
+
+typedef mvm_TeError (*mvm_TfHostFunction)(mvm_VM* vm, mvm_HostFunctionID hostFunctionID, mvm_Value* result, mvm_Value* args, uint8_t argCount);
+
+typedef mvm_TeError (*mvm_TfResolveImport)(mvm_HostFunctionID hostFunctionID, void* context, mvm_TfHostFunction* out_hostFunction);
+
+typedef void (*mvm_TfBreakpointCallback)(mvm_VM* vm, uint16_t bytecodeAddress);
+
+typedef struct mvm_TsMemoryStats {
+  // Total RAM currently allocated by the VM from the host
+  size_t totalSize;
+
+  // Number of distinct, currently-allocated memory allocations (mallocs) from the host
+  size_t fragmentCount;
+
+  // RAM size of VM core state
+  size_t coreSize;
+
+  // RAM allocated to the VM import table (table of functions resolved from the host)
+  size_t importTableSize;
+
+  // RAM allocated to global variables in RAM
+  size_t globalVariablesSize;
+
+  // If the machine registers are allocated (if a call is active), this says how
+  // much RAM these consume. Otherwise zero if there is no active stack.
+  size_t registersSize;
+
+  // Virtual stack size (bytes) currently allocated (if a call is active), or
+  // zero if there is no active stack. Note that virtual stack space is
+  // malloc'd, not allocated on the C stack.
+  size_t stackHeight;
+
+  // Virtual stack space capacity if a call is active, otherwise zero.
+  size_t stackAllocatedCapacity;
+
+  // Maximum stack size over the lifetime of the VM. This value can be used to
+  // tune the MVM_STACK_SIZE port definition
+  size_t stackHighWaterMark;
+
+  // Amount of virtual heap that the VM is currently using
+  size_t virtualHeapUsed;
+
+  // Maximum amount of virtual heap space ever used by this VM
+  size_t virtualHeapHighWaterMark;
+
+  // Current total size of virtual heap (will expand as needed up to a max of MVM_MAX_HEAP_SIZE)
+  size_t virtualHeapAllocatedCapacity;
+
+} mvm_TsMemoryStats;
+
+/**
+ * A handle holds a value that must not be garbage collected.
+ *
+ * Maintainer note: `_value` is the first field so that a `mvm_Handle*` is also
+ * a `mvm_Value*`, which allows some internal functions to be polymorphic in
+ * whether they accept handles or just plain value pointers.
+ */
+typedef struct mvm_Handle { mvm_Value _value; struct mvm_Handle* _next; } mvm_Handle;
+
+#include "microvium_port.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Creates a VM (restores the state of a virtual machine from a snapshot)
+ *
+ * WARNING: The snapshot bytecode is not copied by Microvium, so it needs to
+ * stay in memory (or flash).
+ *
+ * A VM created with mvm_restore needs to be freed with mvm_free.
+ *
+ * Note: the bytecode should be aligned to the processor word size.
+ *
+ * Note: the bytecode needs to live as long as the VM.
+ *
+ * @param resolveImport A callback function that the VM will call when it needs
+ * to import a host function.
+ * @param context Any value. The context for a VM can be retrieved later using
+ * `mvm_getContext`. It can be used to attach user-defined data to a VM.
+ */
+MVM_EXPORT mvm_TeError mvm_restore(mvm_VM** result, MVM_LONG_PTR_TYPE snapshotBytecode, size_t bytecodeSize, void* context, mvm_TfResolveImport resolveImport);
+
+/**
+ * Free all memory associated with a VM. The VM must not be used again after freeing.
+ */
+MVM_EXPORT void mvm_free(mvm_VM* vm);
+
+/**
+ * Call a function in the VM
+ *
+ * @param func The function value to call
+ * @param out_result Where to put the result, or NULL if the result is not
+ * needed
+ * @param args Pointer to arguments array, or NULL if no arguments
+ * @param argCount Number of arguments
+ *
+ * If the JS code throws an exception, the return value will be
+ * MVM_E_UNCAUGHT_EXCEPTION and the exception value will be put into
+ * `out_result`
+ */
+MVM_EXPORT mvm_TeError mvm_call(mvm_VM* vm, mvm_Value func, mvm_Value* out_result, mvm_Value* args, uint8_t argCount);
+
+MVM_EXPORT void* mvm_getContext(mvm_VM* vm);
+
+/**
+ * Handle operations. Handles are used to hold values that must not be garbage
+ * collected. See `doc\handles-and-garbage-collection.md` for more information.
+ */
+MVM_EXPORT void mvm_initializeHandle(mvm_VM* vm, mvm_Handle* handle); // Handle must be released by mvm_releaseHandle
+MVM_EXPORT void mvm_cloneHandle(mvm_VM* vm, mvm_Handle* target, const mvm_Handle* source); // Target must be released by mvm_releaseHandle
+MVM_EXPORT mvm_TeError mvm_releaseHandle(mvm_VM* vm, mvm_Handle* handle);
+static inline mvm_Value mvm_handleGet(const mvm_Handle* handle) { return handle->_value; }
+static inline mvm_Value* mvm_handleAt(mvm_Handle* handle) { return &handle->_value; }
+static inline void mvm_handleSet(mvm_Handle* handle, mvm_Value value) { handle->_value = value; }
+
+/**
+ * Roughly like the `typeof` operator in JS, except with distinct values for
+ * null and arrays
+ */
+MVM_EXPORT mvm_TeType mvm_typeOf(mvm_VM* vm, mvm_Value value);
+
+/**
+ * Converts the value to a string encoded as UTF-8.
+ *
+ * @param out_sizeBytes Returns the length of the string in bytes, or provide NULL if not needed.
+ * @return A pointer to the string data which may be in VM memory or bytecode.
+ *
+ * Note: for convenience, the returned data has an extra null character appended
+ * to the end of it, so that the result is directly usable in printf, strcpy,
+ * etc. The returned size in bytes is the size of the original string data,
+ * excluding the extra null.
+ *
+ * The string data itself is permitted to contain nulls or any other data. For
+ * example, if the string value is "abc\0", the size returned is "4", and the
+ * returned pointer points to the data "abc\0\0" (i.e. with the extra safety
+ * null beyond the user-provided data).
+ *
+ * The memory pointed to by the return value may be transient: it is only guaranteed
+ * to exist until the next garbage collection cycle. See
+ * [memory-management.md](https://github.com/coder-mike/microvium/blob/master/doc/native-vm/memory-management.md)
+ * for details.
+ */
+MVM_EXPORT const char* mvm_toStringUtf8(mvm_VM* vm, mvm_Value value, size_t* out_sizeBytes);
+
+/**
+ * Returns the length of a string as it appears in bytes when encoded as UTF-8
+ * (which is also the internal representation of Microvium).
+ */
+MVM_EXPORT size_t mvm_stringSizeUtf8(mvm_VM* vm, mvm_Value value);
+
+/**
+ * Convert the value to a bool based on its truthiness.
+ *
+ * See https://developer.mozilla.org/en-US/docs/Glossary/Truthy
+ */
+MVM_EXPORT bool mvm_toBool(mvm_VM* vm, mvm_Value value);
+
+/**
+ * Converts the value to a 32-bit signed integer.
+ *
+ * The result of this should be the same as `value|0` in JavaScript code.
+ */
+MVM_EXPORT int32_t mvm_toInt32(mvm_VM* vm, mvm_Value value);
+
+#if MVM_SUPPORT_FLOAT
+/**
+ * Converts the value to a number.
+ *
+ * The result of this should be the same as `+value` in JavaScript code.
+ *
+ * For efficiency, use mvm_toInt32 instead if your value is an integer.
+ */
+MVM_EXPORT MVM_FLOAT64 mvm_toFloat64(mvm_VM* vm, mvm_Value value);
+
+/**
+ * Create a JavaScript number value in the VM.
+ *
+ * WARNING: the result is eligible for garbage collection the next time the VM
+ * has control. See `doc\handles-and-garbage-collection.md` for more information.
+ *
+ * For efficiency, use mvm_newInt32 instead if your value is an integer.
+ *
+ * Design note: mvm_newNumber creates a number *from* a float64, so it's named
+ * `newNumber` and not `newFloat64`
+ */
+MVM_EXPORT mvm_Value mvm_newNumber(mvm_VM* vm, MVM_FLOAT64 value);
+#endif
+
+MVM_EXPORT bool mvm_isNaN(mvm_Value value);
+
+extern const mvm_Value mvm_undefined;
+extern const mvm_Value mvm_null;
+
+/**
+ * Create a JavaScript boolean value in the VM.
+ */
+MVM_EXPORT mvm_Value mvm_newBoolean(bool value);
+
+/**
+ * Create a JavaScript number from a 32-bit integer.
+ *
+ * WARNING: the result is eligible for garbage collection the next time the VM
+ * has control. See `doc\handles-and-garbage-collection.md` for more information.
+ */
+MVM_EXPORT mvm_Value mvm_newInt32(mvm_VM* vm, int32_t value);
+
+/**
+ * Create a new string in Microvium memory.
+ *
+ * WARNING: the result is eligible for garbage collection the next time the VM
+ * has control. See `doc\handles-and-garbage-collection.md` for more information.
+ *
+ * @param valueUtf8 The a pointer to the string content.
+ * @param sizeBytes The size in bytes of the string, excluding any null terminator.
+ */
+MVM_EXPORT mvm_Value mvm_newString(mvm_VM* vm, const char* valueUtf8, size_t sizeBytes);
+
+/**
+ * A Uint8Array in Microvium is an efficient buffer of bytes. It is mutable but
+ * cannot be resized. The new Uint8Array created by this method will contain a
+ * *copy* of the supplied data.
+ *
+ * WARNING: the result is eligible for garbage collection the next time the VM
+ * has control. See `doc\handles-and-garbage-collection.md` for more information.
+ *
+ * Within the VM, you can create a new Uint8Array using the global
+ * `Microvium.newUint8Array`.
+ *
+ * See also: mvm_uint8ArrayToBytes
+ */
+MVM_EXPORT mvm_Value mvm_uint8ArrayFromBytes(mvm_VM* vm, const uint8_t* data, size_t size);
+
+/**
+ * Given a Uint8Array, this will give a pointer to its data and its size (in
+ * bytes).
+ *
+ * Warning: The data pointer should be considered invalid on the next call to
+ * any of the Microvium API methods, since a garbage can move the data. It is
+ * recommended to call this method again each time you need the pointer.
+ *
+ * The returned pointer can also be used to mutate the buffer, with caution.
+ *
+ * See also: mvm_uint8ArrayFromBytes
+ */
+MVM_EXPORT mvm_TeError mvm_uint8ArrayToBytes(mvm_VM* vm, mvm_Value uint8ArrayValue, uint8_t** out_data, size_t* out_size);
+
+/**
+ * Resolves (finds) the values exported by the VM, identified by ID.
+ *
+ * @param ids An array of `count` IDs to look up.
+ * @param results An array of `count` output values that result from each
+ * lookup
+ *
+ * Note: Exports are immutable (shallow immutable), so they don't need to be
+ * captured by a mvm_Handle. In typical usage, exports will each be function
+ * values, but any value type is valid.
+ */
+MVM_EXPORT mvm_TeError mvm_resolveExports(mvm_VM* vm, const mvm_VMExportID* ids, mvm_Value* results, uint8_t count);
+
+/**
+ * Run a garbage collection cycle.
+ *
+ * If `squeeze` is `true`, the GC runs in 2 passes: the first pass computes the
+ * exact required size, and the second pass compacts into exactly that size.
+ *
+ * If `squeeze` is `false`, the GC runs in a single pass, estimating the amount
+ * of needed as the amount of space used after the last compaction, and then
+ * adding blocks as-necessary.
+ */
+MVM_EXPORT void mvm_runGC(mvm_VM* vm, bool squeeze);
+
+/**
+ * Compares two values for equality. The same semantics as JavaScript `===`
+ */
+MVM_EXPORT bool mvm_equal(mvm_VM* vm, mvm_Value a, mvm_Value b);
+
+/**
+ * The current bytecode address being executed (relative to the beginning of the
+ * bytecode image), or null if the machine is not currently active.
+ *
+ * This value can be looked up in the map file generated by the CLI flag
+ * `--map-file`
+ */
+MVM_EXPORT uint16_t mvm_getCurrentAddress(mvm_VM* vm);
+
+/**
+ * Get stats about the VM memory
+ */
+MVM_EXPORT void mvm_getMemoryStats(mvm_VM* vm, mvm_TsMemoryStats* out_stats);
+
+
+/**
+ * Call this at the beginning of an asynchronous host function. It accepts a
+ * pointer to the synchronous result and returns a callback function that can be
+ * used to set the asynchronous result.
+ *
+ * @param out_result The result pointer that was passed to the host function.
+ *                   mvm_asyncStart will set the result. The host must not set
+ *                   or use the result field.
+ *
+ * @returns A JavaScript callback function accepting arguments (isSuccess,
+ * value)
+ *
+ * This function sets the synchronous result `*out_result` to a promise object
+ * (or this promise value may be optimized away in certain cases), and returns a
+ * JavaScript callback function that represents the caller continuation.
+ *
+ * If the asynchronous operation ends successfully, call the callback with
+ * arguments (true, result). If the asynchronous operation fails, call the
+ * callback with arguments (false, error).
+ *
+ * Note: The callback will not invoke the continuation immediately but will
+ * schedule it on Microvium's job queue.
+ *
+ * Note: mvm_asyncStart does not do anything regarding threading. It's up to the
+ * host to promptly return from the host function and to call the callback
+ * later.
+ *
+ * @warning The returned mvm_Value is subject to garbage collection and the host
+ * should keep it in a handle until it's ready to call it.
+ *
+ * @warning The host must call `mvm_asyncStart` right at the beginning of the
+ * host function, before doing anything else, since this accesses an internal
+ * register that is not preserved across function calls.
+ */
+mvm_Value mvm_asyncStart(mvm_VM* vm, mvm_Value* out_result);
+
+
+#if MVM_INCLUDE_SNAPSHOT_CAPABILITY
+/**
+ * Create a snapshot of the VM
+ *
+ * @param vm The virtual machine to snapshot.
+ * @param out_size Pointer to variable which will receive the size of the
+ * generated snapshot
+ *
+ * The snapshot generated by this function is suitable to be used in a call to
+ * mvm_restore to be restored later.
+ *
+ * It's recommended to run a garbage collection cycle (mvm_runGC) before
+ * creating the snapshot, to get as compact a snapshot as possible.
+ *
+ * No snapshots ever contain the stack or register states -- they only encode
+ * the heap and global variable states.
+ *
+ * Note: The result is malloc'd on the host heap, and so needs to be freed with
+ * a call to *free*.
+ */
+MVM_EXPORT void* mvm_createSnapshot(mvm_VM* vm, size_t* out_size);
+#endif // MVM_INCLUDE_SNAPSHOT_CAPABILITY
+
+#if MVM_INCLUDE_DEBUG_CAPABILITY
+/**
+ * Set a breakpoint on the given bytecode address.
+ *
+ * Use (-1) to break on every instruction.
+ *
+ * Whenever the VM executes the instruction at the given bytecode address, the
+ * VM will invoke the breakpoint callback (see mvm_dbg_setBreakpointCallback).
+ *
+ * The given bytecode address is measured from the beginning of the given
+ * bytecode image (passed to mvm_restore). The address point exactly to the
+ * beginning of a bytecode instruction (addresses corresponding to the middle of
+ * a multi-byte instruction are ignored).
+ *
+ * The breakpoint remains registered/active until mvm_dbg_removeBreakpoint is
+ * called with the exact same bytecode address.
+ *
+ * Setting a breakpoint a second time on the same address of an existing active
+ * breakpoint will have no effect.
+ */
+MVM_EXPORT void mvm_dbg_setBreakpoint(mvm_VM* vm, int bytecodeAddress);
+
+/**
+ * Remove a breakpoint added by mvm_dbg_setBreakpoint
+ */
+MVM_EXPORT void mvm_dbg_removeBreakpoint(mvm_VM* vm, uint16_t bytecodeAddress);
+
+/**
+ * Set the function to be called when any breakpoint is hit.
+ *
+ * The callback only applies to the given virtual machine (the callback can be
+ * different for different VMs).
+ *
+ * The callback is invoked with the bytecode address corresponding to where the
+ * VM is stopped. The VM will continue execution when the breakpoint callback
+ * returns. To suspend the VM indefinitely, the callback needs to
+ * correspondingly block indefinitely.
+ *
+ * It's possible but not recommended for the callback itself call into the VM
+ * again (mvm_call), causing control to re-enter the VM while the breakpoint is
+ * still active. This should *NOT* be used to continue execution, but could
+ * theoretically be used to evaluate debug watch expressions.
+ */
+MVM_EXPORT void mvm_dbg_setBreakpointCallback(mvm_VM* vm, mvm_TfBreakpointCallback cb);
+#endif // MVM_INCLUDE_DEBUG_CAPABILITY
+
+#ifdef MVM_GAS_COUNTER
+/**
+ * mvm_stopAfterNInstructions
+ *
+ * Sets the VM to stop (return error MVM_E_INSTRUCTION_COUNT_REACHED) after n
+ * further bytecode instructions have been executed. This may help the host to
+ * catch run-away VMs or infinite loops. Use n = -1 to disable the limit.
+ *
+ * If `n` is zero, the VM will stop before executing any further instructions.
+ *
+ * When the VM reaches the stopped state, further calls to the VM will fail with
+ * the same error until `mvm_stopAfterNInstructions` is called again to reset
+ * the countdown.
+ *
+ * The stopping unwinds the current call stack in a similar way to an exception,
+ * except that it will not hit any catch blocks. However, be aware that if the
+ * call to the VM is reentrant (e.g. the host calls the VM which calls the host
+ * which calls the VM again), the stopping will only unwind the innermost call
+ * stack. The outer call stack will then unwind if the inner host function
+ * returns an error code (e.g. propagating the MVM_E_INSTRUCTION_COUNT_REACHED)
+ * or simply does not reset the countdown, so that the VM will fail again when
+ * the host returns control to the VM.
+ */
+MVM_EXPORT void mvm_stopAfterNInstructions(mvm_VM* vm, int32_t n);
+
+/**
+ * mvm_getInstructionCountRemaining
+ *
+ * If `mvm_stopAfterNInstructions` has been used to set a limit on the number of
+ * instructions to execute, this function can be used to see the remaining
+ * number of instructions before the VM will stop.
+ *
+ * The return value will be negative if the countdown is currently disabled.
+ */
+MVM_EXPORT int32_t mvm_getInstructionCountRemaining(mvm_VM* vm);
+#endif // MVM_GAS_COUNTER
+
+#ifdef __cplusplus
+} // extern "C"
+#endif

+ 286 - 0
non_catalog_apps/js_f0/lib/microvium/microvium_port.h

@@ -0,0 +1,286 @@
+/*
+
+# Instructions
+
+Make a copy of this file and name it exactly `microvium_port.h`. Put the copy somewhere
+in your project where it is accessible by a `#include "microvium_port.h"` directive.
+
+Customize your copy of the port file with platform-specific configurations.
+
+The recommended workflow is to keep the vm source files separate from your
+custom port file, so that you can update the vm source files regularly with bug
+fixes and improvement from the original github or npm repository.
+
+*/
+#pragma once
+
+#include <stdbool.h>
+#include <string.h>
+#include <assert.h>
+#include <stdint.h>
+
+/**
+ * The version of the port interface that this file is implementing.
+ */
+#define MVM_PORT_VERSION 1
+
+/**
+ * Number of bytes to use for the stack.
+ *
+ * Note: the that stack is fixed-size, even though the heap grows dynamically
+ * as-needed.
+ */
+#define MVM_STACK_SIZE 256
+
+/**
+ * When more space is needed for the VM heap, the VM will malloc blocks with a
+ * minimum of this size from the host.
+ *
+ * Note that the VM can also allocate blocks larger than this. It will do so if
+ * it needs a larger contiguous space than will fit in a standard block, and
+ * also during heap compaction (`runGC`) where it defragments the heap into as
+ * few mallocd blocks as possible to make access more efficient.
+ */
+#define MVM_ALLOCATION_BUCKET_SIZE 256
+
+/**
+ * The maximum size of the virtual heap before an MVM_E_OUT_OF_MEMORY error is
+ * given.
+ *
+ * When the VM reaches this level, it will first try to perform a garbage
+ * collection cycle. If a GC cycle does not free enough memory, a fatal
+ * MVM_E_OUT_OF_MEMORY error is given.
+ *
+ * Note: this is the space in the virtual heap (the amount consumed by
+ * allocations in the VM), not the physical space malloc'd from the host, the
+ * latter of which can peak at roughly twice the virtual space during a garbage
+ * collection cycle in the worst case.
+ */
+#define MVM_MAX_HEAP_SIZE 1024
+
+/**
+ * Set to 1 if a `void*` pointer is natively 16-bit (e.g. if compiling for
+ * 16-bit architectures). This allows some optimizations since then a native
+ * pointer can fit in a Microvium value slot.
+ */
+#define MVM_NATIVE_POINTER_IS_16_BIT 0
+
+/**
+ * Set to 1 to compile in support for floating point operations (64-bit). This
+ * adds significant cost in smaller devices, but is required if you want the VM
+ * to be compliant with the ECMAScript standard.
+ *
+ * When float support is disabled, operations on floats will throw.
+ */
+// set to zero for f0 compilation
+#define MVM_SUPPORT_FLOAT 0
+
+#if MVM_SUPPORT_FLOAT
+
+/**
+ * The type to use for double-precision floating point. Note that anything other
+ * than an IEEE 754 double-precision float is not compliant with the ECMAScript
+ * spec and results may not always be as expected. Also remember that the
+ * bytecode is permitted to have floating point literals embedded in it, and
+ * these must match the exact format specification used here if doubles are to
+ * persist correctly across a snapshot.
+ *
+ * Note that on some embedded systems, the `double` type is actually 32-bit, so
+ * this may need to be `long double` or whatever the equivalent 64-bit type is
+ * on your system.
+ */
+#define MVM_FLOAT64 double
+
+/**
+ * Value to use for NaN
+ */
+#define MVM_FLOAT64_NAN ((MVM_FLOAT64)(INFINITY * 0.0))
+
+#endif // MVM_SUPPORT_FLOAT
+
+/**
+ * Set to `1` to enable additional internal consistency checks, or `0` to
+ * disable them. Note that consistency at the API boundary is always checked,
+ * regardless of this setting. Consistency checks make the VM *significantly*
+ * bigger and slower, and are really only intended for testing.
+ */
+#define MVM_SAFE_MODE 1
+
+/**
+ * Set to `1` to enable extra validation checks of bytecode while executing.
+ * This is _beyond_ the basic version and CRC checks that are done upon loading,
+ * and should only be enabled if you expect bugs in the bytecode compiler.
+ */
+#define MVM_DONT_TRUST_BYTECODE 1
+
+/**
+ * Not recommended!
+ *
+ * Set to `1` to enable extra checks for pointer safety within the engine. In
+ * particular, this triggers a GC collection cycle at every new allocation in
+ * order to find potential dangling pointer issues, and each GC collection
+ * shifts the address space a little to invalidate native pointers early.
+ * This option is only intended for testing purposes.
+ */
+#define MVM_VERY_EXPENSIVE_MEMORY_CHECKS 0
+
+/**
+ * A long pointer is a type that can refer to either ROM or RAM. It is not size
+ * restricted.
+ *
+ * On architectures where bytecode is directly addressable with a normal
+ * pointer, this can just be `void*` (e.g. 32-bit architectures). On
+ * architectures where bytecode can be addressed with a special pointer, this
+ * might be something like `__data20 void*` (MSP430). On Harvard architectures
+ * such as AVR8 where ROM and RAM are in different address spaces,
+ * `MVM_LONG_PTR_TYPE` can be some integer type such as `uint32_t`, where you
+ * use part of the value to distinguish which address space and part of the
+ * value as the actual pointer value.
+ *
+ * The chosen representation/encoding of `MVM_LONG_PTR_TYPE` must be an integer
+ * or pointer type, such that `0`/`NULL` represents the null pointer.
+ *
+ * Microvium doesn't access data through pointers of this type directly -- it
+ * does so through macro operations in this port file.
+ */
+#define MVM_LONG_PTR_TYPE void*
+
+/**
+ * Convert a normal pointer to a long pointer
+ */
+#define MVM_LONG_PTR_NEW(p) ((MVM_LONG_PTR_TYPE)p)
+
+/**
+ * Truncate a long pointer to a normal pointer.
+ *
+ * This will only be invoked on pointers to VM RAM data.
+ */
+#define MVM_LONG_PTR_TRUNCATE(p) ((void*)p)
+
+/**
+ * Add an offset `s` in bytes onto a long pointer `p`. The result must be a
+ * MVM_LONG_PTR_TYPE.
+ *
+ * The maximum offset that will be passed is 16-bit.
+ *
+ * Offset may be negative
+ */
+#define MVM_LONG_PTR_ADD(p, s) ((MVM_LONG_PTR_TYPE)((uint8_t*)p + (intptr_t)s))
+
+/**
+ * Subtract two long pointers to get an offset. The result must be a signed
+ * 16-bit integer of p2 - p1 (where p2 is the FIRST param).
+ */
+#define MVM_LONG_PTR_SUB(p2, p1) ((int16_t)((uint8_t*)p2 - (uint8_t*)p1))
+
+/*
+ * Read memory of 1 or 2 bytes
+ */
+#define MVM_READ_LONG_PTR_1(lpSource) (*((uint8_t *)lpSource))
+#define MVM_READ_LONG_PTR_2(lpSource) (*((uint16_t *)lpSource))
+
+/**
+ * Reference to an implementation of memcmp where p1 and p2 are LONG_PTR
+ */
+#define MVM_LONG_MEM_CMP(p1, p2, size) memcmp(p1, p2, size)
+
+/**
+ * Reference to an implementation of memcpy where `source` is a LONG_PTR
+ */
+#define MVM_LONG_MEM_CPY(target, source, size) memcpy(target, source, size)
+
+/**
+ * This is invoked when the virtual machine encounters a critical internal error
+ * and execution of the VM should halt.
+ *
+ * Note that API-level errors are communicated via returned error codes from
+ * each of the API functions and will not trigger a fatal error.
+ *
+ * Note: if malloc fails, this is considered a fatal error since many embedded
+ * systems cannot safely continue when they run out of memory.
+ *
+ * If you need to halt the VM without halting the host, consider running the VM
+ * in a separate RTOS thread, or using setjmp/longjmp to escape the VM without
+ * returning to it. Either way, the VM should NOT be allowed to continue
+ * executing after MVM_FATAL_ERROR (control should not return).
+ */
+// fatalError is required due to lack of exit() on f0
+extern void fatalError(void* vm, int e);
+#define MVM_FATAL_ERROR(vm, e) fatalError(vm, e)
+
+/**
+ * Set MVM_ALL_ERRORS_FATAL to 1 to have the MVM_FATAL_ERROR handler called
+ * eagerly when a new error is encountered, rather than returning an error code
+ * from `mvm_call`. This is mainly for debugging the VM itself, since the
+ * MVM_FATAL_ERROR handler is called before unwinding the C stack.
+ */
+#define MVM_ALL_ERRORS_FATAL 0
+
+#define MVM_SWITCH(tag, upper) switch (tag)
+#define MVM_CASE(value) case value
+
+/**
+ * Macro that evaluates to true if the CRC of the given data matches the
+ * expected value. Note that this is evaluated against the bytecode, so lpData
+ * needs to be a long pointer type. If you don't want the overhead of validating
+ * the CRC, just return `true`. The Microvium compiler uses CCITT16 as the CRC.
+ */
+#define MVM_CHECK_CRC16_CCITT(lpData, size, expected) (default_crc16(lpData, size) == expected)
+
+/**
+ * Set to 1 to compile in the ability to generate snapshots (mvm_createSnapshot)
+ */
+#define MVM_INCLUDE_SNAPSHOT_CAPABILITY 1
+
+/**
+ * Set to 1 to compile support for the debug API (mvm_dbg_*)
+ */
+#define MVM_INCLUDE_DEBUG_CAPABILITY 1
+
+#if MVM_INCLUDE_SNAPSHOT_CAPABILITY
+/**
+ * Calculate the CRC. This is only used when generating snapshots.
+ *
+ * Unlike MVM_CHECK_CRC16_CCITT, pData here is a pointer to RAM.
+ */
+#define MVM_CALC_CRC16_CCITT(pData, size) (default_crc16(pData, size))
+#endif // MVM_INCLUDE_SNAPSHOT_CAPABILITY
+
+/**
+ * On architectures like small ARM MCUs where there is a large address space
+ * (e.g. 32-bit) but only a small region of that is used for heap allocations,
+ * Microvium is more efficient if you can tell it the high bits of the addresses
+ * so it can store the lower 16-bits.
+ *
+ * If MVM_USE_SINGLE_RAM_PAGE is set to 1, then MVM_RAM_PAGE_ADDR must be
+ * the address of the page.
+ */
+#define MVM_USE_SINGLE_RAM_PAGE 0
+
+#if MVM_USE_SINGLE_RAM_PAGE
+/**
+ * Address of the RAM page to use, such that all pointers to RAM are between
+ * MVM_RAM_PAGE_ADDR and (MVM_RAM_PAGE_ADDR + 0xFFFF)
+ */
+#define MVM_RAM_PAGE_ADDR 0x12340000
+#endif
+
+/**
+ * Implementation of malloc and free to use.
+ *
+ * Note that MVM_CONTEXTUAL_FREE needs to accept null pointers as well.
+ *
+ * If MVM_USE_SINGLE_RAM_PAGE is set, pointers returned by MVM_CONTEXTUAL_MALLOC
+ * must always be within 64kB of MVM_RAM_PAGE_ADDR.
+ *
+ * The `context` passed to these macros is whatever value that the host passes
+ * to `mvm_restore`. It can be any value that fits in a pointer.
+ */
+#define MVM_CONTEXTUAL_MALLOC(size, context) MVM_MALLOC(size)
+#define MVM_CONTEXTUAL_FREE(ptr, context) MVM_FREE(ptr)
+
+/**
+ * If defined, this will enable the API methods `mvm_stopAfterNInstructions` and
+ * `mvm_getInstructionCountRemaining`.
+ */
+#define MVM_GAS_COUNTER