From ba6d7697f5e1bf1a837d29152d1209bdcf2271f6 Mon Sep 17 00:00:00 2001
From: Eduardo Barthel <eduardo.barthel@cartesi.io>
Date: Thu, 18 May 2023 15:04:51 -0300
Subject: [PATCH] Add new Lua testing framework

---
 CHANGELOG.md               |    1 +
 src/Makefile               |    1 +
 src/clua-cartesi.cpp       |    4 +
 src/machine.cpp            |    1 -
 src/spec/all-tests.lua     |   32 +
 src/spec/clua-tests.lua    |   49 ++
 src/spec/config-tests.lua  | 1121 ++++++++++++++++++++++++++++++++++++
 src/spec/htif-tests.lua    |  205 +++++++
 src/spec/keccak-tests.lua  |   69 +++
 src/spec/machine-tests.lua |   99 ++++
 src/spec/step-tests.lua    |   61 ++
 src/spec/util/fs.lua       |   55 ++
 src/spec/util/lester.lua   |  594 +++++++++++++++++++
 13 files changed, 2291 insertions(+), 1 deletion(-)
 create mode 100644 src/spec/all-tests.lua
 create mode 100644 src/spec/clua-tests.lua
 create mode 100644 src/spec/config-tests.lua
 create mode 100644 src/spec/htif-tests.lua
 create mode 100644 src/spec/keccak-tests.lua
 create mode 100644 src/spec/machine-tests.lua
 create mode 100644 src/spec/step-tests.lua
 create mode 100644 src/spec/util/fs.lua
 create mode 100644 src/spec/util/lester.lua

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5b6f2847..3f145b066 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 - Updated Lua version to 5.4
 - Use Lua path environment variables specific for version 5.4
+- Refactored and added new Lua tests using a Lua testing framework
 
 ## [0.14.0] - 2023-05-03
 ### Added
diff --git a/src/Makefile b/src/Makefile
index be448c691..2c1e4f190 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -410,6 +410,7 @@ test-jsonrpc: luacartesi jsonrpc
 	./tests/test-jsonrpc-server.sh ./jsonrpc-remote-cartesi-machine "$(LUA) ./cartesi-machine.lua" "$(LUA) ./cartesi-machine-tests.lua" "$(TEST_PATH)" "$(LUA)"
 
 test-scripts: luacartesi
+	$(LUA) spec/all-tests.lua
 	for x in `find tests -maxdepth 1 -type f -name "*.lua"` ; do \
 		echo -n 'CTSICTSI' | $(LUA) $$x local || exit 1; \
 	done
diff --git a/src/clua-cartesi.cpp b/src/clua-cartesi.cpp
index a3c1e2224..a22f5c4d0 100644
--- a/src/clua-cartesi.cpp
+++ b/src/clua-cartesi.cpp
@@ -22,6 +22,7 @@
 #include "clua.h"
 #include "machine-c-api.h"
 #include "riscv-constants.h"
+#include "rtc.h"
 
 /// \file
 /// \brief Scripting interface for the Cartesi SDK.
@@ -122,6 +123,9 @@ CM_API int luaopen_cartesi(lua_State *L) {
     clua_setintegerfield(L, CM_UARCH_BREAK_REASON_REACHED_TARGET_CYCLE, "UARCH_BREAK_REASON_REACHED_TARGET_CYCLE", -1);
     clua_setintegerfield(L, CM_UARCH_BREAK_REASON_HALTED, "UARCH_BREAK_REASON_HALTED", -1);
 
+    clua_setintegerfield(L, UINT64_MAX, "MAX_MCYCLE", -1);
+    clua_setintegerfield(L, RTC_FREQ_DIV, "RTC_FREQ_DIV", -1);
+    clua_setintegerfield(L, RTC_CLOCK_FREQ, "RTC_CLOCK_FREQ", -1);
     clua_setintegerfield(L, MVENDORID_INIT, "MVENDORID", -1);
     clua_setintegerfield(L, MARCHID_INIT, "MARCHID", -1);
     clua_setintegerfield(L, MIMPID_INIT, "MIMPID", -1);
diff --git a/src/machine.cpp b/src/machine.cpp
index e625f06c0..17af59e06 100644
--- a/src/machine.cpp
+++ b/src/machine.cpp
@@ -1560,7 +1560,6 @@ void machine::dump_pmas(void) const {
         }
         std::array<char, 256> filename{};
         (void) sprintf(filename.data(), "%016" PRIx64 "--%016" PRIx64 ".bin", pma->get_start(), pma->get_length());
-        std::cerr << "writing to " << filename.data() << '\n';
         auto fp = unique_fopen(filename.data(), "wb");
         for (uint64_t page_start_in_range = 0; page_start_in_range < pma->get_length();
              page_start_in_range += PMA_PAGE_SIZE) {
diff --git a/src/spec/all-tests.lua b/src/spec/all-tests.lua
new file mode 100644
index 000000000..179419824
--- /dev/null
+++ b/src/spec/all-tests.lua
@@ -0,0 +1,32 @@
+#!/usr/bin/env lua5.4
+
+-- Copyright 2023 Cartesi Pte. Ltd.
+--
+-- This file is part of the machine-emulator. The machine-emulator is free
+-- software: you can redistribute it and/or modify it under the terms of the GNU
+-- Lesser General Public License as published by the Free Software Foundation,
+-- either version 3 of the License, or (at your option) any later version.
+--
+-- The machine-emulator is distributed in the hope that it will be useful, but
+-- WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
+-- for more details.
+--
+-- You should have received a copy of the GNU Lesser General Public License
+-- along with the machine-emulator. If not, see http://www.gnu.org/licenses/.
+--
+
+local lester = require("spec.util.lester")
+
+-- Parse arguments from command line.
+lester.parse_args()
+
+require("spec.keccak-tests")
+require("spec.config-tests")
+require("spec.htif-tests")
+require("spec.machine-tests")
+require("spec.clua-tests")
+require("spec.step-tests")
+
+lester.report() -- Print overall statistic of the tests run.
+lester.exit() -- Exit with success if all tests passed.
diff --git a/src/spec/clua-tests.lua b/src/spec/clua-tests.lua
new file mode 100644
index 000000000..4d84df3c5
--- /dev/null
+++ b/src/spec/clua-tests.lua
@@ -0,0 +1,49 @@
+#!/usr/bin/env lua5.4
+
+-- Copyright 2023 Cartesi Pte. Ltd.
+--
+-- This file is part of the machine-emulator. The machine-emulator is free
+-- software: you can redistribute it and/or modify it under the terms of the GNU
+-- Lesser General Public License as published by the Free Software Foundation,
+-- either version 3 of the License, or (at your option) any later version.
+--
+-- The machine-emulator is distributed in the hope that it will be useful, but
+-- WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
+-- for more details.
+--
+-- You should have received a copy of the GNU Lesser General Public License
+-- along with the machine-emulator. If not, see http://www.gnu.org/licenses/.
+--
+
+local lester = require("spec.util.lester")
+local fs = require("spec.util.fs")
+local cartesi = require("cartesi")
+local describe, it, expect = lester.describe, lester.it, lester.expect
+
+-- Collect garbage after every test so machine references are automatically destroyed
+lester.after(function() collectgarbage() end)
+
+describe("machine clua", function()
+    local dummy_machine = cartesi.machine({
+        ram = { length = 0x4000 },
+        rom = { image_filename = fs.rom_image },
+    })
+
+    it("should fail when trying to pass non machine to a a machine API", function()
+        local read_mcycle = dummy_machine.read_mcycle
+        expect.fail(function() read_mcycle(1) end, "expected cartesi machine object")
+        expect.fail(function() read_mcycle(nil) end, "expected cartesi machine object")
+        expect.fail(function() read_mcycle() end, "expected cartesi machine object")
+        expect.fail(function() read_mcycle({}) end, "expected cartesi machine object")
+        expect.fail(function() read_mcycle(setmetatable({}, {})) end, "expected cartesi machine object")
+    end)
+
+    it("should be able to convert a machine to a string", function()
+        local s = tostring(dummy_machine)
+        expect.truthy(s)
+        expect.equal(s:match("[a-z ]+"), "cartesi machine object")
+    end)
+
+    dummy_machine:destroy()
+end)
diff --git a/src/spec/config-tests.lua b/src/spec/config-tests.lua
new file mode 100644
index 000000000..c56d6617a
--- /dev/null
+++ b/src/spec/config-tests.lua
@@ -0,0 +1,1121 @@
+#!/usr/bin/env lua5.4
+
+-- Copyright 2023 Cartesi Pte. Ltd.
+--
+-- This file is part of the machine-emulator. The machine-emulator is free
+-- software: you can redistribute it and/or modify it under the terms of the GNU
+-- Lesser General Public License as published by the Free Software Foundation,
+-- either version 3 of the License, or (at your option) any later version.
+--
+-- The machine-emulator is distributed in the hope that it will be useful, but
+-- WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
+-- for more details.
+--
+-- You should have received a copy of the GNU Lesser General Public License
+-- along with the machine-emulator. If not, see http://www.gnu.org/licenses/.
+--
+
+local lester = require("spec.util.lester")
+local fs = require("spec.util.fs")
+local cartesi = require("cartesi")
+local util = require("cartesi.util")
+local describe, it, expect = lester.describe, lester.it, lester.expect
+
+-- Collect garbage after every test so machine references are automatically destroyed
+lester.after(function() collectgarbage() end)
+
+local default_initial_config = cartesi.machine.get_default_config()
+
+local expected_initial_config = {
+    processor = {
+        -- these are non zero and depends in our implementation
+        marchid = default_initial_config.processor.marchid,
+        mimpid = default_initial_config.processor.mimpid,
+        mvendorid = default_initial_config.processor.mvendorid,
+        misa = default_initial_config.processor.misa,
+        mstatus = default_initial_config.processor.mstatus,
+        pc = default_initial_config.processor.pc,
+        iflags = default_initial_config.processor.iflags,
+        ilrsc = default_initial_config.processor.ilrsc,
+        -- these we know in advance
+        fcsr = 0,
+        icycleinstret = 0,
+        mcause = 0,
+        mcounteren = 0,
+        mcycle = 0,
+        medeleg = 0,
+        menvcfg = 0,
+        mepc = 0,
+        mideleg = 0,
+        mie = 0,
+        mip = 0,
+        mscratch = 0,
+        mtval = 0,
+        mtvec = 0,
+        satp = 0,
+        scause = 0,
+        scounteren = 0,
+        senvcfg = 0,
+        sepc = 0,
+        sscratch = 0,
+        stval = 0,
+        stvec = 0,
+        x = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
+        f = { [0] = 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
+    },
+    ram = {
+        image_filename = "",
+        length = 0x4000,
+    },
+    rom = {
+        bootargs = "",
+        image_filename = fs.rom_image,
+    },
+    tlb = {
+        image_filename = "",
+    },
+    flash_drive = {},
+    htif = {
+        console_getchar = false,
+        yield_automatic = false,
+        yield_manual = false,
+        fromhost = 0,
+        tohost = 0,
+    },
+    clint = {
+        mtimecmp = 0,
+    },
+    uarch = {
+        processor = {
+            cycle = 0,
+            pc = default_initial_config.uarch.processor.pc,
+            x = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
+        },
+        ram = {
+            image_filename = "",
+            length = 0,
+        },
+    },
+}
+
+local test_config = {
+    processor = {
+        -- these are hardwired constants, and cannot change
+        marchid = default_initial_config.processor.marchid,
+        mimpid = default_initial_config.processor.mimpid,
+        mvendorid = default_initial_config.processor.mvendorid,
+        -- these can be changed, and are set to random values
+        pc = 0x070c1efa257e32e4,
+        misa = 0xff13504ee4da72f1,
+        fcsr = 0x8b337085bc73d6f6,
+        icycleinstret = 0xf4310770998bfaab,
+        ilrsc = 0x5bf71f0fc1c516e1,
+        mcause = 0x73b2dcca2277c070,
+        mcounteren = 0x2aeb5bbda1f4be71,
+        mcycle = 0x072a8a6e298b61cb,
+        medeleg = 0x3d00a03901459100,
+        menvcfg = 0x4cf38ec0407ba557,
+        mepc = 0x23aab25abacae88d,
+        mideleg = 0x2830ed05187f8ab9,
+        mie = 0x4b615ac9c32e2a91,
+        mip = 0x4abb22a9f342d65c,
+        mscratch = 0xa0d39fc9763cdd91,
+        mstatus = 0xd02e272900ea57d5,
+        mtval = 0x41cea506fd53c830,
+        mtvec = 0xa395b0c3b234bfbc,
+        satp = 0x2fd3e21cd171c484,
+        scause = 0xc41a4593c61098ca,
+        scounteren = 0x9fdf00eae96a888d,
+        senvcfg = 0xe8c06242796cffa3,
+        sepc = 0x0e1151b658feb88a,
+        sscratch = 0x59951bc8a8fb4921,
+        stval = 0xb1c067c2c1709a51,
+        stvec = 0xa9ca605ecb0807b6,
+        iflags = 3,
+        x = {
+            1,
+            2,
+            3,
+            4,
+            5,
+            6,
+            7,
+            8,
+            9,
+            10,
+            11,
+            12,
+            13,
+            14,
+            15,
+            16,
+            17,
+            18,
+            19,
+            20,
+            21,
+            22,
+            23,
+            24,
+            25,
+            26,
+            27,
+            28,
+            29,
+            30,
+            31,
+        },
+        f = {
+            [0] = 1,
+            2,
+            3,
+            4,
+            5,
+            6,
+            7,
+            8,
+            9,
+            10,
+            11,
+            12,
+            13,
+            14,
+            15,
+            16,
+            17,
+            18,
+            19,
+            20,
+            21,
+            22,
+            23,
+            24,
+            25,
+            26,
+            27,
+            28,
+            29,
+            30,
+            31,
+            32,
+        },
+    },
+    ram = {
+        image_filename = "",
+        length = fs.get_file_length(fs.linux_image),
+    },
+    rom = {
+        image_filename = fs.rom_image,
+        bootargs = "test",
+    },
+    flash_drive = {
+        {
+            image_filename = fs.rootfs_image,
+            length = fs.get_file_length(fs.rootfs_image),
+            start = 0x80000000000000,
+            shared = false,
+        },
+        {
+            image_filename = "",
+            length = 0x4000,
+            start = 0x90000000000000,
+            shared = false,
+        },
+    },
+    uarch = {
+        processor = {
+            cycle = 7,
+            pc = 0x2000,
+            x = {
+                1,
+                2,
+                3,
+                4,
+                5,
+                6,
+                7,
+                8,
+                9,
+                10,
+                11,
+                12,
+                13,
+                14,
+                15,
+                16,
+                17,
+                18,
+                19,
+                20,
+                21,
+                22,
+                23,
+                24,
+                25,
+                26,
+                27,
+                28,
+                29,
+                30,
+                31,
+            },
+        },
+        ram = { image_filename = fs.uarch_ram_image, length = 0x20000 },
+    },
+    htif = {
+        yield_automatic = true,
+        yield_manual = true,
+        console_getchar = false,
+        fromhost = 0x5555555555555555,
+        tohost = 0xaaaaaaaaaaaaaaaa,
+    },
+    rollup = {
+        rx_buffer = { image_filename = "", start = 0x60000000, length = 0x1000, shared = false },
+        tx_buffer = { image_filename = "", start = 0x60002000, length = 0x1000, shared = false },
+        input_metadata = { image_filename = "", start = 0x60004000, length = 0x1000, shared = false },
+        voucher_hashes = { image_filename = "", start = 0x60006000, length = 0x1000, shared = false },
+        notice_hashes = { image_filename = "", start = 0x60008000, length = 0x1000, shared = false },
+    },
+    tlb = { image_filename = "" },
+    clint = { mtimecmp = 8192 },
+}
+
+describe("machine config", function()
+    it("should set initial configs correctly", function()
+        local machine = cartesi.machine({
+            ram = { length = 0x4000 },
+            rom = { image_filename = fs.rom_image },
+        }, {
+            concurrency = { update_merkle_tree = 1 },
+        })
+        expect.equal(machine:get_initial_config(), expected_initial_config)
+        machine:destroy()
+
+        machine = cartesi.machine({
+            ram = { length = 0x4000 },
+            rom = { image_filename = fs.rom_image },
+            processor = {
+                marchid = -1,
+                mvendorid = -1,
+                mimpid = -1,
+            },
+        }, {
+            concurrency = { update_merkle_tree = 1 },
+        })
+        expect.equal(machine:get_initial_config(), expected_initial_config)
+    end)
+
+    it("should set missing config fields correctly", function()
+        local config = {
+            processor = {
+                x = {},
+                f = {},
+            },
+            ram = {
+                length = 0x4000,
+            },
+            rom = {
+                image_filename = fs.rom_image,
+            },
+            flash_drive = { {
+                length = 0x1000,
+                start = 0x80000000000000,
+            } },
+            uarch = {
+                processor = {
+                    x = {},
+                },
+                rom = { length = 0 },
+                ram = { length = 0 },
+            },
+            rollup = {
+                rx_buffer = { start = 0x60000000, length = 0x2000 },
+                tx_buffer = { start = 0x60002000, length = 0x2000 },
+                input_metadata = { start = 0x60004000, length = 0x2000 },
+                voucher_hashes = { start = 0x60006000, length = 0x2000 },
+                notice_hashes = { start = 0x60008000, length = 0x2000 },
+            },
+            tlb = {},
+            clint = {},
+            htif = {},
+        }
+        local expected_machine_config = {
+            processor = expected_initial_config.processor,
+            ram = expected_initial_config.ram,
+            rom = expected_initial_config.rom,
+            flash_drive = {
+                {
+                    length = 0x1000,
+                    start = 0x80000000000000,
+                    image_filename = "",
+                    shared = false,
+                },
+            },
+            uarch = expected_initial_config.uarch,
+            rollup = {
+                rx_buffer = { start = 0x60000000, length = 0x2000, image_filename = "", shared = false },
+                tx_buffer = { start = 0x60002000, length = 0x2000, image_filename = "", shared = false },
+                input_metadata = { start = 0x60004000, length = 0x2000, image_filename = "", shared = false },
+                voucher_hashes = { start = 0x60006000, length = 0x2000, image_filename = "", shared = false },
+                notice_hashes = { start = 0x60008000, length = 0x2000, image_filename = "", shared = false },
+            },
+            tlb = expected_initial_config.tlb,
+            clint = expected_initial_config.clint,
+            htif = expected_initial_config.htif,
+        }
+        local machine = cartesi.machine(config, {})
+        expect.equal(machine:get_initial_config(), expected_machine_config)
+    end)
+
+    it("should match with initial config", function()
+        local machine = cartesi.machine(test_config)
+        expect.equal(machine:get_initial_config(), test_config)
+    end)
+
+    it("should match halt flags, yield flags and config", function()
+        local machine = cartesi.machine({
+            ram = { length = 1 << 20 },
+            rom = { image_filename = fs.rom_image },
+        })
+        -- Get machine default config  and test for known fields
+        local initial_config = machine:get_initial_config()
+        expect.equal(initial_config.processor.marchid, default_initial_config.processor.marchid)
+        expect.equal(initial_config.processor.pc, 0x1000)
+        expect.equal(initial_config.ram.length, 1 << 20)
+        expect.not_equal(initial_config.rom.image_filename, "")
+        -- Check machine is not halted
+        expect.falsy(machine:read_iflags_H())
+        -- Check machine is not yielded
+        expect.falsy(machine:read_iflags_Y() or machine:read_iflags_X())
+    end)
+
+    it("should fail when attempting to create machine with invalid configs", function()
+        -- rom
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = "some/invalid/image.bin" },
+                })
+            end,
+            "error opening image file"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.linux_image },
+                })
+            end,
+            "is too large for range"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = "" },
+                })
+            end,
+            "image filename is undefined"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = {},
+                })
+            end,
+            "image filename is undefined"
+        )
+
+        -- ram
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { image_filename = "some/invalid/image.bin", length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                })
+            end,
+            "error opening image file"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { image_filename = fs.linux_image, length = 0 },
+                    rom = { image_filename = fs.rom_image },
+                })
+            end,
+            "length cannot be zero"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { image_filename = fs.linux_image, length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                })
+            end,
+            "too large for range"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = {},
+                    rom = { image_filename = fs.rom_image },
+                })
+            end,
+            "invalid length"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0 },
+                    rom = { image_filename = fs.rom_image },
+                })
+            end,
+            "length cannot be zero"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 4095 },
+                    rom = { image_filename = fs.rom_image },
+                })
+            end,
+            "must be multiple of page size"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { image_filename = true, length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                })
+            end,
+            "invalid image_filename"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = 0,
+                    rom = { image_filename = fs.rom_image },
+                })
+            end,
+            "missing ram"
+        )
+
+        -- processor
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    processor = 0,
+                })
+            end,
+            "missing processor"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    processor = { pc = true },
+                })
+            end,
+            "invalid pc"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    processor = { x = { true } },
+                })
+            end,
+            "invalid entry"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    processor = { marchid = 0 },
+                })
+            end,
+            "marchid mismatch"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    processor = { mimpid = 0 },
+                })
+            end,
+            "mimpid mismatch"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    processor = { mvendorid = 0 },
+                })
+            end,
+            "mvendorid mismatch"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    processor = { x = 0 },
+                })
+            end,
+            "invalid processor.x"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    processor = { f = 0 },
+                })
+            end,
+            "invalid processor.f"
+        )
+
+        -- uarch.processor
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    uarch = {
+                        processor = { x = 0 },
+                        ram = { length = 0x1000 },
+                        rom = { length = 0x1000 },
+                    },
+                })
+            end,
+            "invalid uarch.processor.x"
+        )
+
+        -- flash drive
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    flash_drive = { {}, {}, {}, {}, {}, {}, {}, {}, {} },
+                })
+            end,
+            "too many flash drives"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    flash_drive = { false },
+                })
+            end,
+            "memory range not a table"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    flash_drive = { {} },
+                })
+            end,
+            "invalid start"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    flash_drive = { { start = 0x9000000000000, length = 0 } },
+                })
+            end,
+            "length cannot be zero"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    flash_drive = { { start = 0x100000000000000, length = 0x1000 } },
+                })
+            end,
+            "must use at most 56 bits to be addressable"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    flash_drive = { { start = 0, length = 0x1000 } },
+                })
+            end,
+            "overlaps with range of existing"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    flash_drive = { { start = 0, length = 4095 } },
+                })
+            end,
+            "must be multiple of page size"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    flash_drive = {
+                        {
+                            image_filename = "some/invalid/image.bin",
+                            start = 0x9000000000000,
+                            length = 0x1000,
+                        },
+                    },
+                })
+            end,
+            "could not open image file"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    flash_drive = {
+                        {
+                            image_filename = fs.rootfs_image,
+                            start = 0x9000000000000,
+                            length = 0,
+                        },
+                    },
+                })
+            end,
+            "length cannot be zero"
+        )
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    flash_drive = {
+                        {
+                            image_filename = fs.rootfs_image,
+                            start = 0x9000000000000,
+                            length = 0x1000,
+                        },
+                    },
+                })
+            end,
+            "does not match range length"
+        )
+
+        -- rollup
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { length = 0x1000 },
+                    rom = { image_filename = fs.rom_image },
+                    rollup = {
+                        rx_buffer = { start = 0x60000000, length = 0x2000 },
+                        tx_buffer = { start = 0x60002000, length = 0x2000 },
+                        input_metadata = { start = 0x60004000, length = 0x2000 },
+                        voucher_hashes = { start = 0x60006000, length = 0x2000 },
+                        notice_hashes = { start = 0x60008000, length = 0 },
+                    },
+                })
+            end,
+            "incomplete rollup configuration"
+        )
+
+        -- stored
+        expect.fail(function() cartesi.machine("some/invalid/machine") end, "unable to open")
+    end)
+end)
+
+describe("machine state", function()
+    local machine = cartesi.machine(test_config)
+    local P = 0xf6b75ab2bc471c7 -- random prime used to test register write
+
+    it("should read CSRs", function()
+        -- check read_...
+        expect.equal(machine:read_marchid(), test_config.processor.marchid)
+        expect.equal(machine:read_mimpid(), test_config.processor.mimpid)
+        expect.equal(machine:read_mvendorid(), test_config.processor.mvendorid)
+        expect.equal(machine:read_pc(), test_config.processor.pc)
+        expect.equal(machine:read_misa(), test_config.processor.misa)
+        expect.equal(machine:read_fcsr(), test_config.processor.fcsr)
+        expect.equal(machine:read_icycleinstret(), test_config.processor.icycleinstret)
+        expect.equal(machine:read_ilrsc(), test_config.processor.ilrsc)
+        expect.equal(machine:read_mcause(), test_config.processor.mcause)
+        expect.equal(machine:read_mcounteren(), test_config.processor.mcounteren)
+        expect.equal(machine:read_mcycle(), test_config.processor.mcycle)
+        expect.equal(machine:read_medeleg(), test_config.processor.medeleg)
+        expect.equal(machine:read_menvcfg(), test_config.processor.menvcfg)
+        expect.equal(machine:read_mepc(), test_config.processor.mepc)
+        expect.equal(machine:read_mideleg(), test_config.processor.mideleg)
+        expect.equal(machine:read_mie(), test_config.processor.mie)
+        expect.equal(machine:read_mip(), test_config.processor.mip)
+        expect.equal(machine:read_mscratch(), test_config.processor.mscratch)
+        expect.equal(machine:read_mstatus(), test_config.processor.mstatus)
+        expect.equal(machine:read_mtval(), test_config.processor.mtval)
+        expect.equal(machine:read_mtvec(), test_config.processor.mtvec)
+        expect.equal(machine:read_satp(), test_config.processor.satp)
+        expect.equal(machine:read_scause(), test_config.processor.scause)
+        expect.equal(machine:read_scounteren(), test_config.processor.scounteren)
+        expect.equal(machine:read_senvcfg(), test_config.processor.senvcfg)
+        expect.equal(machine:read_sepc(), test_config.processor.sepc)
+        expect.equal(machine:read_sscratch(), test_config.processor.sscratch)
+        expect.equal(machine:read_stval(), test_config.processor.stval)
+        expect.equal(machine:read_stvec(), test_config.processor.stvec)
+        expect.equal(machine:read_iflags(), test_config.processor.iflags)
+        expect.equal(machine:read_uarch_cycle(), test_config.uarch.processor.cycle)
+        expect.equal(machine:read_uarch_pc(), test_config.uarch.processor.pc)
+        expect.equal(machine:read_uarch_ram_length(), test_config.uarch.ram.length)
+
+        -- check read_csr
+        expect.equal(machine:read_csr("marchid"), test_config.processor.marchid)
+        expect.equal(machine:read_csr("mimpid"), test_config.processor.mimpid)
+        expect.equal(machine:read_csr("mvendorid"), test_config.processor.mvendorid)
+        expect.equal(machine:read_csr("pc"), test_config.processor.pc)
+        expect.equal(machine:read_csr("misa"), test_config.processor.misa)
+        expect.equal(machine:read_csr("fcsr"), test_config.processor.fcsr)
+        expect.equal(machine:read_csr("icycleinstret"), test_config.processor.icycleinstret)
+        expect.equal(machine:read_csr("ilrsc"), test_config.processor.ilrsc)
+        expect.equal(machine:read_csr("mcause"), test_config.processor.mcause)
+        expect.equal(machine:read_csr("mcounteren"), test_config.processor.mcounteren)
+        expect.equal(machine:read_csr("mcycle"), test_config.processor.mcycle)
+        expect.equal(machine:read_csr("medeleg"), test_config.processor.medeleg)
+        expect.equal(machine:read_csr("menvcfg"), test_config.processor.menvcfg)
+        expect.equal(machine:read_csr("mepc"), test_config.processor.mepc)
+        expect.equal(machine:read_csr("mideleg"), test_config.processor.mideleg)
+        expect.equal(machine:read_csr("mie"), test_config.processor.mie)
+        expect.equal(machine:read_csr("mip"), test_config.processor.mip)
+        expect.equal(machine:read_csr("mscratch"), test_config.processor.mscratch)
+        expect.equal(machine:read_csr("mstatus"), test_config.processor.mstatus)
+        expect.equal(machine:read_csr("mtval"), test_config.processor.mtval)
+        expect.equal(machine:read_csr("mtvec"), test_config.processor.mtvec)
+        expect.equal(machine:read_csr("satp"), test_config.processor.satp)
+        expect.equal(machine:read_csr("scause"), test_config.processor.scause)
+        expect.equal(machine:read_csr("scounteren"), test_config.processor.scounteren)
+        expect.equal(machine:read_csr("senvcfg"), test_config.processor.senvcfg)
+        expect.equal(machine:read_csr("sepc"), test_config.processor.sepc)
+        expect.equal(machine:read_csr("sscratch"), test_config.processor.sscratch)
+        expect.equal(machine:read_csr("stval"), test_config.processor.stval)
+        expect.equal(machine:read_csr("stvec"), test_config.processor.stvec)
+        expect.equal(machine:read_csr("iflags"), test_config.processor.iflags)
+        expect.equal(machine:read_csr("uarch_cycle"), test_config.uarch.processor.cycle)
+        expect.equal(machine:read_csr("uarch_pc"), test_config.uarch.processor.pc)
+        expect.equal(machine:read_csr("uarch_ram_length"), test_config.uarch.ram.length)
+
+        -- check if CSR addresses are valid
+        local get_csr_addr = cartesi.machine.get_csr_address
+        expect.equal(machine:read_word(get_csr_addr("marchid")), test_config.processor.marchid)
+        expect.equal(machine:read_word(get_csr_addr("mimpid")), test_config.processor.mimpid)
+        expect.equal(machine:read_word(get_csr_addr("mvendorid")), test_config.processor.mvendorid)
+        expect.equal(machine:read_word(get_csr_addr("pc")), test_config.processor.pc)
+        expect.equal(machine:read_word(get_csr_addr("misa")), test_config.processor.misa)
+        expect.equal(machine:read_word(get_csr_addr("fcsr")), test_config.processor.fcsr)
+        expect.equal(machine:read_word(get_csr_addr("icycleinstret")), test_config.processor.icycleinstret)
+        expect.equal(machine:read_word(get_csr_addr("ilrsc")), test_config.processor.ilrsc)
+        expect.equal(machine:read_word(get_csr_addr("mcause")), test_config.processor.mcause)
+        expect.equal(machine:read_word(get_csr_addr("mcounteren")), test_config.processor.mcounteren)
+        expect.equal(machine:read_word(get_csr_addr("mcycle")), test_config.processor.mcycle)
+        expect.equal(machine:read_word(get_csr_addr("medeleg")), test_config.processor.medeleg)
+        expect.equal(machine:read_word(get_csr_addr("menvcfg")), test_config.processor.menvcfg)
+        expect.equal(machine:read_word(get_csr_addr("mepc")), test_config.processor.mepc)
+        expect.equal(machine:read_word(get_csr_addr("mideleg")), test_config.processor.mideleg)
+        expect.equal(machine:read_word(get_csr_addr("mie")), test_config.processor.mie)
+        expect.equal(machine:read_word(get_csr_addr("mip")), test_config.processor.mip)
+        expect.equal(machine:read_word(get_csr_addr("mscratch")), test_config.processor.mscratch)
+        expect.equal(machine:read_word(get_csr_addr("mstatus")), test_config.processor.mstatus)
+        expect.equal(machine:read_word(get_csr_addr("mtval")), test_config.processor.mtval)
+        expect.equal(machine:read_word(get_csr_addr("mtvec")), test_config.processor.mtvec)
+        expect.equal(machine:read_word(get_csr_addr("satp")), test_config.processor.satp)
+        expect.equal(machine:read_word(get_csr_addr("scause")), test_config.processor.scause)
+        expect.equal(machine:read_word(get_csr_addr("scounteren")), test_config.processor.scounteren)
+        expect.equal(machine:read_word(get_csr_addr("senvcfg")), test_config.processor.senvcfg)
+        expect.equal(machine:read_word(get_csr_addr("sepc")), test_config.processor.sepc)
+        expect.equal(machine:read_word(get_csr_addr("sscratch")), test_config.processor.sscratch)
+        expect.equal(machine:read_word(get_csr_addr("stval")), test_config.processor.stval)
+        expect.equal(machine:read_word(get_csr_addr("stvec")), test_config.processor.stvec)
+        expect.equal(machine:read_word(get_csr_addr("iflags")), test_config.processor.iflags)
+        expect.equal(machine:read_word(get_csr_addr("uarch_cycle")), test_config.uarch.processor.cycle)
+        expect.equal(machine:read_word(get_csr_addr("uarch_pc")), test_config.uarch.processor.pc)
+        expect.equal(machine:read_word(get_csr_addr("uarch_ram_length")), test_config.uarch.ram.length)
+    end)
+
+    it("should write CSRs", function()
+        local pc = P & ~3 -- make sure it is 4-byte aligned
+        local a = P
+
+        -- check write_...
+        expect.equal(machine:write_pc(pc) or machine:read_pc(), pc)
+        expect.equal(machine:write_misa(a) or machine:read_misa(), a)
+        expect.equal(machine:write_fcsr(a) or machine:read_fcsr(), a)
+        expect.equal(machine:write_icycleinstret(a) or machine:read_icycleinstret(), a)
+        expect.equal(machine:write_ilrsc(a) or machine:read_ilrsc(), a)
+        expect.equal(machine:write_mcause(a) or machine:read_mcause(), a)
+        expect.equal(machine:write_mcounteren(a) or machine:read_mcounteren(), a)
+        expect.equal(machine:write_mcycle(a) or machine:read_mcycle(), a)
+        expect.equal(machine:write_medeleg(a) or machine:read_medeleg(), a)
+        expect.equal(machine:write_menvcfg(a) or machine:read_menvcfg(), a)
+        expect.equal(machine:write_mepc(a) or machine:read_mepc(), a)
+        expect.equal(machine:write_mideleg(a) or machine:read_mideleg(), a)
+        expect.equal(machine:write_mie(a) or machine:read_mie(), a)
+        expect.equal(machine:write_mip(a) or machine:read_mip(), a)
+        expect.equal(machine:write_mscratch(a) or machine:read_mscratch(), a)
+        expect.equal(machine:write_mstatus(a) or machine:read_mstatus(), a)
+        expect.equal(machine:write_mtval(a) or machine:read_mtval(), a)
+        expect.equal(machine:write_mtvec(a) or machine:read_mtvec(), a)
+        expect.equal(machine:write_satp(a) or machine:read_satp(), a)
+        expect.equal(machine:write_scause(a) or machine:read_scause(), a)
+        expect.equal(machine:write_scounteren(a) or machine:read_scounteren(), a)
+        expect.equal(machine:write_senvcfg(a) or machine:read_senvcfg(), a)
+        expect.equal(machine:write_sepc(a) or machine:read_sepc(), a)
+        expect.equal(machine:write_sscratch(a) or machine:read_sscratch(), a)
+        expect.equal(machine:write_stval(a) or machine:read_stval(), a)
+        expect.equal(machine:write_stvec(a) or machine:read_stvec(), a)
+        expect.equal(machine:write_uarch_cycle(a) or machine:read_uarch_cycle(), a)
+        expect.equal(machine:write_uarch_pc(pc) or machine:read_uarch_pc(), pc)
+        expect.equal(machine:write_iflags(0) or machine:read_iflags(), 0)
+
+        -- update values for next writes
+        pc = pc + 4
+        a = ~a
+
+        -- check write_csr
+        expect.equal(machine:write_csr("pc", pc) or machine:read_pc(), pc)
+        expect.equal(machine:write_csr("misa", a) or machine:read_misa(), a)
+        expect.equal(machine:write_csr("fcsr", a) or machine:read_fcsr(), a)
+        expect.equal(machine:write_csr("icycleinstret", a) or machine:read_icycleinstret(), a)
+        expect.equal(machine:write_csr("ilrsc", a) or machine:read_ilrsc(), a)
+        expect.equal(machine:write_csr("mcause", a) or machine:read_mcause(), a)
+        expect.equal(machine:write_csr("mcounteren", a) or machine:read_mcounteren(), a)
+        expect.equal(machine:write_csr("mcycle", a) or machine:read_mcycle(), a)
+        expect.equal(machine:write_csr("medeleg", a) or machine:read_medeleg(), a)
+        expect.equal(machine:write_csr("menvcfg", a) or machine:read_menvcfg(), a)
+        expect.equal(machine:write_csr("mepc", a) or machine:read_mepc(), a)
+        expect.equal(machine:write_csr("mideleg", a) or machine:read_mideleg(), a)
+        expect.equal(machine:write_csr("mie", a) or machine:read_mie(), a)
+        expect.equal(machine:write_csr("mip", a) or machine:read_mip(), a)
+        expect.equal(machine:write_csr("mscratch", a) or machine:read_mscratch(), a)
+        expect.equal(machine:write_csr("mstatus", a) or machine:read_mstatus(), a)
+        expect.equal(machine:write_csr("mtval", a) or machine:read_mtval(), a)
+        expect.equal(machine:write_csr("mtvec", a) or machine:read_mtvec(), a)
+        expect.equal(machine:write_csr("satp", a) or machine:read_satp(), a)
+        expect.equal(machine:write_csr("scause", a) or machine:read_scause(), a)
+        expect.equal(machine:write_csr("scounteren", a) or machine:read_scounteren(), a)
+        expect.equal(machine:write_csr("senvcfg", a) or machine:read_senvcfg(), a)
+        expect.equal(machine:write_csr("sepc", a) or machine:read_sepc(), a)
+        expect.equal(machine:write_csr("sscratch", a) or machine:read_sscratch(), a)
+        expect.equal(machine:write_csr("stval", a) or machine:read_stval(), a)
+        expect.equal(machine:write_csr("stvec", a) or machine:read_stvec(), a)
+        expect.equal(machine:write_csr("uarch_cycle", a) or machine:read_uarch_cycle(), a)
+        expect.equal(machine:write_csr("uarch_pc", pc) or machine:read_uarch_pc(), pc)
+        expect.equal(machine:write_csr("iflags", 0) or machine:read_iflags(), 0)
+    end)
+
+    it("should read/set/reset iflags", function()
+        expect.equal(machine:read_iflags_H(), false)
+        expect.equal(machine:read_iflags_X(), false)
+        expect.equal(machine:read_iflags_Y(), false)
+        expect.equal(machine:set_iflags_H() or machine:read_iflags_H(), true)
+        expect.equal(machine:set_iflags_X() or machine:read_iflags_X(), true)
+        expect.equal(machine:set_iflags_Y() or machine:read_iflags_Y(), true)
+        expect.equal(machine:reset_iflags_X() or machine:read_iflags_X(), false)
+        expect.equal(machine:reset_iflags_Y() or machine:read_iflags_Y(), false)
+    end)
+
+    it("should read/write x registers", function()
+        expect.equal(machine:read_x(0), 0)
+        for i, defval in ipairs(test_config.processor.x) do
+            local addr = cartesi.machine.get_x_address(i)
+            local val = i * P
+            expect.equal(machine:read_x(i), defval)
+            expect.equal(machine:read_word(addr), defval)
+            expect.equal(machine:write_x(i, val) or machine:read_x(i), val)
+            expect.equal(machine:read_word(addr), val)
+        end
+    end)
+
+    it("should read/write f registers", function()
+        for i = 0, 31 do
+            local addr = cartesi.machine.get_f_address(i)
+            local defval = test_config.processor.f[i]
+            local val = (i + 1) * P
+            expect.equal(machine:read_f(i), defval)
+            expect.equal(machine:read_word(addr), defval)
+            expect.equal(machine:write_f(i, val) or machine:read_f(i), val)
+            expect.equal(machine:read_word(addr), val)
+        end
+    end)
+
+    it("should read/write uarch x registers", function()
+        expect.equal(machine:read_uarch_x(0), 0)
+        for i, defval in ipairs(test_config.uarch.processor.x) do
+            local val = i * P
+            expect.equal(machine:read_uarch_x(i), defval)
+            expect.equal(machine:write_uarch_x(i, val) or machine:read_uarch_x(i), val)
+        end
+    end)
+
+    it("should read/write htif device", function()
+        expect.equal(machine:read_htif_fromhost(), test_config.htif.fromhost)
+        expect.equal(machine:read_csr("htif_fromhost"), test_config.htif.fromhost)
+        expect.equal(machine:write_htif_fromhost(P) or machine:read_htif_fromhost(), P)
+        expect.equal(machine:write_htif_fromhost_data(0) or machine:read_htif_fromhost(), P & ~0xffffffffffff)
+        expect.equal(machine:write_csr("htif_fromhost", ~P) or machine:read_htif_fromhost(), ~P)
+
+        expect.equal(machine:read_htif_tohost(), test_config.htif.tohost)
+        expect.equal(machine:read_htif_tohost_data(), test_config.htif.tohost & 0xffffffffffff)
+        expect.equal(machine:read_htif_tohost_cmd(), (test_config.htif.tohost >> 48) & 0xff)
+        expect.equal(machine:read_htif_tohost_dev(), (test_config.htif.tohost >> 56) & 0xff)
+        expect.equal(machine:read_csr("htif_tohost"), test_config.htif.tohost)
+        expect.equal(machine:write_htif_tohost(P) or machine:read_htif_tohost(), P)
+        expect.equal(machine:write_csr("htif_tohost", ~P) or machine:read_htif_tohost(), ~P)
+
+        expect.equal(machine:read_htif_ihalt(), 0x1)
+        expect.equal(machine:read_csr("htif_ihalt"), 0x1)
+        -- expect.equal(machine:write_htif_ihalt(P) or machine:read_htif_ihalt(), P) -- missing method?
+        expect.equal(machine:write_csr("htif_ihalt", ~P) or machine:read_htif_ihalt(), ~P)
+
+        expect.equal(machine:read_htif_iyield(), 0x3)
+        expect.equal(machine:read_csr("htif_iyield"), 0x3)
+        -- expect.equal(machine:write_htif_iyield(P) or machine:read_htif_iyield(), P) -- missing method?
+        expect.equal(machine:write_csr("htif_iyield", ~P) or machine:read_htif_iyield(), ~P)
+
+        expect.equal(machine:read_htif_iconsole(), 0x2)
+        expect.equal(machine:read_csr("htif_iconsole"), 0x2)
+        -- expect.equal(machine:write_htif_iconsole(P) or machine:read_htif_iconsole(), P) -- missing method?
+        expect.equal(machine:write_csr("htif_iconsole", ~P) or machine:read_htif_iconsole(), ~P)
+    end)
+
+    it("should read/write clint device", function()
+        expect.equal(machine:read_clint_mtimecmp(), test_config.clint.mtimecmp)
+        expect.equal(machine:read_csr("clint_mtimecmp"), test_config.clint.mtimecmp)
+        expect.equal(machine:write_clint_mtimecmp(P) or machine:read_clint_mtimecmp(), P)
+        expect.equal(machine:write_csr("clint_mtimecmp", ~P) or machine:read_clint_mtimecmp(), ~P)
+    end)
+
+    it("should fail when attempting to perform invalid writes", function()
+        expect.fail(function() machine:write_csr("unknown_csr", 0) end, "unknown csr")
+        expect.fail(function() machine:write_csr("marchid", 0) end, "is read-only")
+        expect.fail(function() machine:write_csr("mimpid", 0) end, "is read-only")
+        expect.fail(function() machine:write_csr("mvendorid", 0) end, "is read-only")
+        expect.fail(function() machine:write_csr("uarch_ram_length", 0) end, "is read-only")
+        expect.fail(function() machine:write_pc() end, "got no value")
+        expect.fail(function() machine:write_x(1) end, "got no value")
+        expect.fail(function() machine:write_x(1, nil) end, "got nil")
+        expect.fail(function() machine:write_x(nil, 1) end, "got nil")
+        expect.fail(function() machine:write_x(1, false) end, "got boolean")
+        expect.fail(function() machine:write_x(0, 0) end, "register index out of range")
+        expect.fail(function() machine:write_x(32, 0) end, "register index out of range")
+        expect.fail(function() machine:write_f(-1, 0) end, "register index out of range")
+        expect.fail(function() machine:write_f(32, 0) end, "register index out of range")
+        expect.fail(function() machine:write_uarch_x(-1, 0) end, "register index out of range")
+        expect.fail(function() machine:write_uarch_x(0, 0) end, "register index out of range")
+    end)
+
+    it("should fail when attempting to perform invalid reads", function()
+        expect.fail(function() machine:read_csr("unknown_csr") end, "unknown csr")
+        expect.fail(function() machine:read_x(-1, 0) end, "register index out of range")
+        expect.fail(function() machine:read_x(32, 0) end, "register index out of range")
+        expect.fail(function() machine:read_f(-1, 0) end, "register index out of range")
+        expect.fail(function() machine:read_f(32, 0) end, "register index out of range")
+        expect.fail(function() machine:read_uarch_x(-1, 0) end, "register index out of range")
+        expect.fail(function() machine:read_uarch_x(32, 0) end, "register index out of range")
+    end)
+
+    it("it should fail when attempting to get address for invalid registers", function()
+        expect.fail(function() cartesi.machine.get_csr_address() end, "got no value")
+        expect.fail(function() cartesi.machine.get_csr_address(false) end, "got boolean")
+        expect.fail(function() cartesi.machine.get_csr_address("") end, "unknown csr")
+        expect.fail(function() cartesi.machine.get_csr_address("unknown_csr") end, "unknown csr")
+        expect.fail(function() cartesi.machine.get_x_address(-1) end, "register index out of range")
+        expect.fail(function() cartesi.machine.get_x_address(32) end, "register index out of range")
+        expect.fail(function() cartesi.machine.get_f_address(-1) end, "register index out of range")
+        expect.fail(function() cartesi.machine.get_f_address(32) end, "register index out of range")
+    end)
+
+    machine:destroy()
+end)
+
+describe("machine rollback", function()
+    it("should fail when attempting to perform a snapshot or rollback", function()
+        local machine = cartesi.machine(test_config)
+        expect.fail(function() machine:snapshot() end, "snapshot is not supported")
+        expect.fail(function() machine:rollback() end, "rollback is not supported")
+    end)
+end)
+
+describe("machine store", function()
+    local function remove_temporary_files()
+        fs.remove_files({
+            "temp_machine/0000000000001000-f000.bin",
+            "temp_machine/0000000000020000-6000.bin",
+            "temp_machine/0000000060000000-1000.bin",
+            "temp_machine/0000000060002000-1000.bin",
+            "temp_machine/0000000060004000-1000.bin",
+            "temp_machine/0000000060006000-1000.bin",
+            "temp_machine/0000000060008000-1000.bin",
+            "temp_machine/0000000070000000-20000.bin",
+            "temp_machine/0000000080000000-ee1000.bin",
+            "temp_machine/0080000000000000-4400000.bin",
+            "temp_machine/0090000000000000-4000.bin",
+            "temp_machine/config.protobuf",
+            "temp_machine/hash",
+            "temp_machine",
+        })
+    end
+
+    lester.before(remove_temporary_files)
+    lester.after(remove_temporary_files)
+
+    it("should match hashes and configs between loaded and stored machines", function()
+        local saved_machine = cartesi.machine(test_config)
+        local saved_machine_hash = util.hexhash(saved_machine:get_root_hash())
+        local saved_machine_config = saved_machine:get_initial_config()
+        saved_machine:store("temp_machine")
+
+        local loaded_machine = cartesi.machine("temp_machine")
+        local loaded_machine_hash = util.hexhash(loaded_machine:get_root_hash())
+        local loaded_machine_config = loaded_machine:get_initial_config()
+
+        expect.equal(loaded_machine_hash, saved_machine_hash)
+
+        -- all image filenames are lost and changed when using store
+        saved_machine_config.flash_drive[1].image_filename = "temp_machine/0080000000000000-4400000.bin"
+        saved_machine_config.flash_drive[2].image_filename = "temp_machine/0090000000000000-4000.bin"
+        saved_machine_config.ram.image_filename = "temp_machine/0000000080000000-ee1000.bin"
+        saved_machine_config.rollup.input_metadata.image_filename = "temp_machine/0000000060004000-1000.bin"
+        saved_machine_config.rollup.notice_hashes.image_filename = "temp_machine/0000000060008000-1000.bin"
+        saved_machine_config.rollup.rx_buffer.image_filename = "temp_machine/0000000060000000-1000.bin"
+        saved_machine_config.rollup.tx_buffer.image_filename = "temp_machine/0000000060002000-1000.bin"
+        saved_machine_config.rollup.voucher_hashes.image_filename = "temp_machine/0000000060006000-1000.bin"
+        saved_machine_config.rom.image_filename = "temp_machine/0000000000001000-f000.bin"
+        saved_machine_config.tlb.image_filename = "temp_machine/0000000000020000-6000.bin"
+        saved_machine_config.uarch.ram.image_filename = "temp_machine/0000000070000000-20000.bin"
+
+        -- bootargs are lost when using store()
+        saved_machine_config.rom.bootargs = ""
+
+        expect.equal(loaded_machine_config, saved_machine_config)
+    end)
+
+    it("should fail when trying to saving into an invalid directory", function()
+        local machine = cartesi.machine(test_config)
+        expect.fail(function() machine:store("some/invalid/directory") end, "error creating directory")
+    end)
+end)
diff --git a/src/spec/htif-tests.lua b/src/spec/htif-tests.lua
new file mode 100644
index 000000000..36eb0082c
--- /dev/null
+++ b/src/spec/htif-tests.lua
@@ -0,0 +1,205 @@
+#!/usr/bin/env lua5.4
+
+-- Copyright 2023 Cartesi Pte. Ltd.
+--
+-- This file is part of the machine-emulator. The machine-emulator is free
+-- software: you can redistribute it and/or modify it under the terms of the GNU
+-- Lesser General Public License as published by the Free Software Foundation,
+-- either version 3 of the License, or (at your option) any later version.
+--
+-- The machine-emulator is distributed in the hope that it will be useful, but
+-- WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
+-- for more details.
+--
+-- You should have received a copy of the GNU Lesser General Public License
+-- along with the machine-emulator. If not, see http://www.gnu.org/licenses/.
+--
+
+local cartesi = require("cartesi")
+local fs = require("spec.util.fs")
+local lester = require("spec.util.lester")
+local has_luaposix, unistd = pcall(require, "posix.unistd")
+local describe, it, expect = lester.describe, lester.it, lester.expect
+
+-- Collect garbage after every test so machine references are automatically destroyed
+lester.after(function() collectgarbage() end)
+
+describe("machine htif", function()
+    -- This test will fetch the rollup buffers from the PMA entries; check
+    -- that `rx_buffer` and `input_metadata` are filled with a byte patern;
+    -- then write a byte pattern into `tx_buffer`, `voucher_hashes` and
+    -- `notice_hashes`.
+    it("should write/read rollup buffers", function()
+        local ROLLUP_BUFFER_LENGTH = 4096
+        local machine_config = {
+            ram = { image_filename = fs.tests_path .. "htif_rollup.bin", length = 0x4000000 },
+            rom = { image_filename = fs.tests_path .. "bootstrap.bin" },
+            htif = { yield_automatic = true },
+            rollup = {
+                rx_buffer = { start = 0x60000000, length = ROLLUP_BUFFER_LENGTH, shared = false },
+                tx_buffer = { start = 0x60001000, length = ROLLUP_BUFFER_LENGTH, shared = false },
+                input_metadata = { start = 0x60002000, length = ROLLUP_BUFFER_LENGTH, shared = false },
+                voucher_hashes = { start = 0x60003000, length = ROLLUP_BUFFER_LENGTH, shared = false },
+                notice_hashes = { start = 0x60004000, length = ROLLUP_BUFFER_LENGTH, shared = false },
+            },
+        }
+        local machine = cartesi.machine(machine_config)
+        -- fill input with `pattern`
+        local pattern = string.rep("\xef\xcd\xab\x89\x67\x45\x23\x01", ROLLUP_BUFFER_LENGTH / 8)
+        local rollup = machine_config.rollup
+        machine:write_memory(rollup.rx_buffer.start, pattern, rollup.rx_buffer.length)
+        -- fill input_metadata with `pattern`
+        machine:write_memory(rollup.input_metadata.start, pattern, rollup.input_metadata.length)
+        machine:run(math.maxinteger)
+        -- check that buffers got filled in with `pattern`
+        expect.equal(pattern, machine:read_memory(rollup.tx_buffer.start, rollup.tx_buffer.length))
+        expect.equal(pattern, machine:read_memory(rollup.voucher_hashes.start, rollup.voucher_hashes.length))
+        expect.equal(pattern, machine:read_memory(rollup.notice_hashes.start, rollup.notice_hashes.length))
+        expect.truthy(machine:read_iflags_H())
+        expect.equal(machine:read_mcycle(), 8981)
+        expect.equal(machine:read_htif_tohost_data() >> 1, 0)
+    end)
+
+    local YIELD_MANUAL = cartesi.machine.HTIF_YIELD_MANUAL
+    local YIELD_AUTOMATIC = cartesi.machine.HTIF_YIELD_AUTOMATIC
+    local yields = {
+        { mcycle = 13, data = 10, cmd = YIELD_MANUAL, reason = cartesi.machine.HTIF_YIELD_REASON_PROGRESS },
+        { mcycle = 44, data = 11, cmd = YIELD_MANUAL, reason = cartesi.machine.HTIF_YIELD_REASON_PROGRESS },
+        { mcycle = 75, data = 12, cmd = YIELD_MANUAL, reason = cartesi.machine.HTIF_YIELD_REASON_PROGRESS },
+        { mcycle = 107, data = 13, cmd = YIELD_MANUAL, reason = cartesi.machine.HTIF_YIELD_REASON_RX_ACCEPTED },
+        { mcycle = 139, data = 14, cmd = YIELD_MANUAL, reason = cartesi.machine.HTIF_YIELD_REASON_RX_REJECTED },
+        { mcycle = 171, data = 15, cmd = YIELD_MANUAL, reason = cartesi.machine.HTIF_YIELD_REASON_TX_VOUCHER },
+        { mcycle = 203, data = 16, cmd = YIELD_MANUAL, reason = cartesi.machine.HTIF_YIELD_REASON_TX_NOTICE },
+        { mcycle = 235, data = 17, cmd = YIELD_MANUAL, reason = cartesi.machine.HTIF_YIELD_REASON_TX_REPORT },
+        { mcycle = 267, data = 18, cmd = YIELD_MANUAL, reason = cartesi.machine.HTIF_YIELD_REASON_TX_EXCEPTION },
+        { mcycle = 298, data = 20, cmd = YIELD_AUTOMATIC, reason = cartesi.machine.HTIF_YIELD_REASON_PROGRESS },
+        { mcycle = 329, data = 21, cmd = YIELD_AUTOMATIC, reason = cartesi.machine.HTIF_YIELD_REASON_PROGRESS },
+        { mcycle = 360, data = 22, cmd = YIELD_AUTOMATIC, reason = cartesi.machine.HTIF_YIELD_REASON_PROGRESS },
+        { mcycle = 392, data = 23, cmd = YIELD_AUTOMATIC, reason = cartesi.machine.HTIF_YIELD_REASON_RX_ACCEPTED },
+        { mcycle = 424, data = 24, cmd = YIELD_AUTOMATIC, reason = cartesi.machine.HTIF_YIELD_REASON_RX_REJECTED },
+        { mcycle = 456, data = 25, cmd = YIELD_AUTOMATIC, reason = cartesi.machine.HTIF_YIELD_REASON_TX_VOUCHER },
+        { mcycle = 488, data = 26, cmd = YIELD_AUTOMATIC, reason = cartesi.machine.HTIF_YIELD_REASON_TX_NOTICE },
+        { mcycle = 520, data = 27, cmd = YIELD_AUTOMATIC, reason = cartesi.machine.HTIF_YIELD_REASON_TX_REPORT },
+    }
+    local function make_yield_test(yield_automatic_enable, yield_manual_enable)
+        local test_name =
+            string.format("should sink for yield (automatic=%s manual=%s)", yield_automatic_enable, yield_manual_enable)
+        it(test_name, function()
+            local machine_config = {
+                ram = { image_filename = fs.tests_path .. "htif_yield.bin", length = 0x4000000 },
+                rom = { image_filename = fs.tests_path .. "bootstrap.bin" },
+                htif = { yield_automatic = yield_automatic_enable, yield_manual = yield_manual_enable },
+            }
+            local machine = cartesi.machine(machine_config)
+            local break_reason
+            for _, v in ipairs(yields) do
+                if
+                    (v.cmd == YIELD_MANUAL and yield_manual_enable)
+                    or (v.cmd == YIELD_AUTOMATIC and yield_automatic_enable)
+                then
+                    while not machine:read_iflags_Y() and not machine:read_iflags_X() and not machine:read_iflags_H() do
+                        break_reason = machine:run()
+                    end
+                    -- mcycle should be as expected
+                    local mcycle = machine:read_mcycle()
+                    expect.equal(mcycle, v.mcycle)
+
+                    if yield_automatic_enable and v.cmd == YIELD_AUTOMATIC then
+                        expect.equal(break_reason, cartesi.BREAK_REASON_YIELDED_AUTOMATICALLY)
+                        expect.truthy(machine:read_iflags_X())
+                        expect.falsy(machine:read_iflags_Y())
+                    elseif yield_manual_enable and v.cmd == YIELD_MANUAL then
+                        expect.equal(break_reason, cartesi.BREAK_REASON_YIELDED_MANUALLY)
+                        expect.truthy(machine:read_iflags_Y())
+                        expect.falsy(machine:read_iflags_X())
+                    else
+                        expect.truthy(false)
+                    end
+                    -- data should be as expected
+                    local data = machine:read_htif_tohost_data()
+                    local reason = data >> 32
+                    data = data << 32 >> 32
+                    expect.equal(data, v.data)
+                    expect.equal(reason, v.reason)
+                    expect.equal(machine:read_htif_tohost_cmd(), v.cmd)
+                    -- trying to run it without resetting iflags.Y should not advance
+                    if machine:read_iflags_Y() then
+                        machine:run()
+                        expect.equal(machine:read_mcycle(), mcycle)
+                        expect.truthy(machine:read_iflags_Y())
+                    end
+                    -- now reset it so the machine can be advanced
+                    machine:reset_iflags_Y()
+                    machine:reset_iflags_X()
+                end
+            end
+            -- finally run to completion
+            while not machine:read_iflags_Y() and not machine:read_iflags_H() do
+                break_reason = machine:run()
+            end
+            -- should be halted
+            expect.equal(break_reason, cartesi.BREAK_REASON_HALTED)
+            expect.truthy(machine:read_iflags_H())
+            -- at the expected mcycle
+            expect.equal(machine:read_mcycle(), 561)
+            -- with the expected payload
+            expect.equal((machine:read_htif_tohost_data() >> 1), 42)
+        end)
+    end
+
+    make_yield_test(false, false)
+    make_yield_test(false, true)
+    make_yield_test(true, false)
+    make_yield_test(true, true)
+
+    it("should write to console when getchar is disabled", function()
+        local machine = cartesi.machine({
+            ram = { image_filename = fs.tests_path .. "htif_console.bin", length = 0x4000000 },
+            rom = { image_filename = fs.tests_path .. "bootstrap.bin" },
+            htif = { console_getchar = false },
+        })
+        machine:run(math.maxinteger)
+        -- should be halted
+        expect.truthy(machine:read_iflags_H())
+        -- with the expected payload
+        expect.equal((machine:read_htif_tohost_data() >> 1), 42)
+        -- at the expected mcycle
+        expect.equal(machine:read_mcycle(), 2141)
+        io.write("\n")
+    end)
+
+    -- This test is only enabled if luaposix is installed in the system
+    it("should read/write to console when getchar is enabled", function()
+        -- create new FD for stdin and write in it,
+        -- later the cartesi machine console will consume this value
+        local read_fd, write_fd = unistd.pipe()
+        unistd.dup2(read_fd, unistd.STDIN_FILENO)
+        unistd.write(write_fd, "CTSI")
+        local machine = cartesi.machine({
+            ram = { image_filename = fs.tests_path .. "htif_console.bin", length = 0x4000000 },
+            rom = { image_filename = fs.tests_path .. "bootstrap.bin" },
+            htif = { console_getchar = true },
+        })
+        machine:run(math.maxinteger)
+        -- should be halted
+        expect.truthy(machine:read_iflags_H())
+        -- with the expected payload
+        expect.equal((machine:read_htif_tohost_data() >> 1), 42)
+        -- at the expected mcycle
+        expect.equal(machine:read_mcycle(), 2141)
+        io.write("\n")
+
+        -- we cannot initialize TTY twice
+        expect.fail(
+            function()
+                cartesi.machine({
+                    ram = { image_filename = fs.tests_path .. "htif_console.bin", length = 0x4000000 },
+                    rom = { image_filename = fs.tests_path .. "bootstrap.bin" },
+                    htif = { console_getchar = true },
+                })
+            end,
+            "TTY already initialized"
+        )
+    end, has_luaposix)
+end)
diff --git a/src/spec/keccak-tests.lua b/src/spec/keccak-tests.lua
new file mode 100644
index 000000000..a7096faee
--- /dev/null
+++ b/src/spec/keccak-tests.lua
@@ -0,0 +1,69 @@
+#!/usr/bin/env lua5.4
+
+-- Copyright 2023 Cartesi Pte. Ltd.
+--
+-- This file is part of the machine-emulator. The machine-emulator is free
+-- software: you can redistribute it and/or modify it under the terms of the GNU
+-- Lesser General Public License as published by the Free Software Foundation,
+-- either version 3 of the License, or (at your option) any later version.
+--
+-- The machine-emulator is distributed in the hope that it will be useful, but
+-- WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
+-- for more details.
+--
+-- You should have received a copy of the GNU Lesser General Public License
+-- along with the machine-emulator. If not, see http://www.gnu.org/licenses/.
+--
+
+local lester = require("spec.util.lester")
+local util = require("cartesi.util")
+local describe, it, expect = lester.describe, lester.it, lester.expect
+local keccak = require("cartesi").keccak
+
+local function hexkeccak(...) return util.hexhash(keccak(...)) end
+
+describe("keccak", function()
+    it("should fail when passing invalid arguments", function()
+        expect.fail(function() keccak("a", "b", "c") end, "too many arguments")
+        expect.fail(function() keccak(1, 2) end, "too many arguments")
+        expect.fail(function() keccak() end, "too few arguments")
+    end)
+
+    it("should match hashes for uint64 integers", function()
+        expect.equal(hexkeccak(0), "011b4d03dd8c01f1049143cf9c4c817e4b167f1d1b83e5c6f0f10d89ba1e7bce")
+        expect.equal(hexkeccak(1), "30f692b256e24009bcb34d0ee84da73c298afacc0924e01105e2eb0f01a87fe2")
+        expect.equal(hexkeccak(-1), "ad0bfb4b0a66700aeb759d88c315168cc0a11ee99e2a680e548ecf0a464e7daf")
+        expect.equal(hexkeccak(0x8000000000000000), "f9b31243137c51434c88c419b2a3d7d2103a13948255efab17ca486946dfbf49")
+        expect.equal(hexkeccak(0xf0f10d89ba1e7bce), "86433232ac2024ad7962ccc2fbb7c0219499b98ec24049a81ca6484d206eb288")
+    end)
+
+    it("should match hashes for one string", function()
+        expect.equal(hexkeccak(""), "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470")
+        expect.equal(hexkeccak("0"), "044852b2a670ade5407e78fb2863c51de9fcb96542a07186fe3aeda6bb8a116d")
+        expect.equal(hexkeccak("test"), "9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658")
+        expect.equal(hexkeccak(hexkeccak("")), "79482f93ea0d714e293366322922962af38ecdd95cff648355c1af4b40a78b32")
+    end)
+
+    it("should match hashes for two strings", function()
+        expect.equal(hexkeccak("", ""), "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470")
+        expect.equal(hexkeccak("0", ""), "044852b2a670ade5407e78fb2863c51de9fcb96542a07186fe3aeda6bb8a116d")
+        expect.equal(hexkeccak("", "0"), "044852b2a670ade5407e78fb2863c51de9fcb96542a07186fe3aeda6bb8a116d")
+        expect.equal(hexkeccak("test", ""), "9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658")
+        expect.equal(hexkeccak("tes", "t"), "9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658")
+        expect.equal(hexkeccak("te", "st"), "9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658")
+        expect.equal(hexkeccak("t", "est"), "9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658")
+        expect.equal(hexkeccak("", "test"), "9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658")
+    end)
+
+    it("should match hashes for large ranges", function()
+        expect.equal(
+            hexkeccak(string.rep("a", 8191)),
+            "b52a6c73f463177a28d89360fb470808ba6572ec75de6db05a3bb044ca4d1009"
+        )
+        expect.equal(
+            hexkeccak(string.rep("a", 4096), string.rep("a", 4095)),
+            "b52a6c73f463177a28d89360fb470808ba6572ec75de6db05a3bb044ca4d1009"
+        )
+    end)
+end)
diff --git a/src/spec/machine-tests.lua b/src/spec/machine-tests.lua
new file mode 100644
index 000000000..b631e48f6
--- /dev/null
+++ b/src/spec/machine-tests.lua
@@ -0,0 +1,99 @@
+#!/usr/bin/env lua5.4
+
+-- Copyright 2023 Cartesi Pte. Ltd.
+--
+-- This file is part of the machine-emulator. The machine-emulator is free
+-- software: you can redistribute it and/or modify it under the terms of the GNU
+-- Lesser General Public License as published by the Free Software Foundation,
+-- either version 3 of the License, or (at your option) any later version.
+--
+-- The machine-emulator is distributed in the hope that it will be useful, but
+-- WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
+-- for more details.
+--
+-- You should have received a copy of the GNU Lesser General Public License
+-- along with the machine-emulator. If not, see http://www.gnu.org/licenses/.
+--
+
+local lester = require("spec.util.lester")
+local fs = require("spec.util.fs")
+local util = require("cartesi.util")
+local cartesi = require("cartesi")
+local keccak = require("cartesi").keccak
+local describe, it, expect = lester.describe, lester.it, lester.expect
+
+-- Collect garbage after every test so machine references are automatically destroyed
+lester.after(function() collectgarbage() end)
+
+describe("machine run", function()
+    it("should not break due to mtime interrupts", function()
+        local machine = cartesi.machine({
+            rom = { image_filename = fs.tests_path .. "bootstrap.bin" },
+            ram = { image_filename = fs.tests_path .. "mtime_interrupt.bin", length = 1 << 20 },
+        })
+        machine:run()
+        expect.truthy(machine:read_iflags_H())
+        expect.equal(machine:read_htif_tohost_data() >> 1, 0)
+        expect.equal(machine:read_mcycle(), cartesi.RTC_FREQ_DIV * 2 + 20)
+    end)
+
+    it("should run up to mcycle limit", function()
+        local machine = cartesi.machine({
+            rom = { image_filename = fs.tests_path .. "bootstrap.bin" },
+            ram = { image_filename = fs.tests_path .. "mcycle_overflow.bin", length = 1 << 20 },
+        })
+        -- Stop the machine before the first RAM instruction
+        local WFI_CYCLE = 7
+        expect.equal(machine:run(WFI_CYCLE), cartesi.BREAK_REASON_REACHED_TARGET_MCYCLE)
+        machine:write_mcycle(cartesi.MAX_MCYCLE - 5)
+        -- Run once to trigger an interrupt, which might cause an overflow on the
+        -- next call to machine:run
+        expect.equal(machine:run(cartesi.MAX_MCYCLE - 4), cartesi.BREAK_REASON_REACHED_TARGET_MCYCLE)
+        expect.equal(machine:run(cartesi.MAX_MCYCLE), cartesi.BREAK_REASON_REACHED_TARGET_MCYCLE)
+        expect.equal(machine:read_mcycle(), cartesi.MAX_MCYCLE)
+    end)
+
+    it("shouldn't change state in max mcycle", function()
+        local machine = cartesi.machine({
+            rom = { image_filename = fs.tests_path .. "bootstrap.bin" },
+            ram = { length = 1 << 20 },
+        })
+        machine:write_mcycle(cartesi.MAX_MCYCLE)
+        local hash_before = machine:get_root_hash()
+        expect.equal(machine:run(cartesi.MAX_MCYCLE), cartesi.BREAK_REASON_REACHED_TARGET_MCYCLE)
+        local hash_after = machine:get_root_hash()
+        expect.equal(hash_before, hash_after)
+    end)
+end)
+
+describe("machine dump", function()
+    local pmas_file_names = {
+        "0000000000000000--0000000000001000.bin", -- shadow state
+        "0000000000001000--000000000000f000.bin", -- rom
+        "0000000000010000--0000000000001000.bin", -- shadow pmas
+        "0000000000020000--0000000000006000.bin", -- shadow tlb
+        "0000000002000000--00000000000c0000.bin", -- clint
+        "0000000040008000--0000000000001000.bin", -- htif
+        "0000000080000000--0000000000100000.bin", -- ram
+    }
+    local config = {
+        rom = { image_filename = fs.rom_image },
+        ram = { length = 1 << 20 },
+    }
+
+    -- Auto remove PMA bin files after each test
+    lester.after(function() fs.remove_files(pmas_file_names) end)
+
+    it("should match pmas dumps", function()
+        local machine = cartesi.machine(config)
+        machine:dump_pmas()
+        for _, file_name in ipairs(pmas_file_names) do
+            local mem_start, mem_size = file_name:match("^(%x+)%-%-(%x+)%.bin$")
+            mem_start, mem_size = tonumber(mem_start, 16), tonumber(mem_size, 16)
+            local file_mem = fs.read_file(file_name)
+            local machine_mem = machine:read_memory(mem_start, mem_size)
+            expect.equal(util.hexhash(keccak(file_mem)), util.hexhash(keccak(machine_mem)))
+        end
+    end)
+end)
diff --git a/src/spec/step-tests.lua b/src/spec/step-tests.lua
new file mode 100644
index 000000000..1f1937654
--- /dev/null
+++ b/src/spec/step-tests.lua
@@ -0,0 +1,61 @@
+#!/usr/bin/env lua5.4
+
+-- Copyright 2023 Cartesi Pte. Ltd.
+--
+-- This file is part of the machine-emulator. The machine-emulator is free
+-- software: you can redistribute it and/or modify it under the terms of the GNU
+-- Lesser General Public License as published by the Free Software Foundation,
+-- either version 3 of the License, or (at your option) any later version.
+--
+-- The machine-emulator is distributed in the hope that it will be useful, but
+-- WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
+-- for more details.
+--
+-- You should have received a copy of the GNU Lesser General Public License
+-- along with the machine-emulator. If not, see http://www.gnu.org/licenses/.
+--
+
+local lester = require("spec.util.lester")
+local fs = require("spec.util.fs")
+local cartesi = require("cartesi")
+local describe, it, expect = lester.describe, lester.it, lester.expect
+
+-- Collect garbage after every test so machine references are automatically destroyed
+lester.after(function() collectgarbage() end)
+
+describe("machine step_uarch", function()
+    local only_rom_config = {
+        ram = { length = 0x4000000 },
+        rom = { image_filename = fs.rom_image },
+        uarch = {
+            ram = { image_filename = fs.uarch_ram_image, length = 0x20000 },
+        },
+    }
+    it("should verify state transition and access log", function()
+        local machine = cartesi.machine(only_rom_config)
+        local old_hash = machine:get_root_hash()
+        local access_log = machine:step_uarch({ proofs = true, annotations = true })
+        expect.truthy(access_log.brackets)
+        expect.truthy(access_log.accesses)
+        expect.truthy(access_log.notes)
+        local new_hash = machine:get_root_hash()
+        local res = cartesi.machine.verify_state_transition(old_hash, access_log, new_hash, {})
+        expect.equal(res, 1)
+        res = cartesi.machine.verify_access_log(access_log, {})
+        expect.equal(res, 1)
+    end)
+
+    for _, proofs in ipairs({ true, false }) do
+        it(string.format("should do nothing on max mcycle (proofs=%s)", proofs), function()
+            local machine = cartesi.machine(only_rom_config)
+            machine:write_mcycle(cartesi.MAX_MCYCLE)
+            local log = machine:step_uarch({ proofs = proofs })
+            expect.equal(#log.accesses, 7)
+            local old_hash = machine:get_root_hash()
+            expect.equal(machine:read_mcycle(), cartesi.MAX_MCYCLE)
+            local new_hash = machine:get_root_hash()
+            expect.equal(old_hash, new_hash)
+        end)
+    end
+end)
diff --git a/src/spec/util/fs.lua b/src/spec/util/fs.lua
new file mode 100644
index 000000000..966b83fee
--- /dev/null
+++ b/src/spec/util/fs.lua
@@ -0,0 +1,55 @@
+#!/usr/bin/env lua5.4
+
+-- Copyright 2023 Cartesi Pte. Ltd.
+--
+-- This file is part of the machine-emulator. The machine-emulator is free
+-- software: you can redistribute it and/or modify it under the terms of the GNU
+-- Lesser General Public License as published by the Free Software Foundation,
+-- either version 3 of the License, or (at your option) any later version.
+--
+-- The machine-emulator is distributed in the hope that it will be useful, but
+-- WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License
+-- for more details.
+--
+-- You should have received a copy of the GNU Lesser General Public License
+-- along with the machine-emulator. If not, see http://www.gnu.org/licenses/.
+--
+
+local fs = {}
+
+function fs.adjust_images_path(path)
+    if not path then return "" end
+    return string.gsub(path, "/*$", "") .. "/"
+end
+
+function fs.remove_files(filenames)
+    for _, filename in pairs(filenames) do
+        os.remove(filename)
+    end
+end
+
+function fs.read_file(filename)
+    local file = assert(io.open(filename, "rb"))
+    if not file then return nil end
+    local contents = file:read("*a")
+    file:close()
+    return contents
+end
+
+function fs.get_file_length(filename)
+    local file = io.open(filename, "rb")
+    if not file then return nil end
+    local size = file:seek("end")
+    file:close()
+    return size
+end
+
+fs.images_path = fs.adjust_images_path(os.getenv("CARTESI_IMAGES_PATH"))
+fs.tests_path = fs.adjust_images_path(os.getenv("CARTESI_TESTS_PATH"))
+fs.rom_image = fs.images_path .. "rom.bin"
+fs.linux_image = fs.images_path .. "linux.bin"
+fs.rootfs_image = fs.images_path .. "rootfs.ext2"
+fs.uarch_ram_image = fs.images_path .. "uarch-ram.bin"
+
+return fs
diff --git a/src/spec/util/lester.lua b/src/spec/util/lester.lua
new file mode 100644
index 000000000..273bb2e22
--- /dev/null
+++ b/src/spec/util/lester.lua
@@ -0,0 +1,594 @@
+--[[
+Minimal test framework for Lua.
+lester - v0.1.5 - 18/May/2023
+Eduardo Bart - edub4rt@gmail.com
+https://github.com/edubart/lester
+Minimal Lua test framework.
+See end of file for LICENSE.
+]]
+
+--[[--
+Lester is a minimal unit testing framework for Lua with a focus on being simple to use.
+
+## Features
+
+* Minimal, just one file.
+* Self contained, no external dependencies.
+* Simple and hackable when needed.
+* Use `describe` and `it` blocks to describe tests.
+* Supports `before` and `after` handlers.
+* Colored output.
+* Configurable via the script or with environment variables.
+* Quiet mode, to use in live development.
+* Optionally filter tests by name.
+* Show traceback on errors.
+* Show time to complete tests.
+* Works with Lua 5.1+.
+* Efficient.
+
+## Usage
+
+Copy `lester.lua` file to a project and require it,
+which returns a table that includes all of the functionality:
+
+```lua
+local lester = require 'lester'
+local describe, it, expect = lester.describe, lester.it, lester.expect
+
+-- Customize lester configuration.
+lester.show_traceback = false
+
+-- Parse arguments from command line.
+lester.parse_args()
+
+describe('my project', function()
+  lester.before(function()
+    -- This function is run before every test.
+  end)
+
+  describe('module1', function() -- Describe blocks can be nested.
+    it('feature1', function()
+      expect.equal('something', 'something') -- Pass.
+    end)
+
+    it('feature2', function()
+      expect.truthy(false) -- Fail.
+    end)
+
+    local feature3_test_enabled = false
+    it('feature3', function() -- This test will be skipped.
+      expect.truthy(false) -- Fail.
+    end, feature3_test_enabled)
+  end)
+end)
+
+lester.report() -- Print overall statistic of the tests run.
+lester.exit() -- Exit with success if all tests passed.
+```
+
+## Customizing output with environment variables
+
+To customize the output of lester externally,
+you can set the following environment variables before running a test suite:
+
+* `LESTER_QUIET="true"`, omit print of passed tests.
+* `LESTER_COLOR="false"`, disable colored output.
+* `LESTER_SHOW_TRACEBACK="false"`, disable traceback on test failures.
+* `LESTER_SHOW_ERROR="false"`, omit print of error description of failed tests.
+* `LESTER_STOP_ON_FAIL="true"`, stop on first test failure.
+* `LESTER_UTF8TERM="false"`, disable printing of UTF-8 characters.
+* `LESTER_FILTER="some text"`, filter the tests that should be run.
+
+Note that these configurations can be changed via script too, check the documentation.
+
+## Customizing output with command line arguments
+
+You can also customize output using command line arguments
+if `lester.parse_args()` is called at startup.
+
+The following command line arguments are available:
+
+* `--quiet`, omit print of passed tests.
+* `--no-quiet`, show print of passed tests.
+* `--no-color`, disable colored output.
+* `--no-show-traceback`, disable traceback on test failures.
+* `--no-show-error`, omit print of error description of failed tests.
+* `--stop-on-fail`, stop on first test failure.
+* `--no-utf8term`, disable printing of UTF-8 characters.
+* `--filter="some text"`, filter the tests that should be run.
+
+]]
+
+-- Returns whether the terminal supports UTF-8 characters.
+local function is_utf8term()
+    local lang = os.getenv("LANG")
+    return (lang and lang:lower():match("utf%-?8$")) and true or false
+end
+
+-- Returns whether a system environment variable is "true".
+local function getboolenv(varname, default)
+    local val = os.getenv(varname)
+    if val == "true" then
+        return true
+    elseif val == "false" then
+        return false
+    end
+    return default
+end
+
+-- The lester module.
+local lester = {
+    --- Weather lines of passed tests should not be printed. False by default.
+    quiet = getboolenv("LESTER_QUIET", false),
+    --- Weather the output should  be colorized. True by default.
+    color = getboolenv("LESTER_COLOR", true),
+    --- Weather a traceback must be shown on test failures. True by default.
+    show_traceback = getboolenv("LESTER_SHOW_TRACEBACK", true),
+    --- Weather the error description of a test failure should be shown. True by default.
+    show_error = getboolenv("LESTER_SHOW_ERROR", true),
+    --- Weather test suite should exit on first test failure. False by default.
+    stop_on_fail = getboolenv("LESTER_STOP_ON_FAIL", false),
+    --- Weather we can print UTF-8 characters to the terminal. True by default when supported.
+    utf8term = getboolenv("LESTER_UTF8TERM", is_utf8term()),
+    --- A string with a lua pattern to filter tests. Nil by default.
+    filter = os.getenv("LESTER_FILTER") or "",
+    --- Function to retrieve time in seconds with milliseconds precision, `os.clock` by default.
+    seconds = os.clock,
+}
+
+-- Variables used internally for the lester state.
+local lester_start = nil
+local last_succeeded = false
+local level = 0
+local successes = 0
+local total_successes = 0
+local failures = 0
+local total_failures = 0
+local skipped = 0
+local total_skipped = 0
+local start = 0
+local befores = {}
+local afters = {}
+local names = {}
+
+-- Color codes.
+local color_codes = {
+    reset = string.char(27) .. "[0m",
+    bright = string.char(27) .. "[1m",
+    red = string.char(27) .. "[31m",
+    green = string.char(27) .. "[32m",
+    yellow = string.char(27) .. "[33m",
+    blue = string.char(27) .. "[34m",
+    magenta = string.char(27) .. "[35m",
+}
+
+local quiet_o_char = string.char(226, 151, 143)
+
+-- Colors table, returning proper color code if color mode is enabled.
+local colors = setmetatable({}, { __index = function(_, key) return lester.color and color_codes[key] or "" end })
+
+--- Table of terminal colors codes, can be customized.
+lester.colors = colors
+
+-- Parse command line arguments from `arg` table.
+-- It `arg` is nil then the global `arg` is used.
+function lester.parse_args(arg)
+    for _, opt in ipairs(arg or _G.arg) do
+        local name, value
+        if opt:find("^%-%-filter") then
+            name = "filter"
+            value = opt:match("^%-%-filter%=(.*)$")
+        elseif opt:find("^%-%-no%-[a-z0-9-]+$") then
+            name = opt:match("^%-%-no%-([a-z0-9-]+)$"):gsub("-", "_")
+            value = false
+        elseif opt:find("^%-%-[a-z0-9-]+$") then
+            name = opt:match("^%-%-([a-z0-9-]+)$"):gsub("-", "_")
+            value = true
+        end
+        if
+            value ~= nil
+            and lester[name] ~= nil
+            and (type(lester[name]) == "boolean" or type(lester[name]) == "string")
+        then
+            lester[name] = value
+        end
+    end
+end
+
+--- Describe a block of tests, which consists in a set of tests.
+-- Describes can be nested.
+-- @param name A string used to describe the block.
+-- @param func A function containing all the tests or other describes.
+function lester.describe(name, func)
+    if level == 0 then -- Get start time for top level describe blocks.
+        failures = 0
+        successes = 0
+        skipped = 0
+        start = lester.seconds()
+        if not lester_start then lester_start = start end
+    end
+    -- Setup describe block variables.
+    level = level + 1
+    names[level] = name
+    -- Run the describe block.
+    func()
+    -- Cleanup describe block.
+    afters[level] = nil
+    befores[level] = nil
+    names[level] = nil
+    level = level - 1
+    -- Pretty print statistics for top level describe block.
+    if level == 0 and not lester.quiet and (successes > 0 or failures > 0) then
+        local io_write = io.write
+        local colors_reset, colors_green = colors.reset, colors.green
+        io_write(
+            failures == 0 and colors_green or colors.red,
+            "[====] ",
+            colors.magenta,
+            name,
+            colors_reset,
+            " | ",
+            colors_green,
+            successes,
+            colors_reset,
+            " successes / "
+        )
+        if skipped > 0 then io_write(colors.yellow, skipped, colors_reset, " skipped / ") end
+        if failures > 0 then io_write(colors.red, failures, colors_reset, " failures / ") end
+        io_write(colors.bright, string.format("%.6f", lester.seconds() - start), colors_reset, " seconds\n")
+    end
+end
+
+-- Error handler used to get traceback for errors.
+local function xpcall_error_handler(err) return debug.traceback(tostring(err), 2) end
+
+-- Pretty print the line on the test file where an error happened.
+local function show_error_line(err)
+    local info = debug.getinfo(3)
+    local io_write = io.write
+    local colors_reset = colors.reset
+    local short_src, currentline = info.short_src, info.currentline
+    io_write(" (", colors.blue, short_src, colors_reset, ":", colors.bright, currentline, colors_reset)
+    if err and lester.show_traceback then
+        local fnsrc = short_src .. ":" .. currentline
+        for cap1, cap2 in err:gmatch("\t[^\n:]+:(%d+): in function <([^>]+)>\n") do
+            if cap2 == fnsrc then
+                io_write("/", colors.bright, cap1, colors_reset)
+                break
+            end
+        end
+    end
+    io_write(")")
+end
+
+-- Pretty print the test name, with breadcrumb for the describe blocks.
+local function show_test_name(name)
+    local io_write = io.write
+    local colors_reset = colors.reset
+    for _, descname in ipairs(names) do
+        io_write(colors.magenta, descname, colors_reset, " | ")
+    end
+    io_write(colors.bright, name, colors_reset)
+end
+
+--- Declare a test, which consists of a set of assertions.
+-- @param name A name for the test.
+-- @param func The function containing all assertions.
+-- @param enabled If not nil and equals to false, the test will be skipped and this will be reported.
+function lester.it(name, func, enabled)
+    -- Skip the test silently if it does not match the filter.
+    if lester.filter then
+        local fullname = table.concat(names, " | ") .. " | " .. name
+        if not fullname:match(lester.filter) then return end
+    end
+    local io_write = io.write
+    local colors_reset = colors.reset
+    -- Skip the test if it's disabled, while displaying a message
+    if enabled == false then
+        if not lester.quiet then
+            io_write(colors.yellow, "[SKIP] ", colors_reset)
+            show_test_name(name)
+            io_write("\n")
+        else -- Show just a character hinting that the test was skipped.
+            local o = (lester.utf8term and lester.color) and quiet_o_char or "o"
+            io_write(colors.yellow, o, colors_reset)
+        end
+        skipped = skipped + 1
+        total_skipped = total_skipped + 1
+        return
+    end
+    -- Execute before handlers.
+    for _, levelbefores in pairs(befores) do
+        for _, beforefn in ipairs(levelbefores) do
+            beforefn(name)
+        end
+    end
+    -- Run the test, capturing errors if any.
+    local success, err
+    if lester.show_traceback then
+        success, err = xpcall(func, xpcall_error_handler)
+    else
+        success, err = pcall(func)
+        if not success and err then err = tostring(err) end
+    end
+    -- Count successes and failures.
+    if success then
+        successes = successes + 1
+        total_successes = total_successes + 1
+    else
+        failures = failures + 1
+        total_failures = total_failures + 1
+    end
+    -- Print the test run.
+    if not lester.quiet then -- Show test status and complete test name.
+        if success then
+            io_write(colors.green, "[PASS] ", colors_reset)
+        else
+            io_write(colors.red, "[FAIL] ", colors_reset)
+        end
+        show_test_name(name)
+        if not success then show_error_line(err) end
+        io_write("\n")
+    else
+        if success then -- Show just a character hinting that the test succeeded.
+            local o = (lester.utf8term and lester.color) and quiet_o_char or "o"
+            io_write(colors.green, o, colors_reset)
+        else -- Show complete test name on failure.
+            io_write(last_succeeded and "\n" or "", colors.red, "[FAIL] ", colors_reset)
+            show_test_name(name)
+            show_error_line(err)
+            io_write("\n")
+        end
+    end
+    -- Print error message, colorizing its output if possible.
+    if err and lester.show_error then
+        if lester.color then
+            local errfile, errline, errmsg, rest = err:match("^([^:\n]+):(%d+): ([^\n]+)(.*)")
+            if errfile and errline and errmsg and rest then
+                io_write(colors.blue, errfile, colors_reset, ":", colors.bright, errline, colors_reset, ": ")
+                if errmsg:match("^%w([^:]*)$") then
+                    io_write(colors.red, errmsg, colors_reset)
+                else
+                    io_write(errmsg)
+                end
+                err = rest
+            end
+        end
+        io_write(err, "\n\n")
+    end
+    io.flush()
+    -- Stop on failure.
+    if not success and lester.stop_on_fail then
+        if lester.quiet then
+            io_write("\n")
+            io.flush()
+        end
+        lester.exit()
+    end
+    -- Execute after handlers.
+    for _, levelafters in pairs(afters) do
+        for _, afterfn in ipairs(levelafters) do
+            afterfn(name)
+        end
+    end
+    last_succeeded = success
+end
+
+--- Set a function that is called before every test inside a describe block.
+-- A single string containing the name of the test about to be run will be passed to `func`.
+function lester.before(func)
+    local levelbefores = befores[level]
+    if not levelbefores then
+        levelbefores = {}
+        befores[level] = levelbefores
+    end
+    levelbefores[#levelbefores + 1] = func
+end
+
+--- Set a function that is called after every test inside a describe block.
+-- A single string containing the name of the test that was finished will be passed to `func`.
+-- The function is executed independently if the test passed or failed.
+function lester.after(func)
+    local levelafters = afters[level]
+    if not levelafters then
+        levelafters = {}
+        afters[level] = levelafters
+    end
+    levelafters[#levelafters + 1] = func
+end
+
+--- Pretty print statistics of all test runs.
+-- With total success, total failures and run time in seconds.
+function lester.report()
+    local now = lester.seconds()
+    local colors_reset = colors.reset
+    io.write(
+        lester.quiet and "\n" or "",
+        colors.green,
+        total_successes,
+        colors_reset,
+        " successes / ",
+        colors.yellow,
+        total_skipped,
+        colors_reset,
+        " skipped / ",
+        colors.red,
+        total_failures,
+        colors_reset,
+        " failures / ",
+        colors.bright,
+        string.format("%.6f", now - (lester_start or now)),
+        colors_reset,
+        " seconds\n"
+    )
+    io.flush()
+    return total_failures == 0
+end
+
+--- Exit the application with success code if all tests passed, or failure code otherwise.
+function lester.exit()
+    -- Collect garbage before exiting to call __gc handlers
+    collectgarbage()
+    collectgarbage()
+    os.exit(total_failures == 0)
+end
+
+local expect = {}
+--- Expect module, containing utility function for doing assertions inside a test.
+lester.expect = expect
+
+--- Converts a value to a human-readable string.
+-- If the final string not contains only ASCII characters,
+-- then it is converted to a Lua hexdecimal string.
+function expect.tohumanstring(v)
+    local s = tostring(v)
+    if s:find("[^ -~\n\t]") then -- string contains non printable ASCII
+        return '"' .. s:gsub(".", function(c) return string.format("\\x%02X", c:byte()) end) .. '"'
+    end
+    return s
+end
+
+--- Check if a function fails with an error.
+-- If `expected` is nil then any error is accepted.
+-- If `expected` is a string then we check if the error contains that string.
+-- If `expected` is anything else then we check if both are equal.
+function expect.fail(func, expected)
+    local ok, err = pcall(func)
+    if ok then
+        error("expected function to fail", 2)
+    elseif expected ~= nil then
+        local found = expected == err
+        if not found and type(expected) == "string" then found = string.find(tostring(err), expected, 1, true) end
+        if not found then
+            error("expected function to fail\nexpected:\n" .. tostring(expected) .. "\ngot:\n" .. tostring(err), 2)
+        end
+    end
+end
+
+--- Check if a function does not fail with a error.
+function expect.not_fail(func)
+    local ok, err = pcall(func)
+    if not ok then error("expected function to not fail\ngot error:\n" .. expect.tohumanstring(err), 2) end
+end
+
+--- Check if a value is not `nil`.
+function expect.exist(v)
+    if v == nil then error("expected value to exist\ngot:\n" .. expect.tohumanstring(v), 2) end
+end
+
+--- Check if a value is `nil`.
+function expect.not_exist(v)
+    if v ~= nil then error("expected value to not exist\ngot:\n" .. expect.tohumanstring(v), 2) end
+end
+
+--- Check if an expression is evaluates to `true`.
+function expect.truthy(v)
+    if not v then error("expected expression to be true\ngot:\n" .. expect.tohumanstring(v), 2) end
+end
+
+--- Check if an expression is evaluates to `false`.
+function expect.falsy(v)
+    if v then error("expected expression to be false\ngot:\n" .. expect.tohumanstring(v), 2) end
+end
+
+--- Returns raw tostring result for a value.
+local function rawtostring(v)
+    local mt = getmetatable(v)
+    if mt then setmetatable(v, nil) end
+    local s = tostring(v)
+    if mt then setmetatable(v, mt) end
+    return s
+end
+
+-- Returns key suffix for a string_eq table key.
+local function strict_eq_key_suffix(k)
+    if type(k) == "string" then
+        if k:find("^[a-zA-Z_][a-zA-Z0-9]*$") then -- string is a lua field
+            return "." .. k
+        elseif k:find("[^ -~\n\t]") then -- string contains non printable ASCII
+            return '["' .. k:gsub(".", function(c) return string.format("\\x%02X", c:byte()) end) .. '"]'
+        else
+            return '["' .. k .. '"]'
+        end
+    else
+        return string.format("[%s]", rawtostring(k))
+    end
+end
+
+--- Compare if two values are equal, considering nested tables.
+function expect.strict_eq(t1, t2, name)
+    if rawequal(t1, t2) then return true end
+    name = name or "value"
+    local t1type, t2type = type(t1), type(t2)
+    if t1type ~= t2type then
+        return false, string.format("expected types to be equal for %s\nfirst: %s\nsecond: %s", name, t1type, t2type)
+    end
+    if t1type == "table" then
+        if getmetatable(t1) ~= getmetatable(t2) then
+            return false,
+                string.format(
+                    "expected metatables to be equal for %s\nfirst: %s\nsecond: %s",
+                    name,
+                    expect.tohumanstring(t1),
+                    expect.tohumanstring(t2)
+                )
+        end
+        for k, v1 in pairs(t1) do
+            local ok, err = expect.strict_eq(v1, t2[k], name .. strict_eq_key_suffix(k))
+            if not ok then return false, err end
+        end
+        for k, v2 in pairs(t2) do
+            local ok, err = expect.strict_eq(v2, t1[k], name .. strict_eq_key_suffix(k))
+            if not ok then return false, err end
+        end
+    elseif t1 ~= t2 then
+        return false,
+            string.format(
+                "expected values to be equal for %s\nfirst:\n%s\nsecond:\n%s",
+                name,
+                expect.tohumanstring(t1),
+                expect.tohumanstring(t2)
+            )
+    end
+    return true
+end
+
+--- Check if two values are equal.
+function expect.equal(v1, v2)
+    local ok, err = expect.strict_eq(v1, v2)
+    if not ok then error(err, 2) end
+end
+
+--- Check if two values are not equal.
+function expect.not_equal(v1, v2)
+    if expect.strict_eq(v1, v2) then
+        local v1s, v2s = expect.tohumanstring(v1), expect.tohumanstring(v2)
+        error("expected values to be not equal\nfirst value:\n" .. v1s .. "\nsecond value:\n" .. v2s, 2)
+    end
+end
+
+return lester
+
+--[[
+The MIT License (MIT)
+
+Copyright (c) 2021-2023 Eduardo Bart (https://github.com/edubart)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+]]