Canonical: https://rom-weaver.com/docs/architecture

# Architecture

How rom-weaver is put together: one Rust command core shipped two ways (native CLI and a WASM build that runs in browser workers), with a React webapp driving the WASM build over a JSON event protocol.

```text
                 ┌──────────────────────────────┐
                 │ rom-weaver-cli Cargo package │
                 │  rom_weaver_app shared lib   │  command orchestration
                 └──────────┬──────────┬────────┘
                            │          │
               native build │          │ wasm32-wasip1-threads build
                            ▼          ▼
                 ┌──────────────┐  ┌──────────────────────┐
                 │  rom-weaver  │  │ rom-weaver-app.wasm  │ typed JSON stdio
                 │ CLI binary   │  └──────────┬───────────┘
                 └──────────────┘             │
                                  ┌───────────▼───────────┐
                                  │ webapp src/wasm layer │ OPFS WASI runner,
                                  │    (browser-only)     │ thread pool, worker client
                                  └───────────┬───────────┘
                                  ┌───────────▼───────────┐
                                  │ rom-weaver-webapp     │ workflows, forms, PWA
                                  └───────────────────────┘
```

<!-- START doctoc -->
## Table of contents

- [Workspace layout](<#workspace-layout>)
- [Core abstractions (`crates/rom-weaver-core/src/registry.rs`)](<#core-abstractions-cratesrom-weaver-coresrcregistryrs>)
- [Threading model](<#threading-model>)
- [Browser I/O paths](<#browser-io-paths>)
- [Patch apply: ROM copier headers](<#patch-apply-rom-copier-headers>)
- [Dreamcast `.dcp` patches (the filesystem-level apply path)](<#dreamcast-dcp-patches-the-filesystem-level-apply-path>)
- [rom-weaver-weave.json weaves](<#rom-weaver-weavejson-weaves>)
- [Rust and TypeScript boundary](<#rust-and-typescript-boundary>)
- [Build graph](<#build-graph>)
- [Webapp UI - the loom workbench](<#webapp-ui---the-loom-workbench>)
- [Testing](<#testing>)
- [Other docs](<#other-docs>)

<!-- END doctoc -->

## Workspace layout

| Path | Role |
| --- | --- |
| `crates/rom-weaver-core` | Foundation: registry traits, `RomWeaverError`, I/O helpers, thread planning (`ThreadCapability`/`ThreadExecution` in `src/threads.rs`), and the standalone codec backends (zstd, deflate, lzma, ... in `src/codecs`). Depends on nothing else in the workspace. |
| `crates/rom-weaver-checksum` | Checksum engines (crc32/md5/sha*/blake3/crc32c/crc16/adler32) plus the streaming variant engine shared by `checksum` and extract flows. |
| `crates/rom-weaver-containers` | Container registry + per-format handlers (`src/handlers/*.rs`: zip, 7z, tar*, rvz, z3ds, pbp, cso, ...), native CHD implementation (`src/chd`), and the libarchive wrapper/bindings/build integration (`src/libarchive`, `libarchive/`). |
| `crates/rom-weaver-patches` | Patch format handlers (`src/{ips,bps,ups,ppf,rup,...}.rs`), one file per format, including VCDIFF/xdelta encode+decode (the `src/xdelta.rs` handler over the `src/vcdiff/` codec, parallel window encoding; also exposes `apply_patch_bytes` for in-memory VCDIFF apply, used by `.dcp`). |
| `crates/rom-weaver-cli/src/gdrom` | Dreamcast GD-ROM / CD data-track filesystem: read (`sector` cooking of `MODE1/2352`, `iso9660` parse, `GdRomFs` tree view with the +45000 LBA bias) and write (`iso_writer` authors a cooked ISO9660 image, `mode1` re-encodes EDC/ECC into raw `MODE1/2352`). Pure Rust, wasm-safe. |
| `crates/rom-weaver-cli/src/dcp` | Universal Dreamcast Patcher (`.dcp`) format: ZIP central-directory reader + entry inflate (`zip`), entry classification (`manifest`), per-file apply (`apply`), and full data-track rebuild (`rebuild`). Builds on the app's `gdrom` module + `rom-weaver-patches`'s `xdelta` module. |
| `crates/rom-weaver-cli` | The installable package: the `rom_weaver_app` command library, native `rom-weaver` CLI, `rom-weaver-app` WASM entrypoint, type generator, argument parsing, and reporters. |
| `packages/rom-weaver-webapp/src/wasm` | Browser WASM layer in the webapp package: OPFS WASI runner (`run`/`runJson`), mounts, thread pool, worker client, generated types. |
| `packages/rom-weaver-webapp` | Webapp: workflow controllers, runtime adapters, React forms, workers, PWA shell. |
| `scripts/` | Benches, worktree setup, and WASM toolchain helpers (`scripts/wasm/`); build orchestration moved to `.config/mise.toml` (`mise run build-wasm`). |

Crate dependency flow is one-directional: `core` ← format crates (`checksum`/`containers`/`patches`) ← `cli`. Libarchive is an internal `containers` module rather than another package. The CLI package's `dcp` module consumes its `gdrom` module (data-track filesystem) and `patches`' `xdelta` module (per-file VCDIFF apply).

## Core abstractions (`crates/rom-weaver-core/src/registry.rs`)

Every pluggable format handler implements one of two traits, registered into a registry keyed by `FormatDescriptor` (name + aliases + extensions, used for both CLI `--format` matching and path-based probing):

- `ContainerHandler` (+ `ContainerHandlerOperations`): `probe`, `probe_details`, `list_entries`, `extract`, `create`, plus `capabilities()` describing what the format supports (create, dry-run sizing, ...).
- `PatchHandler`: `probe`, `parse`, `apply`, `create`, with a default `validate` that dry-run applies to a temp path.

Checksums and codecs are not trait-pluggable: `NativeChecksumEngine` (`crates/rom-weaver-checksum/src/core.rs`) covers whole-file and range checksums for every algorithm, and the codec helpers in `crates/rom-weaver-core/src/codecs` are free functions.

Handlers return `OperationReport` (family, format, stage, label, JSON details, percent, thread execution, status) - the single progress/result currency that flows from format code through the `rom_weaver_app` library to the CLI reporters and, in JSON mode, to the browser. `OperationContext` carries cancellation, temp-path allocation, progress sinks, and thread budgets downward.

Registry entries are wrapped in tracing decorators (`traced_container_handler`/`traced_patch_handler`) so every probe/extract/apply gets start/complete `trace!` spans without duplicating that instrumentation in each handler.

Errors use one `thiserror` enum, `RomWeaverError` (`crates/rom-weaver-core/src/error.rs`), with a `pub type Result<T>` alias. Validation failures that need machine-readable codes use the `ValidationCode` variant, which contains a `ValidationCodeError`. Do not add per-crate error types.

## Threading model

`ThreadCapability` (what a format *can* parallelize) and `ThreadExecution` (what a run *actually* used) live in `crates/rom-weaver-core/src/threads.rs`. Formats plan a parallelism level from input size and the requested budget, build a scoped pool, and report the outcome in `OperationReport.thread_execution`.

Patterns that matter when touching this code:

- **Producer/consumer pipelines.** CHD decode/create and RVZ create stream compressed data through bounded channels: a reader stage feeds worker decoders/encoders, and an ordered writer reassembles output. Memory is bounded: CHD decode works through batches of about 64 MiB of hunks.
- **Per-worker reads under WASM (via the OPFS proxy).** Spawned wasm threads cannot `path_open` an OPFS file directly (WASI `os error 44`, worst on Safari). The dedicated OPFS proxy worker resolves this - threads open and read through it - so container/patch decode reads per-worker like native does, and the old read-on-main gates (`ROM_WEAVER_CONTAINER_MAIN_THREAD_READER` / `ROM_WEAVER_PATCH_MAIN_THREAD_READER`) are retired. (Lone remaining read-on-main: RVZ *create* in the inlined `nod` source.) See **Browser I/O paths** below for the full picture.
- **Decoder memory, not input size, limits extract workers.** Each parallel 7z extract worker opens its own reader and allocates its solid block's dictionary, so `libarchive_extract_decoder_cap` (`crates/rom-weaver-containers/src/libarchive_support.rs`) admits only as many concurrent blocks as three quarters of `ROM_WEAVER_MEMORY_LIMIT_BYTES` holds. The browser runner sets that variable to the shared memory maximum it was granted, which is 1 GiB on Apple mobile WebKit. LZMA dictionaries are sized from the folder's or chunk's output rather than the declared size, so a large declared dictionary costs only what the data can reference.
- **Browser thread budgets.** "auto" is resolved on the JS side (`packages/rom-weaver-webapp/src/wasm/workers/browser-thread-budget.ts` and the React `toThreadBudget` path) before it reaches wasm - the wasm fallback is a fixed 4 threads, so passing "auto" through literally caps throughput.
- **Byte-identical parity.** Compression outputs are validated against reference tools (chdman, dolphin-tool). Performance changes to encode paths must keep outputs byte-identical; tests assert this.
- **Reserve the heap before spawning under wasm.** V8 does not synchronize a shared `memory.grow` with the memory size other threads have cached, so a `memory.fill` or `memory.copy` on pages another thread just grew traps "memory access out of bounds" and the trapped thread wedges the run. `ordered_streaming_compress` and CHD decode call `reserve_parallel_heap` (`crates/rom-weaver-core/src/parallel_heap.rs`) on the coordinating thread before their workers start; a new threaded section that allocates while siblings run needs the same.

`docs/development/browser-concurrency.md` covers the browser-side concurrency rules in more depth.

## Browser I/O paths

Every wasm file read/write goes through one stack; only the bottom backend differs:

```text
wasm (Rust std::fs)
  -> WASI import (fd_read / fd_pread / fd_write ...)
    -> WasiRandomAccessFileInode / OpenWasiRandomAccessFile   (wasm/browser-opfs-wasi-file-inode.ts)
      -> a RandomAccessFileLike adapter (.readAt / .writeAt)
        -> ONE of the backends below
```

`OpenWasiRandomAccessFile` adds sequential **write coalescing** (8 MiB buffer; direct writes >=2 MiB bypass it) so per-sector decode output doesn't become round-trip-bound. Note the WASI contract gotcha: a `readAt` of **0 bytes is success**, which Rust `read_exact` surfaces as a generic "read error".

**Input reads** - chosen per source in `packages/rom-weaver-webapp/src/workers/protocol/browser-opfs-source-ref.ts`:

| source | backend | notes |
| --- | --- | --- |
| already on OPFS (extracted file, patch output) | `BrowserProxyRandomAccessFile` by path | no copy; how multi-step workflows chain |
| Blob/File, fast path (`useProxyHandle=false`) | `BrowserVirtualRandomAccessFile` + per-thread `FileReaderSync` + 16 MiB LRU | N readers run at once |
| Blob/File, proxy handle (`useProxyHandle=true`) | `BrowserProxyRandomAccessFile` -> OPFS proxy worker (Blob-backed handle) | exactly one reader, over SAB |
| in-memory `Uint8Array`/`ArrayBuffer` | `BrowserVirtualRandomAccessFile` in-memory | defensive only - no current caller |

The fast-path/proxy choice is browser-gated: `useProxyHandle = isWebKitInputRuntime() && size >= ~64 MiB`. Concurrent reads of one File parallelize on Chrome/Firefox (fast path scales ~Nx) but serialize at WebKit's file layer (one proxy reader avoids the contention; measured RVZ extract ~5747 ms stalled vs ~4612 ms balanced). Small inputs are single-threaded (no contention), so they take the lower-overhead fast path even on Safari. OPFS input *staging* (copying a Blob into OPFS up front) is fully retired.

**Output writes** - wasm-produced files (extracted ISO, created CHD/RVZ, patched ROM) write through `BrowserProxyRandomAccessFile.writeAt` (4 MiB write-back coalescing) -> `OpfsProxyClient.write` -> proxy server `SyncAccessHandle.write`. A separate storage worker (`packages/rom-weaver-webapp/src/workers/storage/browser-opfs-staging.worker.ts`, actions `write`/`truncate`/`cleanup`) backs the app-side large-file VFS and path cleanup, not the per-op wasm output.

**Full storage** - when origin storage is full, Chromium's `SyncAccessHandle.write` returns `2^32 - 8` (its no-space file error as an unsigned count) instead of throwing, and `truncate` grows the file only as far as storage allows without reporting an error. Incognito and other ephemeral profiles keep OPFS in memory and cap an origin at a share of RAM, about 1 GiB on a 16 GB host, so extracting or patching a disc image fills it. The proxy reports that count, a short grow, and `QuotaExceededError` as WASI `ENOSPC`, so the guest fails with "No space left on device" and the app shows its storage message instead of a generic I/O error.

**Output -> user** - `packages/rom-weaver-webapp/src/storage/browser/browser-large-file-vfs.ts` `createOutputRef()` exposes the OPFS result as a lazy `getFile()` plus `saveAs()` (browser download, or write into a user-picked `FileSystemFileHandle`) - the bytes stay OPFS-backed instead of round-tripping the JS heap.

**OPFS proxy topology** - one **dedicated** proxy worker per runner (`packages/rom-weaver-webapp/src/wasm/browser-opfs-proxy-runtime.ts` spawns `packages/rom-weaver-webapp/src/wasm/workers/browser-opfs-proxy-worker.ts`). It is the single owner of every `SyncAccessHandle` (and Blob input handle); its loop is async (`Atomics.waitAsync` doorbell) while **consumers block synchronously** (`Atomics.wait`) - that free event loop is what makes the Blob-handle reads deadlock-free. The SAB channel is forwarded into every spawned WASI thread so they share the one proxy; the mount (`packages/rom-weaver-webapp/src/wasm/browser-opfs-mount.ts`) builds proxy vs virtual inodes and caches inode trees across runs.

**Bounding the fan-out** - an extract emits one output file per archive entry, so anything held per entry scales without limit (a 2048-entry zip killed the tab on iOS). Created output inodes therefore use `closeOnLastFdClose`, and the mount owns a small LRU of idle files (`packages/rom-weaver-webapp/src/wasm/browser-opfs-idle-file-pool.ts`): a file's handle and its coalescing buffers are released once it falls out of the window, so live handles are bounded by *concurrency*, never by entry count. `BrowserProxyRandomAccessFile.reopen()` re-arms an evicted adapter, so a later checksum pass or workflow chaining re-opens the path transparently. The proxy publishes a live/peak handle gauge in its SAB control region; the runner emits it at teardown as `[perf] opfs proxy handles live=… peak=… opened=… adapterBufferBytes=…`, which is the first thing to check for a many-small-files regression.

**Mount handles inside spawned threads** - Safari cannot structured-clone a `FileSystemDirectoryHandle` into a nested worker, so the runner sends every consumer the mount's *root-relative path* (`root.resolve(handle)`) and each re-walks it from `navigator.storage.getDirectory()`. Both the OPFS proxy worker and the WASI thread runtime do this (`mountRootRelativeParts` on the thread runtime payload). A thread that assumes the mount *is* the OPFS root fails silently and confusingly: its mount builds empty, input hydration finds nothing, and the guest gets `ENOENT` for a file that plainly exists - surfacing as "archive is invalid". Only threaded work hits it, so it looks like a size/entry-count threshold rather than a mount bug.

**Native (CLI)** - plain `std::fs::File` + `BufReader`/`BufWriter`, `SplitFileReader` for split inputs, `create_extract_output_file` for outputs (`rom-weaver-core`). Container decode uses the same **per-worker** reader shape as wasm (each worker opens its own range), differing only in backend (`File` vs proxy) - there is no read-on-main on native.

**Constraints that shape all of this:** `SharedArrayBuffer` needs crossOriginIsolation (COOP/COEP headers from `packages/rom-weaver-webapp/scripts/dev-server.mjs` / the prod service worker) - no SAB means no proxy and no threads; OPFS is dedicated-worker only (no main-thread `window`); WebKit allows **one `SyncAccessHandle` per file** (the proxy refcounts to one) and serializes concurrent `FileReaderSync` of one File (the reason for the input-read gate). The gate cut the measured RVZ extract from 5.7 s to 4.6 s (1.2× faster).

## Patch apply: ROM copier headers

Some dumps carry a copier header the patch author may or may not have included (`crates/rom-weaver-checksum/src/rom_headers.rs` is the source of truth: signatures, size-modulus rules, per-header `headered_extension` / `headerless_extension`, and `retained_on_output`). Patch apply handles this with two symmetric policies (`crates/rom-weaver-cli/src/patch_apply.rs`):

- **`--patch-header auto|keep|strip`** - which bytes each patch applies against. Auto decides **per patch in the chain** on checksum proof. For the **first** patch (`CliApp::checksum_basis_proof`) the proof is every whole-file check the patch normalizes into `details.patch.endpoints`, under whatever algorithm it uses - BPS/UPS/PMSR crc32, RUP and Solid md5 - plus anything the user, a weave or the `[crc32:..]` filename token declared, which outranks the patch's own. Each candidate is compared with `patch_plan::compare_states`, so a size-only endpoint (APS GBA, DPS) never counts: size alone is not identity. A checksum that matches neither candidate keeps the header and stops - the fallbacks must not paper over contradicted proof. A first patch with nothing to prove it falls to `patch_basis_decision.rs`:
  1. Record geometry (`rom_weaver_patches::basis_probe` - records past the shorter candidate's end, records inside the copier header, untrimmed record edges). The probe reads IPS records only; every other format has no geometry and drops straight to the tiebreak rather than ending the decision there.
  2. The tiebreak applies the patch both ways with the format's own source and target checks ON (only the patch-integrity check is off - it is the same number for both candidates). A format that verifies its bytes rejects the basis it was not authored against (APS GBA's exact source size and per-block source CRC16s, VCDIFF's per-window target checksum), and a one-sided rejection decides outright. Only a `Validation`/`ValidationCode` error counts as a rejection; anything else (cancellation, I/O) ends the probe rather than being read as evidence.
  3. When both candidates apply, it keeps the basis whose output the platform still recognises, via a validate-only wrapper over the `header_repair_systems` routines. Both speculative outputs are normalized to headerless bytes of equal length before scoring, because those routines pick their header offset from the file length; candidates that do not normalize to one length are reported incomparable and keep the header.

  Chain steps 2..n keep the checksum-only rule. The flag is positional and repeatable - each occurrence binds to the preceding `--patch` and carries forward (`align_patch_header_modes` re-derives the interleave from raw clap indices).
- **`--n64-byte-order auto|keep|<order>`** - which of the three N64 interleavings each patch applies against. The same shape as the header decision: auto matches the declared input checksums, or the patch's embedded source checks when none were declared, against all three variants first, and a **first** patch with no checksum falls to `patch_n64_order_decision.rs`. All three orders permute bytes *inside* each aligned 4-byte word, so `rom_weaver_patches::n64_order_probe` reads each candidate through a word map derived from `N64ByteOrder::transform_word` (`rom-weaver-core`) rather than writing three converted copies. Two rules: a record covering the first word must leave a valid N64 magic (each order spells it differently), and untrimmed record edges rule an order out. If both are silent, the tiebreaker applies the patch all three ways and keeps the one whose result still carries a correct internal boot checksum - gated on the patch actually rewriting that checksum (0x10..0x18), because nothing else can separate the orders. Chain steps 2..n and `patch validate` keep the checksum-only rule; only the first apply step has a report label to declare an inferred order in.
- **`--output-header auto|keep|strip`** - whether the single final output carries the header. Auto keeps emulator-required format headers (iNES/FDS/ LNX/A78) and drops junk copier headers (SNES/PCE/Game Doctor) - except an NSRT-signed SNES copier header (`NSRT` at 0x1e8), which carries real dump metadata and comes back, matching the RUP handler's own normalization. When the final header state changes the ROM's conventional extension, the CLI adjusts the `--output` extension (`.smc` ↔ `.sfc`) and notes it in the report label.

The browser uses the engine's checksum variants to resolve headers without re-hashing. A `remove-header` row carries `transforms.removeHeader`, including `strippedBytes`, `retainOnOutput`, and the extension pair. When `lib/workflow/apply-header-resolution.ts` proves a headerless basis, staging strips the header and sends `removeHeader`. Otherwise it sends `auto`, including for the first patch, so the engine can infer the basis. The download filename follows the path the engine emits, including any extension change.

`resolveApplyHeaderMode` returns `decided: false` when the browser cannot prove the basis. The header control shows `auto` through `formatHeaderAutoLabel` because the engine must resolve the result during apply.

## Dreamcast `.dcp` patches (the filesystem-level apply path)

Most patch formats are byte-stream transforms and fit `PatchHandler`. Universal Dreamcast Patcher (`.dcp`) is different: it is a ZIP of per-file xdelta/VCDIFF deltas (plus verbatim new files and an optional replacement `IP.BIN`) applied *inside* a GD-ROM data track's ISO9660 filesystem, after which the filesystem is rebuilt. It therefore does **not** register as a `PatchHandler`; it has a dedicated path that rebuilds a whole disc track.

- **The app's `gdrom` module** is the filesystem layer. A Dreamcast high-density data track is `MODE1/2352` raw sectors whose ISO9660 records use *absolute* LBAs biased by the track start (45000). `sector` cooks 2352→2048; `GdRomFs::open(reader, start_lba)` parses the PVD/directory tree resolving extents at `lba − start_lba`; `iso_writer::build_iso` authors a cooked image back (deterministic, pinnable timestamp, +start_lba bias); `mode1::encode_mode1_sector` re-adds sync/header/ EDC (poly `0x8001801B`) /ECC (GF(2⁸), poly `0x11D`) - validated byte-for-byte against real disc sectors. The first 16 sectors are the IP.BIN boot area and are carried through (or replaced) on rebuild.
- **The app's `dcp` module** owns the format: `zip` reads the central directory and inflates entries (miniz_oxide, no C deps → wasm-safe); `manifest` classifies each entry as `Delta`/`Verbatim`/`BootSector`; `apply::apply_dcp` produces each patched file via an I/O-free emit closure (so native and browser share it); `rebuild::rebuild_track_to_writer` plans the rebuilt layout from file *sizes* (a patched file's size is read from its VCDIFF header without decoding) and then **streams** the raw `MODE1/2352` track to a writer, producing each file's bytes on demand (delta applied against its freshly-read source, verbatim inflated, or untouched file read through) and dropping them immediately. The cooked image and raw track are never materialized, so peak memory scales with the largest single file's apply working set - not the disc or the patch.
- **CLI wiring.** `patch apply` detects a `.dcp` patch and routes to `crates/rom-weaver-cli/src/patch_apply_dcp.rs`, which requires a disc-sheet (`.cue`/`.gdi`) input, auto-selects the data track (largest track that opens as a `GdRomFs`), rebuilds it, then reuses the shared disc staging (`patch_apply_disc.rs`) to reassemble the full disc and compress to CHD by default (or write the disc beside the output sheet with `--no-compress`).

Parity note: output is currently *file-level* parity (every rebuilt file is byte-correct, validated against the file-based xdelta handler), not necessarily byte-identical to UDP's own disc image. Matching UDP's image byte-for-byte would require reproducing DiscUtils' exact ISO9660 layout and is deferred.

<a id="rom-weaver-bundlejson-bundles"></a>

## rom-weaver-weave.json weaves

Weaves describe ordered patch workflows. The shared schema and parser live in `crates/rom-weaver-cli/src/weave_schema.rs` and `weave_parse.rs`. Validation failures retain stable `bundle.*` codes on `RomWeaverError`. [What a weave is](<https://rom-weaver.com/docs/bundles.md>) explains the user model; the [CLI reference](<https://rom-weaver.com/docs/cli.md#bundles>) and [JSON Schema](<https://github.com/rom-weaver/rom-weaver/blob/main/docs/rom-weaver-weave-v2.schema.json>) own the public fields and options.

**Chain checks.** Version 2 requires `patchBasis` for the shared authored basis, with per-entry `basis` exceptions. `auto` remains the default, and version 1 retains automatic inference. `rom.checks` describes the input and `output.checks` the full-chain result. `checkStates` stores named expected states; `checksRef`, `inputChecksRef`, and `outputChecksRef` share their values. Equal values from unrelated states remain separate. A ROM entry can contain checks and a name without a source; the applying user supplies the bytes. A name mismatch warns, while checksum and size requirements remain strict.

**Execution targets.** A patch's `target` scopes its accumulated chain to a ROM member or generated-output member. Its `input` instead selects exact bytes and retains a fixed producer dependency. Filtering optional patches changes the accumulated chain within each target. It does not change a named dependency. Member extraction reuses the shared input resolver. Authored checksum requirements remain separate from execution selection.

**Creation and parsing.** `weave parse` reads accepted packaging and returns its result under both `details.weave` and `details.bundle`. Rust source fields retain their legacy names; reports also expose canonical weave fields. It extracts selected referenced archive members only when `--output DIR` is supplied and `--no-extract` is absent. `weave create` hashes local inputs, binds metadata options to the preceding patch by argument index, and re-parses the result before writing. It reports hash progress through the standard event stream. `--from` supplies a local-path spec; explicit CLI values override it. `patch apply --emit-weave` reuses this creation pipeline.

**Detection.** The canonical filename and its accepted compressed forms take the fast path. Plain nonempty JSON files can be detected by schema validation. Inside an archive, a canonical root member wins; otherwise, the first schema-valid root JSON member wins in archive order. Compressed alternate names require an explicit weave path. The [weave detection reference](<https://rom-weaver.com/docs/cli.md#bundle-detection>) documents the command behavior.

**Apply resolution.** `weave_apply.rs` merges a weave into a normal apply command: explicit CLI values take precedence over weave values, then built-in defaults. Non-optional patches seed the selection. The first selected patch's `inputChecks` join the expected input checks when it reads the root ROM; they describe the patch's input, so the header and N64 byte-order decision may use them. `rom.checks` describe the resolved ROM file (root or `rom.member`) as supplied and are compared with those raw bytes after the output path checks and before any transform. When the raw bytes differ, a match on the header-stripped or another N64 byte-order form of that file is accepted, because a weave can be recorded from another dump. A raw match never feeds that decision. A transformed match settles it only when the first patch gives no clue of its own: no declared or embedded checksum of any algorithm, and no decisive structural evidence (record geometry, the ROM-header tiebreak, or the N64 boot-checksum tiebreak). It replaces the final keep-as-is fallback, and the report names the form used. `--ignore-checksum-validation` skips them. Output checks use the last selected patch's `outputChecks`, or the weave's `output.checks` when the selection ends the full chain. Inconsistent adjacent checks warn about a skipped step. Native URL sources use target-gated `ureq`; relative URLs resolve against the weave URL.

**Browser integration.** `src/webapp/url-session/` parses URL sessions once at startup. Browser downloads pass through WASM `weave parse` and the normal drop pipeline. Patch sources use `prepareInputFile`, so exports contain extracted patch leaves. ROM checks describe logical bytes even when export reuses a compressed source. `WeaveApplySession` reconstructs each patch's effective checks from the endpoints. The browser compares `rom.checks` with the supplied ROM file's own checksums and size before any header strip or N64 byte-order conversion, so they never reach the engine's `--expect-in`. Every weave is checked, whatever its patches target: the file is the asset on `rom.member` (or the bare file itself when it has no archive or disc around it), else the primary input asset. When the raw file does not match, its staged header-stripped and N64 byte-order variants are tried, so a weave recorded from a headerless or `.z64` ROM accepts a headered or byte-swapped dump. The checks never reach `--expect-in`; when only a variant matched, a patch with no source check of its own (no declared `inputChecks`, user input checksum or embedded source checksum in any algorithm, such as a plain IPS) takes its header and byte-order decision from that variant, while an independent base-basis patch keeps deciding from its own checks. A previous-basis patch inherits the preceding enabled patch's effective header and byte-order layout for the same target; its input checks describe that preceding output, and explicit per-patch drawer choices still win. For transformed multi-patch chains using auto basis, an isolated engine plan on the original input resolves declared and embedded endpoint evidence before layout grouping. Its bases are cached per target and selected sources, declarations, and choices; cancelled or unavailable probes remain retryable. Algorithms staging does not compute, such as SHA-256, are hashed on demand for the raw file and, when it does not match, for its header-stripped and byte-order variants, once per file; one that cannot be computed counts as a mismatch, while a transient hashing failure leaves the patches retryable and replacing the ROM cancels the hash. A mismatch fails the plan pass and the run, so strict validation offers its override; a patch the engine fails keeps its own error. Like the weave's output checks, `rom.checks` stand down while the patch list differs from the weave's (patches added, removed or reordered, or the weave dropped onto a bench that already held patches), along with the ROM card's weave attribution, and apply again once it matches. Its display name comes from output or ROM naming because the schema has no top-level name.

The [webapp integration guide](<https://rom-weaver.com/docs/webapp-integration.md>) documents URL and same-origin OPFS inputs. A hosted weave must not replace the app's `manifest.json`, which the service worker runtime-caches.

## Rust and TypeScript boundary

- **Type generation.** `mise run typegen` (or `npm run typegen`) emits `rom-weaver-rust-types.d.ts`, `rom-weaver-format-metadata.ts`, and `rom-weaver-command-types.ts` into `packages/rom-weaver-webapp/src/wasm/generated/`. CI runs `--check`; any change to a `#[derive(TS)]` type or format registry metadata requires regenerating and committing. The generated format metadata is the single source for codec pickers and format tables in the webapp - do not hand-maintain duplicates.
- **Event protocol.** The WASM entrypoint reads one typed `RomWeaverRunRequest` JSON document from stdin and calls `run_request`. It always emits the shared `ProgressEvent` JSON-lines stream (`RomWeaverRunJsonEvent`) on stdout. The browser passes the typed request through the runner's stdin/stdout surface; it does not call Rust command functions directly. This event stream stays separate from the native CLI's `--json` result-document format.
- **`ingest` - the mainline browser drop path.** The webapp routes every dropped input through the shared `ingest` command (`crates/rom-weaver-cli/src/ingest_command.rs`), one wasm call per source. It classifies the source into a `rom` or `patch` bucket, does nested archive or codec extraction, checksums each ROM leaf, and describes any patches. It resolves ROM titles from the existing checksum variants. It also resolves a patch's expected source title when the patch provides a checksum. The browser stages applicable RWFP1 packs with the source, so no ROM data leaves the browser. A ROM source that also bundled sidecar patches carries both. The consolidated `IngestResult` rides the standard `OperationReport.details` envelope under `details.ingest`. The parse layer keeps the Rust-to-TypeScript shape compile-checked through `#[derive(TS)]`. It has its own CLI smoke test family (`crates/rom-weaver-cli/tests/cli_smoke/ingest.rs`).
- **Workers only.** The OPFS runtime requires a Dedicated Worker because synchronous OPFS access handles are unavailable on the main thread. `packages/rom-weaver-webapp/src/workers/` hosts the worker entrypoints; the protocol types live in its `protocol/` directory.
- **Worker URLs always come from `?worker&url`.** A worker entrypoint is referenced as `import workerUrl from "./x.worker.ts?worker&url"`, never as `new URL("./x.worker.ts", import.meta.url)`. Only the `?worker&url` form makes Vite emit a *built* worker chunk; a bare `new URL()` that Vite's worker detection cannot statically match degrades to a plain asset copy and ships the raw TypeScript into `dist/assets/`, which then 404s or parse-fails in production (dev hides it by transforming `.ts` on the fly). Detection is fragile - it misses the URL inside a `??` chain, or when the `new Worker()` call lives in another module - so the import form is the rule, not a case-by-case judgement. Self-referential imports (a worker that transitively imports its own URL, as the WASI thread worker does to self-spawn) are fine: the URL resolves to the thread worker's own chunk from whichever chunk holds the reference, so no duplicate is emitted.
- **Workers share one runtime chunk.** During production builds, the `rom-weaver-share-worker-runtime-chunks` plugin in `packages/rom-weaver-webapp/scripts/vite-config/worker-chunks.mjs` emits `?worker&url` imports as entries in the main build graph. One code-splitting pass then covers the app and workers. The `wasm-runtime` group selects modules reachable only from worker entries. It keeps `includeDependenciesRecursively` off to avoid moving modules used by the document entry into the worker runtime chunk. For the same reason, `src/wasm/index.ts` re-exports `rom-weaver-browser-opfs-api.ts` as types only. Development uses Vite's worker handling because the plugin has `apply: "build"`.

## Build graph

```text
cargo build (workspace)                     # native CLI
mise run typegen                            # regen TS types when Rust types change
mise run build-wasm-prod                    # WASI SDK build → wasm-opt → brotli
                                            #   → sync into packages/rom-weaver-webapp/src/wasm
npm --prefix packages/rom-weaver-webapp run dev|build
```

The WASM build needs a WASI SDK (v33+, auto-detected; see `scripts/wasm/detect-wasi-sdk.sh` and the `build-wasm` task in `.config/mise.toml`). Generated wasm artifacts in `packages/rom-weaver-webapp/src/wasm` are gitignored; the generated *TypeScript* files are committed and drift-checked.

## Webapp UI - the loom workbench

The React webapp's presentation layer uses the "loom workbench" design language (charcoal chassis, cartridge-orange thread accent, cream hash readouts, dusty-blue verification).

- **Stylesheets.** Hand-written semantic CSS is split by component under `packages/rom-weaver-webapp/src/webapp/design-system/`. `index.css` declares the cascade-layer order and imports the render-critical sheets; `deferred.css` and `docs-route.css` arrive later in their predeclared layers. Tokens live in `tokens.css`, component rules are scoped under `.rw-app`, and there are no utility classes, CSS-in-JS, or Tailwind.
- **Shell.** `packages/rom-weaver-webapp/src/webapp/components/shell.tsx` (masthead, mode rail with the sliding thumb, reveal banners, selvage status strip), `packages/rom-weaver-webapp/src/webapp/components/log-dialog.tsx` (native `<dialog>` trace inspector over `packages/rom-weaver-webapp/src/webapp/log-store.ts`, which chains a capturing sink onto the logger). The selvage state comes from `packages/rom-weaver-webapp/src/lib/activity-store.ts`, which the workflow forms publish to (idle/running/done/failed + the active stage line).
- **Primitives.** `packages/rom-weaver-webapp/src/public/react/components/ds/`
  - the collapsible drawer (`drawer.tsx`, the `.cks` grid-rows pattern; replaces `<details>`), file cards, checksum rows (the whole row is the copy control), the weave meter + recessed progress panels, the 0x01 INPUTS hero/add-row drop zone, and the `needs-input` directives that point empty sections back to 0x01.
- **Steps.** Every workflow renders numbered loom stages: `0x01 Inputs`, then per-mode sections (apply: ROM/Patches/Apply; create: Original/Modified/Patch; trim: ROM/Trim).
- **Theming.** `<html data-theme>` via `packages/rom-weaver-webapp/src/webapp/theme.ts`; the toggle plays the circle-wipe view transition and updates `<meta name="theme-color">`.
- **Localization.** UI chrome strings live in the `ui.*` namespace of `packages/rom-weaver-webapp/src/presentation/localization/catalog.ts` (en/es/de) and are consumed via `useUiLocalizer()` (settings `language` → `createBrowserLocalizer`). Plural ids end in `.one`/`.other` and resolve via `Localizer.messageCount`. The translations themselves live in the `.po` files under `localization/locales/`, which are the source of truth; the `.ts` catalogs beside them are compiled output, untracked, and rebuilt by a Vite plugin on every dev, build, preview, and vitest run. Only the English catalog is in the main bundle; every other locale is its own chunk, fetched the first time the locale resolves. The boot gate in `webapp.ts` preloads the active locale so the first render is translated, and `useUiLocalizer()` re-issues the localizer when a catalog lands. A test that reads translated text synchronously calls `preloadCatalog(locale)` first.
- **Test hooks.** Browser tests rely on the `rom-weaver-*` element ids and a few structural classes (`.card`/`.file`, `.ck`/`.ck-k`/`.ck-v`, `.meter`/`.track`, `.outopts .cks-head`, `.rw-modal.select-modal .seltree`); keep them when touching the markup.

## Testing

| Layer | Where | What |
| --- | --- | --- |
| Rust unit | `crates/*/tests/unit/`, inline `#[test]` | Per-format parsers, registry, I/O, and threads. |
| CLI smoke | `crates/rom-weaver-cli/tests/cli_smoke/` | End-to-end CLI runs against synthesized fixtures, per command family. Shared helpers in `shared.rs`. |
| React unit | `packages/rom-weaver-webapp/tests/unit/` | Patcher state layer plus the loom UI contract (DS primitives, shell, stores, apply-view markup) - vitest, node/happy-dom. |
| WASM node | `packages/rom-weaver-webapp/tests/wasm/` | Worker client, OPFS protocols, format metadata (vitest, node). |
| Browser | `packages/rom-weaver-webapp/tests/browser/` | Playwright + vitest integration tests of the real worker/OPFS/wasm stack, including mobile-Safari-specific cases. |

The live app's axe-core audit runs from `packages/rom-weaver-webapp/scripts/run-webapp-e2e.mjs` against the served production build and covers every workflow tab plus Settings, candidate selection, reset confirmation, patch editing and reordering, and every guided Apply/Create step in both themes at desktop and mobile sizes. The Chromium pass also unions CSS usage across those states and enforces the unused-byte budget in `packages/rom-weaver-webapp/performance-budgets.json`; WebKit runs the same accessibility states without Chromium-only CSS coverage.

CI (`.github/workflows/ci.yml`) runs fmt, clippy `-D warnings`, typegen drift checks, wasm-target checks, the Rust test suite, the WASM build, and the repository/webapp lint, test, and build gates. Jobs are selected by change classification; main and release paths run the full matrix. `.config/lefthook.yml` selects workspace-wide formatting, static-analysis, typegen, dependency-policy, and WASM checks from staged paths. It does not replace the full CI test and build suite.

## Other docs

See the [documentation index](<https://rom-weaver.com/docs.md>) for runtime configuration, browser protocols, verification guides, implementation notes, and format references.
