mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
A filter plug-in gets 50 M instructions and a minute for each run. Filters that order a plotter's paths, pack shapes or run a simulation need minutes, and the app shouldn't freeze while they compute. A filter's manifest may now ask for "budget": "heavy" (Budget): each run gets HEAVY_FUEL (1 G instructions) and HEAVY_WALL_TIME_MS (300 s). Only a filter may ask. plugin.install refuses such a filter without allowHeavy: true, and plugin.inspect returns the entry a module would have without installing it. Install Plug-in… and File › Open ask the user and install on OK; the plug-in store keeps the answer, and folder loads skip heavy filters with a note, since they ask nobody. The app runs a heavy filter on a thread of its own (plugin_runs), not in Background Save's queue, with the status bar and a progress window (Cancel, Continue in Background) that it shares with Background Export (export_progress::window). FilterRun splits plugin.run: prepared on the session, run on any thread, its output handed in, and the next plugin.run with the same params applies that output instead of running the plug-in again, so the edit stays one guarded, journaled command. A result whose document or plug-in changed meanwhile is dropped, and the status bar says to run it again. A cancelled plug-in run is now EngineError::Cancelled. What a filter keeps with the document (#3720) goes with the run: it is given _context.documentData when the run is prepared, and the documentData of its output is applied with its objects, in the same undo step, when the run is handed in (FilterRun::finish). Tests: plugins/tests/abi.rs (100 M loop steps out of a light budget, within a heavy one; folders skip heavy filters), manifest.rs, engine tests_plugins.rs (consent, inspect, the store, hand-in, a handed-in run's document data, cancel) and ui-egui tests_plugin_runs.rs (installing asks, no Preview, a background run as one undo step, a stale result kept out, the progress window). CRAFT_UI_HEAVY_FILTER_WASM=<folder> writes a test filter to try in the app. Its strings and messages have rows in each complete catalog (COMPLETE_MESSAGES). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
193 lines
9.4 KiB
Rust
193 lines
9.4 KiB
Rust
//! Minimal plug-ins in WebAssembly text (WAT): the skeleton `docs/plugins.md` describes, which the
|
|
//! tests turn into modules (with the `wat` crate) to exercise the host.
|
|
|
|
/// Where [`returning`] keeps its output in memory (the manifest sits at 16).
|
|
const OUTPUT_AT: usize = 0x1_0000;
|
|
|
|
/// `s` as the body of a WAT string literal (every byte that isn't a letter, digit or space
|
|
/// escaped).
|
|
pub fn string(s: &str) -> String {
|
|
s.bytes().map(|b| if b.is_ascii_alphanumeric() || b == b' ' { char::from(b).to_string() } else { format!("\\{b:02x}") }).collect()
|
|
}
|
|
|
|
/// A plug-in module: `manifest` (JSON), a `vc_run` whose body is `run` (parameters `$in $il $pp
|
|
/// $pl`, result i64), extra module fields `extra` and `pages` of initial memory. `vc_alloc` grows
|
|
/// memory by whole pages, so its blocks never overlap data segments.
|
|
pub fn module(manifest: &str, run: &str, extra: &str, pages: u32) -> String {
|
|
format!(
|
|
r#"(module
|
|
{extra}
|
|
(memory (export "memory") {pages})
|
|
(data (i32.const 16) "{data}")
|
|
(func (export "vc_abi_version") (result i32) (i32.const 1))
|
|
(func (export "vc_manifest") (result i64) (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const 16)))
|
|
(func (export "vc_alloc") (param $n i32) (result i32) (local $old i32)
|
|
(local.set $old (memory.grow (i32.shr_u (i32.add (local.get $n) (i32.const 65535)) (i32.const 16))))
|
|
(if (result i32) (i32.eq (local.get $old) (i32.const -1)) (then (i32.const 0)) (else (i32.mul (local.get $old) (i32.const 65536)))))
|
|
(func (export "vc_run") (param $in i32) (param $il i32) (param $pp i32) (param $pl i32) (result i64)
|
|
{run}))"#,
|
|
data = string(manifest),
|
|
len = manifest.len(),
|
|
)
|
|
}
|
|
|
|
/// A `vc_run` body returning the input unchanged.
|
|
pub const ECHO: &str = "(i64.or (i64.shl (i64.extend_i32_u (local.get $il)) (i64.const 32)) (i64.extend_i32_u (local.get $in)))";
|
|
|
|
/// A plug-in that always returns `output` (whatever it is given).
|
|
pub fn returning(manifest: &str, output: &str) -> String {
|
|
then_returning(manifest, "", output)
|
|
}
|
|
|
|
/// A plug-in whose `vc_run` runs `first` (instructions, with the locals it declares first), then
|
|
/// returns `output`.
|
|
fn then_returning(manifest: &str, first: &str, output: &str) -> String {
|
|
let run = format!("{first} (i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len());
|
|
let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output));
|
|
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
|
|
}
|
|
|
|
/// A plug-in that counts `steps` down before it returns `output`. Each step costs a few
|
|
/// instructions, so a test picks work that a light budget can't finish and a heavy one can, or
|
|
/// that runs until it is cancelled.
|
|
pub fn spinning(manifest: &str, steps: u64, output: &str) -> String {
|
|
let spin = format!(
|
|
"(local $n i64) (local.set $n (i64.const {steps}))
|
|
(block $done (loop $step
|
|
(br_if $done (i64.le_s (local.get $n) (i64.const 0)))
|
|
(local.set $n (i64.sub (local.get $n) (i64.const 1)))
|
|
(br $step)))"
|
|
);
|
|
then_returning(manifest, &spin, output)
|
|
}
|
|
|
|
/// What [`when_given`] looks through.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Given {
|
|
/// The input JSON (the objects).
|
|
Input,
|
|
/// The parameters JSON, `_context` included.
|
|
Params,
|
|
}
|
|
|
|
/// Where [`when_given`] keeps the text it looks for.
|
|
const NEEDLE_AT: usize = 0x8000;
|
|
|
|
/// A plug-in that returns `output` when what it is given (`given`) holds the text `needle`, and
|
|
/// its input unchanged otherwise. A live effect made from it draws one shape or the other, so a
|
|
/// test sees from the geometry whether the host handed the plug-in something.
|
|
pub fn when_given(manifest: &str, given: Given, needle: &str, output: &str) -> String {
|
|
let (src, len) = match given {
|
|
Given::Input => ("$in", "$il"),
|
|
Given::Params => ("$pp", "$pl"),
|
|
};
|
|
let n = needle.len();
|
|
// For each place `$i` the needle fits, compare it byte by byte (`$j`).
|
|
let run = format!(
|
|
r#"(local $i i32) (local $j i32)
|
|
(block $found
|
|
(block $none
|
|
(loop $at
|
|
(br_if $none (i32.gt_u (i32.add (local.get $i) (i32.const {n})) (local.get {len})))
|
|
(local.set $j (i32.const 0))
|
|
(block $next
|
|
(loop $cmp
|
|
(br_if $found (i32.eq (local.get $j) (i32.const {n})))
|
|
(br_if $next (i32.ne (i32.load8_u (i32.add (local.get {src}) (i32.add (local.get $i) (local.get $j))))
|
|
(i32.load8_u (i32.add (i32.const {NEEDLE_AT}) (local.get $j)))))
|
|
(local.set $j (i32.add (local.get $j) (i32.const 1)))
|
|
(br $cmp)))
|
|
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
|
(br $at)))
|
|
(return {ECHO}))
|
|
(i64.or (i64.shl (i64.const {out}) (i64.const 32)) (i64.const {OUTPUT_AT}))"#,
|
|
out = output.len(),
|
|
);
|
|
let extra = format!(r#"(data (i32.const {NEEDLE_AT}) "{}") (data (i32.const {OUTPUT_AT}) "{}")"#, string(needle), string(output));
|
|
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
|
|
}
|
|
|
|
/// The manifest of a test plug-in `id` of `kind` with `params` (a JSON object of specs).
|
|
pub fn manifest(id: &str, kind: &str, params: &str) -> String {
|
|
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"{kind}","params":{params}}}"#)
|
|
}
|
|
|
|
/// The manifest of a test exporter `id` writing `extensions` (a JSON list) with `params`.
|
|
pub fn exporter(id: &str, extensions: &str, params: &str) -> String {
|
|
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"exporter","extensions":{extensions},"params":{params}}}"#)
|
|
}
|
|
|
|
/// What [`echo_exporter`] writes as its file.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Echo {
|
|
/// The input: the document, as the host hands it over.
|
|
Input,
|
|
/// The parameters JSON, `_context` included.
|
|
Params,
|
|
}
|
|
|
|
/// Where [`echo_exporter`] keeps the base64 alphabet, then its output's opening and closing text.
|
|
const ALPHABET_AT: usize = 0x8000;
|
|
|
|
/// An exporter (manifest `manifest`) whose file is exactly what it is given (`echo`), so a test
|
|
/// reads back what the host hands a plug-in: it returns `{"dataBase64": "…"}`, encoding the bytes
|
|
/// in base64 itself, three bytes to four characters, the last group padded with `=`.
|
|
pub fn echo_exporter(manifest: &str, echo: Echo) -> String {
|
|
let (src, len) = match echo {
|
|
Echo::Input => ("$in", "$il"),
|
|
Echo::Params => ("$pp", "$pl"),
|
|
};
|
|
let (open, close) = (r#"{"dataBase64":""#, r#""}"#);
|
|
let (open_at, close_at) = (ALPHABET_AT + 64, ALPHABET_AT + 96);
|
|
// One character of the alphabet: the six bits of `$b` from `shift` up.
|
|
let char_at = |shift: u32| {
|
|
format!("(i32.load8_u (i32.add (i32.const {ALPHABET_AT}) (i32.and (i32.shr_u (local.get $b) (i32.const {shift})) (i32.const 63))))")
|
|
};
|
|
let byte = |k: u32| format!("(i32.load8_u (i32.add (local.get {src}) (i32.add (local.get $i) (i32.const {k}))))");
|
|
let run = format!(
|
|
r#"(local $o i32) (local $n i32) (local $w i32) (local $i i32) (local $b i32) (local $rest i32)
|
|
(local.set $n (i32.add (i32.const {frame}) (i32.mul (i32.const 4) (i32.div_u (i32.add (local.get {len}) (i32.const 2)) (i32.const 3)))))
|
|
(local.set $o (memory.grow (i32.add (i32.shr_u (local.get $n) (i32.const 16)) (i32.const 1))))
|
|
(if (i32.eq (local.get $o) (i32.const -1)) (then (return (i64.const -1))))
|
|
(local.set $o (i32.mul (local.get $o) (i32.const 65536)))
|
|
(memory.copy (local.get $o) (i32.const {open_at}) (i32.const {open_len}))
|
|
(local.set $w (i32.add (local.get $o) (i32.const {open_len})))
|
|
(block $done (loop $triple
|
|
(br_if $done (i32.gt_u (i32.add (local.get $i) (i32.const 3)) (local.get {len})))
|
|
(local.set $b (i32.or (i32.or (i32.shl {b0} (i32.const 16)) (i32.shl {b1} (i32.const 8))) {b2}))
|
|
(i32.store8 (local.get $w) {c18}) (i32.store8 offset=1 (local.get $w) {c12})
|
|
(i32.store8 offset=2 (local.get $w) {c6}) (i32.store8 offset=3 (local.get $w) {c0})
|
|
(local.set $w (i32.add (local.get $w) (i32.const 4)))
|
|
(local.set $i (i32.add (local.get $i) (i32.const 3)))
|
|
(br $triple)))
|
|
(local.set $rest (i32.sub (local.get {len}) (local.get $i)))
|
|
(if (i32.ne (local.get $rest) (i32.const 0)) (then
|
|
(local.set $b (i32.shl {b0} (i32.const 16)))
|
|
(if (i32.eq (local.get $rest) (i32.const 2)) (then (local.set $b (i32.or (local.get $b) (i32.shl {b1} (i32.const 8))))))
|
|
(i32.store8 (local.get $w) {c18}) (i32.store8 offset=1 (local.get $w) {c12})
|
|
(i32.store8 offset=2 (local.get $w) (if (result i32) (i32.eq (local.get $rest) (i32.const 2)) (then {c6}) (else (i32.const 61))))
|
|
(i32.store8 offset=3 (local.get $w) (i32.const 61))
|
|
(local.set $w (i32.add (local.get $w) (i32.const 4)))))
|
|
(memory.copy (local.get $w) (i32.const {close_at}) (i32.const {close_len}))
|
|
(i64.or (i64.shl (i64.extend_i32_u (local.get $n)) (i64.const 32)) (i64.extend_i32_u (local.get $o)))"#,
|
|
frame = open.len() + close.len(),
|
|
open_len = open.len(),
|
|
close_len = close.len(),
|
|
b0 = byte(0),
|
|
b1 = byte(1),
|
|
b2 = byte(2),
|
|
c18 = char_at(18),
|
|
c12 = char_at(12),
|
|
c6 = char_at(6),
|
|
c0 = char_at(0),
|
|
);
|
|
let extra = format!(
|
|
r#"(data (i32.const {ALPHABET_AT}) "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")
|
|
(data (i32.const {open_at}) "{}")
|
|
(data (i32.const {close_at}) "{}")"#,
|
|
string(open),
|
|
string(close)
|
|
);
|
|
module(manifest, &run, &extra, 1)
|
|
}
|