diff --git a/.gitignore b/.gitignore index 8e1d546..8a5b313 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /database +.zig-cache diff --git a/Cargo.lock b/Cargo.lock index d75fb51..a705f92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 1360e87..8e04f74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/haku-wasm/src/lib.rs b/crates/haku-wasm/src/lib.rs index 416092f..1878026 100644 --- a/crates/haku-wasm/src/lib.rs +++ b/crates/haku-wasm/src/lib.rs @@ -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(); diff --git a/crates/haku/src/system.rs b/crates/haku/src/system.rs index 9d39ac6..aa53f1d 100644 --- a/crates/haku/src/system.rs +++ b/crates/haku/src/system.rs @@ -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 { + 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 { + 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 { + 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 { let value = args.get(vm, 0); Ok(Value::from(value.is_falsy())) diff --git a/crates/haku/src/value.rs b/crates/haku/src/value.rs index ce4c53f..494d154 100644 --- a/crates/haku/src/value.rs +++ b/crates/haku/src/value.rs @@ -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 { diff --git a/crates/haku2/Cargo.toml b/crates/haku2/Cargo.toml new file mode 100644 index 0000000..e134c61 --- /dev/null +++ b/crates/haku2/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "haku2" +version = "0.1.0" +edition = "2021" + +[dependencies] +log.workspace = true diff --git a/crates/haku2/build.rs b/crates/haku2/build.rs new file mode 100644 index 0000000..8d54495 --- /dev/null +++ b/crates/haku2/build.rs @@ -0,0 +1,61 @@ +use std::{ + env, + error::Error, + path::PathBuf, + process::{Command, Stdio}, +}; + +fn main() -> Result<(), Box> { + 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(()) +} diff --git a/crates/haku2/build.zig b/crates/haku2/build.zig new file mode 100644 index 0000000..471b287 --- /dev/null +++ b/crates/haku2/build.zig @@ -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); +} diff --git a/crates/haku2/build.zig.zon b/crates/haku2/build.zig.zon new file mode 100644 index 0000000..003291b --- /dev/null +++ b/crates/haku2/build.zig.zon @@ -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 ` 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", + }, +} diff --git a/crates/haku2/src/allocator.zig b/crates/haku2/src/allocator.zig new file mode 100644 index 0000000..79863fe --- /dev/null +++ b/crates/haku2/src/allocator.zig @@ -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, + }, +}; diff --git a/crates/haku2/src/bytecode.zig b/crates/haku2/src/bytecode.zig new file mode 100644 index 0000000..d04603a --- /dev/null +++ b/crates/haku2/src/bytecode.zig @@ -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); + } +}; diff --git a/crates/haku2/src/haku2.zig b/crates/haku2/src/haku2.zig new file mode 100644 index 0000000..f201fbd --- /dev/null +++ b/crates/haku2/src/haku2.zig @@ -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); +} diff --git a/crates/haku2/src/lib.rs b/crates/haku2/src/lib.rs new file mode 100644 index 0000000..489559c --- /dev/null +++ b/crates/haku2/src/lib.rs @@ -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:?}") + } + } +} diff --git a/crates/haku2/src/scratch.zig b/crates/haku2/src/scratch.zig new file mode 100644 index 0000000..97b91f4 --- /dev/null +++ b/crates/haku2/src/scratch.zig @@ -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(); +} diff --git a/crates/haku2/src/system.zig b/crates/haku2/src/system.zig new file mode 100644 index 0000000..3df802f --- /dev/null +++ b/crates/haku2/src/system.zig @@ -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, + } } }; +} diff --git a/crates/haku2/src/value.zig b/crates/haku2/src/value.zig new file mode 100644 index 0000000..ec700d6 --- /dev/null +++ b/crates/haku2/src/value.zig @@ -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, + }; +}; diff --git a/crates/haku2/src/vm.zig b/crates/haku2/src/vm.zig new file mode 100644 index 0000000..96eb3cd --- /dev/null +++ b/crates/haku2/src/vm.zig @@ -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}); + }, + } +} diff --git a/crates/rkgk/Cargo.toml b/crates/rkgk/Cargo.toml index 5622fd6..cdff7dc 100644 --- a/crates/rkgk/Cargo.toml +++ b/crates/rkgk/Cargo.toml @@ -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"