diff --git a/src/Grob.Core/Chunk.cs b/src/Grob.Core/Chunk.cs
index fcf8cdc..90fba28 100644
--- a/src/Grob.Core/Chunk.cs
+++ b/src/Grob.Core/Chunk.cs
@@ -2,15 +2,19 @@ namespace Grob.Core;
///
/// A compiled bytecode chunk: instruction bytes, constant pool, and per-instruction
-/// source line numbers.
+/// source positions (line + column).
///
/// Write surface (WriteByte, AddConstant) is used by the compiler
/// (Sprint 2 Increment D) and by hand-constructed test chunks (Increment A/B).
+/// Source columns are 1-based; 0 is the sentinel used when a chunk byte
+/// has no meaningful column origin (synthetic prologue, hand-built test bytecode
+/// that did not supply one, etc.).
///
public sealed class Chunk {
private readonly List _code = [];
private readonly List _constants = [];
- private readonly List _lines = []; // parallel to _code: source line per byte
+ private readonly List _lines = []; // parallel to _code: source line per byte
+ private readonly List _columns = []; // parallel to _code: source column per byte (0 = unknown)
// ----- Read surface (Disassembler and VM) -----
@@ -29,17 +33,51 @@ public sealed class Chunk {
/// Source line number for the instruction byte at .
public int GetLine(int offset) => _lines[offset];
+ ///
+ /// Source column number for the instruction byte at .
+ /// Returns 0 when no column was supplied at write time. Columns are
+ /// 1-based when present.
+ ///
+ public int GetColumn(int offset) => _columns[offset];
+
// ----- Write surface (Compiler / hand-constructed tests) -----
- /// Append a raw byte attributed to .
- public void WriteByte(byte value, int line) {
+ ///
+ /// Append a raw byte attributed to . Column is
+ /// recorded as 0 ("unknown"); prefer the
+ /// overload from the compiler so runtime errors can point at the exact
+ /// column.
+ ///
+ public void WriteByte(byte value, int line) => WriteByte(value, line, 0);
+
+ ///
+ /// Append a raw byte attributed to and
+ /// (1-based; 0 means "unknown").
+ ///
+ ///
+ /// Thrown when is less than 1 (lines are
+ /// always 1-based and never optional) or when
+ /// is negative. Columns are 1-based when present, or 0 for
+ /// "unknown"; negative values are never valid source metadata.
+ ///
+ public void WriteByte(byte value, int line, int column) {
+ ArgumentOutOfRangeException.ThrowIfLessThan(line, 1);
+ ArgumentOutOfRangeException.ThrowIfNegative(column);
_code.Add(value);
_lines.Add(line);
+ _columns.Add(column);
}
/// Append an opcode byte attributed to .
public void WriteOpCode(OpCode opCode, int line) =>
- WriteByte((byte)opCode, line);
+ WriteByte((byte)opCode, line, 0);
+
+ ///
+ /// Append an opcode byte attributed to and
+ /// (1-based; 0 means "unknown").
+ ///
+ public void WriteOpCode(OpCode opCode, int line, int column) =>
+ WriteByte((byte)opCode, line, column);
///
/// Add a value to the constant pool and return its index.
diff --git a/src/Grob.Core/GrobArithmeticException.cs b/src/Grob.Core/GrobArithmeticException.cs
new file mode 100644
index 0000000..df5640e
--- /dev/null
+++ b/src/Grob.Core/GrobArithmeticException.cs
@@ -0,0 +1,26 @@
+namespace Grob.Core;
+
+///
+/// Arithmetic runtime error: integer overflow, division by zero, modulo by
+/// zero, math domain violations. Maps to the Grob ArithmeticError
+/// exception type (D-284).
+///
+public sealed class GrobArithmeticException : GrobRuntimeException {
+ ///
+ /// Initialises a new with the
+ /// supplied error , source ,
+ /// and human-readable .
+ /// is recorded as 0.
+ ///
+ public GrobArithmeticException(string code, int line, string message)
+ : base(code, line, message) { }
+
+ ///
+ /// Initialises a new with the
+ /// supplied error , source ,
+ /// 1-based , and human-readable
+ /// .
+ ///
+ public GrobArithmeticException(string code, int line, int column, string message)
+ : base(code, line, column, message) { }
+}
diff --git a/src/Grob.Core/GrobRuntimeException.cs b/src/Grob.Core/GrobRuntimeException.cs
new file mode 100644
index 0000000..b41c01b
--- /dev/null
+++ b/src/Grob.Core/GrobRuntimeException.cs
@@ -0,0 +1,59 @@
+namespace Grob.Core;
+
+///
+/// Base type for Grob runtime errors raised by the VM. The two-mode error
+/// model (D-284): the compiler/checker collect all errors; the VM stops on
+/// the first runtime error. Carries the error code from grob-error-codes.md
+/// and the source position (line + column) from the chunk's per-instruction
+/// position arrays. is 0 when no column was
+/// recorded for the failing instruction.
+///
+public class GrobRuntimeException : Exception {
+ /// The grob-error-codes.md identifier (e.g. E5001).
+ public string Code { get; }
+
+ /// The source line attributed to the failing instruction.
+ public int Line { get; }
+
+ ///
+ /// The 1-based source column attributed to the failing instruction.
+ /// 0 indicates the chunk byte was written without a column
+ /// (e.g. hand-built test bytecode or synthetic prologue).
+ ///
+ public int Column { get; }
+
+ ///
+ /// Initialises a new with the supplied
+ /// error , source , and
+ /// human-readable . is
+ /// recorded as 0 ("unknown"); prefer the four-argument constructor
+ /// when a column is available.
+ ///
+ public GrobRuntimeException(string code, int line, string message)
+ : this(code, line, 0, message) { }
+
+ ///
+ /// Initialises a new with the supplied
+ /// error , source ,
+ /// 1-based , and human-readable
+ /// .
+ ///
+ ///
+ /// Thrown when is or empty,
+ /// or when is or empty.
+ ///
+ ///
+ /// Thrown when is less than 1 or
+ /// is negative.
+ ///
+ public GrobRuntimeException(string code, int line, int column, string message)
+ : base(message) {
+ ArgumentException.ThrowIfNullOrEmpty(code);
+ ArgumentOutOfRangeException.ThrowIfLessThan(line, 1);
+ ArgumentOutOfRangeException.ThrowIfNegative(column);
+ ArgumentException.ThrowIfNullOrEmpty(message);
+ Code = code;
+ Line = line;
+ Column = column;
+ }
+}
diff --git a/src/Grob.Vm/ValueStack.cs b/src/Grob.Vm/ValueStack.cs
new file mode 100644
index 0000000..500026d
--- /dev/null
+++ b/src/Grob.Vm/ValueStack.cs
@@ -0,0 +1,85 @@
+using Grob.Core;
+
+namespace Grob.Vm;
+
+///
+/// The VM operand stack: a fixed-capacity array of
+/// slots. Pushing a primitive (Bool/Int/Float) is a 24-byte struct copy with
+/// no allocation (D-303, D-304).
+///
+/// Authority: grob-vm-architecture.md — value stack section.
+///
+public sealed class ValueStack {
+ ///
+ /// Maximum simultaneous live values on the operand stack. Chosen to comfortably
+ /// exceed Sprint 2's needs (no call frames yet) while leaving headroom for
+ /// future locals and intermediate computation. The value-stack overflow
+ /// path surfaces as a runtime error, not an unguarded array write.
+ ///
+ public const int Capacity = 16384;
+
+ private readonly GrobValue[] _values = new GrobValue[Capacity];
+ private int _top;
+
+ /// Number of values currently on the stack.
+ public int Count => _top;
+
+ ///
+ /// Push onto the top of the stack. On overflow
+ /// throws carrying
+ /// rather than an unguarded array write.
+ ///
+ public void Push(GrobValue value, int line) {
+ if (_top == _values.Length)
+ throw new GrobRuntimeException("E5903", line, "value stack overflow");
+ _values[_top++] = value;
+ }
+
+ ///
+ /// Pop and return the top of the stack. Underflow is a compiler/VM bug,
+ /// not a user-reachable runtime error — surfaces as
+ /// .
+ ///
+ public GrobValue Pop() {
+ if (_top == 0)
+ throw new GrobInternalException("value stack underflow");
+ var value = _values[--_top];
+ _values[_top] = default; // release reference slots for GC (D-304)
+ return value;
+ }
+
+ ///
+ /// Read the value at below the top without
+ /// popping. distance == 0 is the top. Negative distances and
+ /// distances past the bottom of the live region are compiler/VM bugs
+ /// and surface as .
+ ///
+ public GrobValue Peek(int distance = 0) {
+ if (distance < 0)
+ throw new GrobInternalException("value stack peek with negative distance");
+ int index = _top - 1 - distance;
+ if (index < 0)
+ throw new GrobInternalException("value stack peek underflow");
+ return _values[index];
+ }
+
+ ///
+ /// Logically empty the stack. Clears the live region (slots 0..Count)
+ /// via so any reference values
+ /// left over from a prior run are released to the GC (D-304), then resets
+ /// the top pointer to zero. Used by VirtualMachine.Run to start each
+ /// invocation from a clean operand stack regardless of any leftovers from
+ /// a prior exception-terminated run.
+ ///
+ internal void Reset() {
+ if (_top > 0)
+ Array.Clear(_values, 0, _top); // release reference slots for GC (D-304)
+ _top = 0;
+ }
+
+ ///
+ /// Snapshot the live region of the stack — used by the
+ /// #if DEBUG trace hook to render the stack each iteration.
+ ///
+ internal ReadOnlySpan AsSpan() => _values.AsSpan(0, _top);
+}
diff --git a/src/Grob.Vm/VirtualMachine.cs b/src/Grob.Vm/VirtualMachine.cs
new file mode 100644
index 0000000..85cd3e6
--- /dev/null
+++ b/src/Grob.Vm/VirtualMachine.cs
@@ -0,0 +1,248 @@
+using Grob.Core;
+
+namespace Grob.Vm;
+
+///
+/// The Grob stack-based bytecode VM. Owns the operand stack and the
+/// fetch-decode-execute dispatch loop. Sprint 2 Increment B implements the
+/// subset of needed to execute hand-constructed chunks
+/// up to print(2 + 3 * 4) — see for the supported
+/// set; out-of-scope opcodes (control flow, calls, globals, structs, arrays,
+/// closures, exceptions, increments, properties, build-string, etc.) raise
+/// until their owning increment lands.
+///
+/// Authority: grob-vm-architecture.md (dispatch loop, value stack, developer
+/// diagnostics) and grob-v1-requirements.md §3.3 (the OpCode set).
+///
+public sealed class VirtualMachine {
+ private readonly ValueStack _stack = new();
+ private readonly TextWriter _out;
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Major Code Smell",
+ "S4487:Unread \"private\" fields should be removed",
+ Justification = "Read only inside #if DEBUG by the per-instruction trace hook; appears unread to Release-mode static analysis. Field is required so a single VM instance can be configured with a trace writer regardless of build configuration.")]
+ private readonly TextWriter _trace;
+
+ ///
+ /// Construct a VM whose output goes to
+ /// . receives the
+ /// #if DEBUG per-instruction trace (defaults to
+ /// , which is also the only meaningful value
+ /// in Release where the trace call is compiled out entirely).
+ ///
+ public VirtualMachine(TextWriter output, TextWriter? trace = null) {
+ ArgumentNullException.ThrowIfNull(output);
+ _out = output;
+ _trace = trace ?? TextWriter.Null;
+ }
+
+ /// The operand stack, exposed for tests to inspect post-run state.
+ public ValueStack Stack => _stack;
+
+ ///
+ /// Execute until .
+ /// Running off the end of the bytecode without a
+ /// is treated as a malformed chunk — it raises
+ /// , because the compiler always emits
+ /// a terminating Return and hand-constructed test chunks must do
+ /// the same.
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Major Code Smell",
+ "S3776:Cognitive Complexity of methods should not be too high",
+ Justification = "Bytecode dispatch loop. Per D-302 each opcode is handled inline in a single switch to keep dispatch branch-free; extracting per-opcode handlers would add a call frame per instruction and is explicitly rejected.")]
+ public void Run(Chunk chunk) {
+ ArgumentNullException.ThrowIfNull(chunk);
+
+ // Defensive: a prior Run that terminated by exception may have left
+ // values on the operand stack. Start every invocation clean so the
+ // VM behaves the same on the Nth chunk as on the first.
+ _stack.Reset();
+
+ int ip = 0;
+ int line = 0;
+ int column = 0;
+
+ try {
+ while (true) {
+ if (ip >= chunk.Count)
+ throw new GrobInternalException(
+ "execution ran past end of chunk without Return");
+
+ line = chunk.GetLine(ip);
+ column = chunk.GetColumn(ip);
+
+#if DEBUG
+ TraceInstruction(chunk, ip);
+#endif
+
+ byte instruction = chunk.ReadByte(ip);
+ ip++;
+
+ switch ((OpCode)instruction) {
+ // --- Constants and singletons ---
+ case OpCode.Constant: {
+ byte index = chunk.ReadByte(ip++);
+ _stack.Push(chunk.ReadConstant(index), line);
+ break;
+ }
+ case OpCode.ConstantLong: {
+ int index = (chunk.ReadByte(ip) << 8) | chunk.ReadByte(ip + 1);
+ ip += 2;
+ _stack.Push(chunk.ReadConstant(index), line);
+ break;
+ }
+ case OpCode.Nil: _stack.Push(GrobValue.Nil, line); break;
+ case OpCode.True: _stack.Push(GrobValue.FromBool(true), line); break;
+ case OpCode.False: _stack.Push(GrobValue.FromBool(false), line); break;
+
+ case OpCode.Pop: _stack.Pop(); break;
+ case OpCode.PopN: {
+ byte count = chunk.ReadByte(ip++);
+ for (int i = 0; i < count; i++) _stack.Pop();
+ break;
+ }
+
+ // --- Integer arithmetic (checked; OverflowException → E5001) ---
+ case OpCode.AddInt: {
+ long b = _stack.Pop().AsInt();
+ long a = _stack.Pop().AsInt();
+ _stack.Push(GrobValue.FromInt(checked(a + b)), line);
+ break;
+ }
+ case OpCode.SubtractInt: {
+ long b = _stack.Pop().AsInt();
+ long a = _stack.Pop().AsInt();
+ _stack.Push(GrobValue.FromInt(checked(a - b)), line);
+ break;
+ }
+ case OpCode.MultiplyInt: {
+ long b = _stack.Pop().AsInt();
+ long a = _stack.Pop().AsInt();
+ _stack.Push(GrobValue.FromInt(checked(a * b)), line);
+ break;
+ }
+ case OpCode.DivideInt: {
+ long b = _stack.Pop().AsInt();
+ long a = _stack.Pop().AsInt();
+ if (b == 0L)
+ throw new GrobArithmeticException("E5002", line, column, "integer division by zero");
+ // long.MinValue / -1 overflows: caught below as E5001.
+ _stack.Push(GrobValue.FromInt(checked(a / b)), line);
+ break;
+ }
+ case OpCode.ModuloInt: {
+ long b = _stack.Pop().AsInt();
+ long a = _stack.Pop().AsInt();
+ if (b == 0L)
+ throw new GrobArithmeticException("E5003", line, column, "integer modulo by zero");
+ _stack.Push(GrobValue.FromInt(checked(a % b)), line);
+ break;
+ }
+ case OpCode.NegateInt: {
+ long a = _stack.Pop().AsInt();
+ _stack.Push(GrobValue.FromInt(checked(-a)), line);
+ break;
+ }
+
+ // --- Float arithmetic (D-273: x / 0.0 and x % 0.0 throw) ---
+ case OpCode.AddFloat: {
+ double b = _stack.Pop().AsFloat();
+ double a = _stack.Pop().AsFloat();
+ _stack.Push(GrobValue.FromFloat(a + b), line);
+ break;
+ }
+ case OpCode.SubtractFloat: {
+ double b = _stack.Pop().AsFloat();
+ double a = _stack.Pop().AsFloat();
+ _stack.Push(GrobValue.FromFloat(a - b), line);
+ break;
+ }
+ case OpCode.MultiplyFloat: {
+ double b = _stack.Pop().AsFloat();
+ double a = _stack.Pop().AsFloat();
+ _stack.Push(GrobValue.FromFloat(a * b), line);
+ break;
+ }
+ case OpCode.DivideFloat: {
+ double b = _stack.Pop().AsFloat();
+ double a = _stack.Pop().AsFloat();
+#pragma warning disable S1244 // Exact-zero check is intentional per D-273: +0.0/-0.0 both caught, NaN propagates as NaN.
+ if (b == 0.0)
+#pragma warning restore S1244
+ throw new GrobArithmeticException("E5004", line, column, "float division by zero");
+ _stack.Push(GrobValue.FromFloat(a / b), line);
+ break;
+ }
+ case OpCode.ModuloFloat: {
+ double b = _stack.Pop().AsFloat();
+ double a = _stack.Pop().AsFloat();
+#pragma warning disable S1244 // Exact-zero check is intentional per D-273: +0.0/-0.0 both caught, NaN propagates as NaN.
+ if (b == 0.0)
+#pragma warning restore S1244
+ throw new GrobArithmeticException("E5005", line, column, "float modulo by zero");
+ _stack.Push(GrobValue.FromFloat(a % b), line);
+ break;
+ }
+ case OpCode.NegateFloat: {
+ double a = _stack.Pop().AsFloat();
+ _stack.Push(GrobValue.FromFloat(-a), line);
+ break;
+ }
+
+ // --- Strings ---
+ case OpCode.Concat: {
+ string b = _stack.Pop().AsString();
+ string a = _stack.Pop().AsString();
+ _stack.Push(GrobValue.FromString(string.Concat(a, b)), line);
+ break;
+ }
+
+ // --- Promotion ---
+ case OpCode.IntToFloat: {
+ long a = _stack.Pop().AsInt();
+ _stack.Push(GrobValue.FromFloat(a), line);
+ break;
+ }
+
+ // --- I/O ---
+ case OpCode.Print:
+ _out.WriteLine(_stack.Pop().ToString());
+ break;
+
+ // --- Top-level return ends this chunk's execution ---
+ case OpCode.Return:
+ return;
+
+ default:
+ throw new GrobInternalException(
+ $"opcode {(OpCode)instruction} not implemented in Sprint 2 Increment B dispatch loop");
+ }
+ }
+ } catch (OverflowException) {
+ // Centralised handler for `checked(...)` arithmetic: any int op
+ // that overflows surfaces as E5001 carrying the failing line.
+ throw new GrobArithmeticException("E5001", line, column, "integer overflow");
+ }
+ }
+
+#if DEBUG
+ ///
+ /// D-306 per-instruction trace: renders the value stack and the
+ /// about-to-execute instruction every iteration of the dispatch loop.
+ /// Compiled into Debug builds only — entirely absent in Release so that
+ /// D-302 benchmarks measure a branch-free dispatch path.
+ ///
+ private void TraceInstruction(Chunk chunk, int ip) {
+ _trace.Write(" ");
+ var span = _stack.AsSpan();
+ for (int i = 0; i < span.Length; i++) {
+ _trace.Write("[ ");
+ _trace.Write(span[i].ToString());
+ _trace.Write(" ]");
+ }
+ _trace.WriteLine();
+ Disassembler.DisassembleInstruction(chunk, ip, _trace);
+ }
+#endif
+}
diff --git a/tests/Grob.Core.Tests/ChunkColumnTests.cs b/tests/Grob.Core.Tests/ChunkColumnTests.cs
new file mode 100644
index 0000000..352db54
--- /dev/null
+++ b/tests/Grob.Core.Tests/ChunkColumnTests.cs
@@ -0,0 +1,30 @@
+using Grob.Core;
+using Xunit;
+
+namespace Grob.Core.Tests;
+
+public class ChunkColumnTests {
+ [Fact]
+ public void GetColumn_DefaultOverloads_ReturnZero() {
+ var chunk = new Chunk();
+ chunk.WriteOpCode(OpCode.Return, 7);
+ chunk.WriteByte(0xAB, 7);
+
+ Assert.Equal(7, chunk.GetLine(0));
+ Assert.Equal(0, chunk.GetColumn(0));
+ Assert.Equal(7, chunk.GetLine(1));
+ Assert.Equal(0, chunk.GetColumn(1));
+ }
+
+ [Fact]
+ public void GetColumn_ColumnAwareOverloads_RoundtripPerByte() {
+ var chunk = new Chunk();
+ chunk.WriteOpCode(OpCode.Constant, line: 12, column: 5);
+ chunk.WriteByte(0x00, line: 12, column: 14);
+ chunk.WriteOpCode(OpCode.Return, line: 13, column: 1);
+
+ Assert.Equal((12, 5), (chunk.GetLine(0), chunk.GetColumn(0)));
+ Assert.Equal((12, 14), (chunk.GetLine(1), chunk.GetColumn(1)));
+ Assert.Equal((13, 1), (chunk.GetLine(2), chunk.GetColumn(2)));
+ }
+}
diff --git a/tests/Grob.Vm.Tests/VirtualMachineTests.cs b/tests/Grob.Vm.Tests/VirtualMachineTests.cs
new file mode 100644
index 0000000..3b187ae
--- /dev/null
+++ b/tests/Grob.Vm.Tests/VirtualMachineTests.cs
@@ -0,0 +1,502 @@
+using System.Text;
+using Grob.Core;
+using Xunit;
+
+namespace Grob.Vm.Tests;
+
+///
+/// Dispatch-loop tests — all against hand-constructed chunks.
+/// Sprint 2 Increment B: no compiler exists yet; chunks are built directly
+/// via and .
+///
+public sealed class VirtualMachineTests {
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private static (VirtualMachine vm, StringWriter output) NewVm() {
+ var output = new StringWriter();
+ var vm = new VirtualMachine(output);
+ return (vm, output);
+ }
+
+ private static byte ConstByte(Chunk chunk, GrobValue value) =>
+ (byte)chunk.AddConstant(value);
+
+ // -------------------------------------------------------------------------
+ // The 2 + 3 * 4 chunk — the acceptance witness
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void TwoPlusThreeTimesFour_PrintsFourteen() {
+ var chunk = new Chunk();
+ byte i2 = ConstByte(chunk, GrobValue.FromInt(2));
+ byte i3 = ConstByte(chunk, GrobValue.FromInt(3));
+ byte i4 = ConstByte(chunk, GrobValue.FromInt(4));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i2, 1);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i3, 1);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i4, 1);
+ chunk.WriteOpCode(OpCode.MultiplyInt, 1);
+ chunk.WriteOpCode(OpCode.AddInt, 1);
+ chunk.WriteOpCode(OpCode.Print, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, output) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal($"14{Environment.NewLine}", output.ToString());
+ Assert.Equal(0, vm.Stack.Count);
+ }
+
+ [Fact]
+ public void TwoPlusThreeTimesFour_LeavesFourteenOnStackWhenNoPrint() {
+ var chunk = new Chunk();
+ byte i2 = ConstByte(chunk, GrobValue.FromInt(2));
+ byte i3 = ConstByte(chunk, GrobValue.FromInt(3));
+ byte i4 = ConstByte(chunk, GrobValue.FromInt(4));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i2, 1);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i3, 1);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i4, 1);
+ chunk.WriteOpCode(OpCode.MultiplyInt, 1);
+ chunk.WriteOpCode(OpCode.AddInt, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal(1, vm.Stack.Count);
+ Assert.Equal(14L, vm.Stack.Peek().AsInt());
+ }
+
+ // -------------------------------------------------------------------------
+ // Integer arithmetic
+ // -------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(OpCode.AddInt, 5L, 7L, 12L)]
+ [InlineData(OpCode.SubtractInt, 10L, 4L, 6L)]
+ [InlineData(OpCode.MultiplyInt, 6L, 7L, 42L)]
+ [InlineData(OpCode.DivideInt, 7L, 2L, 3L)] // truncating
+ [InlineData(OpCode.DivideInt, -7L, 2L, -3L)] // truncating, signed
+ [InlineData(OpCode.ModuloInt, 7L, 3L, 1L)]
+ [InlineData(OpCode.ModuloInt, -7L, 3L, -1L)] // sign of dividend
+ public void IntArithmetic_BinaryOpsProduceExpectedResult(OpCode op, long a, long b, long expected) {
+ var chunk = new Chunk();
+ byte ia = ConstByte(chunk, GrobValue.FromInt(a));
+ byte ib = ConstByte(chunk, GrobValue.FromInt(b));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ia, 1);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ib, 1);
+ chunk.WriteOpCode(op, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal(expected, vm.Stack.Peek().AsInt());
+ }
+
+ [Fact]
+ public void NegateInt_FlipsSign() {
+ var chunk = new Chunk();
+ byte i = ConstByte(chunk, GrobValue.FromInt(42));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i, 1);
+ chunk.WriteOpCode(OpCode.NegateInt, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal(-42L, vm.Stack.Peek().AsInt());
+ }
+
+ // -------------------------------------------------------------------------
+ // Float arithmetic
+ // -------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(OpCode.AddFloat, 1.5, 2.25, 3.75)]
+ [InlineData(OpCode.SubtractFloat, 3.5, 1.25, 2.25)]
+ [InlineData(OpCode.MultiplyFloat, 2.0, 2.5, 5.0)]
+ [InlineData(OpCode.DivideFloat, 7.0, 2.0, 3.5)]
+ [InlineData(OpCode.ModuloFloat, 7.5, 2.0, 1.5)]
+ public void FloatArithmetic_BinaryOpsProduceExpectedResult(OpCode op, double a, double b, double expected) {
+ var chunk = new Chunk();
+ byte ia = ConstByte(chunk, GrobValue.FromFloat(a));
+ byte ib = ConstByte(chunk, GrobValue.FromFloat(b));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ia, 1);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ib, 1);
+ chunk.WriteOpCode(op, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal(expected, vm.Stack.Peek().AsFloat());
+ }
+
+ [Fact]
+ public void NegateFloat_FlipsSign() {
+ var chunk = new Chunk();
+ byte i = ConstByte(chunk, GrobValue.FromFloat(3.14));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i, 1);
+ chunk.WriteOpCode(OpCode.NegateFloat, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal(-3.14, vm.Stack.Peek().AsFloat());
+ }
+
+ [Fact]
+ public void IntToFloat_PromotesIntegerToDouble() {
+ var chunk = new Chunk();
+ byte i = ConstByte(chunk, GrobValue.FromInt(7));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i, 1);
+ chunk.WriteOpCode(OpCode.IntToFloat, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.True(vm.Stack.Peek().IsFloat);
+ Assert.Equal(7.0, vm.Stack.Peek().AsFloat());
+ }
+
+ // -------------------------------------------------------------------------
+ // Concat
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Concat_JoinsTwoStrings() {
+ var chunk = new Chunk();
+ byte ia = ConstByte(chunk, GrobValue.FromString("hello, "));
+ byte ib = ConstByte(chunk, GrobValue.FromString("world"));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ia, 1);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ib, 1);
+ chunk.WriteOpCode(OpCode.Concat, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal("hello, world", vm.Stack.Peek().AsString());
+ }
+
+ // -------------------------------------------------------------------------
+ // Arithmetic errors — carry the correct source line
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void IntOverflowOnAdd_ThrowsArithmeticErrorWithLine() {
+ const int line = 7;
+ var chunk = new Chunk();
+ byte ia = ConstByte(chunk, GrobValue.FromInt(long.MaxValue));
+ byte ib = ConstByte(chunk, GrobValue.FromInt(1));
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ia, line);
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ib, line);
+ chunk.WriteOpCode(OpCode.AddInt, line);
+ chunk.WriteOpCode(OpCode.Return, line);
+
+ var (vm, _) = NewVm();
+ var ex = Assert.Throws(() => vm.Run(chunk));
+ Assert.Equal("E5001", ex.Code);
+ Assert.Equal(line, ex.Line);
+ }
+
+ [Fact]
+ public void IntOverflowOnNegateMinValue_ThrowsArithmeticError() {
+ const int line = 3;
+ var chunk = new Chunk();
+ byte i = ConstByte(chunk, GrobValue.FromInt(long.MinValue));
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(i, line);
+ chunk.WriteOpCode(OpCode.NegateInt, line);
+ chunk.WriteOpCode(OpCode.Return, line);
+
+ var (vm, _) = NewVm();
+ var ex = Assert.Throws(() => vm.Run(chunk));
+ Assert.Equal("E5001", ex.Code);
+ Assert.Equal(line, ex.Line);
+ }
+
+ [Fact]
+ public void IntDivideByZero_ThrowsE5002WithLine() {
+ const int line = 11;
+ var chunk = new Chunk();
+ byte ia = ConstByte(chunk, GrobValue.FromInt(10));
+ byte ib = ConstByte(chunk, GrobValue.FromInt(0));
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ia, line);
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ib, line);
+ chunk.WriteOpCode(OpCode.DivideInt, line);
+ chunk.WriteOpCode(OpCode.Return, line);
+
+ var (vm, _) = NewVm();
+ var ex = Assert.Throws(() => vm.Run(chunk));
+ Assert.Equal("E5002", ex.Code);
+ Assert.Equal(line, ex.Line);
+ }
+
+ [Fact]
+ public void IntModuloByZero_ThrowsE5003() {
+ const int line = 1;
+ const int column = 9;
+ var chunk = new Chunk();
+ byte ia = ConstByte(chunk, GrobValue.FromInt(10));
+ byte ib = ConstByte(chunk, GrobValue.FromInt(0));
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ia, line);
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ib, line);
+ chunk.WriteOpCode(OpCode.ModuloInt, line, column);
+ chunk.WriteOpCode(OpCode.Return, line);
+
+ var (vm, _) = NewVm();
+ var ex = Assert.Throws(() => vm.Run(chunk));
+ Assert.Equal("E5003", ex.Code);
+ Assert.Equal(line, ex.Line);
+ Assert.Equal(column, ex.Column);
+ }
+
+ [Fact]
+ public void FloatDivideByZero_ThrowsE5004() {
+ const int line = 1;
+ const int column = 11;
+ var chunk = new Chunk();
+ byte ia = ConstByte(chunk, GrobValue.FromFloat(1.0));
+ byte ib = ConstByte(chunk, GrobValue.FromFloat(0.0));
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ia, line);
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ib, line);
+ chunk.WriteOpCode(OpCode.DivideFloat, line, column);
+ chunk.WriteOpCode(OpCode.Return, line);
+
+ var (vm, _) = NewVm();
+ var ex = Assert.Throws(() => vm.Run(chunk));
+ Assert.Equal("E5004", ex.Code);
+ Assert.Equal(line, ex.Line);
+ Assert.Equal(column, ex.Column);
+ }
+
+ [Fact]
+ public void FloatModuloByZero_ThrowsE5005() {
+ const int line = 1;
+ const int column = 13;
+ var chunk = new Chunk();
+ byte ia = ConstByte(chunk, GrobValue.FromFloat(1.0));
+ byte ib = ConstByte(chunk, GrobValue.FromFloat(0.0));
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ia, line);
+ chunk.WriteOpCode(OpCode.Constant, line); chunk.WriteByte(ib, line);
+ chunk.WriteOpCode(OpCode.ModuloFloat, line, column);
+ chunk.WriteOpCode(OpCode.Return, line);
+
+ var (vm, _) = NewVm();
+ var ex = Assert.Throws(() => vm.Run(chunk));
+ Assert.Equal("E5005", ex.Code);
+ Assert.Equal(line, ex.Line);
+ Assert.Equal(column, ex.Column);
+ }
+
+ [Fact]
+ public void VmStopsOnFirstRuntimeError_SubsequentInstructionsNotExecuted() {
+ var chunk = new Chunk();
+ byte ia = ConstByte(chunk, GrobValue.FromInt(1));
+ byte ib = ConstByte(chunk, GrobValue.FromInt(0));
+ byte hello = ConstByte(chunk, GrobValue.FromString("should-not-print"));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ia, 1);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ib, 1);
+ chunk.WriteOpCode(OpCode.DivideInt, 1);
+ chunk.WriteOpCode(OpCode.Constant, 2); chunk.WriteByte(hello, 2);
+ chunk.WriteOpCode(OpCode.Print, 2);
+ chunk.WriteOpCode(OpCode.Return, 2);
+
+ var (vm, output) = NewVm();
+ Assert.Throws(() => vm.Run(chunk));
+ Assert.Equal(string.Empty, output.ToString());
+ }
+
+ // -------------------------------------------------------------------------
+ // Print display forms
+ // -------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(GrobValueKind.Int, "42")]
+ [InlineData(GrobValueKind.Float, "3.14")]
+ [InlineData(GrobValueKind.Bool, "true")]
+ [InlineData(GrobValueKind.String, "hello")]
+ [InlineData(GrobValueKind.Nil, "nil")]
+ public void Print_WritesDisplayFormOfScalarKinds(GrobValueKind kind, string expected) {
+ var chunk = new Chunk();
+ GrobValue value = kind switch {
+ GrobValueKind.Int => GrobValue.FromInt(42),
+ GrobValueKind.Float => GrobValue.FromFloat(3.14),
+ GrobValueKind.Bool => GrobValue.FromBool(true),
+ GrobValueKind.String => GrobValue.FromString("hello"),
+ GrobValueKind.Nil => GrobValue.Nil,
+ _ => throw new InvalidOperationException(),
+ };
+
+ if (kind == GrobValueKind.Nil) {
+ chunk.WriteOpCode(OpCode.Nil, 1);
+ } else {
+ byte ci = ConstByte(chunk, value);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ci, 1);
+ }
+ chunk.WriteOpCode(OpCode.Print, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, output) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal(expected + Environment.NewLine, output.ToString());
+ }
+
+ // -------------------------------------------------------------------------
+ // ValueStack overflow surfaces as a runtime error
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void ValueStackOverflow_SurfacesAsRuntimeErrorNotUnguardedWrite() {
+ var stack = new ValueStack();
+ for (int i = 0; i < ValueStack.Capacity; i++)
+ stack.Push(GrobValue.FromInt(i), line: 1);
+
+ var ex = Assert.Throws(
+ () => stack.Push(GrobValue.FromInt(0), line: 99));
+ Assert.Equal(99, ex.Line);
+ Assert.Equal(ValueStack.Capacity, stack.Count);
+ }
+
+ [Fact]
+ public void ValueStack_PushPopPeek_Roundtrip() {
+ var stack = new ValueStack();
+ stack.Push(GrobValue.FromInt(1), 1);
+ stack.Push(GrobValue.FromInt(2), 1);
+ stack.Push(GrobValue.FromInt(3), 1);
+ Assert.Equal(3, stack.Count);
+ Assert.Equal(3L, stack.Peek(0).AsInt());
+ Assert.Equal(2L, stack.Peek(1).AsInt());
+ Assert.Equal(1L, stack.Peek(2).AsInt());
+ Assert.Equal(3L, stack.Pop().AsInt());
+ Assert.Equal(2L, stack.Pop().AsInt());
+ Assert.Equal(1L, stack.Pop().AsInt());
+ Assert.Equal(0, stack.Count);
+ }
+
+ // -------------------------------------------------------------------------
+ // End-without-Return: malformed chunk → GrobInternalException
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void ChunkEndingWithoutReturn_RaisesInternalException() {
+ var chunk = new Chunk();
+ byte i = ConstByte(chunk, GrobValue.FromInt(1));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i, 1);
+ // No Return appended — deliberately malformed.
+
+ var (vm, _) = NewVm();
+ Assert.Throws(() => vm.Run(chunk));
+ }
+
+ // -------------------------------------------------------------------------
+ // Constants and singletons
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void ConstantLong_LoadsFromTwoByteBigEndianIndex() {
+ var chunk = new Chunk();
+ // Fill the pool until we need a 2-byte index.
+ const int target = 300;
+ int targetIndex = -1;
+ for (int i = 0; i <= target; i++) {
+ int idx = chunk.AddConstant(GrobValue.FromInt(i));
+ if (i == target) targetIndex = idx;
+ }
+ chunk.WriteOpCode(OpCode.ConstantLong, 1);
+ chunk.WriteByte((byte)((targetIndex >> 8) & 0xFF), 1);
+ chunk.WriteByte((byte)(targetIndex & 0xFF), 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal((long)target, vm.Stack.Peek().AsInt());
+ }
+
+ [Fact]
+ public void NilTrueFalse_PushSingletons() {
+ var chunk = new Chunk();
+ chunk.WriteOpCode(OpCode.Nil, 1);
+ chunk.WriteOpCode(OpCode.True, 1);
+ chunk.WriteOpCode(OpCode.False, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal(3, vm.Stack.Count);
+ Assert.False(vm.Stack.Peek(0).AsBool());
+ Assert.True(vm.Stack.Peek(1).AsBool());
+ Assert.True(vm.Stack.Peek(2).IsNil);
+ }
+
+ [Fact]
+ public void Pop_DiscardsTopOfStack() {
+ var chunk = new Chunk();
+ byte ia = ConstByte(chunk, GrobValue.FromInt(1));
+ byte ib = ConstByte(chunk, GrobValue.FromInt(2));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ia, 1);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ib, 1);
+ chunk.WriteOpCode(OpCode.Pop, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal(1, vm.Stack.Count);
+ Assert.Equal(1L, vm.Stack.Peek().AsInt());
+ }
+
+ [Fact]
+ public void PopN_DiscardsCountValues() {
+ var chunk = new Chunk();
+ for (int i = 0; i < 4; i++) {
+ byte ci = ConstByte(chunk, GrobValue.FromInt(i));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(ci, 1);
+ }
+ chunk.WriteOpCode(OpCode.PopN, 1); chunk.WriteByte(3, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var (vm, _) = NewVm();
+ vm.Run(chunk);
+
+ Assert.Equal(1, vm.Stack.Count);
+ Assert.Equal(0L, vm.Stack.Peek().AsInt());
+ }
+
+#if DEBUG
+ // -------------------------------------------------------------------------
+ // D-306 trace hook — Debug-only behaviour assertion
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void TraceInstructionInDebug_WritesStackAndDisassemblyEveryIteration() {
+ var chunk = new Chunk();
+ byte i2 = ConstByte(chunk, GrobValue.FromInt(2));
+ byte i3 = ConstByte(chunk, GrobValue.FromInt(3));
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i2, 1);
+ chunk.WriteOpCode(OpCode.Constant, 1); chunk.WriteByte(i3, 1);
+ chunk.WriteOpCode(OpCode.AddInt, 1);
+ chunk.WriteOpCode(OpCode.Return, 1);
+
+ var output = new StringWriter();
+ var trace = new StringWriter();
+ var vm = new VirtualMachine(output, trace);
+ vm.Run(chunk);
+
+ string traced = trace.ToString();
+ // Stack rendering markers and opcode names from the disassembler.
+ Assert.Contains("[ 2 ]", traced);
+ Assert.Contains("[ 2 ][ 3 ]", traced);
+ Assert.Contains("Constant", traced);
+ Assert.Contains("AddInt", traced);
+ Assert.Contains("Return", traced);
+ }
+#endif
+}