Getting a run out of the app¶
henad-clicould already write a run to disk three ways, andhenad-appcould write nothing at all. A run you watched was a run you could not keep, so analysing one meant re-running the configuration headlessly. The app now has an Export panel writing the stat series, a recording, the final state, the viewport and the run's details, all through onerfdsave path that works on native and on the web. The CSV writer moved from the CLI binary down into henad-core, so the two front ends emit one format rather than two that drift. The issue named the obstacle itself: the chart history is a ring buffer, so an export off it would only ever return the tail of a run. It answers that twice, with an unlimited history setting and with a write-through recorder.
State before¶
crates/henad-cli/src/stats_export.rs held StatsWriter<W: Write> with fifteen tests: column planning from the first sample, .x/.y/.magnitude for a vector, one column per bucket plus .total for a histogram, CSV escaping.
It was private to the henad-cli binary, so nothing else could reach it.
write_state in main.rs formatted the final state inline and had no tests.
henad-app contained no file IO at all: no std::fs, no save, no export, no rfd.
Its only web_sys use was the canvas lookup in main.rs.
StatsHistory stored one f64 per series per sample, flattened through StatValue::scalar() at the push in lib.rs before the history ever saw the value.
A vector stat therefore reached it as a magnitude and a histogram as a total, both unrecoverable.
Its capacity was a plain usize with a 100..=1_000_000 slider, and the oldest samples were overwritten once it filled.
What was done¶
New files marked +, modified marked ~, moved marked →.
CHANGELOG.md ~ Unreleased/Added
Cargo.toml ~ rfd 0.17
docs/license.html ~ regenerated (rfd, dispatch2)
crates/henad-core/
└── src/
├── lib.rs ~ pub mod export
├── view.rs ~ StatsHistory: unlimited, full values, entries()
└── export/
├── mod.rs + re-exports
├── stats_csv.rs → from henad-cli, anyhow dropped, into_inner()
└── state.rs + write_grid, write_points, lifted off SimState
crates/henad-cli/
└── src/
├── main.rs ~ delegates both formats to henad_core::export
└── stats_export.rs → moved out
crates/henad-app/
├── Cargo.toml ~ rfd, serde_json
└── src/
├── lib.rs ~ recording, save and capture polling
├── state.rs ~ recording, save channel, capture, history_capacity
└── ui/
├── mod.rs ~ pub mod export
├── agent_layer.rs ~ AgentDraw::record, offscreen_draw
├── charts.rs ~ Unlimited history control
├── dock.rs ~ Tab::Export, layout, generalised tests
└── export/
├── mod.rs + the panel and Recording
├── image.rs + offscreen capture at the layers' resolution
├── save.rs + spawn_save, one arm per target
└── metadata.rs + run details as JSON
docs/
├── guide/app.md ~ Export tab section, Unlimited history
├── reference/cli.md ~ points at the app's twin
└── developing/agent-record/…-27-…md + this record
zensical.toml ~ nav entry
The CSV writer moved down.
CLI plus GUI is the two callers AGENTS.md asks for before anything is extracted.
henad-core has no dependencies, so anyhow was replaced by a StatsWriteError with exactly the two failure modes the writer has, an IO error and a shape change, carrying Display, Error and From<io::Error> so the CLI's ? still works through anyhow.
The W: Write bound stayed: the CLI keeps streaming into a BufWriter<File> and flat memory, the app builds a Vec<u8> for the dialog.
into_inner was added for the app, which owns its destination; finish is now a thin wrapper over it.
The final-state format was lifted off SimState.
The CLI reaches it through grid_view()/point_view(), the app through the snapshot's CpuLayers, so write_grid and write_points take slices and each caller adapts. The bytes are unchanged, and the format gained the two tests it never had.
StatsHistory keeps what a scalar cannot round-trip.
capacity became Option<usize>, None retaining everything; Vec::with_capacity only pre-allocates in the bounded arm, and the ring arithmetic moved into one buf_index helper the readers share.
A second store, full: Vec<Option<Vec<StatValue>>>, holds full values for the series that need them, decided on the first sample the way the CSV layout is.
StatValue is 56 bytes against an f64's 8, so storing everything that way would have cost seven times the memory for data the charts never read; only boids and gpu_boids have a non-scalar stat, and every other model pays nothing.
push became push_entries, taking &[StatEntry] so the history sees the value before it is flattened, and entries(j) rebuilds a sample in the shape StatsWriter takes.
The charts' get(col, j) read path is untouched.
Recording is the write-through twin.
It feeds a StatsWriter<Vec<u8>> from the snapshot poll, so its columns are whatever the model declares and its memory is the CSV text rather than a structured sample per tick.
It survives Offload and a rebuild: both now close a running recording off instead of dropping it, since freeing a simulation should not throw away captured rows.
One save path for both targets.
rfd 0.17 defaults to xdg-portal plus wayland rather than gtk3, so the Linux CI job needs no new packages, and its wasm backend turns save_file into a browser download.
ui/export/save.rs follows painted.rs's idiom, one same-signature spawn per cfg: a thread plus pollster on native, spawn_local on the web.
Outcomes return over a flume channel drained each frame.
An export failure reports into the panel, never through report_fault, which calls offload_simulation and would tear the simulation down over a failed file write.
The viewport capture draws again rather than photographing the panel.
The first attempt was ViewportCommand::Screenshot cropped to the viewport rect, which is implemented for wgpu on both targets and is wrong: the panel fits the layers to whatever rect it has, so a 1024 cell grid squeezed into an 800 point panel came back as moire.
ui/export/image.rs renders the layers into an offscreen target sized from the data, through the same pipelines the panel uses. A CPU grid is written straight in with write_texture, since it has no pipeline of its own; a GPU display runs its existing fullscreen-triangle pass; the population is drawn over the top. AgentDraw::record came out of CallbackTrait::paint so one body serves egui's pass and this one.
Resolution comes from the layers. One pixel per cell for a grid. A model with agents grows its field by a whole number of times until the long side reaches MIN_AGENT_DIM, because a field is coarse next to the population over it and rounding 50 000 ants into a 201 pixel image loses every sub-cell position. Whole numbers keep each cell a square block under the nearest sampling both display paths already use. Sprites are three pixels, as on screen.
Readback follows CounterReadback: map_async right after submission, polled every frame, never blocking, since the web cannot block on the main thread. Rows are padded to COPY_BYTES_PER_ROW_ALIGNMENT and the surface's BGRA order is swapped back on the way out.
Four things came back out again.
The panel wrapped itself in a ScrollArea, which egui_dock already does for a tab's contents, so a tab that overflows scrolls twice.
Its five headings went through a heading(ui, icon, text) wrapper for an icon each; both went, and the headings are a bare ui.strong.
image_name was a named call to file_name(app, "viewport", "png") with one caller.
Recording::is_running was written and never called.
A sweep for more of the same found to_rgba8 and from_rgba8: two functions with identical bodies, named for the direction each was used in, over an is_bgra and a swap_rb. Swapping red and blue is its own inverse, so the four are one match_channel_order.
make_target, write_grid and draw_layers have one caller each and stayed. They name the three phases of a submission and keep start under clippy::too_many_lines, which is a different thing from wrapping a call in a name.
State after¶
./check.sh is green, the web build included, which is where the rfd dependency was most at risk.
docs/license.html was regenerated: rfd and dispatch2, both MIT, both already on deny.toml's allow list.
Twelve new tests.
Seven on StatsHistory: bounded and unlimited retention, a vector series keeping its components while the chart still reads a magnitude, a scalar series rebuilding from its own column, full values wrapping in step with their scalars, resize in both directions, and a vector series costing more heap than a scalar one.
Two on the state format.
Two on the seam that matters, under export::stats_csv::tests::parity: the same samples pushed into a recording and into a history must render the same CSV, and a wrapped history must render the tail of the recording with an identical header.
ui/dock.rs's layout tests were generalised rather than renumbered — a STACKED const now names the pairs the default layout stacks on purpose, so the next tab added fails the test for a reason worth reading.
Driven through the live app over the egui inspection port: the panel renders, the sample count and tick span track the run, and every export builds its bytes and opens a dialog with the right filename.
The capture was checked on one model of each shape, reading the resolution the panel reports and then taking it:
- SIR, a CPU grid with nothing over it, 1024 x 1024, one pixel per cell,
- boids, a CPU population with no field, 1000 x 1000, one pixel per world unit,
- ants, a CPU population over a field, 1005 x 1005, its 201 cell field five pixels to a cell,
- ant foraging on the GPU, 1000 x 1000 from a 200 cell field, with Export state correctly refused,
- Game of Life on the GPU, 1024 x 1024, no growth since it carries no agents.
Issues found & future directions¶
- The save dialog itself is unverified, and cannot be verified from here.
A
cargo runbinary has no bundle identifier, so computer-use cannot reach a nativeNSSavePanel, and the dialogs opened during testing were never completed. Everything up to the dialog is covered — the bytes by tests, the dispatch and the resolution by the panel's own readout — butrfd's write call has not been watched to land a file, so no exported PNG has been looked at. A bundled.appwould be drivable. - A save panel can open behind the app window.
That is what the four unactioned dialogs turned out to be, and the status line read
Saving …throughout, which looks like a stall rather than something waiting on the user. The wording is nowChoose where to save …. A modal-style hint in the panel would be better still. - Only one finished recording is held at a time. Stopping a second one replaces the first, unsaved, without warning.
- The Export panel is tall, and where it sits decides whether that shows.
It went behind Pacing in the bottom-left first, where five sections did not fit and the buttons clipped until the panel was enlarged.
Behind Charts it has the height and the width for all five, and
egui_dock's own scroll covers a window small enough to lose them again. MIN_AGENT_DIMis a number, not a decision the user makes. A thousand pixels suits the four agent models in the tree and nothing else was measured against it. A resolution field in the panel was the alternative, and is what a user wanting a poster or a thumbnail would reach for.- A GPU model cannot export its final state.
GpuSimStatehas a stats readback and no buffer readback, so the button is disabled with a reason.henad-cli --exportis CPU-only for the same reason, so this is parity rather than a gap, and one readback path would fix both. - The app still passes no seed.
state.rshandsNoneto the factory, so a run uses the model's fixed default. That is reproducible, which is why the run-details JSON is worth anything, but a settable seed is what would make that file round-trip into a CLI invocation. serdeis declared in henad-app and used by nothing.serde_jsonis now a real dependency,serdeis still the leftover fromeframe_templateit always was.
Manual notes (human)¶
- Started this session independently
- Edited and rewrote the majority of GUI generated
- Reverted unnecessary refactors
- Corrected LLM understanding of
egui_dock - Rewrote relavent documentation
- Redirected LLM 5 times in other scope issues