beginning of haku2: a reimplementation of haku in Zig

the goal is to rewrite haku completely, starting with the VM---because it was the most obvious point of improvement
the reason is because Rust is kinda too verbose for low level stuff like this. compare the line numbers between haku1 and haku2's VM and how verbose those lines are, and it's kind of an insane difference
it also feels like Zig's compilation model can work better for small wasm binary sizes

and of course, I also just wanted an excuse to try out Zig :3
This commit is contained in:
リキ萌 2025-06-01 23:13:34 +02:00
parent 598c0348f6
commit 01d4514a65
19 changed files with 1946 additions and 11 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target
/database
.zig-cache

10
Cargo.lock generated
View file

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@ -613,6 +613,13 @@ dependencies = [
"paste",
]
[[package]]
name = "haku2"
version = "0.1.0"
dependencies = [
"log",
]
[[package]]
name = "handlebars"
version = "6.0.0"
@ -1280,6 +1287,7 @@ dependencies = [
"derive_more",
"eyre",
"haku",
"haku2",
"handlebars",
"indexmap",
"jotdown",

View file

@ -4,6 +4,7 @@ members = ["crates/*"]
[workspace.dependencies]
haku.path = "crates/haku"
haku2.path = "crates/haku2"
log = "0.4.22"
rkgk-image-ops.path = "crates/rkgk-image-ops"

View file

@ -493,6 +493,7 @@ unsafe extern "C" fn haku_begin_brush(instance: *mut Instance, brush: *const Bru
panic!("brush is not compiled and ready to be used");
};
info!("old image: {:?}", instance.vm.image());
instance.vm.restore_image(&instance.vm_image);
instance.vm.apply_defs(&instance.defs);
instance.reset_exception();

View file

@ -207,12 +207,12 @@ pub mod fns {
0x17 Nary "cbrt" => cbrtf,
0x18 Nary "exp" => expf,
0x19 Nary "exp2" => exp2f,
0x1A Nary "ln" => logf,
0x1B Nary "log2" => log2f,
0x1C Nary "log10" => log10f,
0x1D Nary "hypot" => hypotf,
0x1E Nary "sin" => sinf,
0x1F Nary "cos" => cosf,
0x1a Nary "ln" => logf,
0x1b Nary "log2" => log2f,
0x1c Nary "log10" => log10f,
0x1d Nary "hypot" => hypotf,
0x1e Nary "sin" => sinf,
0x1f Nary "cos" => cosf,
0x20 Nary "tan" => tanf,
0x21 Nary "asin" => asinf,
0x22 Nary "acos" => acosf,
@ -223,9 +223,12 @@ pub mod fns {
0x27 Nary "sinh" => sinhf,
0x28 Nary "cosh" => coshf,
0x29 Nary "tanh" => tanhf,
0x2A Nary "asinh" => asinhf,
0x2B Nary "acosh" => acoshf,
0x2C Nary "atanh" => atanhf,
0x2a Nary "asinh" => asinhf,
0x2b Nary "acosh" => acoshf,
0x2c Nary "atanh" => atanhf,
0x2d Nary "min" => min,
0x2e Nary "max" => max,
0x30 Nary "lerp" => lerp_f,
0x40 Unary "!" => not,
0x41 Binary "==" => eq,
@ -390,6 +393,133 @@ pub mod fns {
math1 "atanh" atanhf,
}
pub fn min(_: &System, vm: &mut Vm, args: FnArgs) -> Result<Value, Exception> {
if args.num() != 2 {
return Err(vm.create_exception("`min` expects 2 arguments (min a b)"));
}
let a = args.get(vm, 0);
let b = args.get(vm, 1);
if !a.is_same_type_as(&b) {
return Err(vm.create_exception("arguments to `min` must have the same type"));
}
match a {
Value::Nil | Value::False | Value::True | Value::Tag(_) | Value::Number(_) => {
Ok(if a < b { a } else { b })
}
Value::Vec4(a) => {
let b = b.to_vec4().unwrap();
Ok(Value::Vec4(Vec4 {
x: a.x.min(b.x),
y: a.y.min(b.y),
z: a.z.min(b.z),
w: a.w.min(b.w),
}))
}
Value::Rgba(a) => {
let b = b.to_rgba().unwrap();
Ok(Value::Rgba(Rgba {
r: a.r.min(b.r),
g: a.g.min(b.g),
b: a.b.min(b.b),
a: a.a.min(b.a),
}))
}
Value::Ref(_) => {
Err(vm.create_exception("arguments passed to `min` cannot be compared"))
}
}
}
pub fn max(_: &System, vm: &mut Vm, args: FnArgs) -> Result<Value, Exception> {
if args.num() != 2 {
return Err(vm.create_exception("`max` expects 2 arguments (max a b)"));
}
let a = args.get(vm, 0);
let b = args.get(vm, 1);
if !a.is_same_type_as(&b) {
return Err(vm.create_exception("arguments to `max` must have the same type"));
}
match a {
Value::Nil | Value::False | Value::True | Value::Tag(_) | Value::Number(_) => {
Ok(if a < b { a } else { b })
}
Value::Vec4(a) => {
let b = b.to_vec4().unwrap();
Ok(Value::Vec4(Vec4 {
x: a.x.max(b.x),
y: a.y.max(b.y),
z: a.z.max(b.z),
w: a.w.max(b.w),
}))
}
Value::Rgba(a) => {
let b = b.to_rgba().unwrap();
Ok(Value::Rgba(Rgba {
r: a.r.max(b.r),
g: a.g.max(b.g),
b: a.b.max(b.b),
a: a.a.max(b.a),
}))
}
Value::Ref(_) => {
Err(vm.create_exception("arguments passed to `max` cannot be compared"))
}
}
}
fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
pub fn lerp_f(_: &System, vm: &mut Vm, args: FnArgs) -> Result<Value, Exception> {
if args.num() != 3 {
return Err(vm.create_exception("`lerp` expects 3 arguments (max a b)"));
}
let a = args.get(vm, 0);
let b = args.get(vm, 1);
if !a.is_same_type_as(&b) {
return Err(
vm.create_exception("1st and 2nd arguments to `lerp` must have the same type")
);
}
let t = args.get_number(vm, 2, "3rd argument to `lerp` must be a number")?;
match a {
Value::Number(a) => {
let b = b.to_number().unwrap();
Ok(Value::Number(lerp(a, b, t)))
}
Value::Vec4(a) => {
let b = b.to_vec4().unwrap();
Ok(Value::Vec4(Vec4 {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
z: lerp(a.z, b.z, t),
w: lerp(a.w, b.w, t),
}))
}
Value::Rgba(a) => {
let b = b.to_rgba().unwrap();
Ok(Value::Rgba(Rgba {
r: lerp(a.r, b.r, t),
g: lerp(a.g, b.g, t),
b: lerp(a.b, b.b, t),
a: lerp(a.a, b.a, t),
}))
}
Value::Nil | Value::False | Value::True | Value::Tag(_) | Value::Ref(_) => {
Err(vm
.create_exception("arguments passed to `lerp` cannot be linearly interpolated"))
}
}
}
pub fn not(_: &System, vm: &mut Vm, args: FnArgs) -> Result<Value, Exception> {
let value = args.get(vm, 0);
Ok(Value::from(value.is_falsy()))

View file

@ -1,4 +1,7 @@
use core::ops::{Add, Div, Mul, Neg, Sub};
use core::{
mem::discriminant,
ops::{Add, Div, Mul, Neg, Sub},
};
use alloc::vec::Vec;
@ -54,6 +57,10 @@ impl Value {
_ => None,
}
}
pub fn is_same_type_as(&self, other: &Value) -> bool {
discriminant(self) == discriminant(other)
}
}
impl From<()> for Value {

7
crates/haku2/Cargo.toml Normal file
View file

@ -0,0 +1,7 @@
[package]
name = "haku2"
version = "0.1.0"
edition = "2021"
[dependencies]
log.workspace = true

61
crates/haku2/build.rs Normal file
View file

@ -0,0 +1,61 @@
use std::{
env,
error::Error,
path::PathBuf,
process::{Command, Stdio},
};
fn main() -> Result<(), Box<dyn Error>> {
println!("cargo::rerun-if-changed=src");
let out_dir = env::var("OUT_DIR").unwrap();
let out_path = PathBuf::from(&out_dir);
let profile = env::var("PROFILE").unwrap();
let target = env::var("TARGET").unwrap();
// TODO: Use of PROFILE is discouraged, so we should switch this to a more fine-grained system
// reading back from other environment variables.
let optimize = match &profile[..] {
"debug" => "Debug",
"release" => "ReleaseSmall",
_ => panic!("unknown profile"),
};
let color = match env::var("NO_COLOR").is_ok() {
true => "off",
false => "on",
};
let target = match &target[..] {
"x86_64-unknown-linux-gnu" => "x86_64-linux-gnu",
_ => &target,
};
let output = Command::new("zig")
.arg("build")
.arg("install")
// Terminal output
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.arg("--prominent-compile-errors")
.arg("--color")
.arg(color)
// Build output
.arg("--cache-dir")
.arg(out_path.join("zig-cache"))
.arg("--prefix")
.arg(out_path.join("zig-out").join(target))
// Build settings
.arg(format!("-Doptimize={optimize}"))
.arg(format!("-Dtarget={target}"))
.output()?;
if !output.status.success() {
panic!("zig failed to build");
}
println!("cargo::rustc-link-search={out_dir}/zig-out/{target}/lib");
println!("cargo::rustc-link-lib=haku2");
Ok(())
}

59
crates/haku2/build.zig Normal file
View file

@ -0,0 +1,59 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Library
const mod = b.createModule(.{
.root_source_file = b.path("src/haku2.zig"),
.target = target,
.optimize = optimize,
.pic = true,
});
const lib = b.addStaticLibrary(.{
.name = "haku2",
.root_module = mod,
});
lib.pie = true;
b.installArtifact(lib);
const mod_wasm = b.createModule(.{
.root_source_file = b.path("src/haku2.zig"),
.target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .freestanding,
}),
.optimize = optimize,
});
const exe_wasm = b.addExecutable(.{
.linkage = .static,
.name = "haku2",
.root_module = mod_wasm,
});
exe_wasm.entry = .disabled;
exe_wasm.rdynamic = true;
b.installArtifact(exe_wasm);
// Tests
const lib_unit_tests = b.addTest(.{
.root_module = mod,
});
const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_lib_unit_tests.step);
// Checks
const lib_check = b.addLibrary(.{
.linkage = .static,
.name = "haku2_check",
.root_module = mod,
});
const check_step = b.step("check", "Check if the project compiles");
check_step.dependOn(&lib_check.step);
}

View file

@ -0,0 +1,46 @@
.{
.name = .haku2,
.version = "0.0.0",
.fingerprint = 0x32e7a3050d9a4fcf, // Changing this has security and trust implications.
.minimum_zig_version = "0.14.1",
.dependencies = .{
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding
// // `hash`, otherwise you are communicating that you expect to find the old hash at
// // the new URL. If the contents of a URL change this will result in a hash mismatch
// // which will prevent zig from using it.
// .url = "https://example.com/foo.tar.gz",
//
// // This is computed from the file contents of the directory of files that is
// // obtained after fetching `url` and applying the inclusion rules given by
// // `paths`.
// //
// // This field is the source of truth; packages do not come from a `url`; they
// // come from a `hash`. `url` is just one of many possible mirrors for how to
// // obtain a package matching this `hash`.
// //
// // Uses the [multihash](https://multiformats.io/multihash/) format.
// .hash = "...",
//
// // When this is provided, the package is found in a directory relative to the
// // build root. In this case the package's hash is irrelevant and therefore not
// // computed. This field and `url` are mutually exclusive.
// .path = "foo",
//
// // When this is set to `true`, a package is declared to be lazily
// // fetched. This makes the dependency only get fetched if it is
// // actually used.
// .lazy = false,
//},
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
// For example...
//"LICENSE",
//"README.md",
},
}

View file

@ -0,0 +1,37 @@
const std = @import("std");
const mem = std.mem;
extern fn __haku2_alloc(size: usize, alignment: usize) ?[*]u8;
extern fn __haku2_realloc(ptr: [*]u8, size: usize, alignment: usize, new_size: usize) ?[*]u8;
extern fn __haku2_dealloc(ptr: [*]u8, size: usize, alignment: usize) void;
fn hostAlloc(_: *anyopaque, len: usize, alignment: mem.Alignment, _: usize) ?[*]u8 {
return __haku2_alloc(len, alignment.toByteUnits());
}
fn hostResize(_: *anyopaque, memory: []u8, alignment: mem.Alignment, new_len: usize, _: usize) bool {
// Rust doesn't have an API for resizing memory in place.
// Callers will have to call remap instead.
_ = memory;
_ = alignment;
_ = new_len;
return false;
}
fn hostRemap(_: *anyopaque, memory: []u8, alignment: mem.Alignment, new_len: usize, _: usize) ?[*]u8 {
return __haku2_realloc(memory.ptr, memory.len, alignment.toByteUnits(), new_len);
}
fn hostFree(_: *anyopaque, memory: []u8, alignment: mem.Alignment, _: usize) void {
__haku2_dealloc(memory.ptr, memory.len, alignment.toByteUnits());
}
pub const hostAllocator = mem.Allocator{
.ptr = undefined,
.vtable = &mem.Allocator.VTable{
.alloc = hostAlloc,
.resize = hostResize,
.remap = hostRemap,
.free = hostFree,
},
};

View file

@ -0,0 +1,79 @@
const std = @import("std");
const mem = std.mem;
/// NOTE: This must be mirrored in bytecode.rs.
pub const Opcode = enum(u8) {
// Push literal values onto the stack.
nil,
false,
true,
tag,
number, // (float: f32)
rgba, // (r: u8, g: u8, b: u8, a: u8)
// Duplicate existing values.
local, // (index: u8) push a value relative to the bottom of the current stack window
set_local, // (index: u8) set the value of a value relative to the bottom of the current stack window
capture, // (index: u8) push a value captured by the current closure
def, // (index: u16) get the value of a definition
set_def, // (index: u16) set the value of a definition
// Create lists.
list, // (len: u16)
// Create literal functions.
function, // (params: u8, then: u16), at `then`: (local_count: u8, capture_count: u8, captures: [capture_count](source: u8, index: u8))
// Control flow.
jump, // (offset: u16)
jump_if_not, // (offset: u16)
field, // (count: u8, tags: [count]u16) look up a closure capture named by a tag. this is used to implement records with fields
// Function calls.
call, // (argc: u8)
system, // (index: u8, argc: u8) fast path for system calls
ret, // must be the last opcode; opcodes after .ret are treated as invalid
_, // invalid opcodes
};
// Constants used by the .function opcode to indicate capture sources.
pub const capture_local: u8 = 0;
pub const capture_capture: u8 = 1;
pub const Chunk = struct {
bytecode: []const u8,
};
pub const Loc = u16;
pub const Defs = struct {
num_defs: usize,
num_tags: usize,
// Unlike the Rust version, we currently do not store strings here, because we still don't
// support stack traces.
// The VM therefore never needs the names of Defs for any practical purposes.
pub fn parse(
a: mem.Allocator,
// These strings are expected to contain a list of names, where each name is terminated
// by a newline (LF).
defs_string: []const u8,
tags_string: []const u8,
) !*Defs {
const d = try a.create(Defs);
errdefer a.destroy(d);
d.* = .{
.num_defs = mem.count(u8, defs_string, "\n"),
.num_tags = mem.count(u8, tags_string, "\n"),
};
return d;
}
pub fn destroy(defs: *Defs, a: mem.Allocator) void {
a.destroy(defs);
}
};

100
crates/haku2/src/haku2.zig Normal file
View file

@ -0,0 +1,100 @@
const std = @import("std");
const mem = std.mem;
const bytecode = @import("bytecode.zig");
const Scratch = @import("scratch.zig");
const value = @import("value.zig");
const Vm = @import("vm.zig");
const hostAllocator = @import("allocator.zig").hostAllocator;
// Scratch
export fn haku2_scratch_new(max: usize) ?*Scratch {
return Scratch.create(hostAllocator, max) catch return null;
}
export fn haku2_scratch_destroy(scratch: *Scratch) void {
scratch.destroy(hostAllocator);
}
export fn haku2_scratch_reset(scratch: *Scratch) void {
scratch.fixedBuffer = std.heap.FixedBufferAllocator.init(scratch.buffer);
}
// Limits
export fn haku2_limits_new() ?*Vm.Limits {
return hostAllocator.create(Vm.Limits) catch null;
}
export fn haku2_limits_destroy(limits: *Vm.Limits) void {
hostAllocator.destroy(limits);
}
export fn haku2_limits_set_stack_capacity(limits: *Vm.Limits, new: usize) void {
limits.stack_capacity = new;
}
export fn haku2_limits_set_call_stack_capacity(limits: *Vm.Limits, new: usize) void {
limits.call_stack_capacity = new;
}
export fn haku2_limits_set_fuel(limits: *Vm.Limits, new: u32) void {
limits.fuel = new;
}
// Defs
export fn haku2_defs_parse(
defs_string: [*]const u8,
defs_len: usize,
tags_string: [*]const u8,
tags_len: usize,
) ?*bytecode.Defs {
return bytecode.Defs.parse(
hostAllocator,
defs_string[0..defs_len],
tags_string[0..tags_len],
) catch null;
}
export fn haku2_defs_destroy(defs: *bytecode.Defs) void {
defs.destroy(hostAllocator);
}
// VM
export fn haku2_vm_new(s: *Scratch, defs: *const bytecode.Defs, limits: *const Vm.Limits) ?*Vm {
const vm = hostAllocator.create(Vm) catch return null;
errdefer hostAllocator.destroy(vm);
vm.* = Vm.init(s.allocator(), defs, limits) catch return null;
return vm;
}
export fn haku2_vm_run_main(
vm: *Vm,
scratch: *Scratch,
code: [*]const u8,
code_len: usize,
local_count: u8,
) bool {
const chunk = bytecode.Chunk{
.bytecode = code[0..code_len],
};
const closure = value.Closure{
.chunk = &chunk,
.start = 0,
.param_count = 0,
.local_count = local_count,
.captures = &[_]value.Value{},
};
vm.run(scratch.allocator(), &closure, vm.stack_top) catch return false;
return true;
}
export fn haku2_vm_destroy(vm: *Vm) void {
hostAllocator.destroy(vm);
}

37
crates/haku2/src/lib.rs Normal file
View file

@ -0,0 +1,37 @@
use std::{
alloc::{self, Layout},
ptr,
};
#[unsafe(no_mangle)]
unsafe extern "C" fn __haku2_alloc(size: usize, align: usize) -> *mut u8 {
if let Ok(layout) = Layout::from_size_align(size, align) {
alloc::alloc(layout)
} else {
ptr::null_mut()
}
}
#[unsafe(no_mangle)]
unsafe extern "C" fn __haku2_realloc(
ptr: *mut u8,
size: usize,
align: usize,
new_size: usize,
) -> *mut u8 {
if let Ok(layout) = Layout::from_size_align(size, align) {
alloc::realloc(ptr, layout, new_size)
} else {
ptr::null_mut()
}
}
#[unsafe(no_mangle)]
unsafe extern "C" fn __haku2_dealloc(ptr: *mut u8, size: usize, align: usize) {
match Layout::from_size_align(size, align) {
Ok(layout) => alloc::dealloc(ptr, layout),
Err(_) => {
log::error!("__haku2_dealloc: invalid layout size={size} align={align} ptr={ptr:?}")
}
}
}

View file

@ -0,0 +1,30 @@
const std = @import("std");
const mem = std.mem;
const Scratch = @This();
fixedBuffer: std.heap.FixedBufferAllocator,
buffer: []u8,
pub fn create(a: mem.Allocator, max: usize) !*Scratch {
const s = try a.create(Scratch);
errdefer a.destroy(s);
const buffer = try a.alloc(u8, max);
s.* = .{
.fixedBuffer = std.heap.FixedBufferAllocator.init(buffer),
.buffer = buffer,
};
return s;
}
pub fn destroy(scratch: *Scratch, a: mem.Allocator) void {
a.free(scratch.fixedBuffer.buffer);
a.destroy(scratch);
}
pub fn allocator(scratch: *Scratch) mem.Allocator {
return scratch.fixedBuffer.allocator();
}

716
crates/haku2/src/system.zig Normal file
View file

@ -0,0 +1,716 @@
const std = @import("std");
const mem = std.mem;
const meta = std.meta;
const math = std.math;
const value = @import("value.zig");
const Value = value.Value;
const Vm = @import("vm.zig");
pub const Context = struct {
vm: *Vm,
allocator: mem.Allocator,
args: []Value,
pub fn arg(cx: *const Context, i: usize) ?Value {
if (i < cx.args.len) {
return cx.args[i];
} else {
return null;
}
}
};
pub const Fn = *const fn (Context) Vm.Error!Value;
pub const FnTable = [256]Fn;
fn invalid(cx: Context) !Value {
return cx.vm.throw("invalid system function", .{});
}
fn todo(cx: Context) !Value {
return cx.vm.throw("not yet implemented", .{});
}
fn typeError(vm: *Vm, val: Value, i: usize, expect: []const u8) Vm.Error {
return vm.throw("argument #{}: {s} expected, but got {s}", .{ i + 1, expect, val.typeName() });
}
// Strongly-typed type tags for Vec4 and Rgba, which are otherwise the same type.
// Used for differentiating between Vec4 and Rgba arguments.
const Vec4 = struct { value: value.Vec4 };
const Rgba = struct { value: value.Rgba };
fn fromArgument(cx: Context, comptime T: type, i: usize, val: Value) Vm.Error!T {
// NOTE: THIS IS CURRENTLY TERRIBLY BROKEN BECAUSE i IS ITERATED EVEN WHEN IT DOESN'T NEED TO BE.
switch (T) {
// Context variables
Context => return cx,
*Vm => return cx.vm,
mem.Allocator => return cx.allocator,
// No conversion
Value => return val,
// Primitives
f32 => {
if (val != .number) return typeError(cx.vm, val, i, "number");
return val.number;
},
Vec4 => {
if (val != .vec4) return typeError(cx.vm, val, i, "vec4");
return .{ .value = val.vec4 };
},
Rgba => {
if (val != .rgba) return typeError(cx.vm, val, i, "rgba");
return .{ .value = val.rgba };
},
// Refs
value.List => {
if (val != .ref or val.ref.* != .list) return typeError(cx.vm, val, i, "list");
return val.ref.list;
},
*const value.Shape => {
if (val != .ref or val.ref.* != .shape) return typeError(cx.vm, val, i, "shape");
return &val.ref.shape;
},
*const value.Closure => {
if (val != .ref or val.ref.* != .closure) return typeError(cx.vm, val, i, "function");
return &val.ref.closure;
},
else => {},
}
@compileError(@typeName(T) ++ " is unsupported as an argument type");
}
fn intoReturn(cx: Context, any: anytype) Vm.Error!Value {
const T = @TypeOf(any);
return switch (T) {
Value => any,
bool => if (any) .true else .false,
f32 => .{ .number = any },
value.Ref => {
const ref = cx.allocator.create(value.Ref) catch return cx.vm.outOfMemory();
ref.* = any;
return .{ .ref = ref };
},
else => switch (@typeInfo(T)) {
.optional => if (any) |v| intoReturn(cx, v) else .nil,
.error_union => intoReturn(cx, try any),
else => @compileError(@typeName(T) ++ " is unsupported as a return type"),
},
};
}
/// Erase a well-typed function into a function that operates on raw values.
/// The erased function performs all the necessary conversions automatically.
///
/// Note that the argument order is important---function arguments go first, then context (such as
/// the VM or the allocator.) Otherwise argument indices will not match up.
fn erase(comptime func: anytype) Fn {
return Erased(func).call;
}
fn Erased(comptime func: anytype) type {
return struct {
fn call(cx: Context) Vm.Error!Value {
const param_count = @typeInfo(@TypeOf(func)).@"fn".params.len;
if (cx.args.len != param_count) {
return cx.vm.throw("function expects {} arguments, but it received {}", .{ param_count, cx.args.len });
}
const Args = meta.ArgsTuple(@TypeOf(func));
var args: Args = undefined;
inline for (meta.fields(Args), 0..) |field, i| {
@field(args, field.name) = try fromArgument(cx, field.type, i, cx.args[i]);
}
const result = @call(.auto, func, args);
return intoReturn(cx, result);
}
};
}
const SparseFn = struct { u8, Fn };
const SparseFnTable = []const SparseFn;
fn makeFnTable(init: SparseFnTable) FnTable {
var table = [_]Fn{invalid} ** @typeInfo(FnTable).array.len;
for (init) |entry| {
const index, const func = entry;
table[index] = func;
}
return table;
}
pub const fns = makeFnTable(&[_]SparseFn{
// NOTE: The indices here must match those defined in system.rs.
// Once the rest of the compiler is rewritten in Zig, it would be a good idea to rework this
// system _not_ to hardcode the indices here.
.{ 0x00, erase(add) }, // +
.{ 0x01, erase(sub) }, // -
.{ 0x02, erase(mul) }, // *
.{ 0x03, erase(div) }, // /
.{ 0x04, erase(neg) }, // -_
.{ 0x10, erase(floor) }, // floor
.{ 0x11, erase(ceil) }, // ceil
.{ 0x12, erase(round) }, // round
.{ 0x13, erase(abs) }, // abs
.{ 0x14, erase(mod) }, // mod
.{ 0x15, erase(pow) }, // pow
.{ 0x16, erase(sqrt) }, // sqrt
.{ 0x17, erase(cbrt) }, // cbrt
.{ 0x18, erase(exp) }, // exp
.{ 0x19, erase(exp2) }, // exp2
.{ 0x1a, erase(ln) }, // ln
.{ 0x1b, erase(log2) }, // log2
.{ 0x1c, erase(log10) }, // log10
.{ 0x1d, erase(hypot) }, // hypot
.{ 0x1e, erase(sin) }, // sin
.{ 0x1f, erase(cos) }, // cos
.{ 0x20, erase(tan) }, // tan
.{ 0x21, erase(asin) }, // asin
.{ 0x22, erase(acos) }, // acos
.{ 0x23, erase(atan) }, // atan
.{ 0x24, erase(atan2) }, // atan2
.{ 0x25, erase(expMinus1) }, // expMinus1
.{ 0x26, erase(ln1Plus) }, // ln1Plus
.{ 0x27, erase(sinh) }, // sinh
.{ 0x28, erase(cosh) }, // cosh
.{ 0x29, erase(tanh) }, // tanh
.{ 0x2a, erase(asinh) }, // asinh
.{ 0x2b, erase(acosh) }, // acosh
.{ 0x2c, erase(atanh) }, // atanh
.{ 0x2d, erase(min) }, // min
.{ 0x2e, erase(max) }, // max
.{ 0x30, erase(lerp) }, // lerp
.{ 0x40, erase(not) }, // !
.{ 0x41, erase(Value.eql) }, // ==
.{ 0x42, erase(notEql) }, // !=
.{ 0x43, erase(less) }, // <
.{ 0x44, erase(lessOrEqual) }, // <=
.{ 0x45, erase(greater) }, // >
.{ 0x46, erase(greaterOrEqual) }, // >=
.{ 0x80, vec }, // vec
.{ 0x81, erase(vecX) }, // vecX
.{ 0x82, erase(vecY) }, // vecY
.{ 0x83, erase(vecZ) }, // vecZ
.{ 0x84, erase(vecW) }, // vecW
.{ 0x85, rgba }, // rgba
.{ 0x86, erase(rgbaR) }, // rgbaR
.{ 0x87, erase(rgbaG) }, // rgbaG
.{ 0x88, erase(rgbaB) }, // rgbaB
.{ 0x89, erase(rgbaA) }, // rgbaA
.{ 0x90, erase(listLen) }, // len
.{ 0x91, erase(listIndex) }, // index
.{ 0x92, erase(range) }, // range
.{ 0x93, erase(map) }, // map
.{ 0x94, erase(filter) }, // filter
.{ 0x95, erase(reduce) }, // reduce
.{ 0x96, erase(flatten) }, // flatten
.{ 0xc0, erase(valueToShape) }, // toShape
.{ 0xc1, erase(line) }, // line
.{ 0xc2, erase(rect) }, // rect
.{ 0xc3, erase(circle) }, // circle
.{ 0xe0, erase(stroke) }, // stroke
.{ 0xe1, erase(fill) }, // fill
.{ 0xf0, erase(withDotter) }, // withDotter
});
fn add(a: Value, b: Value, vm: *Vm) Vm.Error!Value {
if (meta.activeTag(a) != meta.activeTag(b)) {
return vm.throw("arguments must be of the same type", .{});
}
return switch (a) {
.number => .{ .number = a.number + b.number },
.vec4 => .{ .vec4 = a.vec4 + b.vec4 },
.rgba => .{ .rgba = a.rgba + b.rgba },
else => vm.throw("number, vec4, or rgba arguments expected, but got {s}", .{a.typeName()}),
};
}
fn sub(a: Value, b: Value, vm: *Vm) Vm.Error!Value {
if (meta.activeTag(a) != meta.activeTag(b)) {
return vm.throw("arguments must be of the same type", .{});
}
return switch (a) {
.number => .{ .number = a.number - b.number },
.vec4 => .{ .vec4 = a.vec4 - b.vec4 },
.rgba => .{ .rgba = a.rgba - b.rgba },
else => vm.throw("number, vec4, or rgba arguments expected, but got {s}", .{a.typeName()}),
};
}
fn mul(a: Value, b: Value, vm: *Vm) Vm.Error!Value {
if (meta.activeTag(a) != meta.activeTag(b)) {
return vm.throw("arguments must be of the same type", .{});
}
return switch (a) {
.number => .{ .number = a.number * b.number },
.vec4 => .{ .vec4 = a.vec4 * b.vec4 },
.rgba => .{ .rgba = a.rgba * b.rgba },
else => vm.throw("number, vec4, or rgba arguments expected, but got {s}", .{a.typeName()}),
};
}
fn div(a: Value, b: Value, vm: *Vm) Vm.Error!Value {
if (meta.activeTag(a) != meta.activeTag(b)) {
return vm.throw("arguments must be of the same type", .{});
}
return switch (a) {
.number => .{ .number = a.number * b.number },
.vec4 => .{ .vec4 = a.vec4 * b.vec4 },
.rgba => .{ .rgba = a.rgba * b.rgba },
else => vm.throw("number, vec4, or rgba arguments expected, but got {s}", .{a.typeName()}),
};
}
fn neg(a: Value, vm: *Vm) Vm.Error!Value {
return switch (a) {
.number => .{ .number = -a.number },
.vec4 => .{ .vec4 = -a.vec4 },
else => vm.throw("number or vec4 argument expected, but got {s}", .{a.typeName()}),
};
}
fn floor(a: f32) f32 {
return @floor(a);
}
fn ceil(a: f32) f32 {
return @ceil(a);
}
fn round(a: f32) f32 {
return @round(a);
}
fn abs(a: f32) f32 {
return @abs(a);
}
fn mod(a: f32, b: f32) f32 {
return @mod(a, b);
}
fn pow(a: f32, b: f32) f32 {
return math.pow(f32, a, b);
}
fn sqrt(a: f32) f32 {
return @sqrt(a);
}
fn cbrt(a: f32) f32 {
return math.cbrt(a);
}
fn exp(a: f32) f32 {
return @exp(a);
}
fn exp2(a: f32) f32 {
return @exp2(a);
}
fn ln(a: f32) f32 {
return @log(a);
}
fn log2(a: f32) f32 {
return @log2(a);
}
fn log10(a: f32) f32 {
return @log10(a);
}
fn hypot(a: f32, b: f32) f32 {
return math.hypot(a, b);
}
fn sin(a: f32) f32 {
return @sin(a);
}
fn cos(a: f32) f32 {
return @cos(a);
}
fn tan(a: f32) f32 {
return @tan(a);
}
fn asin(a: f32) f32 {
return math.asin(a);
}
fn acos(a: f32) f32 {
return math.acos(a);
}
fn atan(a: f32) f32 {
return math.atan(a);
}
fn atan2(y: f32, x: f32) f32 {
return math.atan2(y, x);
}
fn expMinus1(a: f32) f32 {
return math.expm1(a);
}
fn ln1Plus(a: f32) f32 {
return math.log1p(a);
}
fn sinh(a: f32) f32 {
return math.sinh(a);
}
fn cosh(a: f32) f32 {
return math.cosh(a);
}
fn tanh(a: f32) f32 {
return math.tanh(a);
}
fn asinh(a: f32) f32 {
return math.asinh(a);
}
fn acosh(a: f32) f32 {
return math.acosh(a);
}
fn atanh(a: f32) f32 {
return math.atanh(a);
}
fn min(a: Value, b: Value, vm: *Vm) Vm.Error!Value {
if (meta.activeTag(a) != meta.activeTag(b)) {
return vm.throw("arguments must be of the same type", .{});
}
return switch (a) {
.number => .{ .number = @min(a.number, b.number) },
.vec4 => .{ .vec4 = @min(a.vec4, b.vec4) },
.rgba => .{ .rgba = @min(a.rgba, b.rgba) },
else => if (a.lt(b).?) a else b,
};
}
fn max(a: Value, b: Value, vm: *Vm) Vm.Error!Value {
if (meta.activeTag(a) != meta.activeTag(b)) {
return vm.throw("arguments must be of the same type", .{});
}
return switch (a) {
.number => .{ .number = @max(a.number, b.number) },
.vec4 => .{ .vec4 = @max(a.vec4, b.vec4) },
.rgba => .{ .rgba = @max(a.rgba, b.rgba) },
else => if (a.gt(b).?) a else b,
};
}
fn lerp(a: Value, b: Value, t: f32, vm: *Vm) Vm.Error!Value {
if (meta.activeTag(a) != meta.activeTag(b)) {
return vm.throw("arguments must be of the same type", .{});
}
return switch (a) {
.number => .{ .number = math.lerp(a.number, b.number, t) },
.vec4 => .{ .vec4 = math.lerp(a.vec4, b.vec4, @as(value.Vec4, @splat(t))) },
.rgba => .{ .rgba = math.lerp(a.rgba, b.rgba, @as(value.Rgba, @splat(t))) },
else => vm.throw("number, vec4, or rgba expected, but got {s}", .{a.typeName()}),
};
}
fn not(a: Value) bool {
return !a.isTruthy();
}
// Value.eql is used as-is
fn notEql(a: Value, b: Value) bool {
return !a.eql(b);
}
fn less(a: Value, b: Value, vm: *Vm) Vm.Error!bool {
return a.lt(b) orelse vm.throw("{s} and {s} cannot be compared", .{ a.typeName(), b.typeName() });
}
fn greater(a: Value, b: Value, vm: *Vm) Vm.Error!bool {
return a.gt(b) orelse vm.throw("{s} and {s} cannot be compared", .{ a.typeName(), b.typeName() });
}
fn lessOrEqual(a: Value, b: Value, vm: *Vm) Vm.Error!bool {
const isGreater = try greater(a, b, vm);
return !isGreater;
}
fn greaterOrEqual(a: Value, b: Value, vm: *Vm) Vm.Error!bool {
const isLess = try less(a, b, vm);
return !isLess;
}
fn vec(cx: Context) Vm.Error!Value {
if (cx.args.len > 4) return cx.vm.throw("function expects 1 to 4 arguments, but it received {}", .{cx.args.len});
for (cx.args) |arg| {
if (arg != .number) return cx.vm.throw("number expected, but got {s}", .{arg.typeName()});
}
const zero: Value = .{ .number = 0 };
return .{ .vec4 = .{
(cx.arg(0) orelse zero).number,
(cx.arg(1) orelse zero).number,
(cx.arg(2) orelse zero).number,
(cx.arg(3) orelse zero).number,
} };
}
fn vecX(v: Vec4) f32 {
return v.value[0];
}
fn vecY(v: Vec4) f32 {
return v.value[1];
}
fn vecZ(v: Vec4) f32 {
return v.value[2];
}
fn vecW(v: Vec4) f32 {
return v.value[3];
}
fn rgba(cx: Context) Vm.Error!Value {
if (cx.args.len > 4) return cx.vm.throw("function expects 1 to 4 arguments, but it received {}", .{cx.args.len});
for (cx.args) |arg| {
if (arg != .number) return cx.vm.throw("number expected, but got {s}", .{arg.typeName()});
}
const zero: Value = .{ .number = 0 };
return .{ .rgba = .{
(cx.arg(0) orelse zero).number,
(cx.arg(1) orelse zero).number,
(cx.arg(2) orelse zero).number,
(cx.arg(3) orelse zero).number,
} };
}
fn rgbaR(v: Rgba) f32 {
return v.value[0];
}
fn rgbaG(v: Rgba) f32 {
return v.value[1];
}
fn rgbaB(v: Rgba) f32 {
return v.value[2];
}
fn rgbaA(v: Rgba) f32 {
return v.value[3];
}
fn listLen(list: value.List) f32 {
return @floatFromInt(list.len);
}
/// `index`
fn listIndex(list: value.List, index: f32, vm: *Vm) Vm.Error!Value {
const i: usize = @intFromFloat(index);
if (i >= list.len) return vm.throw("list index out of bounds. length is {}, index is {}", .{ list.len, i });
return list[i];
}
fn range(fstart: f32, fend: f32, vm: *Vm, a: mem.Allocator) Vm.Error!value.Ref {
const start: u32 = @intFromFloat(fstart);
const end: u32 = @intFromFloat(fend);
// Careful here. We don't want someone to generate a list that's so long it DoSes the server.
// Therefore generating a list consumes fuel, in addition to bulk memory.
// The cost is still much cheaper than doing it manually.
const count = @max(start, end) - @min(start, end);
try vm.consumeFuel(&vm.fuel, count);
const list = a.alloc(Value, count) catch return vm.outOfMemory();
if (start < end) {
var i = start;
for (list) |*element| {
element.* = .{ .number = @floatFromInt(i) };
i += 1;
}
} else {
var i = end;
for (list) |*element| {
element.* = .{ .number = @floatFromInt(i) };
i -= 1;
}
}
return .{ .list = list };
}
fn map(list: value.List, f: *const value.Closure, vm: *Vm, a: mem.Allocator) Vm.Error!value.Ref {
if (f.param_count != 1) {
return vm.throw("function passed to map must have a single parameter (\\x -> x), but it has {}", .{f.param_count});
}
const mapped_list = a.dupe(Value, list) catch return vm.outOfMemory();
for (list, mapped_list) |src, *dst| {
const bottom = vm.stack_top;
try vm.push(src);
try vm.run(a, f, bottom);
dst.* = try vm.pop();
}
return .{ .list = mapped_list };
}
fn filter(list: value.List, f: *const value.Closure, vm: *Vm, a: mem.Allocator) Vm.Error!value.Ref {
if (f.param_count != 1) {
return vm.throw("function passed to filter must have a single parameter (\\x -> True), but it has {}", .{f.param_count});
}
// Implementing filter is a bit tricky to do without resizable arrays.
// There are a few paths one could take, but the simplest is to duplicate the list and truncate
// its length. This wastes a lot of memory, but it probably isn't going to matter in practice.
//
// Serves you right for wanting to waste cycles on generating a list and then filtering it down
// instead of just generating the list you want, I guess.
//
// In the future we could try allocating a bit map for the filter's results, but I'm not sure
// it's worth it.
const filtered_list = a.alloc(Value, list.len) catch return vm.outOfMemory();
var len: usize = 0;
for (list) |val| {
const bottom = vm.stack_top;
try vm.push(val);
try vm.run(a, f, bottom);
const condition = try vm.pop();
if (condition.isTruthy()) {
filtered_list[len] = val;
len += 1;
}
}
return .{ .list = filtered_list };
}
fn reduce(list: value.List, init: Value, f: *const value.Closure, vm: *Vm, a: mem.Allocator) Vm.Error!Value {
if (f.param_count != 2) {
return vm.throw("function passed to reduce must have two parameters (\\acc, x -> acc), but it has {}", .{f.param_count});
}
var accumulator = init;
for (list) |val| {
const bottom = vm.stack_top;
try vm.push(accumulator);
try vm.push(val);
try vm.run(a, f, bottom);
accumulator = try vm.pop();
}
return accumulator;
}
fn flatten(list: value.List, vm: *Vm, a: mem.Allocator) Vm.Error!value.Ref {
var len: usize = 0;
for (list) |val| {
if (val == .ref and val.ref.* == .list) {
len += val.ref.list.len;
} else {
len += 1;
}
}
const flattened_list = a.alloc(Value, len) catch return vm.outOfMemory();
var i: usize = 0;
for (list) |val| {
if (val == .ref and val.ref.* == .list) {
@memcpy(flattened_list[i..][0..val.ref.list.len], val.ref.list);
i += val.ref.list.len;
} else {
flattened_list[i] = val;
i += 1;
}
}
return .{ .list = flattened_list };
}
fn toShape(val: value.Value) ?value.Shape {
return switch (val) {
.nil, .false, .true, .tag, .number, .rgba => null,
.vec4 => |v| .{ .point = value.vec2From4(v) },
.ref => |r| if (r.* == .shape) r.shape else null,
};
}
/// `toShape`
fn valueToShape(val: value.Value) ?value.Ref {
if (toShape(val)) |shape| {
return .{ .shape = shape };
} else {
return null;
}
}
fn line(start: Vec4, end: Vec4) value.Ref {
return .{ .shape = .{ .line = .{
.start = value.vec2From4(start.value),
.end = value.vec2From4(end.value),
} } };
}
fn rect(top_left: Vec4, size: Vec4) value.Ref {
return .{ .shape = .{ .rect = .{
.top_left = value.vec2From4(top_left.value),
.size = value.vec2From4(size.value),
} } };
}
fn circle(center: Vec4, radius: f32) value.Ref {
return .{ .shape = .{ .circle = .{
.center = value.vec2From4(center.value),
.radius = radius,
} } };
}
fn stroke(thickness: f32, color: Rgba, shape: *const value.Shape) value.Ref {
return .{ .scribble = .{ .stroke = .{
.thickness = thickness,
.color = color.value,
.shape = shape.*,
} } };
}
fn fill(color: Rgba, shape: *const value.Shape) value.Ref {
return .{ .scribble = .{ .fill = .{
.color = color.value,
.shape = shape.*,
} } };
}
fn withDotter(cont: *const value.Closure, vm: *Vm) Vm.Error!value.Ref {
if (cont.param_count != 1) {
return vm.throw("function passed to withDotter must have a single parameter (\\d -> _), but it has {}", .{cont.param_count});
}
return .{ .reticle = .{ .dotter = .{
.draw = cont,
} } };
}

156
crates/haku2/src/value.zig Normal file
View file

@ -0,0 +1,156 @@
const std = @import("std");
const meta = std.meta;
const mem = std.mem;
const bytecode = @import("bytecode.zig");
pub const Value = union(enum) {
nil,
false,
true,
tag: TagId,
number: f32,
vec4: Vec4,
rgba: Rgba,
ref: *const Ref,
pub fn isTruthy(value: Value) bool {
return switch (value) {
.nil => false,
.false => false,
else => true,
};
}
pub fn eql(a: Value, b: Value) bool {
if (meta.activeTag(a) == meta.activeTag(b)) return true;
return switch (a) {
.nil, .false, .true => true, // the values are equal
.tag => |t| t == b.tag,
.number => |n| n == b.number,
.vec4 => |v| @reduce(.Or, v == b.vec4),
.rgba => |r| @reduce(.Or, r == b.rgba),
.ref => |r| r == b.ref,
};
}
pub fn lt(a: Value, b: Value) ?bool {
if (meta.activeTag(a) == meta.activeTag(b)) {
return switch (a) {
.nil, .false, .true => false, // the values are equal
.tag => |t| @intFromEnum(t) < @intFromEnum(b.tag), // arbitrary order
.number => |n| n < b.number,
.vec4, .rgba, .ref => null,
};
}
return @intFromEnum(a) < @intFromEnum(b);
}
pub fn gt(a: Value, b: Value) ?bool {
return !a.eql(b) and !(a.lt(b) orelse return null);
}
pub fn typeName(value: Value) []const u8 {
return switch (value) {
.nil => "nil",
.false, .true => "boolean",
.tag => "tag",
.number => "number",
.vec4 => "vec4",
.rgba => "rgba",
.ref => |r| switch (r.*) {
.closure => "function",
.list => "list",
.shape => "shape",
.scribble => "scribble",
.reticle => "reticle",
},
};
}
};
pub const TagId = enum(u16) {
Nil = 0,
From = 1,
To = 2,
Num = 3,
_, // user-defined tags
};
pub const Vec2 = @Vector(2, f32);
pub const Vec4 = @Vector(4, f32);
pub fn vec2From4(vec: Vec4) Vec2 {
return .{ vec[0], vec[1] };
}
pub const Rgba8 = @Vector(4, u8);
pub const Rgba = @Vector(4, f32);
pub fn rgbaFrom8(rgba: Rgba8) Rgba {
return @as(Rgba, @floatFromInt(rgba)) / @as(Rgba, @splat(255.0));
}
pub const Ref = union(enum) {
closure: Closure,
list: List,
shape: Shape,
scribble: Scribble,
reticle: Reticle,
};
pub const Closure = struct {
chunk: *const bytecode.Chunk,
start: bytecode.Loc,
param_count: u8,
local_count: u8,
captures: []Value,
};
pub const List = []Value;
pub const Shape = union(enum) {
point: Vec2,
line: Line,
rect: Rect,
circle: Circle,
pub const Line = struct {
start: Vec2,
end: Vec2,
};
pub const Rect = struct {
top_left: Vec2,
size: Vec2,
};
pub const Circle = struct {
center: Vec2,
radius: f32,
};
};
pub const Scribble = union(enum) {
stroke: Stroke,
fill: Fill,
pub const Stroke = struct {
thickness: f32,
color: Rgba,
shape: Shape,
};
pub const Fill = struct {
color: Rgba,
shape: Shape,
};
};
pub const Reticle = union(enum) {
dotter: Dotter,
pub const Dotter = struct {
draw: *const Closure,
};
};

458
crates/haku2/src/vm.zig Normal file
View file

@ -0,0 +1,458 @@
const std = @import("std");
const mem = std.mem;
const testAllocator = std.testing.allocator;
const bytecode = @import("bytecode.zig");
const system = @import("system.zig");
const value = @import("value.zig");
const Value = value.Value;
const Vm = @This();
stack: []Value,
stack_top: u32 = 0,
call_stack: []CallFrame,
call_stack_top: u32 = 0,
defs: []Value,
fuel: u32,
exception_buffer: [1024]u8 = [_]u8{0} ** 1024, // buffer for exception message
exception: ?Exception = null,
pub const Limits = struct {
stack_capacity: usize = 256,
call_stack_capacity: usize = 256,
fuel: u32 = 63336,
};
pub const CallFrame = struct {
closure: *const value.Closure,
ip: [*]const u8,
bottom: u32,
};
pub const Exception = struct {
message: []const u8,
};
/// All errors coming from inside the VM get turned into a single Exception type, which signals
/// any kind of error that prevented the machine from running any further.
/// Details about the exception can be found in the VM's `exception` field.
pub const Error = error{Exception};
// NOTE: A VM is only ever initialized. There is no function for deallocating its resources.
// The intent is to use it with a freshly-reset Scratch, and deallocate its resources through that.
pub fn init(a: mem.Allocator, defs: *const bytecode.Defs, limits: *const Limits) !Vm {
return .{
.stack = try a.alloc(Value, limits.stack_capacity),
.call_stack = try a.alloc(CallFrame, limits.call_stack_capacity),
.defs = try a.alloc(Value, defs.num_defs),
.fuel = limits.fuel,
};
}
pub fn throw(vm: *Vm, comptime fmt: []const u8, args: anytype) Error {
const message = std.fmt.bufPrint(vm.exception_buffer[0..], fmt, args) catch {
vm.exception = .{ .message = "[exception message is too long; format string: " ++ fmt ++ "]" };
return error.Exception;
};
vm.exception = .{ .message = message };
return error.Exception;
}
pub fn outOfMemory(vm: *Vm) Error {
return vm.throw("out of memory", .{});
}
/// Debug assertion for bytecode validity.
/// In future versions, this may become disabled in release builds.
fn validateBytecode(vm: *Vm, ok: bool, comptime fmt: []const u8, args: anytype) Error!void {
if (!ok) {
return vm.throw("corrupted bytecode: " ++ fmt, args);
}
}
pub fn consumeFuel(vm: *Vm, fuel: *u32, amount: u32) Error!void {
const new_fuel, const overflow = @subWithOverflow(fuel.*, amount);
if (overflow > 0) {
return vm.throw("code ran for too long (out of fuel!)", .{});
}
fuel.* = new_fuel;
}
pub fn push(vm: *Vm, val: Value) Error!void {
if (vm.stack_top >= vm.stack.len) {
return vm.throw("too many live temporary values (local variables and expression operands)", .{});
}
vm.stack[vm.stack_top] = val;
vm.stack_top += 1;
}
pub fn pop(vm: *Vm) Error!Value {
try vm.validateBytecode(vm.stack_top > 0, "value stack underflow", .{});
vm.stack_top -= 1;
return vm.stack[vm.stack_top];
}
pub fn pushCall(vm: *Vm, frame: CallFrame) Error!void {
if (vm.call_stack_top >= vm.call_stack.len) {
return vm.throw("too much recursion", .{});
}
vm.call_stack[vm.call_stack_top] = frame;
vm.call_stack_top += 1;
}
pub fn popCall(vm: *Vm) Error!CallFrame {
try vm.validateBytecode(vm.call_stack_top > 0, "call stack underflow", .{});
vm.call_stack_top -= 1;
return vm.call_stack[vm.call_stack_top];
}
pub fn local(vm: *Vm, bottom: u32, offset: u8) Error!*Value {
const index = bottom + offset;
try vm.validateBytecode(index < vm.stack_top, "stack index out of bounds", .{});
return &vm.stack[bottom + offset];
}
pub fn def(vm: *Vm, index: u16) Error!*Value {
try vm.validateBytecode(index < vm.defs.len, "def index out of bounds", .{});
return &vm.defs[index];
}
// Utility struct for storing and restoring the VM's state variables across FFI boundaries.
const Context = struct {
fuel: u32,
};
fn restoreContext(vm: *Vm) Context {
return .{
.fuel = vm.fuel,
};
}
fn storeContext(vm: *Vm, context: Context) void {
vm.fuel = context.fuel;
}
inline fn read(comptime T: type, ip: *[*]const u8) T {
const result = mem.readInt(T, ip.*[0..@sizeOf(T)], std.builtin.Endian.little);
ip.* = ip.*[@sizeOf(T)..];
return result;
}
inline fn readOpcode(ip: *[*]const u8) bytecode.Opcode {
return @enumFromInt(read(u8, ip));
}
/// Before calling this, vm.stack_top should be saved and the appropriate amount of parameters must
/// be pushed onto the stack to execute the provided closure.
/// The saved vm.stack_top must be passed to init_bottom.
///
/// allocator should be a scratch buffer; there is no way to free the memory allocated by a VM.
pub fn run(
vm: *Vm,
allocator: mem.Allocator,
init_closure: *const value.Closure,
init_bottom: u32,
) Error!void {
var closure = init_closure;
var ip: [*]const u8 = closure.chunk.bytecode[closure.start..].ptr;
var bottom = init_bottom;
var fuel = vm.fuel;
for (0..closure.local_count) |_| {
try vm.push(.nil);
}
const call_bottom = vm.call_stack_top;
try vm.pushCall(.{
.closure = closure,
.ip = ip,
.bottom = bottom,
});
next: switch (readOpcode(&ip)) {
.nil => {
try vm.consumeFuel(&fuel, 1);
try vm.push(.nil);
continue :next readOpcode(&ip);
},
.false => {
try vm.consumeFuel(&fuel, 1);
try vm.push(.false);
continue :next readOpcode(&ip);
},
.true => {
try vm.consumeFuel(&fuel, 1);
try vm.push(.true);
continue :next readOpcode(&ip);
},
.tag => {
try vm.consumeFuel(&fuel, 1);
const tag_id: value.TagId = @enumFromInt(read(u16, &ip));
try vm.push(.{ .tag = tag_id });
continue :next readOpcode(&ip);
},
.number => {
try vm.consumeFuel(&fuel, 1);
const number: f32 = @bitCast(read(u32, &ip));
try vm.push(.{ .number = number });
continue :next readOpcode(&ip);
},
.rgba => {
try vm.consumeFuel(&fuel, 1);
const r = read(u8, &ip);
const g = read(u8, &ip);
const b = read(u8, &ip);
const a = read(u8, &ip);
try vm.push(.{ .rgba = value.rgbaFrom8(value.Rgba8{ r, g, b, a }) });
continue :next readOpcode(&ip);
},
.local => {
try vm.consumeFuel(&fuel, 1);
const index = read(u8, &ip);
const l = try vm.local(bottom, index);
try vm.push(l.*);
continue :next readOpcode(&ip);
},
.set_local => {
try vm.consumeFuel(&fuel, 1);
const index = read(u8, &ip);
const new = try vm.pop();
const l = try vm.local(bottom, index);
l.* = new;
continue :next readOpcode(&ip);
},
.capture => {
try vm.consumeFuel(&fuel, 1);
const index = read(u8, &ip);
try vm.validateBytecode(index < closure.captures.len, "capture index out of bounds", .{});
const capture = closure.captures[index];
try vm.push(capture);
continue :next readOpcode(&ip);
},
.def => {
try vm.consumeFuel(&fuel, 1);
const index = read(u16, &ip);
const d = try vm.def(index);
try vm.push(d.*);
continue :next readOpcode(&ip);
},
.set_def => {
try vm.consumeFuel(&fuel, 1);
const index = read(u16, &ip);
const new = try vm.pop();
const d = try vm.def(index);
d.* = new;
continue :next readOpcode(&ip);
},
.list => {
const len = read(u16, &ip);
try vm.consumeFuel(&fuel, len);
const list_end = vm.stack_top;
vm.stack_top -= len;
const elements = vm.stack[vm.stack_top..list_end];
const list = allocator.dupe(Value, elements) catch return vm.outOfMemory();
const ref = allocator.create(value.Ref) catch return vm.outOfMemory();
ref.* = .{ .list = list };
try vm.push(.{ .ref = ref });
continue :next readOpcode(&ip);
},
.function => {
try vm.consumeFuel(&fuel, 1);
const param_count = read(u8, &ip);
const then = read(u16, &ip);
const body = ip;
ip = closure.chunk.bytecode[then..].ptr;
const local_count = read(u8, &ip);
const capture_count = read(u8, &ip);
const captures = allocator.alloc(Value, capture_count) catch return vm.outOfMemory();
for (captures) |*capture| {
const capture_kind = read(u8, &ip);
const index = read(u8, &ip);
capture.* = switch (capture_kind) {
bytecode.capture_local => (try vm.local(bottom, index)).*,
bytecode.capture_capture => blk: {
try vm.validateBytecode(index < closure.captures.len, "capture index out of bounds", .{});
break :blk closure.captures[index];
},
else => .nil,
};
}
const new_closure = value.Closure{
.chunk = closure.chunk,
.start = @truncate(body - closure.chunk.bytecode.ptr),
.param_count = param_count,
.local_count = local_count,
.captures = captures,
};
const ref = allocator.create(value.Ref) catch return vm.outOfMemory();
ref.* = .{ .closure = new_closure };
try vm.push(.{ .ref = ref });
continue :next readOpcode(&ip);
},
.jump => {
try vm.consumeFuel(&fuel, 1);
const offset = read(u16, &ip);
ip = closure.chunk.bytecode[offset..].ptr;
continue :next readOpcode(&ip);
},
.jump_if_not => {
try vm.consumeFuel(&fuel, 1);
const offset = read(u16, &ip);
const condition = try vm.pop();
if (!condition.isTruthy()) {
ip = closure.chunk.bytecode[offset..].ptr;
}
continue :next readOpcode(&ip);
},
.field => {
try vm.consumeFuel(&fuel, 1);
const tag_count = read(u8, &ip);
const field_tag = try vm.pop();
if (field_tag != .tag) {
return vm.throw("name of data field to look up must be a tag (starting with an uppercase letter)", .{});
}
const field_tag_id = field_tag.tag;
var found_index: ?usize = null;
for (0..tag_count) |i| {
const tag_id: value.TagId = @enumFromInt(read(u16, &ip));
if (tag_id == field_tag_id) {
found_index = i;
}
}
if (found_index) |index| {
try vm.validateBytecode(index < closure.captures.len, "field index out of bounds", .{});
const field = closure.captures[index];
try vm.push(field);
} else {
return vm.throw("field with this name does not exist", .{});
}
continue :next readOpcode(&ip);
},
.call => {
try vm.consumeFuel(&fuel, 1);
const arg_count = read(u8, &ip);
const function_value = try vm.pop();
if (function_value != .ref or function_value.ref.* != .closure) {
return vm.throw("attempt to call a value that is not a function", .{});
}
const called_closure = &function_value.ref.closure;
if (arg_count != called_closure.param_count) {
return vm.throw(
"function expects {} arguments, but it received {}",
.{ called_closure.param_count, arg_count },
);
}
const call_frame = CallFrame{
.closure = closure,
.ip = ip,
.bottom = bottom,
};
const new_bottom, const overflow = @subWithOverflow(vm.stack_top, arg_count);
try vm.validateBytecode(overflow == 0, "not enough values on the stack for arguments", .{});
closure = called_closure;
ip = closure.chunk.bytecode[closure.start..].ptr;
bottom = new_bottom;
for (0..closure.local_count) |_| {
try vm.push(.nil);
}
try vm.pushCall(call_frame);
continue :next readOpcode(&ip);
},
.system => {
try vm.consumeFuel(&fuel, 1);
const index = read(u8, &ip);
const arg_count = read(u8, &ip);
const system_fn = system.fns[index];
vm.storeContext(.{ .fuel = fuel });
const result = try system_fn(.{
.vm = vm,
.allocator = allocator,
.args = vm.stack[vm.stack_top - arg_count ..],
});
const context = vm.restoreContext();
fuel = context.fuel;
vm.stack_top -= arg_count;
try vm.push(result);
continue :next readOpcode(&ip);
},
.ret => {
try vm.consumeFuel(&fuel, 1);
const result = try vm.pop();
const call_frame = try vm.popCall();
try vm.validateBytecode(
bottom <= vm.stack_top,
"called function popped too many values. bottom={} stack_top={}",
.{ bottom, vm.stack_top },
);
try vm.validateBytecode(
call_bottom <= vm.call_stack_top,
"called function popped too many call frames. call_bottom={} call_stack_top={}",
.{ call_bottom, vm.call_stack_top },
);
vm.stack_top = bottom;
try vm.push(result);
if (vm.call_stack_top == call_bottom) {
// If this is the last call frame from this `run` instance, return from the function.
vm.storeContext(Context{ .fuel = fuel });
break :next;
}
closure = call_frame.closure;
ip = call_frame.ip;
bottom = call_frame.bottom;
continue :next readOpcode(&ip);
},
_ => |invalid_opcode| {
try vm.consumeFuel(&fuel, 1);
// NOTE: Not a validateBytecode call because this is zero-cost on the happy path,
// so we don't need to disable it on release builds.
return vm.throw("corrupted bytecode (invalid opcode {})", .{invalid_opcode});
},
}
}

View file

@ -15,6 +15,7 @@ dashmap = "6.0.1"
derive_more = { version = "1.0.0", features = ["try_from"] }
eyre = "0.6.12"
haku.workspace = true
haku2.workspace = true
handlebars = "6.0.0"
indexmap = { version = "2.4.0", features = ["serde"] }
jotdown = "0.5.0"