VexelRay

VexelRay Framework

Readiness for v1 · 10 Oct 2026

v1 means an application built on it needs no major refactor for the foreseeable future, and later releases add to the abstractions it already has. It also carries no legacy: nothing stays for backwards compatibility. By that bar the framework is not close: one of seven surfaces is ready to freeze, and the concurrency model's declaration side is half built — components declare their mailboxes and what they send, and supervision watches every lane, but the copier, capture and the rest of the channel seam are not there. Most of what the dimensions below grade is built well, somewhere else.

314 tests green core 58 · shell 106 · template 55 · processor 90 · automation 5 6 modules built · 1 planned 1 template, framework-shaped · 1 starter 23 automation verbs on the wire threading rules held 20 of 33 v1 surfaces ready 1 · close 2 · open 4 of 7 dimensions read @ 777d496
The v1 line · what v1 means

Would landing it later break an app?

v1 is a promise that an application built on it needs no major refactor for the foreseeable future, and later releases add to the abstractions it already has. That is a question about whether a shape is final, which is not the same as whether the capability is built. The five dimensions below grade the second; this ledger grades the first. Six surfaces are frozen at v1, and a seventh, the sibling types Shell hands over, bounds them all.

Surface Ready to freeze? What stands between it and the freeze
The wire protocol Ready Framing, ok/err and twenty-three verbs, exercised by a real window on every acceptance run; zoom and resize landed. What is still asked for (keydown/keyup, await on existence) is additive.
Shell and the phases Close Phases carry the stack's own constraints, three ported applications ran on them, and every generated one does now. Open: what Shell exposes of the siblings, and whether a new Phase can ever be additive.
The launch surface Close FRAMEWORK_KEYS and the dropped-capability reports are settled. --profile is reserved and nothing honours it, and the module behind it has no name yet.
The annotations and the processor's semantics Decisions open Exact-type matching, what a null provider does, and the two promises that need a method body are unargued. Every new check is a way to break a build that was accepted, so the checks that exist must be the checks v1 keeps.
The component model Declaration half built Threads and mailboxes are the container's; @Subscribe and @Publishes declare a component's mailboxes and sends, the blocking-cycle and immutable-payload checks run, and supervision (Watchdog, LivenessPolicy) watches every lane. Missing: @Subscribe off a component, capture (T2.5, undecided), and the generated copier. It reshapes how every component is written.
The build shape One instance One starter, no BOM, no aggregated reachability metadata — the suite's applications build Windows native executables in two editions, and each carries its own metadata and flags, which the template now copies into every project — and a composition-root question (GuiApp or VexelEngine) that decides what an application inherits.
The sibling boundary Undecided Shell returns Gui, GuiApp, KronoGui, TitleBar, Settings, Atchung, ComputeQueue and dev.vexelray.os.Icon, and the framework's Windows, Window and Dialogs hand over Gui, TitleBar and Modal. v1 is only as stable as they are. Gui is ruled frozen and its methods are not yet classified; memory() is deleted, app() is ruled the device, and the windows and the dialogs are roots of their own. One bus also carries one tree, upstream.

The extension test

What v1 leaves out is fine if the seam it will use already exists. One of these still has none, and it is the rest of the channel seam, which is why the concurrency work still comes first.

Left out of v1 The seam it attaches to Exists?
Diagnostics, --profileA starter and @ConditionalOnType; atchung-probeYes the name is undecided
A second automation socketDriver.open(shell, gui, controls, offset)Yes
Framework SDF vocabularyA @Provides and a placed componentWhen the component model is final
Simulation vocabularyFrameStage.APP and a placed componentPartly
Supervision policies, restartLivenessPolicy, handed back, acting through a Context (exit, interrupt)Yes restart is a new Context method
elektro-Q, cross-process actorsThe channel declaration: @Subscribe, @PublishesPartly on components only
Reactive automation, macros, extensionsA @Subscribe that is not on a componentNo
The scale

The five dimensions below are graded by where the capability lives, because that is the question this framework exists to answer. It is not the question v1 asks, which is whether the shape is final — see the ledger above. The end state for almost everything here is the rightmost stop.

Absent Nothing anywhere — not designed, not built.
Specified Written down and reasoned through; no code.
In the stack Real code in a sibling repo. The app reaches it directly.
In the framework A default, a phase, or a Shell accessor. No hunting.
Dimension 01 · Rendering

General-purpose SDF pipeline

Mature in the engine · absent here

The pixels path is real end to end and has been pressure-tested by a design tool: Surface → SurfaceCompiler → SdfScene → SdfComposer → SPIR-V → SampledColorTarget → Node.image. What is missing is a compositing vocabulary and any framework-level presence at all — not one mention of SDF across the six modules' sources.

Surface tree → SPIR-V, with symbolic AD and a structural shader cache In the stack
Runtime parameters without a recompile — push constants, then the buffer road In the stack
N techniques in one pass, interleaved per pixel by shared depth In the stack
GUI viewport compositing — marched target sampled as a laid-out node In the stack
One composition root — today the engine and GuiApp are two Specified
Layer & blend vocabulary — order and depth are the only composition Absent
A rasterising technique — ClipDepth still has one author, one consumer Absent
Framework SDF vocabulary — no viewport component, no scene provider Absent

Standing on its own

  • Complete modelling vocabulary — primitives, domain ops, all three smooth booleans, Implicit with forward-mode symbolic AD.
  • ShaderCache keyed on structural equality of the whole SdfScene: records all the way down, so caching needed no bespoke fingerprinting.
  • ConeField reads geometry from a storage buffer — one pipeline for every scene, after a few hundred segments were measured at 5 s of pipeline build on the frame loop.
  • The designer's three B0 stages — parameters, function-abstracted lowering, node identity — are all done.

What an app still retypes

  • The two kinds of dirty: a scene change is new SPIR-V and a new pipeline; a frame change is six floats of push constant.
  • The queue discipline: VkQueue is not thread-safe, so a drag handler raises a flag and all GPU work happens in one pump() in FrameStage.APP.
  • Every SDF app writes both by hand. That is the same drift the framework was created to absorb, one layer down.
  • Designer stages P3–P8 open: cost walk, off-thread compile, resolution swap, edit vocabulary, serialisation, pick pass.
Dimension 02 · Concurrency

Multithreaded by default

The threads are the container's · colour is checked, not yet copied

This dimension has started. The plan is unchanged — one component, one thread, one mailbox, with the mapping decided in the wiring and never at runtime — but it is no longer only a plan. Lanes in -core owns the application's handler, offload and component threads; Placement in -shell owns a component's thread, its mailboxes and the drain-then-stop around it; the generated wiring places each @Component on both, and no application code can, and @Subscribe and @Publishes put each mailbox and each send on a declaration. The grep this page prints as its own check used to return zero and now returns 60 hits across eight files — which is the intended shape rather than a leak: 36 are Lanes, 12 are Watchdog, 5 are Placement, one each in Stalls, Shell and PointerLock, and the last four are words: two in the template's list of Java keywords and a method name in the processor's Channels. Twenty of threading.md's thirty-three rules are held, and four still say processor. Eight came from Lanes and Placement, which needed something to be the rule rather than a processor; most of the rest are the processor's checks — placement, colour, the immutable payload, the declared channel graph and its blocking cycles — and supervision came from Watchdog and LivenessPolicy.

The model itself — static placement, platform threads, the Rate round trip Specified
Mailbox substrate — Pump, State, Fold, Backpressure In the stack
Cross-process actors — elektro-Q: Actor, Conduit, four transports In the stack
Timeline bridge — KronBridge folds a topic into a cell on the baton In the stack
The deadline half — Kron.sleepTimeout() already registered In the framework
The container owns the application's bus — one fabric; the dialogs and the other windows keep their trees on buses of their own In the framework
…and the threads everything runs on — Lanes: handler, offload, one per component In the framework
Work that outlasts a frame — Lanes.offload(), bounded in threads and never virtual In the framework
One component: a thread, a mailbox, a wake — @Component and @Subscribe, placed by the generated wiring; drain then stop on a timer In the framework
A wake nobody has to remember — a delivery wakes the loop; an expired park does not In the framework
A mailbox the bus owns — Pump.drain(timeout), parked without holding a lock In the stack
One fabric per application — two trees cannot share a bus, so two windows are two Absent
@MainThread actually enforced — a compile error, the framework's GuiApp included In the framework
The process fault policy is the framework's — not inherited In the framework
What a full mailbox does — per mailbox, @Subscribe(overflow = …); an edge and a sample on one topic is a compile error In the framework
Somebody notices a blocked main thread — Stalls, 250 ms, always on In the framework
Supervision — Watchdog names a lane stuck inside one delivery; LivenessPolicy decides, report then exit by default In the framework

Settled, with reasons

  • Platform threads, not virtual — Kronometer's kernel recommends a single-carrier scheduler, so components on virtual threads would be the documented deadlock, not merely slower.
  • A component thread never touches the timeline. Work arrives Rate → mailbox → component thread → Topic → Cell, and the one drain period of latency is priced in advance rather than discovered.
  • FrameStage vs Rate settled: the frame is the main thread's recipe and its order is causal; a Rate is everyone else's grid.

Still open

  • The handler lane has no bound, and the blocker is a census rather than a fix. "One bus is not one thread" is closed — Gui takes both lanes now and closes only the ones it built itself, which turned two upstream rules into one change. What replaced it is smaller and less certain: the one application checked for blocking handlers turned out to route everything through GuiApp.post instead, so nobody yet knows what does block one. -Dvexelray.stall.ms=16 is the setting that answers it by running the applications rather than reading them.
  • The dream case — responsive through a component's infinite loop — is built for the lanes. Each component lane is its own ThreadGroup with a busy gauge; Watchdog, on no lane, reports a stalled delivery once by name and halts past a deadline; close has a total bound; and the policy is the application's to replace. What is left: the main thread is not watched (a modal dialog legitimately blocks it), the handler and offload pools are not grouped and have no cancellation, Context has no restart, and W2 has no wedge that ignores interrupts, so the halt backstop has not fired in a witness.
  • Three vocabulary mismatches are recorded for the processor's brief: @Component is a DI contract not an actor one, @MainThread is two-coloured, FrameHooks is the barrier's N=1 case.
Dimension 03 · Ergonomics

One language, not five

Solved at the shell · open at the fabric

The app edge now has one language and the measurement is real: three ported wirings run 186 / 296 / 556 lines against a ~350-line scaffold that each application used to carry for itself, and two of the three import four or fewer sibling types. The app fabric is half there: Shell.bus() is the application's own and a component speaks on it in @Subscribe and @Publishes, but Shell.krono() hands over a clock and then you are in Kronometer's language, and graphics is still vexelray's.

Input, clipboard, window memory, icon, dialogs — defaults nobody types In the framework
…and replaced by a @Provides — the look, input, clipboard, liveness policy and dialogs; the icon is named in @VexelApp(icon); pacing not yet In the framework
Phases, and a Wiring a lambda can no longer satisfy In the framework
Appearance: the theme, the zoom range, the chrome — one applyTo In the framework
Kronometer vocabulary — Rate, Settlement, Overrun, Moment in no signature In the stack
Graphics vocabulary — vexelray and SupirVast reach the app directly In the stack
A bus seam — Shell.bus(), the application's own; components declare their side of it In the framework
elektro-Q — named only as the processor's precedent, never as a dependency Absent

Gotchas already closed

  • The central contract stops being satisfiable by a lambda — as an interface, run(() -> myAppInfo, args) compiled and produced a blank window that refused nothing.
  • Shell's accessors refuse to hand over what does not exist yet, so work in the wrong phase is a throw rather than a null.
  • One Settings per application, which was the singleton invariant two repos maintained by a comment.
  • Launch.FRAMEWORK_KEYS means --automation is asked for the same way on every application on the desk.
  • "Overriding one is a @Provides method" was true of the look alone. A provided input backend or clipboard is now handed back to the shell and the framework's is never opened — and one built too late for the framework to use it is a compile error naming the phase.

Gotchas still open

  • Two reserved keys nothing consumes. --profile parses and no module honours it. The processor evaluates @ConditionalOnType now, so a part can be present or absent by what is on the classpath — but nothing uses that to honour a profile.
  • shell.wake(gui::onWork) is redundant — GuiApp already wires every tree it presents — and reads like the line that makes the wake happen.
  • A non-default zoom range would disagree across the editor's three windows, because two of them also run under a host with no Shell to ask.
Dimension 04 · The thesis

The Boot analogue

Container and generator real · one starter · no instrument

The no-reflection thesis holds, and three applications ran on it before being deleted on purpose — the witnesses are generated now: the template's project, the two-window (W1), component (W2) and long-running (W3) witnesses, and the suite's calculator and text editor, Vexplore, Pix and the simulation demos. Of the three things that make Boot Boot, the generator is built: vocabulary is enforcement, and the template's wiring is generated. The starter has its first instance — the driving socket, named in @VexelApp — but no BOM makes one dependency enough. And the instrument that answers "why did we miss a frame" is still only specified.

Compile-time container — phases, launch, pacing, disposal In the framework
The driving socket — automation, off unless asked for In the framework
The annotation processor — the checks, then the generated wiring the template runs on In the framework
-diagnostics, the Actuator analogue — blocked on a name Specified
@Subscribe — the actor substrate, on a component; the extension API and reactive automation want it off one, which is unbuilt In the framework
Starters — AutomationStarter, a @Default an application names and may back off In the framework
A BOM — six modules, and an application still lists the ones it wants Absent
Aggregated reachability metadata — "probably the highest-leverage feature not yet listed" Absent
Simulation vocabulary — fixed step, scene, assets; the game-engine half Absent

Holding

  • Nothing reflects. Retention is CLASS deliberately; no Class.forName on the startup path.
  • -api and -core stay JDK-only, so every decision the container makes is testable with no GPU — which is why all 314 tests run anywhere, a real GuiApp and a placed component among them.
  • The spreadsheet half was demonstrated three times over — one window, three windows, and two windows on one device — and those three applications have since been deleted on purpose. What they found is written up; keeping them ported was a tax on every change.

Not yet true

  • "A compile error beats a startup error" is in force for declarations and not yet for bodies. The processor refuses a misused @Component, @MainThread or @Setting, and a generated wiring cannot put a part in the wrong phase — but a hand-written Wiring is still a method body, and a backwards Phase dependency in one is still a startup error.
  • The game-engine half exists in the engine — render == sim, one IR to SPIR-V and to a CPU AST — and nowhere in the framework. FrameStage.APP is an empty slot, by design.
  • -diagnostics may be mostly free: atchung-probe already has spans, counters and stall-hunting, and Automation already imports it. The always-on half was taken without waiting for the module — Stalls reports a stage that held the main thread past 250 ms, which is deliberately the narrow question did somebody block this thread rather than the profiler's.
Dimension 05 · Verification

Driving the real application

Mature · drives, does not yet react

The strongest area on this page, and the only capability where the framework's share is already finished. A running desktop application — real window, real Vulkan device, real frame loop — is driven over a loopback line protocol: one command in, one reply out, terminated by a lone ., replies beginning ok or err so a script branches on two characters. Twenty-three verbs, a real cursor behind them, and a CLI whose exit status is the verdict.

The socket, wired once — a starter to name; --automation=7654 parses in every application In the framework
The verb set — find, click, drag, type, go, settle, await, shot In the stack
Pointer fidelity — a stateful cursor stepped at 125 Hz and paced in real time In the stack
Semantic read-model — roles, names and structure, joined to layout by id and frame In the stack
A client that is not a test fixture — ottermate, empty dependency block In the stack
A settle that consults the clock — the framework's driver hands it the application's In the framework
zoom, resize, dpi, windows — on WindowControls; per-app --capture is retired In the stack
A second socket per application — the designer places its own at port() + 1 Specified
The reactive half — a macro can drive the application but cannot react to it Absent

Why it is trustworthy

  • One input path, not a second one. The driver moves the same cursor a hand does, so a gesture that works here is a gesture that works — no synthesised event delivered to a coordinate.
  • Reveal or refuse. A click on a row a hundred-thousand-row table has scrolled past used to hit the header drawn over it, sort the table, and answer ok. Now the layout model publishes what is actually on screen and the target is revealed or the command is refused.
  • A busy port never stops a launch. Driver.open reports the drop by name and the application runs undriven — one of the six dropped-capability reports.
  • Proven against a planted defect: a hover-path bug diagnosed from the CSV alone, and recorded as necessary rather than sufficient.
  • Settled means the motion has stopped. settle used to answer as soon as nothing was owed, and photographed a panel three seconds before its fade finished. It waits for the clock now — including a fade a click handler has only just posted — and the acceptance loop times a real pulse to keep it that way.

What is left

  • A scene cannot hold a modifier or wait on existence. key is press-and-release, so Shift-click is untestable from outside, and await waits on text, so a script that wants "the dialog appeared" needs the application to print something — a witness flaked on exactly that. keydown/keyup and a bare await <landmark> are both additive.
  • An application that never goes quiet cannot be settled. A repeating cue keeps the clock busy by definition, so settle reports it after ten seconds and names await instead — honest, and a real limit for a clock face or a spinner.
  • Automation is command-in, response-out. The @Subscribe seam is what would let a macro wait on something the application says.

Where each tool meets an application

The framework's promise is that a developer never goes hunting. This is the current honest answer, tool by tool.

Tool What it holds Presence here How an application reaches it today
vexelray-gui Frame loop, widgets, windows Absorbed The framework is its edge. Shell is the whole surface.
tactroller Every device event on the desk Absorbed InputBackend — a default, and a named report when a platform module is missing. PointerLock carries the GUI's lock intent onto the device.
…-gui-automation Driving a live application end to end Absorbed A module of its own, so the socket is linked only into an application that wants to be driven, and built only in one that names AutomationStarter. --automation=7654, then ottermate from outside.
kronometer Timing, animation, latency compensation Lifecycle only Constructed, ticked, parked on and woken by the framework — then Shell.krono() hands over the vocabulary whole.
vexelray Vulkan runtime, canvas, MSDF, SDF None Through GuiApp's device, or directly; Shell.computeQueue() and Shell.windowMark() hand over a queue and the window's icon. On Windows it presents through DXGI only, carried by vexelray-gui-core. Viewports and scenes are the application's.
supirvast SPIR-V generation None Transitively, behind vexelray-shader's byte[] boundary.
atchung The typed bus Fabric and mailboxes Shell.bus() is the application's own, and a @Component is placed on it by the generated wiring, with a Pump for an inbox. A second Gui still may not join it — see dimension 02.
elektroq Transport-agnostic messaging, actors None Not at all. Named only as the processor's precedent, and in no application.

What has landed

Steps 01–06, in the order they were taken. Each decided a vocabulary the next one baked in. What is left is under the road to v1, below.

01

Hand the container a bus landed

Shell.bus(), built in the constructor and available in every phase; the framework's Gui is on it, so an application is one fabric rather than one per tree. The half that could not be added later without moving everything built on top of it. What is left of this step is the handler executor, which needs Gui's worker pool to stop being a field initializer — an upstream change, not another argument.

02

Decide what a full mailbox does decided

A bus fault is the framework's to answer and still halts — saving first is what turns a crash into a hang. A wedged component is a different event, answered by the Backpressure of its channel: a sample coalesces, an edge must not be lost, and a channel carrying both gets split. What it left open was supervision, since built: Watchdog names a lane that has stopped draining, and LivenessPolicy decides what happens next.

03

Write the first component by hand landed

The designer's shader composition, on its own platform thread with a capacity-one latest-wins mailbox and a WakeSource of its own. It found a real defect on the way in: the hand-rolled version checked for a newer edit before composing rather than around it, so a superseded design could finish last and land on screen. Nothing extracted into the framework yet, deliberately — one component is not a census, and what two of them share is what the container should own.

04

The component seam: the thread, not the mailbox landed

Lanes owns the application's threads and the generated wiring puts each @Component on one with its mailboxes, its wake and its drain-then-stop; upstream, Gui takes both lanes rather than minting a pool per tree. A component's concurrency is now a property of what the wiring placed rather than of how many windows an application happens to hold. Eight of threading.md's rules moved to held and none of them needed the processor — they needed something to be the rule.

05

Close the acceptance loop landed

mvn clean install -Pacceptance drives the builder API, asserts the Blueprint in memory, writes it, builds it — its own tests and the framework's processor included — and runs it with mvn compile exec:exec under automation: the template's text editor opens a folder, switches files by click and by key, edits and saves one, takes a screenshot, then resizes, zooms and changes density and checks each picture is the size asked for. The three ported applications were the witness and they are gone on purpose. A generated project has no conventions of its own to defend, and the automation step is what stops the builder and the framework agreeing with each other about being wrong.

06

Write the processor landed

Checks, then the wiring: every rule a declaration can decide is a compile error, and an application's Wiring is generated from its @VexelApp and its @Provides methods — each part built once, in the phase its parameters put it in. The template ships a Recipes configuration rather than a hand-written wiring, and -Pacceptance builds and drives what it generates. Four of the thirty-three threading rules still say processor, and none can be decided from a declaration: they want a method body, a send nobody declared, or the component tree. The framework's own defaults turned out to want handing back rather than backing off, because a hand-written wiring still has to start with input; that made overriding the input backend and the clipboard real, and what generation left is in the TODO.

The road to v1

What is left, in the order that is load-bearing: each step decides a vocabulary the next one bakes in. The detail, and why each entry blocks v1, is in TODO.md.

07

Finish the component model

@Subscribe off a component, the capture rule (undecided), the generated copier, the @Subscribe defaults, the rest of supervision (the main thread, the handler and offload pools, restart), and the way an offloaded result gets back onto the frame loop. The component mailboxes, the declared channel graph, its checks and the lane watchdog have landed. It reshapes how every component is written and is where new compile errors come from. It is also the seam elektro-Q, reactive automation and extensions are waiting for, which is why nothing else can be judged final before it.

08

Decide what nobody has argued

Exact-type matching, what a null provider does, the two promises that need a method body, which defaults are replaceable, whether a Phase can ever be added without breaking, and how a new processor check is allowed to land. Cheap while nothing depends on the answer and breaking afterwards. An application relies on these without ever writing them.

09

Settle the boundary with the siblings

What Shell hands over of vexelray-gui and atchung; the upstream bus that admits one tree; then whether GuiApp or VexelEngine is the composition root. v1 is only as stable as the types an application holds, and every side of this edge is the author's own.

10

Add witnesses

Two or three generated applications that differ where it matters: several windows on one device, component-heavy with real placement, long-running with nothing to settle. The component witness (W2) and the long-running one (W3) are built and pass, and the suite's applications are generated from the template. W1, several windows on one device, is built too, and shaped the Window seam and the dialogs. Each new shape was expected to find something, and what it finds is written up as the ports were.

11

The build shape

A second starter, the BOM, and aggregated reachability metadata; and a name for the diagnostics module, since a module name is Maven coordinates an application writes down. After the component model, because what an application inherits depends on what the stack turns out to reach. The single highest-leverage item on the open-questions list.

12

Sweep legacy

Delete what exists only for backwards compatibility: kept constructors, redundant seams, reserved names nothing honours, test seams in public API, dead API, and prose that describes how something used to work. The second v1 condition: there is no legacy in v1. It comes after the component model, which makes some seams redundant, and before the freeze, which would otherwise make them permanent.

13

Freeze

Mark each public type frozen or experimental, walk the whole surface for anything that would be renamed today, fix it, and cut 1.0. Releases are 0.x until then. The marking exists and does not reflect: @Stability, on every public type in -api, -core, -shell and -automation, and every one says EXPERIMENTAL. Nothing is frozen yet, and vexelray-gui wants a marker of its own. The pass is meaningless while any step above is open.

How to re-check this page

The five dimensions were regraded on 10 October 2026 at 777d496 against TODO.md, threading.md's matrix, components.md and the code, not inferred from the docs alone. The hash is when this page was read, not what it describes. The threading grep reads src/main/java only, because a template's resources are a generated project's source rather than the framework's. The v1 ledger and the road to v1 were first assembled on 28 September 2026 and re-read on the same day as the dimensions, with one read of Shell's public signatures for the sibling types it returns. Nothing checked them against the whole public surface; that walk is step 13.

# 314 green, offline: core 58, shell 106, template 55, processor 90, automation 5
mvn -o test

# 60 hits in eight files: Lanes 36, Watchdog 12, Placement 5, one each in Stalls, Shell and
# PointerLock, two keywords in the template's Checks, two words in the processor's Channels.
# A ninth file is a new thread seam
grep -rn "Thread\|Executor\|java.util.concurrent\|volatile\|synchronized\|Atomic" \
     --include=*.java vexelray-framework-*/src/main/java | grep -v MainThread

# 41 hits, four names: atchung 17, tactroller 14 and probe 8 in the framework's Java,
# and kronometer 2 in the template's Motion.java. A fifth name is a new dependency edge
grep -rn "import sibarum" --include=*.java vexelray-framework-*/src/main

# dimension 5, against a real window: exit status is the verdict
printf 'find Save\nclick save\nsettle\nshot after.png\n' | ottermate --script -