feat: minimal virtual machine / runtime port from ink-c

This commit is contained in:
Brett Broadhurst 2026-03-02 11:51:08 -07:00
parent 619eb3b338
commit 849908f251
Failed to generate hash of commit
5 changed files with 833 additions and 35 deletions

544
src/Story.zig Normal file
View file

@ -0,0 +1,544 @@
//! Virtual machine state for story execution.
const std = @import("std");
const tokenizer = @import("./tokenizer.zig");
const Ast = @import("./Ast.zig");
const AstGen = @import("./AstGen.zig");
pub const Object = @import("./object.zig").Object;
const assert = std.debug.assert;
const Story = @This();
allocator: std.mem.Allocator,
dump_writer: ?*std.Io.Writer = null,
is_exited: bool = false,
can_advance: bool = false,
gc_objects: std.SinglyLinkedList = .{},
globals: std.StringHashMapUnmanaged(?*Object) = .empty,
paths: std.ArrayListUnmanaged(*Object) = .empty,
stack: std.ArrayListUnmanaged(?*Object) = .empty,
call_stack: std.ArrayListUnmanaged(CallFrame) = .empty,
stack_max: usize = 128,
pub const CallFrame = struct {
ip: usize,
sp: usize,
callee: *Object.ContentPath,
};
pub const Opcode = enum(u8) {
exit,
ret,
pop,
true,
false,
add,
sub,
mul,
div,
mod,
neg,
not,
cmp_eq,
cmp_lt,
cmp_gt,
cmp_lte,
cmp_gte,
jmp,
jmp_t,
jmp_f,
call,
divert,
load_const,
load,
store,
load_global,
store_global,
load_choice_id,
content,
line,
glue,
choice,
flush,
_,
};
fn getObjectType(object: *const Object) []const u8 {
switch (object.tag) {
.number => return "Number",
.string => return "String",
.content_path => return "ContentPath",
}
}
fn printObject(writer: *std.Io.Writer, object: *const Object) !void {
const type_string = getObjectType(object);
switch (object.tag) {
.number => {
const typed_object: *const Object.Number = @ptrCast(object);
switch (typed_object.data) {
.boolean => |value| {
try writer.print("<type={s} value={s}, address={*}>", .{
type_string,
if (value) "true" else "false",
object,
});
},
.floating => |value| {
try writer.print("<type={s} value={d}, address={*}>", .{
type_string,
value,
object,
});
},
.integer => |value| {
try writer.print("<type={s} value={d}, address={*}>", .{
type_string,
value,
object,
});
},
}
},
.string => {
const typed_object: *const Object.String = @ptrCast(object);
const string_bytes = typed_object.bytes[0..typed_object.length];
try writer.print("<type={s} value=\"{s}\", address={*}>", .{
type_string,
string_bytes,
object,
});
},
.content_path => {
try writer.print("<type={s} address={*}>", .{ type_string, object });
},
}
}
pub const Dumper = struct {
story: *const Story,
fn dumpSimpleInst(
_: *const Dumper,
writer: *std.Io.Writer,
offset: usize,
op: Opcode,
) !usize {
try writer.print("{s}\n", .{@tagName(op)});
return offset + 1;
}
fn dumpByteInst(
_: *const Dumper,
writer: *std.Io.Writer,
context: *const Object.ContentPath,
offset: usize,
op: Opcode,
) !usize {
const arg = context.bytes[offset + 1];
if (op == .load_const) {
try writer.print("{s} {d} (", .{ @tagName(op), arg });
try printObject(writer, context.const_pool[arg]);
try writer.print(")\n", .{});
} else {
try writer.print("{s} {x}\n", .{ @tagName(op), arg });
}
return offset + 2;
}
fn dumpGlobalInst(
_: *const Dumper,
writer: *std.Io.Writer,
context: *const Object.ContentPath,
offset: usize,
op: Opcode,
) !usize {
const arg = context.bytes[offset + 1];
const global_name: *Object.String = @ptrCast(context.const_pool[arg]);
const name_bytes = global_name.bytes[0..global_name.length];
try writer.print("{s} {x} '{s}'\n", .{ @tagName(op), arg, name_bytes });
return offset + 2;
}
fn dumpJumpInst(
_: *const Dumper,
writer: *std.Io.Writer,
context: *const Object.ContentPath,
offset: usize,
op: Opcode,
) !usize {
var jump: u16 = @as(u16, context.bytes[offset + 1]) << 8;
jump |= context.bytes[offset + 2];
try writer.print("{s} 0x{x:0>4} (0x{x:0>4} -> 0x{x:0>4})\n", .{
@tagName(op),
jump,
offset,
offset + 3 + jump,
});
return offset + 3;
}
pub fn dumpInst(
self: *const Dumper,
writer: *std.Io.Writer,
path: *const Object.ContentPath,
offset: usize,
should_prefix: bool,
) !usize {
const name_object = path.name;
const name_bytes = name_object.bytes[0..name_object.length];
const op: Opcode = @enumFromInt(path.bytes[offset]);
if (should_prefix) {
try writer.print("<{s}>:0x{x:0>4} | ", .{ name_bytes, offset });
} else {
try writer.print("0x{x:0>4} | ", .{offset});
}
switch (op) {
.exit,
.ret,
.pop,
.true,
.false,
.add,
.sub,
.mul,
.div,
.mod,
.neg,
.not,
.cmp_eq,
.cmp_lt,
.cmp_lte,
.cmp_gt,
.cmp_gte,
.flush,
.load_choice_id,
.content,
.choice,
.line,
.glue,
=> return self.dumpSimpleInst(writer, offset, op),
.load_const,
.load,
.store,
=> return self.dumpByteInst(writer, path, offset, op),
.load_global,
.store_global,
.call,
.divert,
=> return self.dumpGlobalInst(writer, path, offset, op),
.jmp,
.jmp_t,
.jmp_f,
=> return self.dumpJumpInst(writer, path, offset, op),
else => |code| {
try writer.print("Unknown opcode 0x{x:0>4}\n", .{@intFromEnum(code)});
return offset + 1;
},
}
}
pub fn dump(
self: *const Dumper,
writer: *std.Io.Writer,
path: *const Object.ContentPath,
) !void {
const name_object = path.name;
const name_bytes = name_object.bytes[0..name_object.length];
try writer.print("=== {s}(args: {d}, locals: {d}) ===\n", .{
name_bytes,
path.arity,
path.locals_count,
});
var index: usize = 0;
while (index < path.bytes.len) {
index = try self.dumpInst(writer, path, index, false);
}
return writer.flush();
}
};
pub fn deinit(story: *Story) void {
const gpa = story.allocator;
var next = story.gc_objects.first;
while (next) |node| {
const object: *Object = @alignCast(@fieldParentPtr("node", node));
next = node.next;
object.destroy(story);
}
story.globals.deinit(gpa);
story.paths.deinit(gpa);
story.stack.deinit(gpa);
story.call_stack.deinit(gpa);
}
pub fn trace(story: *Story, writer: *std.Io.Writer, frame: *CallFrame) !void {
try writer.print("\tStack => stack_pointer={d}, objects=[", .{frame.sp});
const stack = &story.stack;
const stack_top = story.stack.items.len;
if (stack_top > 0) {
const last_slot = stack.items[stack.items.len - 1];
for (stack.items[frame.sp .. stack.items.len - 1]) |slot| {
if (slot) |object| {
try printObject(writer, object);
} else {
try writer.writeAll("NULL");
}
try writer.writeAll(", ");
}
if (last_slot) |object| {
try printObject(writer, object);
} else {
try writer.writeAll("NULL");
}
}
try writer.writeAll("]\n");
const dumper = Dumper{ .story = story };
_ = try dumper.dumpInst(writer, frame.callee, frame.ip, true);
return writer.flush();
}
fn isCallStackEmpty(vm: *const Story) bool {
return vm.call_stack.items.len == 0;
}
fn currentFrame(vm: *Story) *CallFrame {
return &vm.call_stack.items[vm.call_stack.items.len - 1];
}
fn peekStack(vm: *Story, offset: usize) ?*Object {
const stack_top = vm.stack.items.len;
assert(stack_top > offset);
assert(stack_top != 0);
return vm.stack.items[stack_top - offset - 1];
}
fn pushStack(vm: *Story, object: *Object) !void {
const gpa = vm.allocator;
const stack_top = vm.stack.items.len;
const max_stack_top = vm.stack_max;
if (stack_top >= max_stack_top) return error.StackOverflow;
return vm.stack.append(gpa, object);
}
fn popStack(vm: *Story) ?*Object {
return vm.stack.pop() orelse unreachable;
}
fn getConstant(_: *Story, frame: *CallFrame, offset: u8) !*Object {
const constant_pool = frame.callee.const_pool;
if (offset >= constant_pool.len) return error.InvalidArgument;
return constant_pool[offset];
}
fn getLocal(vm: *Story, frame: *CallFrame, offset: u8) ?*Object {
const stack_top = vm.stack.capacity;
const stack_offset = frame.sp + offset;
assert(stack_top > stack_offset);
return vm.stack.items[stack_offset];
}
fn setLocal(vm: *Story, frame: *CallFrame, offset: u8, value: *Object) void {
const stack_top = vm.stack.capacity;
const stack_offset = frame.sp + offset;
assert(stack_top > stack_offset);
vm.stack.items[stack_offset] = value;
}
fn getGlobal(vm: *Story, key: *const Object.String) !*Object {
const key_bytes = key.bytes[0..key.length];
const val = vm.globals.get(key_bytes) orelse return error.InvalidVariable;
return val orelse unreachable;
}
fn setGlobal(vm: *Story, key: *const Object.String, value: *Object) !void {
const key_bytes = key.bytes[0..key.length];
return vm.globals.putAssumeCapacity(key_bytes, value);
}
fn execute(vm: *Story, writer: *std.Io.Writer) !void {
defer {
vm.can_advance = false;
}
if (vm.isCallStackEmpty()) return;
const frame = vm.currentFrame();
const code = std.mem.bytesAsSlice(Opcode, frame.callee.bytes);
while (true) {
if (vm.trace_writer) |w| {
vm.trace(w, frame) catch {};
}
switch (code[frame.ip]) {
.exit => {
vm.is_exited = true;
return;
},
.pop => {
const object_top = vm.popStack();
if (object_top == null) return error.InvalidArgument;
frame.ip += 1;
},
.add => {
const lhs = vm.peekStack(1) orelse return error.Bugged;
const rhs = vm.peekStack(0) orelse return error.Bugged;
const value = try Object.add(vm, lhs, rhs);
_ = vm.popStack();
_ = vm.popStack();
try vm.pushStack(value);
frame.ip += 1;
},
.sub, .mul, .div, .mod => |op| {
const lhs = vm.peekStack(1) orelse return error.Bugged;
const rhs = vm.peekStack(0) orelse return error.Bugged;
const value = try Object.Number.performArithmetic(vm, op, @ptrCast(lhs), @ptrCast(rhs));
_ = vm.popStack();
_ = vm.popStack();
try vm.pushStack(@ptrCast(value));
frame.ip += 1;
},
.neg => {
const arg_object = vm.peekStack(0);
if (arg_object) |arg| {
_ = Object.Number.negate(@ptrCast(arg));
}
frame.ip += 1;
},
.content => {
const arg_object = vm.popStack();
if (arg_object) |object| {
const string_object = try Object.String.fromObject(vm, object);
const string_bytes = string_object.bytes[0..string_object.length];
try writer.writeAll(string_bytes);
}
frame.ip += 1;
},
.load_const => {
const index: u8 = @intFromEnum(code[frame.ip + 1]);
const value = try vm.getConstant(frame, index);
try vm.pushStack(value);
frame.ip += 2;
},
.load => {
const arg_offset: u8 = @intFromEnum(code[frame.ip + 1]);
const value = vm.getLocal(frame, arg_offset) orelse return error.Bugged;
try vm.pushStack(value);
frame.ip += 2;
},
.store => {
const value = vm.peekStack(0);
if (value) |arg| {
const arg_offset: u8 = @intFromEnum(code[frame.ip + 1]);
vm.setLocal(frame, arg_offset, arg);
}
frame.ip += 2;
},
.load_global => {
const arg_offset: u8 = @intFromEnum(code[frame.ip + 1]);
const global_name = try vm.getConstant(frame, arg_offset);
assert(global_name.tag == .string);
const global_value = try vm.getGlobal(@ptrCast(global_name));
try vm.pushStack(global_value);
frame.ip += 2;
},
.store_global => {
const arg_offset: u8 = @intFromEnum(code[frame.ip + 1]);
const global_name = try vm.getConstant(frame, arg_offset);
assert(global_name.tag == .string);
const value = vm.peekStack(0);
if (value) |arg| {
try vm.setGlobal(@ptrCast(global_name), arg);
_ = vm.popStack();
try vm.pushStack(arg);
}
frame.ip += 2;
},
else => return error.InvalidInstruction,
}
}
}
pub fn advance(story: *Story) ![]const u8 {
const gpa = story.allocator;
var aw = std.Io.Writer.Allocating.init(gpa);
try story.execute(&aw.writer);
return aw.toOwnedSlice();
}
fn divert(vm: *Story, path_name: []const u8) !void {
const gpa = vm.allocator;
const path_object: ?*Object.ContentPath = blk: {
for (vm.paths.items) |object| {
const current_path: *Object.ContentPath = @ptrCast(object);
const current_name = current_path.name;
// TODO(Brett): We probably should create a method for doing this.
const name_bytes = current_name.bytes[0..current_name.length];
if (std.mem.eql(u8, name_bytes, path_name)) break :blk current_path;
}
break :blk null;
};
if (path_object) |path| {
// TODO(Brett): Add arguments?
const stack_needed = path.arity + path.locals_count;
const stack_ptr = vm.stack.items.len;
try vm.stack.ensureUnusedCapacity(gpa, stack_needed);
try vm.call_stack.ensureUnusedCapacity(gpa, 1);
vm.stack.appendNTimesAssumeCapacity(null, stack_needed);
vm.call_stack.appendAssumeCapacity(.{ .ip = 0, .sp = stack_ptr, .callee = path });
} else return error.InvalidPath;
}
pub const LoadOptions = struct {
dump_writer: ?*std.Io.Writer = null,
stderr_writer: *std.Io.Writer,
use_color: bool = true,
};
pub fn loadFromString(
gpa: std.mem.Allocator,
source_bytes: [:0]const u8,
options: LoadOptions,
) !Story {
var arena_allocator = std.heap.ArenaAllocator.init(gpa);
defer arena_allocator.deinit();
const arena = arena_allocator.allocator();
const ast = try Ast.parse(gpa, arena, source_bytes, "<STDIN>", 0);
if (options.dump_writer) |w| {
try ast.render(gpa, w, .{
.use_color = options.use_color,
});
}
if (ast.errors.len > 0) {
try ast.renderErrors(gpa, options.stderr_writer, .{
.use_color = options.use_color,
});
return error.Invalid;
}
var story = try AstGen.generate(gpa, &ast);
errdefer story.deinit();
try story.divert("@main@");
story.dump_writer = options.dump_writer;
story.can_advance = true;
return story;
}