Skip to content

Prove revert-on-reject state transitions#389

Open
diegonehab wants to merge 137 commits into
mainfrom
feature/revert-root-hash
Open

Prove revert-on-reject state transitions#389
diegonehab wants to merge 137 commits into
mainfrom
feature/revert-root-hash

Conversation

@diegonehab

@diegonehab diegonehab commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Part of #388 (the issue stays open for the Dave integration items). The companion PR regenerates the Solidity side: cartesi/machine-solidity-step#98

Problem

When the guest rejects an advance-state input, the canonical machine state goes back to
the pre-input state, but dispute verifiers could not prove that transition. The
pre-input root hash was poked into the machine by the host through a separate,
unverified write_revert_root_hash() call, and no verifier ever substituted it for the
machine root hash after a rejection.

Solution

The first commit implements the write side. send_cmio_response,
log_send_cmio_response, and verify_send_cmio_response take the revert root hash as
their first argument, threaded as const_machine_hash_view through every interface
layer (machine, i_machine, local and jsonrpc machines, the JSON-RPC wire protocol, the
C API, and the Lua bindings) and as a bytes32 alias in the Solidity-translatable
core. The core commits the hash into the revert_root_hash shadow leaf as a single
full-leaf logged write right after the iflags.Y precondition check, so the value
becomes part of the proof. The record/replay rx-buffer write logic is hoisted into
label-parameterized helpers shared with the new accessor.

The second commit implements the read side. uarch_reset_state resets the uarch as
before, then reads iflags.Y, conditionally reads htif.tohost, decodes the
dev/cmd/reason fields, and on a rejected manual yield calls the new revert_state()
accessor, which reads the revert leaf (one logged access carrying the value) and
switches the canonical root hash atomically. The replay accessor replaces its context
root hash, so verify_reset_uarch is untouched. The record accessor reports the
resulting hash through a caller-owned context, so log_reset_uarch needs no branch.
For the ZK path, replay_step_state_access::finish() applies the same rule, reading
the revert leaf from the shadow registers page that every step log already contains,
and log_step records the substituted hash in the log header. The risc0 guest inherits
the behavior with no source changes. Unknown yield reasons (GIO domains, accepted,
exception) cause no substitution and no error. The physical machine is never reverted,
a rejected log_reset_uarch still resets the uarch.

cm.h gains the HTIF tohost/fromhost field shifts and masks, exposed through the Lua
module so the machine-solidity-step constants generator can query them.

Tests

  • machine-bind (local and jsonrpc) covers the new access shapes, the rejected reset and
    step round trips against the revert hash, tampered revert leaf values, and the
    accepted-reason control case.
  • The corrupted-log specs cover the new revert-hash write access of the send.
  • The uarch json log generator emits a rejected-reset fixture consumed by the
    machine-solidity-step Foundry tests.
  • Verified locally: all Lua suites, C API tests, the 54 uarch tests, uarch.bin
    cross-build, and the risc0 cpp guest cross-build.

The dtc interrupt-provider checker warns that interrupt providers must
declare both #address-cells and #interrupt-cells. The per-CPU
riscv,cpu-intc node only set #interrupt-cells, surfacing a lint
warning whenever the DTB was decompiled (e.g. via dtc -I dtb -O dts
/sys/firmware/fdt inside a running machine). The node has no
addressable children, so #address-cells = <0> is the right value and
Linux's behaviour is unchanged. The DTB bytes do change, so every
freshly-instantiated machine's state hash shifts going forward.
Expose the revert_root_hash shadow register (at AR_SHADOW_REVERT_ROOT_HASH_START)
through all API layers: machine, i_machine/local_machine (NVI pattern), JSON-RPC
client/server, cm.h C API, and Lua bindings. Previously the only way to reach this
register from Lua or the C API was write_memory/read_memory against the magic offset.
Add cm_pma_did enum to cm.h with all device IDs (empty, memory, shadow
state, flash drive, CLINT, HTIF, PLIC, CMIO RX/TX, uarch shadow, VirtIO,
NVRAM). Static asserts in cm.cpp verify the values against pmas-defines.h.
Expose all constants as cartesi.PMA_*_DID in the Lua API via clua-cartesi.cpp.
Before: DTB_BOOTARGS_ROOT glued the kernel `root=` and `init=`
arguments into one string, and DTB_BOOTARGS_INIT was the combined
default for dtb.bootargs. cm.h mirrored that shape with CM_ prefix.

Two issues. The atom set (CONSOLE / UIO / ROOT / INIT in cm.h) was
asymmetric: ROOT covered two kernel arguments, so splicing one
without the other required parsing. And the project-wide <NAME>_INIT
convention ("initial value of NAME", as in PC_INIT, MCYCLE_INIT,
MARCHID_INIT) collides with any symmetrisation that puts INIT on the
init= atom.

After: one atom per kernel argument, suffixed _PART; DTB_BOOTARGS_INIT
returns to meaning the combined initial value.

  DTB_BOOTARGS_CONSOLE_PART  "quiet earlycon=sbi console=hvc0 "
  DTB_BOOTARGS_UIO_PART      "uio_pdrv_genirq.of_id=generic-uio "
  DTB_BOOTARGS_ROOT_PART     "root=/dev/pmem0 rw"
  DTB_BOOTARGS_INIT_PART     "init=/usr/sbin/cartesi-init"
  DTB_BOOTARGS_INIT          (CONSOLE_PART UIO_PART ROOT_PART INIT_PART)

cm.h mirrors with CM_ prefix; cm.cpp's static_asserts match the new
set. clua-cartesi.cpp exposes the four _PART atoms alongside the
unchanged DTB_BOOTARGS_INIT. cartesi-machine.lua's
--no-root-flash-drive handler now strips both ROOT_PART and
INIT_PART (the previous single gsub against the glued DTB_BOOTARGS_ROOT
covered both); the spec-cm-cli.lua assertion that `pmem0` and `init=`
are absent after the flag continues to pass.
- Add docgen.lua filter that executes {pipe=sh} blocks and spans,
  splicing command stdout back into the document.
- Add host-replacements/_build_all.sh orchestrator that runs every
  expensive helper once and caches outputs under $CACHE_DIR, so every
  block/span reduces to a `cat` of a pre-computed file.
- Rewrite README.md.template: %machine.* placeholders replaced with
  pipe spans (or printf forms for mixed-content backtick spans).
- Simplify Dockerfile around cartesi/machine-emulator:$TAG: drop the
  panpipe stage, install pandoc + net-tools + genext2fs, symlink
  doc-local images into /usr/share/cartesi-machine/images so the
  emulator finds them at its default paths.
- Update Makefile rule to invoke pandoc with --lua-filter docgen.lua.
- Remove panpipe.diff.

Compatibility fixes uncovered along the way:
- Lua 5.4: cartesi.machine(require "X") now passes require's two
  return values; split into a local + call in every helper script.
- Use sha256sum (Debian) instead of shasum.
- Add `printf --` terminator so spans starting with `--flag=...`
  aren't parsed as printf options.
Mirror the DEV_ENV_HAS_TOOLCHAIN dispatch pattern from src/Makefile:
- Add DEV_ENV_HAS_DOCGEN ?= no to doc/Makefile. The README.md rule
  delegates to playground-exec when unset, and falls through to the
  direct pandoc invocation (_README.md.local) when set to "yes".
- Set ENV DEV_ENV_HAS_DOCGEN=yes in doc/Dockerfile so the recursive
  make README.md call inside the container takes the local branch
  without looping.
- Fix playground-exec: use $(PLAYGROUND_IMAGE):$(TAG) (was
  cartesi/machine-emulator:linux-env), mount doc/ at /work (was
  /opt/cartesi/machine-emulator, leaving the Dockerfile symlinks
  dangling), rename hostname to playground.
- Install make in the playground image.
- Drop the empty images: phony target.
A Makefile under doc/recipes/ replaces the 226-line bash orchestrator.
Required cache files are auto-detected from the template, and script-
to-script dependencies are auto-derived by grep'ing each recipe for
$CACHE_DIR reads. Default convention is one script per cache key
(cache.<key>.sh -> $CACHE_DIR/<key>.out); five multi-output workloads
(rolling-ioctl-echo-loop, rolling-calc-template, remote, remote-end,
lua.remote) use grouped targets.

Retarget to cartesi/playground:0.5.0, the pre-uarch version the docs
were written for. Switch pandoc to the markdown reader (2.9.2.1 lacks
gfm+attributes), convert template fence attrs to pandoc syntax, and
revert addresses to the long-form layout used by 0.5.0.

Move CACHE_DIR under doc/recipes/cache so it persists via the existing
$(CURDIR) bind mount instead of the ephemeral /tmp/ path.
Add a `cache=FILE` attribute handled by the lua filter that reads
$CACHE_DIR/FILE and substitutes the contents - collapsing the most
common pipeline pattern from a 3-line ```{pipe=sh}\ncat ...\n```
fence to a 2-line ```{cache=X.out}\n``` fence (42 block sites).

Inline forms use an empty bracketed Span: []{cache=X.out} for a
plain substitution, [...]{cache=X.out} for a value with a literal
trailing ellipsis. The Span body becomes the suffix and the whole
thing renders as inline Code, so hashes etc. stay monospace inline
(27 inline sites).

Inline `pipe=sh` Code now keeps its Code wrapper instead of being
replaced by a plain Str, so the few remaining inline shell
substitutions also render as monospace.

Pandoc 2.9.2.1's gfm writer renders unattributed CodeBlocks as
4-space-indented; give classless substitutions a synthetic .text
class so they emit as fenced ``` blocks consistently.

recipes/Makefile auto-detection picks up `cache=KEY.out` references
alongside the existing `$CACHE_DIR/KEY.out` reads.
Each cache.*.sh now declares its own outputs and reads as bash arrays
before sourcing lib.sh, which emits a self-contained make rule to stdout
when invoked with --emit-deps. The Makefile shrinks to ~25 lines: one
pattern rule generates each .d sidecar, a second pattern rule runs
single-output scripts, and -include pulls everything in. docgen.lua
emits template.d as a standard make rule augmenting the all target's
prereqs directly.

Also replace the &&-chained subshells in 20 scripts with flat command
sequences: set -euo pipefail (inherited from lib.sh) handles
abort-on-error correctly, and trap '...' EXIT guarantees artifact
cleanup on both success and failure.
- Fold strip-ansi.sh's sed/tr pipe into docgen.lua's read_cache(); cache
  .out files now hold raw output and 34 cache.*.sh scripts drop the
  trailing | bash strip-ansi.sh pipe.
- Move `cd "$HERE"` from 20 cache scripts into lib.sh, after the
  --emit-deps early exit.
- Add .DELETE_ON_ERROR: to recipes/Makefile so a failed recipe does not
  leave a stale target that Make treats as up-to-date; drop the
  $@.tmp && mv atomic-write workaround from the %.sh.d rule.
- Add .NOTPARALLEL: to recipes/Makefile because cache scripts share
  files in $HERE and bind fixed localhost ports, so make -jN would
  corrupt state or fail with EADDRINUSE.
- Inline the _README.md.local helper target into README.md and add a
  clean target in doc/Makefile.
- Add `script=<KEY> [name=<NAME>] [subst=VAR->KEY,...]` attribute to
  docgen.lua. It extracts a `# docs:begin/end` region from
  `recipes/cache.<KEY>.sh`, strips the trailing `> "$out" 2>&1` redirect,
  and substitutes `$VAR` references with the value of `cache/<KEY>.out`.
  Eliminates the duplication where the README hand-typed a command above
  the cached output of a script that ran a different command.
- Rename the existing `cache=<KEY>.out` attribute to `output=<KEY>`. The
  `.out` extension was an internal cache-layout detail.
- Promote RECIPES_DIR to the canonical, configurable env var; CACHE_DIR
  is unconditionally derived as `$(RECIPES_DIR)/cache`. Both Makefiles
  and docgen.lua now share this anchor.
- Add `# docs:begin/end` markers and convert 16 README blocks to the new
  form (limit-exec, nothing, persistent-flash, persistent-machine,
  persistent-stored-hash, proofs-pristine-run, rarely-append-bootargs-
  loglevel, rarely-append-bootargs-single-id, rarely-id, rarely-periodic-
  hashes, rarely-step, remote, rolling-ioctl-echo-loop, state-hashes-
  limit-exec, state-hashes-no-limit, templates-store).
- persistent-{machine,stored-hash} now name the store directory after the
  final state hash (`machine-$hash`) so the displayed
  `--load="machine-<HASH>"` matches what actually runs.
Continues the script= migration. Each conversion replaces a hand-typed
fenced bash block in README.md.template with a script= attribute that
extracts the displayed command from the same cache.<KEY>.sh file that
produces the cached output, eliminating prose/recipe drift.

- ls: trivial single-region wrap.
- flash: recipe is now self-contained — runs mkdir foo and writes
  bar.txt at build time inside named "setup" / "run" regions, so the
  README displays the same multi-step pipeline the prose previously
  hand-typed. The pre-existing recipes/foo/ orphan is removed; trap
  cleans up foo, foo.tar, foo.ext2.
- templates-run: split into templates-run.sh (cartesi-machine
  invocation, persists output.raw which is gitignored) and a new
  templates-run-result.sh (depends on it via reads=, runs the
  displayed lua5.3 line). The long decimal result, previously
  hand-typed in the template as a constant, is now driven by the
  cached output of templates-run-result.
- proofs-output-run: wrap the post-setup truncate-through-cartesi-
  machine section. Template's leading "rm -f output.raw" is dropped
  (recipe trap handles cleanup).
- interactive-ls: intentionally not converted; recipe heredoc form
  would mislead readers about how to use interactive mode. Header
  comment in the recipe documents the decision.

Also fix bugs in three cache recipes

- templates-hash: redirections were in the wrong order
  (2>&1 > "$out"), letting stderr leak to the terminal and
  capturing only stdout. Fixed to > "$out" 2>&1.

- remote-begin-client: the recipe ran --remote-shutdown so the
  background server would terminate, but the prose at this section
  shows --no-remote-destroy. The captured "shutting down" message
  was therefore wrong relative to what the README displays. Folded
  the capture into remote-end.sh (which already runs the
  prose-correct first invocation) and removed the divergent recipe.

- rolling-calc-template: the printf-built JSON missed a comma after
  the "msg_sender" line; same typo lived in the prose heredocs.
  rollup-memory-range happened to accept both, but the input is
  still invalid JSON. Added the comma in all four locations.
Convert templates-hash, templates-run-result, proofs-input-json,
remote-end (begin/end-client), rolling-calc-template
(template/client), and rolling-ioctl-echo-loop (encode) blocks to
script= filter invocations using # docs:begin/end region markers.

Also fix the parallel JSON missing-comma bug in
rolling-ioctl-echo-loop msg_sender (matching the Tier 3
rolling-calc-template fix), align payload strings with prose
("hello from input N!" with trailing !), and make
persistent-flash.sh self-contained so it no longer depends on
flash.sh leaving foo/ behind.
Bash bodies move from doc/recipes/*.sh into the doc template itself
as keyed code blocks. Each block carries its dependencies, outputs,
and render directive (replace=output|source|null) inline -- no more
matching identifiers between *.sh files and the template, no more
.null declarations, no more separate inputs=/outputs= attributes
layered on the reference site.

docgen.lua walks the template in document order, hashing each resolved
body, writing recipes/cache/<hash>/script.sh, and emitting a Make 3.81
fragment with primary+sibling rules for multi-output scripts. The $K
substitution resolves to the dep's cache directory using longest-key
matching against the in-memory key map, so dotted/dashed keys parse
unambiguously. Cache invalidation is structural: a body change
rehashes it; a dep change rehashes consumers because dep paths are
textually substituted into the body before hashing.

The lib.sh preamble is gone -- docgen.lua prepends the preamble to
each cached script directly. The 74 doc/recipes/*.sh files and lib.sh
are deleted.
docgen.lua

- Replace the single-pass walk with two passes. Pass 1 records every
  key= block body into `pending` via pandoc.walk_block. Pass 2 walks
  in document order; each ref=K (or subst=...->K) calls
  ensure_defined(K), which lazily defines K via DFS through depends=.
  Both ref= and depends= are now order-independent within the
  template -- a key= block may sit anywhere relative to the prose
  and the inline references that use it.

- Make replace= required on every key=/ref= site. No more implicit
  "source" / "output" defaults that hid intent.

- Document the full pipeline in the file header: dry-run with
  DEPS_FILE emits a self-contained makefile fragment; the outer
  Makefile includes it so `make` populates the cache via topological
  execution of cache/<hash>/script.sh recipes; the real run reads
  the cached outputs and renders the final document.

- Simplify the script PREAMBLE: drop the $HERE indirection. Scripts
  now cd into their own cache directory and use a relative `out`
  variable, which matches what authors actually write in block bodies.

README.md.template

- Replace seven hardcoded hashes (three 64-hex command outputs, four
  trunc-8 inline references) with derived ref= blocks driven by
  merkle-tree-hash recipes. With the order-independent filter, every
  key= definition now lives at its visual location.

- Consolidate the "create the calculator template" setup that had
  been duplicated across seven keys. The canonical instance lives on
  templates-store as outputs=out,calculator-template; consumers
  reach the artifact via depends=templates-store/calculator-template
  plus a one-line ln -s symlink prelude. This caught a latent bug
  where the duplicated copies had been silently omitting
  --append-rom-bootargs="single=yes".

- Convert the remaining inline backtick-form ref placeholders to the
  empty-Span form for consistency.

Build infrastructure

- doc/Makefile: add a run-playground target for an interactive shell
  in the playground container.
- doc/recipes/Makefile: drop .NOTPARALLEL -- recipes are independent
  and safe to run in parallel under make.
- doc/recipes/find-htif-putchar.lua: switch to
  require"config.nothing-to-do" to match the directory layout.
- doc/recipes/config/nothing-to-do.lua: regenerate as a full dumped
  config (baseline refresh).
Validate that every base key (key=, depends=, subst=, ref=) matches
[a-zA-Z_][a-zA-Z0-9_]* at parse time, so dots and hyphens are caught
immediately with a named-offender error rather than failing later with a
confusing pattern mismatch.

Add a postamble in build_script_content that checks each declared output
exists after the body runs.  Without this, a missing "> "$out"" redirect
produces either a cryptic "cannot open .../out" from the downstream
consumer or a "No rule to make target" from make -- both blaming the
wrong place.  The postamble exits with "key=<name>: declared output
"<sub>" was not created" pointing at the producing block.  The postamble
is only in the generated script; sources[key] (used by replace=source)
holds the substituted body only, so rendered source blocks are unaffected.
Replaces the old "every block redirects to $out, strip_redirect scrubs
it from rendered source" scheme with a uniform addressing system and
runner-side capture.

docgen.lua
- PREAMBLE now self-captures all three streams via process substitution:
    exec > >(tee stdout >> both) 2> >(tee stderr >> both)
  so script bodies are pure code in their own language. The combined
  `both` file is roughly time-ordered (two parallel tees, not byte-exact
  kernel ordering); good enough for current consumers, swap to script(1)
  if a block ever needs precise interleaving.
- The PREAMBLE EXIT trap closes FDs 1/2 *before* `wait` so the tees see
  EOF and exit; without the close, `wait` deadlocks (trap runs while the
  shell still holds the tee pipes open, tees never EOF).
- Reserved names {stdout, stderr, both, source, null} cannot appear in
  outputs=. Single addressing scheme covers display, references, and
  substitutions:
    replace=<thing>            -- own stream/artifact/source
    replace=K/<thing>          -- cross-block
    replace=null               -- drop
    subst=VAR->K[/<thing>]     -- substitute in rendered source
  `ref=` and `block=` are folded into the slash-form. `subst=` and
  `depends=` defaults updated (both -> stream, stdout -> stream prereq).
- emit_rule emits stdout/stderr/both as siblings; declared artifacts
  remain. strip_redirect, resolve_ref, sub_to_filename, and the per-out
  variable emission are gone.

README.md.template
- Big-bang migration of all ~88 key blocks and ~60 inline references:
    cmd > "$out" 2>&1     -> cmd, plus replace=both
    cmd > "$out"          -> cmd, plus replace=stdout
    cmd 2> "$out"         -> cmd, plus replace=stderr  (rarely_step)
    > "$out_X" 2>&1       -> > X 2>&1 (cwd is the cache dir)
    ref=K replace=output  -> replace=K/<thing>
    block=R replace=source -> replace=source/R
- Multi-output blocks (cmdline_remote, cmdline_remote_end,
  rolling_ioctl_echo_loop, rolling_calc_template) wrap their per-region
  cartesi-machine commands in `{ ... } > <artifact> 2>&1` braces with
  # docs:begin/end *inside* the braces, so the redirect stays out of the
  rendered source without needing strip_redirect.
- Cycle/hash readers that flow through last-cycles.sh / find-hash.sh now
  read /both (cartesi-machine prints these to stderr); blocks whose own
  primary output is on stdout (printf, find-lua-val.sh, ...) keep
  /stdout.

Verified by `make README.md TAG=0.5.0` from a clean cache; rendered
prose differs from the previous render only in expected places (cycle
counts, e2ls cosmetics for the new mtime/owner-pinned foo_ext2 recipe,
and the rarely_step splash interleaving noted above).
…unners

Rename doc/docgen.lua to doc/replace.lua and extend it with
multi-language runner support, docs:begin/end null regions,
\$_REPLACE_KEY substitution, and runner-content hashing. Add the
per-language runners doc/run-bash.sh and doc/run-lua.sh, and add
doc/alerts.lua (pandoc filter for GitHub-style alerts). Update
doc/Makefile, doc/README.md.template, and doc/recipes/Makefile
accordingly.
Make `make README.md` work again on the 0.20 branch by rebasing the
docs container on a locally-built emulator and adding an opt-in
mechanism that lets snippets be migrated to the 0.20 surface one at
a time instead of all at once.

Container:
- doc/Dockerfile: FROM cartesi/playground:0.5.0 → cartesi/machine-emulator:$TAG.
  The released cartesi/machine-emulator:0.20.0 binary cannot drive
  tests/dependencies' kernel (built against this branch's unreleased
  emulator with UIO/etc.), so the docs image must layer on top of
  what `build-emulator-image` produces locally.
- doc/Makefile: rename PLAYGROUND_IMAGE → DOCS_IMAGE; TAG default
  0.5.0 → devel (matches root Makefile); build-docs-image now first
  invokes `$(MAKE) -C .. build-emulator-image TAG=$(TAG)`.

Per-block enable/disable:
- doc/replace.lua: add `enabled=yes|no` block attribute and read
  `-M default-replace=` from doc.meta. Disabled blocks render their
  body verbatim (no execution, no region trimming, no cache rules);
  cross-block replace= sites for disabled keys render empty;
  ensure_defined errors if an enabled block depends on a disabled one.
- doc/{Makefile,recipes/Makefile}: pass `-M default-replace=false` to
  every pandoc invocation. With no per-block enabled=, the README
  renders source-only against pandoc alone — the container is only
  needed for blocks explicitly opted in.

First batch of opted-in snippets:
- doc/README.md.template: enabled=yes on `help`, `linux_sha`,
  `rootfs_sha`, `nothing`; image paths /opt/cartesi/share/images →
  /usr/share/cartesi-machine/images. Rewrite cmdline_interactive_ls
  to feed input via --console-io=input_source:from_file: 0.20's `-i`
  defaults console.input_source to from_stdin which calls open_tty()
  and fails in a non-TTY container; from_file goes through fopen()
  and /sbin/init halts the machine when the user shell exits.
Update the Initialization section: drop --rom-image (ROM is gone),
update image paths to /usr/share/cartesi-machine/images/, rename
filename: to data_filename: in --flash-drive, and replace the Emulator
SDK reference with machine-linux-image and machine-guest-tools.  Point
readers to the cartesi/machine-emulator-docs image for sample files.

Enable the ls, foo_ext2, flash, not_shared_flash,
e2ls_not_shared_foo_ext2, shared_flash, and e2ls_shared_foo_ext2
snippets.

Update the Persistent flash drives section: drop the ROM mention,
rename --dump-pmas to --dump-address-ranges.  Add a note explaining
that /sbin/init runs commands as the unprivileged user dapp, that
flash drives are mounted with root ownership by default, and that
user:dapp must be passed to --flash-drive to allow writes (with
--user=root as a non-preferred alternative).  Add user:dapp to the
not_shared_flash and shared_flash examples accordingly.
Update recipes/find-htif-putchar.lua to use the generic read_reg("name")
API introduced in 0.19, replacing the removed per-accessor methods
read_iflags_H/Y(), read_htif_tohost(), and read_mcycle().

Enable config_dump_nothing, cycles_limit_exec, max_mcycle_arg, limit_exec,
and all state_hashes_* blocks. Also add enabled=yes to the inline
[]{replace=...} spans in the surrounding prose.
Enable the persistent_machine_cmd, machine_dirname, persistent_machine,
and persistent_stored_hash blocks (plus the cross-block replace= sites
and inline spans they reference).

Rewrite the prose claim that --store writes the state hash inside the
directory and --load verifies it: in 0.20 the store directory contains
config.json, per-AR data files, and hash_tree.sht/.phtc, but no separate
hash file. --load verifies the archive format version recorded in
config.json instead. The pre-store and post-load state hashes still
match because the same machine state is restored.
Drop --htif-yield-automatic from the example command (automatic yield is
now the default). Rephrase the surrounding prose to reflect the inverted
model: the emulator honors automatic yields by default, and
--no-htif-yield-automatic disables them (with a note that doing so
prevents Rolling Cartesi Machines from generating outputs). Replace the
gRPC interface reference with JSON-RPC. Drop the now-redundant mention
of --htif-yield-manual from the closing sentence.
Replace --append-rom-bootargs="single=yes" with --user=root, switch
flash-drive filename: to data_filename:, swap --replace-flash-drive for
--replace-memory-range using labels, and bump lua5.3 to lua5.4. Add
mke2fs:false,mount:false to the raw flash drives so init does not try
to format and mount them, plus a prose paragraph explaining the default
init behavior. Replace gRPC with JSON-RPC in the interfaces sentence,
and rewrite the --replace-memory-range explanation now that labels
identify drives.

Also port the dtc example.
mpernambuco
mpernambuco previously approved these changes Jun 13, 2026
When iflags.Y is set but the manual yield reason is RX_ACCEPTED,
reset_uarch does not revert to the revert root hash, so the final root
hash is the ordinary post-reset state. The new create_json_reset_accepted_log
generator captures this path and registers its context alongside the
existing plain and rejected reset fixtures. This gives the EVM step
verifier a fixture that exercises the branch where tohost is decoded but
no revert occurs, mirroring the accepted/noop symmetry already present on
the send-cmio-response side.
snapshot.ubuntu.com mirrors binary packages before their matching source
index is published, so gather-dpkg.sh could silently emit a WARNING
instead of a real Source block depending on when regeneration ran. That
made the CI check for a byte-identical committed report a lottery.

gather-dpkg.sh now distinguishes provenance. If apt-cache madison shows
a package is available from a repository, its source must resolve; the
script retries and then exits with an error rather than falling through
to the WARNING path. The WARNING is kept only for packages with no
repository availability, i.e. those installed from a local .deb such as
machine-guest-tools. scan-local.sh and build-rootfs-docs-license.sh
were updated to let that non-zero exit propagate: build-rootfs-docs-license.sh
now captures the scan into a variable so set -e sees the failure instead
of the pipeline tail masking it.

The Ubuntu snapshot is rolled back from 20260529T000000Z to 20260421T000000Z,
the latest date at which every repository source resolves under the hard
fail. The ext2 was rebuilt at that snapshot and the report regenerated;
two consecutive runs now produce byte-identical output.
Replace the per-entry Lua-pattern dispatch table with plain string option
names (trailing "=" marks a value-taking option) and a single dispatch loop.
Short value options now use docker-style space form (-v host:guest, -p port,
-u name, -e K=V, -w dir, -h host); long options take their value only via
--name=value.

parse_options gains a positional sub-key (keys[1]) so bare comma items (no
colon, not a declared key name) are routed to a named sub-key rather than
rejected. This lets --load/--store take a directory, --log-step a filename,
--periodic-hashes and --dense-uarch-hashes a period/count, and
--store-config/--load-config a filename, all as unadorned first arguments.

--log-step argument order changes from count,file to file,count:<n>.
--periodic-hashes and --dense-uarch-hashes change the optional second
argument from a bare number to start:<mcycle>.
--virtio-9p changes from tag:dir positional form to tag:t,host_directory:d.
--volume and --port-forward long forms change to key:value sub-options;
short -v/-p remain docker-compatible.

The four *-json-* option twins (--initial-json-proof, --final-json-proof,
--store-json-config, --load-json-config) are removed. Their role is taken by
a format:<lua|json> sub-key on --initial-proof, --final-proof, --store-config,
and --load-config. When format: is absent the serializer is inferred from the
filename extension (.json or .lua), defaulting to Lua.

-h is reassigned from a help alias to the hostname short option; a bare -h
now prints a hint pointing at --help.

bash.lua completion generator is updated to work from plain names and the
positional sub-key. Tests, README template, and risc0 Makefile are updated
to the new syntax.
The option handlers mutated nine separate config-holder locals (processor,
ram, dtb, tlb, virtio, cmio, pmas, uarch, hash_tree) that were later collected
into the machine config passed to machine:create. Collapse them into one
cmdline_config table built up front, so handlers mutate cmdline_config.<field>
directly, and rename the read-back config (get_initial_config) from main_config
to initial_config.

This frees the bare hash_tree name and makes the build-vs-read-back distinction
explicit: cmdline_config is the configuration requested on the command line,
initial_config is the machine's actual configuration at instantiation.
Promote doc/recipes/hash-tree.lua to the installed module
src/cartesi/hash-tree.lua and extend it with a frontier-based
incremental keccak Merkle accumulator (frontier, frontier_push_back,
frontier_get_root_hash, frontier_next_proofs). The accumulator matches
the fixed-height outputs tree used by libcmt.

The roll_hash_up_tree function now iterates up to proof.log2_root_size
instead of HASH_TREE_LOG2_ROOT_SIZE, so it works for both whole-machine
proofs and the shallower outputs-tree proofs. Recipe files that pass
whole-machine proofs add an assertion on log2_root_size to make the
assumption explicit.

Add CM_CMIO_LOG2_MAX_OUTPUT_COUNT (63) to cm.h, the matching C++
constant to send-cmio-response.hpp, and expose both to Lua as
cartesi.CMIO_LOG2_MAX_OUTPUT_COUNT.

Wire new --cmio-advance-state sub-options:

  output_proof    per-accepted-output Merkle proof against the epoch
                  outputs root, written after all inputs are processed
  rejected_output output files for outputs of rejected inputs, which
                  never enter the outputs tree
  last_output_proof
                  a previous epoch's last output proof that seeds the
                  frontier so this epoch continues the same genesis-
                  rooted tree at the running global output index
  check_output_hashes_root_hash
                  cross-check the host frontier root against the root
                  the guest wrote to the tx buffer on each accepted
                  input (default on)

Outputs are now buffered until the input's accept/reject verdict
arrives. Accepted outputs enter the frontier and accumulate for the
end-of-epoch proof batch. Rejected outputs go to rejected_output and
never advance the global output index.

BREAKING CHANGE: the default output filename pattern changes from
"input-%i-output-%o.bin" to "output-%o-input-%i.bin", and %o is now
the global output index across all accepted inputs rather than a per-
input counter. Callers that rely on the old pattern or on %o resetting
to zero on each accepted input must update their filenames.

Add util.read_file and util.write_file to cartesi.util and switch all
ad-hoc open/read/write/close sequences in cartesi-machine.lua to them.

Add spec-hash-tree-lua.lua covering frontier proofs, push-back root
tracking, epoch-split equivalence, and verify_slice rejection, and
extend spec-cm-cli.lua with tests for the new sub-options including a
two-epoch continuation with a mid-epoch reject.
The docgen engine (doc/replace.lua) gains a new capability: when a block
is defined with include=, its body (with docs:begin/end markers retained)
is now also stored as the key's source.  Consumers can therefore write
replace=K/source/<region> to render a named region straight out of the
included file.  The published cache/<K>/both stream is still marker-free;
a source reference records a dependency on it so consumers invalidate when
the included file changes.  The spec comment and cross_read guard are
updated to match.

doc/README.md.template is updated to use the new capability and to track
the output-proofs feature already committed:

- The proof_module include block now points at the installed module path
  (../../usr/share/lua/5.4/cartesi/hash-tree.lua) so the file is included
  once; the four function-excerpt blocks (roll_hash_up_tree, verify_slice,
  verify_splice, get_root_hash) switch to replace=proof_module/source/<region>
  instead of four separate include= keys.
- The four recipe scripts no longer symlink hash-tree.lua from the recipes
  directory, and all four change their require to require("cartesi.hash-tree").
- Rollups-walkthrough output filenames follow the new global output-index
  scheme (output-%o-input-%i.bin).  A prose note explains that rejected
  outputs are saved under a rejected-output-* name for debugging.
- doc/README.md is regenerated to reflect all of the above (it had been
  accidentally dropped by the feature commit; this restores it).
--cmio-advance-state output_proof files are now written as
Lua chunks by default instead of JSON. Any caller that reads the proof
files by extension or assumes .json content must add format:json or
rename the pattern explicitly.

Add a format:<lua|json> sub-option to --cmio-advance-state that selects
the serialization for output_proof and last_output_proof. When
output_proof is left at its default pattern, the filename extension
tracks format (format:json yields a .json file). An explicit
output_proof filename is left as written while format still controls
the content. read_proof and save_cmio_output_proofs thread the format
value through resolve_format so explicit format always wins over the
extension heuristic.

Add two spec tests: "output proof format" confirms the default produces
a valid Lua chunk (round-tripped via load) and that format:json forces
JSON content regardless of a .lua filename; "output hashes root hash
accumulates across inputs" guards against a per-accept tree reset in
the guest rollup utility by verifying that after two accepted inputs the
guest-written root covers both inputs' outputs, not just the second's.
This test caught a real bug in guest-tools v0.18.0-test3, fixed in
test4.

Bump machine-guest-tools to v0.18.0-test4 across tests/dependencies,
tests/dependencies.sha256, doc/Dockerfile, doc/recipes/Dockerfile.rootfs-docs,
and the rebuilt doc/recipes/rootfs-docs.ext2. The guest-tools fix is
required by the accumulation test and by the two-epoch doc walkthrough.

The Rolling Calculator Machine walkthrough in doc/README.md.template
now spans two epochs against a single kept-alive server. Six inputs are
encoded via an encode_input shell function. Epoch 1 processes inputs 1-3
(valid, rejected, valid) with --no-remote-destroy to leave the server
and machine alive. Epoch 2 passes --no-remote-create to reuse the live
machine and seeds last_output_proof with epoch 1's last proof so the
host can rebuild its outputs tree copy and emit proofs at the correct
global indices. The decode example now shows the sqrt(2) result from
epoch 2. doc/README.md is the regenerated output.
Promote the calc example as the primary worked example under "Rolling
Cartesi Machines". It now runs directly from a flash drive across two
epochs, carrying the full yield-and-hash walkthrough that was previously
attached to puppet. The second epoch run adds an inspect-state query
(the golden ratio) and decodes its report, with a note that inspect-
state reverts all state changes and cannot emit verifiable outputs.

The introductory paragraph about /dev/cmio, HTIF, and CMIO RX/TX
buffers is replaced with a short forward reference to "Communication
between guest and host" in the guest perspective, keeping unexplained
internals out of the command-line chapter.

"Rolling Cartesi Machine templates" is reframed as the genesis of a
Rollups application. The section now stores the calc machine as a
template and replays the same inputs through it, confirming the outputs
are identical to the direct run. puppet moves down to "The libcmt
library" where its source is shown, and gets its own live run there.

calc.sh is rewritten without the advance_state branch: an associative
array maps the request type to the emit verb (notice or report), so
the single pipeline handles both advance-state and inspect-state.

Verification-game server ports are shifted to 8087/8088 so all
examples can run in parallel without port conflicts.
run-bash.sh and run-lua.sh create both/stdout/stderr before the block body
runs, so a block that produces no output keeps a start-of-run mtime older
than the artifacts the body writes. A consumer that orders off the
producer's both marker could then build against stale artifacts on an
incremental parallel make, needing make clean to recover. Touch the
markers after tee flushes so the ordering edge always advances and stays
newer than the block's artifacts.
The output proof format case did not redirect output_hashes_root_hash, so
the cmio advance wrote its default to a cwd-relative path. That fails on a
read-only working directory, as in CI. Disable the file in the two runs
that only inspect proofs.
Add an output_hashes_root_hash_proof sub-option to --cmio-advance-state.
On each accepted input the guest writes the output hashes root hash to
the first word of the cmio tx buffer, so a state value proof of that word
ties the root hash, against which the per-output proofs are built, back
into the machine state hash. The proof is captured at the accept yield
and serialized like output_proof (Lua default, json sub-option).

Extract frontier_copy in the hash-tree module for the shallow frontier
copy the running-root check needs, replacing the inline table.unpack.
Add --initial-hash=<filename> and --final-hash=<filename> forms that write
the raw 32-byte state hash to a file, matching the raw form used for the
output hashes root hash. The bare flags still print the hash. This lets a
rolling run publish the machine state hash an epoch settles on, which a
verifier needs to check output proofs against. Also tidy the
output_hashes_root_hash_proof help text to say "tx buffer", as elsewhere.
Add an Output proofs section to the Lua interface that generates an epoch's
per-output proofs and the tx-buffer-word proof from a rolling calculator run
(run-rolling-calculator-output-proofs.lua). Add an Output verification
example to the Blockchain perspective (verify-output-proof.lua) that checks
an output against those two proofs from the settled machine state hash
alone, with no machine. Wire the rolling calculator CLI walkthrough to save
the settled state hash with --final-hash, and regenerate README.md.
Break the combined Merkle tree operations section in two. "Slicing and
splicing" keeps the word-leaf state-tree mechanics and precedes its
applications, Template instantiation and Result extraction, now promoted to
siblings. A new "The outputs tree" section explains the keccak256-leaf
outputs tree and its frontier operations (frontier_node, frontier_push_back,
frontier_get_root_hash, frontier, frontier_next_proofs), and precedes Output
verification. Repoint the three cross-references that each meant one half or
the other, and capture the outputs-tree height and leaf size from cm.h.

Also make the verification-game example deterministic: the players are told
apart only by connection order, so wait on an established connection to the
server before launching the dishonest player, in both vg_run and
vg_run_reset.
Add a figure to the outputs tree section showing the three regions
frontier_node reads left to right: the frontier supplying a complete left
subtree (AB), this epoch's new outputs as the active region (C-F), and
pristine padding (GH). The frontier and pristine subtrees enter whole at
aligned boundaries, drawn collapsed, so only the active outputs are
individual leaves, mirroring frontier_node and frontier_next_proofs.

The figure renders from a committed Graphviz source, images/outputs-tree.dot,
into images/outputs-tree.svg. A pattern rule drives dot, and the svg is an
order-only prerequisite of README.md so a render refreshes it only when the
source changes, and never forces a re-render when just the image bytes do.
Add graphviz to the docs image so dot is available in the pinned container.
Regenerate README.md.
Add the physical-memory attributes of each address range to its
address_range_description, so callers can tell memory from devices and
inspect access permissions. The new fields are is_memory, is_device,
is_readable, is_writeable, is_executable, is_read_idempotent,
is_write_idempotent, and driver_id, each read from the matching
address_range accessor. get_address_ranges still returns every range,
devices included, since callers need the whole occupied address space to
check for overlap. The fields flow through to_json, ju_get_opt_field, and
the JSON-RPC AddressRangeDescription schema.

Use the new is_memory flag in --dump-address-ranges to write a file only
for memory ranges. Device ranges are not memory-backed and hash as
pristine, so dumping their bytes produced meaningless files. Update the
cm-cli test to expect a file per memory range and none for devices.
Add two tested SVG figures to the documentation. In "Hash-view of state", a
state hash-tree figure draws a real machine's state as a Merkle tree over the
address space, generated by images/state-tree.lua, which instantiates a
machine and reads its memory ranges. In "The outputs tree", a frontier figure
shows the frontier holding completed left subtrees while the active region and
pristine padding fill the rest, from images/outputs-tree.dot.

Both render through the images/ diagram rules as order-only prerequisites of
README.md, so a render refreshes them without forcing a rebuild. state-tree is
a Lua program that emits SVG directly; outputs-tree pins its node positions and
renders with neato -n.
apt-get source --print-uris emits whichever location currently serves
each file. While a version is still in the pool that is the live mirror
with a SHA256 digest, and once it has been superseded it is the pinned
snapshot host with a SHA512 digest. The committed report therefore
drifted whenever Ubuntu published a security update that pushed an
installed package out of the live pool, even with the snapshot date and
the rootfs unchanged, and the CI sync check failed. The two forms cannot
be reconciled as text because the digest algorithms differ and SHA512
cannot be derived from SHA256.

Normalize every captured source URI to the durable snapshot host and
drop the digest field, which is not required to satisfy the source
distribution obligation. The report now depends only on the committed
ext2 and the snapshot pin, so it stays byte stable until one of those
changes.
…rfaces

The microarchitecture had a dedicated ECALL (function code 3) that let
uarch interpreter code mark a physical page dirty. This was the wrong
abstraction layer. The uarch should not know about dirty-page tracking,
and exposing the call through i_state_access bloated the interface for
every accessor that had nothing to do with dirty pages.

Pull the responsibility up to the machine layer. Introduce
i_accept_dirty_pages as an opt-in capability interface, mirroring
i_accept_counters, and guard the mark call in write_ram_uint64 with
is_an_i_accept_dirty_pages_v. Add machine::mark_write_tlb_dirty_page
so the write-TLB eviction path and the overwrite path both mark the
outgoing page through a single spot. Remove the host-address overload of
machine::mark_dirty_page so all callers pass a physical address.

On the uarch side, delete the ECALL handler, its constants, and all
plumbing that threaded do_mark_dirty_page through every accessor
implementation. Function code 3 is left as an intentional gap in the
ECALL table. Replace the ecall-mark-page-dirty assembly test with
ecall-removed-mark-page-dirty, which issues fn=3 and asserts it traps,
keeping the gap behavior under test.
Address reviewer confusion in the output proofs and output hashes tree
sections without changing any behavior.

Define what an output index is and how an output is verified from a
proof that its hash is the leaf at that index. Note that the report
command takes the same input format as notice but has no index to
print, because reports are not verifiable.

Drop the vague "shared accessor", "running accumulator", and "matching
reader" labels in favor of the plain function names. Spell out the
frontier_node argument placeholders and define is_proof where the
frontier constructor uses it.

Make explicit that the outputs and their proofs live outside the
machine, in the Cartesi Node, and that the machine state commits to
them through the output hashes root hash alone. Add that proofs for a
past epoch are produced when that epoch is finalized and are checked
against the hash settled then, not against a later one.

Rename "outputs tree" to "output hashes tree" and "rolling calculator"
to "Rolling Cartesi Machine calculator" throughout, and unify the root
name as "output hashes root hash", removing the stray "output-hashes
Merkle root" from the guest pseudocode.
The option only writes a file for memory ranges. Device ranges hold no
state and are always pristine, so they are skipped, which makes "memory
ranges" the accurate name. This reverts the rename made earlier in this
unreleased cycle and restores the 0.20.0 spelling.

Rename the option, its handler variable, and the dump function, and align
the help text, the spec-cm-cli coverage, and the doc prose, fixing the
prose that claimed all mapped spans in the address space were dumped.
The static verify_step, verify_step_uarch, verify_reset_uarch, and
verify_send_cmio_response now take their root-hash inputs as
const_machine_hash_view instead of const machine_hash &, matching the
revert-hash parameter that already used the view. A view is a non-owning
borrow that suits a consumed parameter, while owning hashes are still
returned by value or const reference.

The uarch and send-cmio replay context constructors take the view as well,
which lets the verify bodies pass the parameter straight through with no
materialization copy. Comparisons of an owning machine_hash against a view
use std::ranges::equal.

Also rename the freestanding std::vector guard in machine-hash.hpp from the
ZKARCHITECTURE/MICROARCHITECTURE pair to a single NO_STD_VECTOR, defined by
the uarch and risc0 builds, so the include site names the actual constraint
instead of inferring it from the architecture.
mpernambuco
mpernambuco previously approved these changes Jun 26, 2026

@mpernambuco mpernambuco left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM Nice call with i_accept_dirty_pages

Comment thread src/machine.cpp
// Overwrite the processor shadow state with the provided data
static_assert(AR_SHADOW_STATE_LENGTH == sizeof(m_s->shadow));
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
memcpy(reinterpret_cast<unsigned char *>(&m_s->shadow), data, sizeof(m_s->shadow));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this overwrites the shadow tlb without going through write_??verified_tlb, could a pending write-tlb dirty page be lost here? should it mark_write_tlb_dirty_pages() before the memcpy?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, if you call this function when the machine already has some hot entries. I'll fix it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

…stdin

htif-console.lua now passes input_source = "from_buffer" in the runtime
config and calls machine:write_console_input to supply the test string,
removing the dependency on host stdin. Because run() breaks early with
BREAK_REASON_CONSOLE_INPUT when the buffer drains, the test loops until
iflags_H is set. run-lua-tests.sh drops the echo pipe that was feeding
stdin to every test script, since only htif-console.lua ever consumed it.
@diegonehab diegonehab force-pushed the feature/revert-root-hash branch from 9b3880f to b1988c5 Compare June 26, 2026 15:28
machine::write_memory has a special case that replaces the entire shadow
state and reinitializes the hot TLB. Pages written by the guest through
the write TLB are dirtied in host memory but only tracked by the write
TLB until the next hash update flushes them into the dirty-page tree.
Resetting the hot TLB and overwriting the shadow TLB discarded that
pending information, so any such page lost its dirty mark. A later
incremental hash then failed to recompute it and produced a root hash
that did not reflect actual memory.

Flush the write TLB dirty pages into the dirty-page tree before replacing
the shadow, matching what update_hash_tree and verify_hash_tree already do.

Add a regression test that runs a small program whose store dirties a page
through the write TLB, overwrites the shadow state, and checks that the
root hash still matches the hash computed directly from memory. The test
fails before this change and passes after.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Waiting Merge

Development

Successfully merging this pull request may close these issues.

3 participants