NFTY
A deterministic generator and pixel editor for layered asset collections. Grayscale value-maps colorized at generation time, with OpenSea metadata built in.
git clone https://github.com/corderro-artz/nfty.git仕様Specification
構成Composition
開発Project
目次Contents
文書Document
A deterministic generator and pixel editor for layered asset collections.
Most layered generators composite fixed images from an indexed stack. nfty puts a grayscale colorizer at the core instead: a single value-map is cast to any color by rolling a hue and a saturation over it, with every pixel's own lightness preserved. Those layers are ordered and flattened into one image — the shape its namesake, the web3 NFT collection, is built from — so a handful of drawings becomes thousands of distinct assets. A pixel editor is built into the application, so the art and the collection it belongs to are authored in one environment rather than two. The end result is an expansive, reusable asset catalog, or a finished drop with OpenSea metadata written beside every image.
None of it is approximate. The same book and the same seed produce byte-identical output on any machine, in any locale, on any CPU architecture, and the program counts the exact number of distinct assets a book admits before you ask it for any. The demo built into the binary admits 615,600 of them, out of eighteen 32×32 sprites.
Access
Delivery
Runtime
Using the app rather than working on it? Read the User Manual instead — this file is for developers. Its source is in
docs/manual/.
Overview
nfty is two front-ends over one engine. Nfty.Core holds the whole of it — the archive formats, the
colorizer, the roller, the rules engine, the rarity math and the text reports — and carries no UI or
CLI dependency, so the command line and the Avalonia desktop app produce identical bytes from
identical inputs. A collection is authored as a small tree of ZIP archives, generated against a
string seed, and published as images plus two parallel sets of per-asset metadata.
Capabilities
| Capability | Details |
|---|---|
| Value-map colorization | One grayscale drawing rolls a hue and saturation per asset; lightness is the art's, color is the collection's |
| Three layer kinds | Dynamic (rolled color), Static (one fixed color, no RNG), Custom (full-color RGBA, composited as-is) |
| Counted output space | UniqueSpace reports the exact number of distinct assets a book admits, and says which direction it is bounded in |
| Deterministic runs | SplitMix64 from a string seed; identical output across locales, machines and CPU architectures |
| Optional layers | A per-recipe chance that a layer is left out entirely, added without changing any existing seed's output |
| Incompatibility rules | Exclude and require constraints between named variants, enforced by re-rolling inside a bounded retry budget |
| Built-in pixel editor | Brush, eraser, shapes, line, flood fill, select-and-move, undo/redo — over a value-map or a color raster |
| Dual metadata | Standards-pure OpenSea metadata/NNNN.json plus a rich nfty/NNNN.json with DNA, seed, rarity and per-layer color |
| Export and sealing | Four independent content axes, a plan you read before it writes, and AES-256-GCM sealing for a view-only handoff |
| Extend | Re-open a cooked Set, add to it, and recompute rarity across the whole collection |
| Open formats | Every archive is a ZIP with a manifest.json; the custom extension is a renamed .zip |
Requirements
| Requirement | Version | Notes |
|---|---|---|
| .NET SDK | 10.0 | Pinned by global.json; needed to build, not to run a release |
| .NET desktop runtime | 10.0 | Only for the two framework-dependent download shapes |
| OS | Windows, macOS, Linux | The desktop head is Avalonia; the CLI is platform-agnostic |
| Python | 3.10+ | Optional — the manual, the demo generator and the icon build are Python tools |
Dependencies
| Category | Packages |
|---|---|
| Imaging | SixLabors.ImageSharp — pinned to 3.1.11 on purpose; 4.0.0 requires a build-time license key |
| CLI | System.CommandLine |
| UI | Avalonia, Avalonia.Desktop, Avalonia.Themes.Fluent, Avalonia.Skia, Avalonia.HarfBuzz, CommunityToolkit.Mvvm |
| Composition | Microsoft.Extensions.DependencyInjection |
| Tests | xunit.v3, Avalonia.Headless.XUnit, coverlet.collector |
Package versions live in Directory.Packages.props and never in a
.csproj.
Installation
Prebuilt Windows builds are on the releases page. Every download carries both front-ends, the demo CookBook, and the licenses.
| Shape | Size | .NET 10 needed | For |
|---|---|---|---|
| Portable | 84 MB | No | Unzip anywhere and run. Start here if you are unsure. |
| Single file | 76 MB | No | One .exe. Unpacks itself on first launch, so that launch is slower. |
| Single file, .NET | 14 MB | Yes | One .exe with the runtime left out. |
| Framework-dependent | 14 MB | Yes | The same, as a folder. |
nfty keeps its settings in a .nfty folder beside the executable, so a copy carries its own
recent-files list and palette and writes nothing elsewhere. Move or delete the folder and nothing is
left behind.
To build from source:
git clone https://github.com/corderro-artz/nfty.git
cd nfty
dotnet build nfty.slnNote: Native AOT is deliberately unsupported. It links, but the resulting app cannot resolve its own views (
ViewLocatorbuilds a type name at runtime) or read a.cbk(System.Text.Jsonreflection). A release must not contain a binary that starts and then does nothing.
Quick Start
A first collection, end to end, against the demo CookBook built into the program — no file to find and no archive to download:
dotnet run --project src/Nfty.Cli -- demo . # writes ChestDemo.cbk here
dotnet run --project src/Nfty.Cli -- inspect ChestDemo.cbk # what is in it
dotnet run --project src/Nfty.Cli -- validate ChestDemo.cbk # is it sound
dotnet run --project src/Nfty.Cli -- stats ChestDemo.cbk # what odds its weights imply
dotnet run --project src/Nfty.Cli -- generate ChestDemo.cbk --count 8 --seed hello --out ./outThe desktop app is the same engine with a UI on top:
dotnet run --project src/Nfty.DesktopIts Landing screen carries Open the demo CookBook, which unpacks that same book beside the app and opens it. The demo is yours to break, and reopening it keeps whatever you did to it.
The demo is a small collection of layered chests: two Recipes, seven Ingredients covering all three layer kinds, two optional layers, one incompatibility rule, and 615,600 distinct assets out of eighteen 32×32 sprites — which is the argument for value-map colorization, made in a file you can open.
Concepts
The Six Words
The domain is a cooking metaphor, and the words are the model — the file extensions follow them.
| Term | File | What it is |
|---|---|---|
| CookBook | .cbk |
The top-level container: canvas size, collection metadata, weighted Recipes |
| Recipe | .rcp |
A complete template for one character type — an ordered layer stack plus its incompatibility rules |
| Ingredient | .igt |
One layer, or trait-category, with its weighted variant images |
| Variant | — | A single image with a weight and a name, held inside an Ingredient |
| Set | .set |
The generated output: images, per-item metadata, rarity, and the seed that made it |
| Kitchen | .ktn |
A workspace folder of loose parts; its contents are discovered by scanning, never recorded |
Generating a CookBook rolls a Recipe per asset, so one book yields a mixed collection. .cbk nests
.rcp nests .igt, so the archive layers mirror the domain layers.
Layer Kinds
| Kind | Source art | At generation time |
|---|---|---|
| Dynamic | Grayscale value-map | Rolls a hue and saturation per asset from a weighted range |
| Static | Grayscale value-map | Applies one fixed color, deterministically, consuming no RNG |
| Custom | Full-color RGBA | Composited exactly as-is; never recolored |
Dynamic and static take their value from the grayscale map and only their hue and saturation from
the color, which is what multiplies the output space. Custom carries no colorization at all, and
Validator refuses one that does.
Depth
A Recipe's layer stack is ordered bottom-to-top, and that order is the paint order — depth 1
paints first and sits furthest back. Because it is a list rather than a stored z, two layers can
never share a depth.
nfty move ingredient cat.rcp --id shades --to 3…or drag a row in the desktop app. Reordering produces a different collection, not a re-render: the generator consumes one roll per layer in order, so moving a layer moves which roll reaches it.
Two Ways to Mint
Rejecting duplicates is the default, and it is a promise: no two assets in a Set are the same, and a run that cannot fill its count from the unique space fails and states the real maximum.
It is not the only way. Allow repeats in the Cook dialog, or --unlimited on the command line,
keeps every roll — so any count is producible from any book, and identity is the token number as
ERC-721 defines it. The rules are still enforced and the weights still decide rarity: a low-weight
variant stays uncommon, a layer with a low appearance chance is rarer still, and the rarest asset in
the drop is the low-weight variant of a low-chance layer.
The two modes are not interchangeable for reproducibility. The same book and seed give a different
collection under each, because rejecting a duplicate spends a roll the other mode does not — which is
why set.json records uniqueDna alongside the seed.
Architecture
nfty/
├── src/
│ ├── Nfty.Core/ The engine — no UI or CLI dependency (net10.0)
│ │ ├── Model/ Immutable domain records
│ │ ├── Formats/ ZIP + manifest IO, Validator
│ │ ├── Imaging/ Conversion, colorization, compositing, preview
│ │ ├── Generation/ RNG, rollers, DNA, rules engine, orchestrator
│ │ ├── Editing/ Layer depth, region edits, image import
│ │ ├── Output/ Set writer, dual metadata, extend loader
│ │ ├── Publish/ Export planning and AES-256-GCM sealing
│ │ └── Stats/ Rarity, and the reports both front-ends print
│ ├── Nfty.Cli/ System.CommandLine wiring (net10.0)
│ ├── Nfty.App/ Avalonia GUI — ViewModels, Views, Themes
│ └── Nfty.Desktop/ Desktop head — window, clipboard, pickers
├── tests/ 2,291 tests across three xunit.v3 projects
│ └── fixtures/ Archives an older build wrote, and still reads
├── docs/
│ ├── manual/ The end-user manual (Material for MkDocs)
│ ├── design/archive/ A dated visual record — history, not spec
│ └── superpowers/ Design specs, newest first
├── tools/ Demo, icons, docs capture, release build
└── nfty.sln
Generation Pipeline
Source: docs/diagrams/generation-pipeline.mmd
Both rejections re-enter at the recipe roll and share one MaxRerollsPerAsset budget per asset. Spending it throws RuleConflictException or UniqueSpaceExhaustedException, depending on the cause. Dedup runs before the render, so a collision never costs a composited canvas — and it is the step Allow repeats turns off, which is why that mode can fill any count from any book.
The DNA is a SHA-256 over the recipe id, each layer's variant id, and the quantized color of
each colorized layer. Quantizing folds a continuous color space into something countable, which is
what lets stats tell you how many unique assets a book can produce before you try to mint them.
Design Principles
- The engine is the product; the front-ends are views of it. Anything a user reads — a report, a
DNA-space figure, an export summary — is rendered in
Nfty.Coreso the CLI and the GUI cannot disagree about it. - Determinism is a contract, not a side effect. Every sort that reaches an output file uses
StringComparer.Ordinal; the seed hash is read little-endian rather than native-endian; anything that sums stays sequential, because floating-point addition is not associative. - Roll sequentially, render in parallel. The Nth asset's decisions depend on every decision
before it, so rolling can never be parallel. Rendering is a pure function of a decided roll, so it
parallelizes freely — and
GenerateStreamingis the sequential oracle the parallel path is tested against at 1, 2, 3 and 8 threads. - A count says which direction it is bounded in.
Exact,AtLeast,AtMost,Unknown— a product built on an under-count bounds the truth in neither direction, and aboolcould not say so. - Nothing resamples. Every image is pixel art shown at a size unrelated to its pixel count, so the whole stack scales with nearest neighbor and import refuses a wrong-sized picture rather than scaling it.
- Callers own images.
Nfty.Corehands back liveImage<Rgba32>;LoadedCookBookandGeneratedSetareIDisposableand free the whole tree.
Command Line
dotnet run --project src/Nfty.Cli -- --help| Command | What it does |
|---|---|
demo <dir> |
Write out the built-in demo CookBook — embedded, so it works on a copy with nothing beside it |
inspect <file> |
Print the tree of a .cbk / .rcp / .igt / .set / .tin, or list a .ktn. --voxel adds a partial-alpha report |
validate <cbk> |
Report every problem found, not just the first |
stats <cbk> |
The odds the weights imply, per Recipe and per Variant |
preview <file> |
Render a PNG exactly as generation would — one variant, or a whole rolled stack |
generate <cbk> |
Generate a Set |
extend <cbk> <set> |
Grow an existing Set, recomputing rarity across all of it |
export <set> |
Publish a cooked Set: choose what goes with it, and in what shape |
new ingredient|recipe|cookbook|kitchen |
Build an archive from a manifest and its parts |
add variant|ingredient|recipe|rule |
Append to an existing archive |
move ingredient |
Reorder a Recipe's layers |
remove rule |
Remove one incompatibility rule, addressed by its 1-based position |
set chance |
Set how often a layer is left out of an asset entirely |
Errors surface as a message, never a stack trace — add --verbose for the trace.
Lining Art Up
Layered art only works if the pieces register against each other, so both front-ends can composite a layer against the ones it will actually sit between:
nfty preview cat.rcp --seed alpha # the whole stack, one deterministic roll
nfty preview cat.rcp --seed alpha --only body,shades # just those layers, at their real depthsSealing
export --seal is AES-256-GCM over PBKDF2-SHA256 at 600,000 iterations, and the view-only mark is
HMAC-bound to a second key derived from the same passphrase, so a third party cannot strip it. A
sealed export takes its own extension (.tin) rather than being a .set whose bytes are ciphertext,
because a recipient double-clicking it has to be told what it is before opening it.
There is deliberately no --passphrase flag anywhere — argv is visible to every process, lands in
shell history and is captured by CI logs. --key names a source instead: env:NAME, file:PATH,
stdin or prompt, and an unprefixed value is an error rather than a guess.
Note: Against the person holding the passphrase, the view-only mark buys a refusal in the product and nothing more — the format is open source. It is a lock on a door, not a wall.
Desktop Application
The same Nfty.Core engine behind an Avalonia UI: an Explorer over the open CookBook, a per-type
detail pane, a Set browser over cooked output, and an Ingredient Editor with a full paint stack.
- Two paint modes. Grayscale edits the value-map; color mode paints RGBA and saves as a Custom ingredient. Each mode carries its own saved palette, and they swap when the mode does.
- The opacity lock is on by default and keeps every painted pixel fully opaque or fully erased, because partial alpha does not voxelize cleanly.
- The reference panel composites sibling layers, or loose
.igtfiles from the open Kitchen, under and over the art while you draw. - A gesture previews the pixels it will commit, not an outline standing in for them — the shape tools fill, and a line commits at the brush's size in the brush's ink.
- Pixel art scales with nearest neighbor, everywhere. Nothing in the app or the engine blurs, resamples or anti-aliases an author's pixels.
Performance
Measured inside the real app with Nfty.Core/Diagnostics/Perf.cs
— named scopes with wall time and managed allocation, free when off, enabled by NFTY_PERF=1. It
measures whole user-facing operations rather than microbenchmarks, which is why it is not
BenchmarkDotNet.
| Operation | Before | After | What changed |
|---|---|---|---|
| Generate 200 assets @ 512px | 3,020 ms | 662 ms | Rendering parallelized; rolling stays sequential |
| Write 200 assets @ 512px | 2,078 ms | 454 ms | Per-asset writes parallelized; rarity finished first |
| Set browser resize (8 steps) | 1,213 ms / 90 MB | 342 ms / 24 MB | Row chunking caches its last answer |
Thumbnail decode scales with the canvas the author chose — ~0.5 ms from a 64px source, 7.1 ms from a 1000px one — so a screenful of forty large tiles was ~280 ms of frozen UI per scroll. It decodes off the UI thread now. A ZIP archive is not thread-safe, so archive extraction stays sequential and only the decode runs wide, and anything that sums stays sequential too: a different addition order is a different number, and rarity and the DNA-space count are both promises.
Development
dotnet build nfty.sln # build everything
dotnet test nfty.sln # run all tests
dotnet run --project src/Nfty.Cli -- --help
dotnet run --project src/Nfty.DesktopTesting
dotnet test tests/Nfty.Core.Tests # one project
dotnet test --filter FullyQualifiedName~DnaTests # one class
dotnet test --filter FullyQualifiedName~DnaTests.Same_selection_same_dnaTests are named Snake_case_sentences, which is what --filter matches. Fixtures are built in
memory from tiny synthetic images; there are no golden-image files, and every image assertion
reads a pixel. tests/fixtures/ is the one exception and the only place real archives are read from
disk — their value is that an older build wrote them and they still read, so they are never
regenerated to make a failing test pass.
Visual Verification
Visual work is verified from a rendered frame, never from the markup:
NFTY_CAPTURE=1 NFTY_CAPTURE_DIR=./frames dotnet test tests/Nfty.App.Tests \
--filter FullyQualifiedName~VisualCapture…then look at the PNGs. Nearly every GUI defect this project has fixed was found that way and would
have been missed by reading code. Three companion sweeps catch what a frame cannot:
DarkModeContrastTests scores every text run against the surface it lands on in both themes,
ThemeResourceTests proves every DynamicResource the markup names resolves in both, and
MinimumWindowFitTests drives every page at the smallest window the app allows.
House Rules
- The build is the lint.
TreatWarningsAsErrorsandGenerateDocumentationFileare on, so a warning or an undocumented public member fails the build. Zero warnings, always. - Generated files are committed, and the generator is the source.
Themes/Icons.axamlcomes fromassets/icons/*.svgviapython tools/icons/build.py; the demo.cbkcomes fromtools/demo/. Edit the generator, re-run it, commit both — a test fails if the pair disagrees. - Token brushes only in views. No raw hex outside
Themes/Tokens.axaml, and a new color goes in both theme dictionaries. - Reserve the space, toggle the ink. A control appearing or disappearing must not move, resize or reflow anything around it.
- Never edit anything in
docs/design/archive/. It is a dated record; the app has moved past it by design, and a difference is not a defect.
Deployment
python tools/release/build.py <version> win-x64 # writes four archives to .release/
gh release create v<version> .release/*.zip- Version:
<Version>inDirectory.Build.props, stated once and carried by every assembly in the solution. - CI:
ci.yml— builds and tests on Windows, audits the dependency graph for advisories separately, and re-runs the engine and CLI on Ubuntu, macOS (arm64) and Windows, because determinism is promised across architectures. - Manual:
docs.ymlpublishesdocs/manual/to GitHub Pages. The site is built there rather than committed. - Releases: GitHub Releases — each carries both front-ends, the demo CookBook, the MIT license and the SIL Open Font License.
Troubleshooting
Note: ImageSharp must stay at 3.1.11. Version 4.0.0 requires a build-time license key (
sixlabors.lic), which is account-specific and is not in this repository.
Note: The manual needs its own toolchain:
pip install -r docs/requirements.txt, thenmkdocs serve.docs_dirisdocs/manual, notdocs/, so the archived mockups and design specs stay out of the published site.
Note: GUI tests render through Avalonia's headless backend with Skia, so they run on Windows in CI. The engine and CLI carry the determinism guarantees and are the ones re-run cross-platform.
Note: Sealing needs
AesGcm, which is a platform capability rather than a language feature.Seal.IsSupportedanswers for the current machine.
Links
| Resource | Link |
|---|---|
| Repository | github.com/corderro-artz/nfty |
| User manual | vaporsoft.dev/nfty |
| Latest release | GitHub releases |
| Developer briefing | CLAUDE.md · AGENTS.md |
| Deferred work | BACKLOG.md |
| Design specs | docs/superpowers/ |
| CI workflow | .github/workflows/ci.yml |
| Docs workflow | .github/workflows/docs.yml |
| Actions | GitHub Actions |
| Issues | GitHub issues |
| Pull requests | GitHub pull requests |
| License | LICENSE |
| Vaporsoft | vaporsoft.dev |
Contributing
Read CLAUDE.md first. It is the real briefing — the domain model, the file formats, and the invariants that are load-bearing but not obvious from the code. Several of its rules are the kind you would otherwise only learn by breaking something. AGENTS.md is the short version.
- Create a branch from
mainfor the change. - Write tests in the existing style — seeded distribution tests for rollers, exact-pixel tests for imaging, round-trip tests for archives.
- Run
dotnet build nfty.slnanddotnet test nfty.slnbefore opening a pull request; both must be clean, and a warning is a failure. - For any visual change, capture frames and look at them. Attach one if it helps the review.
- Open a pull request with enough context to review the domain, the invariants and the visual impact.
License
MIT. The interface is set in IBM Plex, bundled with the app under the SIL Open Font License — that license travels with any redistribution of the binaries, which is why every release archive carries a copy.
Copyright © 2026 Corderro Artz / Vaporsoft.
版Published
v1.7.1
16 Sep 2026The export card scrolled on every tab, at every window size the app allows.
2,286 tests, 0 failures.
What was wrong
Opening Export and switching to IMAGES put the whole watermark group below the fold, cut
"Stamp each asset's set number in a corner" through the middle of its letters at the
scroller edge, and left the corner picker — the control that is supposed to say what it
does by its shape — drawn as four unexplained specks.
None of it was visible in review, because the card was being photographed **780 pixels
tall. A modal in this app is never given more than 526**. So every frame of it was a
picture of a card no window hosts, and it looked tidy in all of them.
The one assertion about its height checked that the card clamps to the page it is in —
which the framework does on its own. Nothing asked whether the contents fit inside it.
What changed
Measured, the three pages want 246, 251 and 213 pixels. The shared floor said 262 and
credited that to CONTENTS; the tallest page is IMAGES.
- The head was three rows for one idea — a
• EXPORTkicker above a title reading
"Export this Set" above the tabs, 98px of a 470px card. The title and the tabs share one
row now, which is how the Set browser's own header already reads. 98 → 40.
- The manifest's contents are a run, not a column. Five short phrases down five lines
spent about 80px of a pinned block on fifteen words.
- The manifest is reserved at its tallest. The pages already shared one height so they
could not move it; nothing stopped it moving *them*. It measured 113, 145 and 130 as a
spritesheet line and a debug caveat came and went, and the card is centered — so
switching tabs grew the card and shifted every row in it.
All three tabs are now identical, the card fits the smallest window with 30px to spare,
and nothing scrolls.
The corner picker
Unarmed, it was disabled, and the app dims a disabled control to 38% — which is legible
over strong ink and invisible over a hairline and a near-black ground. Restoring the
opacity brightened the dots and still drew no cells: the edge is painted by a part of the
button template whose brush Fluent replaces in its own disabled state, so it has to be
asked for again. The cells keep their strength now and the mark carries the state.
Every capture of this card had both boxes already ticked, so no frame had ever drawn it.
Downloads
| Size | .NET 10 needed | |
|---|---|---|
| Portable | 84 MB | no — unzip anywhere and run. Start here if you are unsure. |
| Single file | 77 MB | no — one .exe. Unpacks itself on first run, so that launch is slower. |
| Single file, .NET | 14 MB | yes — one .exe, runtime left out. |
| Framework-dependent | 14 MB | yes — the same, as a folder. |
The two .NET builds need the [.NET 10 desktop
runtime](https://dotnet.microsoft.com/download/dotnet/10.0); the other two carry everything.
v1.7.0
16 Sep 2026Spritesheets, a debug number on every sprite, and a DNA space that stopped fitting in a long.
2,286 tests, 0 failures.
Two tags in one release: v1.6.0 is the large-integer audit, v1.7.0 the features on top of it.
Export as spritesheet
Every asset stitched into one image, left to right then top to bottom **in the Set's own
numbering** — so cell *n* is asset *n+1*, and an engine indexing frames agrees with a reader
counting rows without anything being recorded. You give it columns and rows; both boxes open
already filled with the squarest grid that holds the collection, and the pixel size sits beside
them so the two numbers explain themselves.
A short last row leaves transparent cells, because that is what a spritesheet looks like and
closing the gap would renumber every frame after it. A missing asset leaves a hole rather than
failing the sheet — a gap among drawn cells is unmistakable, which is the point.
There is a real ceiling, and it refuses with the numbers rather than dying: a sheet is one
contiguous raster, so 500 assets at 1000px in a 23×23 grid is 529 megapixels and about 2 GB of
RGBA. A refusal an author can act on beats an OutOfMemoryException half a minute in.
A set number stamped on every sprite
Loose sprites lose their filenames the moment they are dragged into an engine or a chat window,
and a folder of five hundred near-identical characters is unsortable. Tick the box, pick a corner,
and every exported asset carries its set number — so a loose sprite cross-references
nfty/NNNN.json with nothing else written down.
The digits are a 3×5 bitmap font, not a typeface. A real font would mean a new dependency
beside an ImageSharp pinned on purpose, and would put grey anti-aliased ink on art that has no
grey anywhere in it. White over a one-pixel black halo, so it reads on any ground; it declines to
draw rather than clip, because a "12" cropped to "1" is a wrong answer where a missing stamp is
merely a missing one.
It never touches your Set. The export renders a stamped copy and ships that. Stamping runs
before stitching, so the sheet carries the same numbers its sprites do.
Progress that means something
Opening a Set ran on the UI thread. That unpacks a .set into a temporary directory and reads a
file per asset, so a real collection froze the window for seconds with nothing on screen saying
why. The async path had always existed — nothing was calling it.
There is one small card for every job that has no screen of its own, and the export's bar is a
real fraction now instead of spinning whatever it was doing: a barber pole for minutes is
indistinguishable from a hang. Indeterminate survives only where the work genuinely cannot count.
The export card is three pages — CONTENTS, IMAGES, SEAL — with the manifest and the footer
outside the tabs, because what is going and what is stopping it are the two things you should
never have to go looking for. It also stopped stretching, which was costing about 230px of empty
panel on every page.
The DNA space outgrew a long
Six layers of ten variants at a 10°/10% quantize is about 2.2e21 distinct assets, against a
long's 9.22e18 — so that book's headline figure read more than 9,223,372,036,854,775,807: a
constant, printed where an author is trying to read a count. The totals are BigInteger now and
the ceiling is gone rather than raised again, along with the saturating arithmetic that
existed to keep a number inside a type too small to hold it.
The figure prints in full whenever it fits, measured against the cell at the window you
actually have — so a maximised window shows what the smallest one abbreviates — and the exact
digits are always on the tooltip. Past the named magnitudes it becomes an exponent, because there
is no name above "quintillion" that a reader knows.
Seven smaller things went with it, each probed by reverting the fix:
- A
NaNcolorization range validated clean. Every check inCheckRangeis false forNaN,
so the inverted-range test and both axis tests all waved it through.
Describeclaimed "at most 1 in a trillion" on a bounded figure, which is a tighter bound
than the arithmetic supports. That branch had only ever been tested unbounded.
- A target over an
AtLeastspace warned that the supply would not fit. That total is a
floor, so it proves nothing — and it is exactly the count a big book produces.
- A Set past ten thousand assets loaded in the wrong order: the reader sorted by filename, and
the stem pads to four, so 10000.json sorts before 9999.json.
- A non-positive enumeration budget was laundered into an answer instead of refused.
- One unchecked multiply in the odds walk, safe only at the default budget.
- Rare traits all printed
0%— the same string a trait nothing carries gets — and sorted
arbitrarily, because a data source was rounding for display. Two of the three surfaces that
print a share were also culture-sensitive, under comments promising otherwise.
Downloads
| Size | .NET 10 needed | |
|---|---|---|
| Portable | 84 MB | no — unzip anywhere and run. Start here if you are unsure. |
| Single file | 76 MB | no — one .exe. Unpacks itself on first run, so that launch is slower. |
| Single file, .NET | 14 MB | yes — one .exe, runtime left out. |
| Framework-dependent | 14 MB | yes — the same, as a folder. |
The two .NET builds need the [.NET 10 desktop
runtime](https://dotnet.microsoft.com/download/dotnet/10.0); the other two carry everything.
v1.5.1
15 Sep 2026One mark instead of two, and a reorder that waits for its own write.
2,220 tests, 0 failures.
The brand mark is the application icon's own symbol
The product wore two marks and nothing in the code said they were meant to agree: a
lowercase "n" turned 45° in the titlebar, and a plain rotated square on the quick-reference
sheet — which is what the titlebar's own comment says it had replaced. The sheet's had
already drifted.
A letter rotated into a diamond spells the name and says nothing about what the app makes.
The mark is a stack of layers with the top one live, which is what an asset *is* here —
drawn once in Views/BrandMarkView.axaml and used by both. Its ink is AccentBrush over
FgBrush, so light and dark are one drawing rather than two, exactly the way the icon set
already works.
It carries two layers where the 256px card carries three, and the shipped 64px favicon
makes the same cut: a third row at 24px is a smear rather than a layer.
tools/icons/make-app-icon.py redraws nfty.ico from the theme's own token values rather
than exporting a copy, and keeps its per-size tuning — the small sizes now vary *how many
layers* rather than a letter's contrast. The .ico shows the symbol on the app's washed
tile rather than the card, because the wordmark is unreadable below 64 and a near-black
ground vanishes into a dark taskbar, which is the size and the place this is seen most.
TextBlock.brandmark is deleted with the glyph it described. The README's second badge
points at the brand art in the site repo, closing the backlog entry that asked for it.
A locked file was the disguise on a real race
ExplorerTreeReorderTests' drop test failed about four runs in six with an IOException
out of Cleanup — the temp directory deleted while the persist still held
book.cbk.<guid>.tmp open. That reads as a file-locking nuisance and is nothing of the kind.
A teardown that throws replaces the assertion failure underneath it. The drop handler is
async void, so MouseUp returns the instant PersistAsync reaches its first await — the
assertion on the moved stack was failing too, and the finally's exception took its place.
Dispatcher.UIThread.RunJobs() drains what has been posted and returns; it is not a wait,
which is why this tracked disk speed and passed nine times running on a fast machine.
Two fixes, each probed by breaking it:
- Every reorder shares one in-flight flag. The tree's drag and its reentrant
Alt+Up
chord arrived through MoveNodeToAsync carrying no guard at all — the same door
MoveLayerAsync was already guarded for, reopened one screen over. They all write the
whole book back to one archive, so two in flight collide whichever gesture started them.
One flag, not one per gesture: a second would only make each door safe against itself.
Refused rather than queued — a queued move is computed against a stack the reader can no
longer see.
ExplorerView.PendingReorderis the task the gesture started, which is the one
observable trace an async void handler leaves. The gesture tests await it instead of
pumping once or polling on a timer; a sleep-until-it-looks-right loop passes for the wrong
reason on a fast disk.
The guard is pinned by re-entering from inside ICookBookSession.Replace — the one moment a
persist is *provably* mid-flight — because starting a second gesture from the test body and
hoping it overlaps proves nothing on the machine where it does not.
Downloads
| Size | .NET 10 needed | |
|---|---|---|
| Portable | 84 MB | no — unzip anywhere and run. Start here if you are unsure. |
| Single file | 76 MB | no — one .exe. Unpacks itself on first run, so that launch is slower. |
| Single file, .NET | 14 MB | yes — one .exe, runtime left out. |
| Framework-dependent | 14 MB | yes — the same, as a folder. |
The two .NET builds need the [.NET 10 desktop
runtime](https://dotnet.microsoft.com/download/dotnet/10.0); the other two carry everything.
v1.4.0
15 Sep 2026Two features and a fix to the harness. 2,219 tests, 0 failures.
The combined chance is exact now, when the CookBook is at hand
The Set browser's this combo 1 in N was the product of the rarity rows above it, and
that is an estimate twice over: it assumes the layers roll independently, and it is
built from the shares this *Set* happened to produce rather than from the weights the
book asked for.
CookBookLocator already finds a Set's book by hash — so a folder of a dozen books
produces no answer rather than a wrong one — and SelectionOdds (in Core, so the CLI
and the GUI cannot disagree) prices an asset from it. Generation is rejection sampling,
so the correction is the whole feature:
```
w_r/W × P(layers)
P = ------------------------------------------------
SUM over recipes of w/W × P(it rolls legal)
```
The denominator sums over the whole book, not one recipe, because a rule violation
throws the recipe roll away too. With no rules anywhere it is exactly 1 and the figure
reduces to the product — which is the right relationship between the two: the estimate
is this model with its correction dropped.
On the test book, whose background rolls 70/30 and whose aura rolls 60/40 with a rule
excluding one pair, the surviving combinations are worth 47.73%, 31.82% and
20.45%. Multiplying the Set's own twelve-asset shares says 48.61%.
Giving up costs a *direction*, not the answer: every legal mass is at most 1, so a book
too large to walk reports the numerator as a true floor — *at most 1 in N* — rather than
a guess. And it reads the book as manifests alone (ArchivePeek.CookBookTree),
because a probability is a few dozen weights and a full read would pull every variant
PNG in the collection into memory to reach them.
Without a book beside the Set nothing changes except honesty: the figure carries a ~
and its tooltip says what it assumed.
The canvas zooms and pans
An 8px sprite in the editor's tile is forty device pixels to the art pixel. A 512px
drawing in the same tile is under two thirds of one — finer than the backdrop will even
draw a lattice at.
- Wheel to zoom, anchored on the pointer.
- Middle-drag, or the arrow keys, to pan.
+/-/0, and a chip at the corner of the canvas whose label is the current
zoom and whose click is *fit*.
The zoom exists in exactly one function — the one the pointer, the marquee and the
backdrop lattice are already mapped through — so the pixel you click is the pixel you
paint, and the lattice stays in phase with the art, at every magnification.
The editor opens in the view you left
The pixel grid, its step, the preview tile's size and which half of the rail is showing
are remembered between sessions. An author who paints 16px sprites on an 8px lattice was
setting that on every layer they opened.
Zoom and pan are deliberately not remembered — they belong to the image, and opening
an unrelated layer magnified and panned into a corner is a lost canvas rather than a
restored preference.
And four screenshots that were of the wrong thing
The harness's four REFERENCES frames reached that panel by scrolling the colorize rail
to its end. Splitting the rail into two tabs moved the panel out of that scroller and
the scroll became a no-op — leaving four frames named refs, every one a picture of the
colorize rail, with the tab bar beside them announcing "REFERENCES 3/5" about a panel
nothing showed. Nothing failed, because a capture asserts nothing. It asserts this much
now.
Downloads
| Size | .NET 10 needed | |
|---|---|---|
| Portable | 84 MB | no — unzip anywhere and run. Start here if you are unsure. |
| Single file | 76 MB | no — one .exe. Unpacks itself on first run, so that launch is slower. |
| Single file, .NET | 14 MB | yes — one .exe, runtime left out. |
| Framework-dependent | 14 MB | yes — the same, as a folder. |
The two .NET builds need the [.NET 10 desktop
runtime](https://dotnet.microsoft.com/download/dotnet/10.0); the other two carry everything.
v1.3.3
15 Sep 2026One placement fix. 2,172 tests, 0 failures.
The rarity unit is a page-level control now
The % | 1 in N toggle sat in the rarity table's own sub-header, which said it was *that
table's* setting — and it is not. It drives the trait column and the asset's own combined
chance, which sit in different panels. A reader who switched to odds and then found one
figure still reading in percent has been told the screen is inconsistent about its own
numbers. It always did drive both; only its placement said otherwise.
It is in the page header now, beside Export — everything to the left of there is a fact
about the Set, everything to the right is a control:
```
VaporCats [ASSETS 6] [SEED seed1] [RECIPES 1] RARITY [ % | 1 in N ] [Export…]
```
Exactly one tray, which is what stops a second copy reappearing next to the thing it governs.
Two tests hold it: one asserts the column header, the rarest-trait line and the combined
chance all move together; the other asserts the tray's count *and* its place.
The label is its own class rather than the chip label's — a bare .ck is only styled inside a
chip, so outside one it took the inherited 14px and read a size larger than every chip on the
row. Caught on a captured frame.
Downloads
| Size | .NET 10 needed | |
|---|---|---|
| Portable | 84 MB | no — unzip anywhere and run. Start here if you are unsure. |
| Single file | 76 MB | no — one .exe. Unpacks itself on first run, so that launch is slower. |
| Single file, .NET | 14 MB | yes — one .exe, runtime left out. |
| Framework-dependent | 14 MB | yes — the same, as a folder. |
The two .NET builds need the [.NET 10 desktop
runtime](https://dotnet.microsoft.com/download/dotnet/10.0); the other two carry everything.
v1.3.2
15 Sep 2026One fix. 2,170 tests, 0 failures.
A missing asset image now looks like damage
SetItemRow.Decode falls back to a 1×1 transparent bitmap rather than throwing — a browser
over a damaged Set should show the damage, not refuse to open. But the moment that
placeholder was published IsLoading went false, the breathing diamond that covers a pending
decode went with it, and the tile settled into a flat empty square: indistinguishable from
a decode still in flight, and from a fully transparent asset, which is a legal thing to mint.
Found by opening a Set whose folder had been half cleaned up — 24 images for a 60-asset Set,
and 36 tiles that looked like they were still thinking.
The decode returns the flag beside the bitmap now, and the tile draws a mark: the **same
diamond**, so an unfilled tile still looks like part of the product rather than a hole in it,
but static and in the warning ink. Static is the whole point — a pulse means "coming", and
nothing is coming. The detail rail's preview carries the same mark, because a blank square
there is the identical defect one level over, and both carry a tooltip naming what happened.
Two tests either side of it: one asserts the mark is drawn only on the row that lost its file
and that the loading pulse is absent; the other that an intact Set draws no marks at all.
Neither can pass on a constant.
Downloads
| Size | .NET 10 needed | |
|---|---|---|
| Portable | 84 MB | no — unzip anywhere and run. Start here if you are unsure. |
| Single file | 76 MB | no — one .exe. Unpacks itself on first run, so that launch is slower. |
| Single file, .NET | 14 MB | yes — one .exe, runtime left out. |
| Framework-dependent | 14 MB | yes — the same, as a folder. |
The two .NET builds need the [.NET 10 desktop
runtime](https://dotnet.microsoft.com/download/dotnet/10.0); the other two carry everything.
v1.3.1
15 Sep 2026Two fixes to what the app *says*, following 1.3.0. 2,167 tests, 0 failures.
A brand-new CookBook no longer greets you with an error
Make a CookBook and the status bar says 1 problem before you have made a single
decision. The problem is real — CookBook has zero total recipe weight — and it is exactly
what the CLI prints, but to a first-time author it reads as something they broke.
The report leads with the explanation now:
> 1 problem — Fresh Book
> *This CookBook has no Recipes yet. Add one, then add Ingredients to it — until there is
> something to stack, there is nothing to cook. Nothing below is a mistake you made.*
> 1 · CookBook has zero total recipe weight.
It is derived from the graph, not from matching Validator's wording — a reworded message
must not silently stop being recognised — and it names the empty Recipes, because "add an
Ingredient" without saying where is barely better than the raw problem.
The kicker gets a third state: a half-built book is not a broken one, so it takes a plain
dot rather than the warning ink. And the footer stops saying "cooking is refused until these
are fixed" on a book nobody has started — true, but the reader was just told what to do one
line above, so a refusal underneath is a telling-off carrying no information. On a genuinely
broken book it still says exactly that.
An asset's rarity, as a percentage or as odds
Three answers the Set browser's rail could not give, all from the numbers it was already
showing.
The unit toggles. A percentage compares traits to each other; odds are what "how rare is
this one" actually asks — 4.17% against 2.08% reads as a near-miss where 1 in 24 against
1 in 48 does not. The whole column switches, header included.
The whole asset's odds. "The lock is 1 in 4 and the bands are 1 in 3" is not an answer to
how rare the *asset* is. The rail now prints this combo 1 in 43,200 beside the id — the
product of exactly the rows underneath it, recipe share included, so you can check it by
hand. It assumes the layers roll independently, which incompatibility rules and optional
layers both break, and the tooltip says so.
The rarest trait stays beside the RARITY heading. Deliberately not a rarity *score*: the
sum of the reciprocals is a convention this project has never adopted and would have to
invent, where the rarest trait is a fact already in the table.
Downloads
| Size | .NET 10 needed | |
|---|---|---|
| Portable | 84 MB | no — unzip anywhere and run. Start here if you are unsure. |
| Single file | 76 MB | no — one .exe. Unpacks itself on first run, so that launch is slower. |
| Single file, .NET | 14 MB | yes — one .exe, runtime left out. |
| Framework-dependent | 14 MB | yes — the same, as a folder. |
The two .NET builds need the [.NET 10 desktop
runtime](https://dotnet.microsoft.com/download/dotnet/10.0); the other two carry everything.
v1.3.0
15 Sep 2026Four fixes and three features across the Explorer, the Recipe and Ingredient panes, the
Ingredient Editor and the Set browser. 2,154 tests, 0 failures.
The one that mattered
The Ingredient Editor could save into a read-only CookBook. The pencil opens the editor
whether or not the edit lock is on, and that is deliberate — the editor is also how you
*look* at a layer. Saving from it was not. The editor took no lock at all, so a book the
Explorer was refusing to add to, delete from or reorder could have a layer's manifest
rewritten, its KIND changed and the whole archive persisted, from one click away, with the
titlebar still saying read-only.
The gate is checked inside Save() as well as in CanSave: a generated
RelayCommand's ExecuteAsync does not consult its own CanExecute, so the disabled
button was the entire enforcement — and a disabled button is a label. Painting stays live;
a read-only editor is a scratch surface, and the footer says so from the moment it opens.
Reorder in the Contents tree
Drag, or Alt+Up / Alt+Down, on recipes and layers, among their own siblings only.
The two moves are not the same act and the status line says which. A layer move rewrites
layerOrder, which is the paint order — and the generator consumes one RNG draw per layer
in order, so the same seed over a reordered recipe is a *different collection*. A recipe
move is presentation and cannot change an asset, because the roller sorts its keys ordinally
before it rolls anything. Both are gated by the edit lock all the same, and books open
read-only.
Recipe order had nowhere to live, so CookBookManifest.RecipeOrder is new: optional,
additive, no schema bump, and tolerant of going stale.
The rest
- The reroll die has six faces, lands on a new one every press, and its single pip is
red. No more "sample 1" — that number was the reroll *count*, not a fact about the recipe.
- The recipe's layer rows scroll under a pinned header instead of running off the pane.
- The ingredient hero is a 2×2 block that pages — one height in every book, where before
it grew a row per two variants and pushed the pane's own buttons off the screen.
- The validity count is a button. "3 problems" used to put the problems on a tooltip;
both it and the CookBook card's chip now open the full numbered report. It opens on a
*valid* book too and says what was checked.
- The Set browser's rail shows the asset it is describing — a live preview that follows
the hover, the right-click-to-keep and the inspector, beside the id it already showed.
Known, not fixed
A cooked Set whose image files are missing draws blank tiles and says nothing — a missing
file ends up looking like one still loading, forever, and identical to a fully transparent
asset. Found by opening a Set whose folder had been half cleaned up. Recorded in CLAUDE.md.
Downloads
| Size | .NET 10 needed | |
|---|---|---|
| Portable | 84 MB | no — unzip anywhere and run. Start here if you are unsure. |
| Single file | 76 MB | no — one .exe. Unpacks itself on first run, so that launch is slower. |
| Single file, .NET | 14 MB | yes — one .exe, runtime left out. |
| Framework-dependent | 14 MB | yes — the same, as a folder. |
The two .NET builds need the [.NET 10 desktop
runtime](https://dotnet.microsoft.com/download/dotnet/10.0); the other two carry everything.
v1.2.8
15 Sep 2026The README gets a hero frame. No engine, CLI or GUI behaviour changes — the binaries are
1.2.6's, rebuilt so every archive carries the current file.
The frame
One pair, light and dark, swapped by the reader's theme: the Explorer with the built-in demo
CookBook open — a layer tree beside a card reading **2 recipes · 12 layers · 31 variants ·
615,600 unique DNA**. It is the README's own thesis stated as a picture.
Shot from the running app with tools/docs-capture, not from the VisualCapture harness,
whose 8×8 fixtures render empty panels and quote "2 unique DNA".
It lives in assets/readme/, deliberately outside docs/manual/images/. That whole figure set
still shows Vapor Pets on the pre-1.2 card layout, and BACKLOG.md holds the reshoot as a single
pass that has to move the tutorial prose with the pictures — half of it would leave one document
showing two collections. A README hero sits outside that document, so it can be current on its own.
assets/readme/README.md carries how to reshoot it and what the frame has to show.
It is referenced by absolute URL rather than a relative path, because every release archive carries
README.md and a relative image path there points at a file the archive does not contain.
Also
- Backlogged: the project icon the other Vaporsoft repos carry beside the Vaporsoft logo. The
mark exists and tools/icons/make-app-icon.py already generates it from the theme's own values —
the work is teaching that script to emit a web-sized PNG too, which is the only way to add one
without creating the second copy IconSourceTests exists to prevent.
- Corrected a stale number in
BACKLOG.md: it said the window minimum was 1200x712. It has been
1280x720 since the milestone commit.
Downloads
| Size | .NET 10 needed | |
|---|---|---|
| Portable | 84 MB | no — unzip anywhere and run. Start here if you are unsure. |
| Single file | 76 MB | no — one .exe. Unpacks itself on first run, so that launch is slower. |
| Single file, .NET | 14 MB | yes — one .exe, runtime left out. |
| Framework-dependent | 14 MB | yes — the same, as a folder. |
The two .NET builds need the [.NET 10 desktop
runtime](https://dotnet.microsoft.com/download/dotnet/10.0); the other two carry everything.
v1.2.7
15 Sep 2026The README is rewritten. No engine, CLI or GUI behaviour changes in this release — the
binaries are 1.2.6's, rebuilt so every archive carries the new file.
The README
It now follows the flow the other Vaporsoft repos use: tagline, grounded intro, grouped
badges, Table of Contents, Overview and Capabilities, Requirements, Installation, Quick
Start, Concepts, Architecture, Command Line, Desktop Application, Performance,
Development, Deployment, Troubleshooting, Links, Contributing.
Every figure in it was checked against the repository rather than carried over:
- Five real commands were missing —
demo,export,add rule,remove ruleand
set chance — and inspect had grown .set and .tin since the table was written.
- The test badge said 1672 and the layout said 1454. A full run is 2,088: 152 CLI,
1,058 Core, 878 App.
- 615,600 was re-derived with
stats, and the demo's eighteen distinct sprites counted
out of the archive itself.
- Sealing, export, optional layers and the counted DNA space had no place in the file
at all, which left the two features most likely to be asked about undocumented.
- Two screenshots were drafted and pulled again:
docs/manual/images/still shows Vapor
Pets on the pre-1.2 card layout, which BACKLOG.md already holds as a deferred reshoot.
A stale frame in a README is worse than none.
The project tree and the pipeline sketch were also brought under 80 columns, so a narrow
viewport no longer cuts the right-hand end off every line.
Downloads
| Size | .NET 10 needed | |
|---|---|---|
| Portable | 84 MB | no — unzip anywhere and run. Start here if you are unsure. |
| Single file | 76 MB | no — one .exe. Unpacks itself on first run, so that launch is slower. |
| Single file, .NET | 14 MB | yes — one .exe, runtime left out. |
| Framework-dependent | 14 MB | yes — the same, as a folder. |
The two .NET builds need the [.NET 10 desktop
runtime](https://dotnet.microsoft.com/download/dotnet/10.0); the other two carry everything.
v1.2.6
14 Sep 2026Six fixes, every one of them found by looking at a rendered frame rather than by
a failing test. None was a crash; all six screens looked perfectly tidy.
The editor's two strips wrap by GROUP (v1.2.1)
Both were WrapPanels handed a dozen loose controls, so the break fell wherever
the arithmetic put it. At the window the app opens at, the toolstrip wanted
557px of a 556px pane and broke after the swatch — the brush-size box alone on a
second line beside six hundred pixels of empty row. The palette strip fitted its
first three groups by nine pixels and stranded the alpha axis and its lock
the same way.
Controls/StripPanel.cs takes GROUPS, so a break always falls between whole
ideas, and it chooses the break that leaves the least *visible* gap. One child
per line may be elastic: the value ramp and the saved-swatch run both grow into
a wide window, and growing the saved run is the only thing that makes its own
horizontal scroller stop appearing.
One saved palette per paint mode (v1.2.2)
The saved swatches did not swap with the mode — the ramp did and the row beneath
it did not, so painting a value-map was done over a row of saturated cells that
each silently armed a gray, several of them the same gray. There is a palette
per mode now; a CookBook's own palette, which records no mode, is routed by
grayness.
And the destructive direction is the other one: gray → color is a widening that
loses nothing, while color → gray loses no pixel either and changes what Save
writes. The art stays on the screen, the canvas stops showing it, and the file
will not contain it. It asks once per session, and the footer keeps saying it
afterwards.
The wizards fit the smallest window (v1.2.3)
Five modal states scrolled at the window minimum, and the test meant to
prevent exactly that agreed with all of them: it measured at a raw 1180x720,
wider *and* taller than any page a modal is ever given. The real area is about
1067x526. Measured there: New Ingredient overflowed by 112px, its loose state by
149, Import Image by 194, New CookBook by 58.
The form was never too big — the card was too narrow. 880px, two columns, and
the halves are the two questions each form asks. The import preview went from
64px to 160: 64px of a 512px drawing is a swatch, not a preview.
Six screens that knew something and did not say it (v1.2.4)
The rule dialog was the one modal not speaking the app's grammar. The invalid
CookBook said 1 problem and never said what. A search that matched nothing drew
the CookBook root with nothing under it. The selected tile was invisible in light
mode. The Ingredient pane's hero grew a share bar per variant and pushed its own
buttons off the screen. And COLORWAYS showed no colorways.
Landing said the Kitchen's name twice (v1.2.5)
Three hundred pixels apart, on the same screen.
v1.2.0
14 Sep 2026Fixed
The canvas grid did not line up with the pixels. It was a fixed 18px checker tiled from the pane's own corner, so its squares stood in no relation to the art drawn on top of them — a pixel covered part of one square and part of the next, and how much changed with the canvas size and with where the artboard happened to sit. That is harsher on the eye than no grid at all: the drawing reads as out of register with its own background.
A square is now one canvas pixel, and the lattice starts on the art's own corner, so every pixel edge falls on a square edge.
New
A grid step. How many canvas pixels one square covers. One is a true pixel grid and the default; set it to 8 and a 16px sprite is two squares across. It is also what to reach for on a large canvas, where one square per pixel is finer than the screen can draw — a 512px canvas in the editor's tile puts a pixel at under two thirds of a device pixel, and a checker that fine is not a pattern, it is a flat average, so below that floor the flat ground is drawn instead.
An off switch. Paints the theme's own page background instead of the lattice — dark in dark mode, light in light. Small sprites on a transparent field read better against a grid; a full-bleed illustration reads better against nothing.
Both controls sit on the canvas itself, bottom-left, opposite the preview tile and in the same chrome. Neither is in the tool strips above, which already wrap at the window sizes this app allows.
Downloads
| Archive | For |
|---|---|
single-file-net10 | Start here if you have the .NET 10 runtime — one .exe, 14 MB |
single-file | One .exe, no runtime needed |
framework-dependent | A folder, needs the .NET 10 runtime |
portable | A folder, no runtime needed |
v1.1.1
14 Sep 2026The current build. Everything from v1.0.1 through v1.1.0 is in it.
Fixed
The detail rail was updating where you could not see it. Clicking a tile selects the asset *and* opens the viewer, in that order — so the panel did change, underneath a modal covering it, and only appeared to catch up once you closed the viewer again. A grid of hundreds of tiles is scanned rather than read, and the only way to find out what one was meant opening the viewer directly over the panel that answers the question.
The rail now describes whatever the pointer is over, falling back to the selection. Moving off the tiles puts it back on the selection rather than leaving it describing an asset you have left.
And the right button keeps one. Left-click still means "show me this bigger". Right-click marks a tile active and opens nothing — for comparing one asset's rarity against another, or saving it, which had no gesture at all.
Three states, three marks, and none of them moves anything on the screen: the selected tile takes an accent hairline, the hovered tile a brighter neutral one, and the rail carries a line saying which of the two it is describing. Save reads the same asset the rail shows, so the two cannot disagree.
Downloads
| Archive | For |
|---|---|
single-file-net10 | Start here if you have the .NET 10 runtime — one .exe, 14 MB |
single-file | One .exe, no runtime needed |
framework-dependent | A folder, needs the .NET 10 runtime |
portable | A folder, no runtime needed |
v1.1.0
14 Sep 2026New
Import a picture as a layer. Most people have art before they have an .igt, and the only way in was to create an empty ingredient, open the editor, import the file into its blank variant and save. Import now takes .png, .jpg and .jpeg alongside .rcp and .igt.
A picture opens a form that asks the one question that matters and is not reversible by hand — which kind of layer this becomes:
- Custom keeps the picture untouched and composites it as-is. This is what "import my art" usually means.
- Dynamic and Static keep only its *lightness* and take their color at generation time. That throws the picture's own colors away on purpose — it is how one drawing becomes thousands of colorways — so the form previews the picture as the chosen kind will store it, and says plainly when there is color to lose.
A wrong-sized picture is refused before the form opens rather than after you have filled it in: every variant in a book is the canvas size, and nfty resamples nothing. A name already taken in the recipe is reported while you can still retype it. The new layer lands in the editor afterwards, like every other way of creating one.
Colors are collapsed by *luminance*, not by reading one channel — so pure green imports as mid-bright rather than black. That rule now lives in one place, shared with the editor's own variant import, so the same file cannot import differently depending on which button you pressed.
Downloads
| Archive | For |
|---|---|
single-file-net10 | Start here if you have the .NET 10 runtime — one .exe, 14 MB |
single-file | One .exe, no runtime needed |
framework-dependent | A folder, needs the .NET 10 runtime |
portable | A folder, no runtime needed |
v1.0.6
14 Sep 2026Two faults on the Export screen, both reported.
Fixed
The preset stopped being lit for the wrong reason. A preset names *what leaves the machine*; folder-or-file is packaging. The match compared the shape too, so every preset came un-named the moment you changed it: ticking all four content boxes and choosing Single file left *Full project* lit until the shape was touched, and then lit nothing at all. *Marketplace* did the same in the other direction and was never reported, because its default shape is the one nobody changes.
"Source CookBook" meant "and now go and find the file." A Set records its book's hash and never the book, so the dialog had no path to follow — but it can *check* one. It now looks at the books the app already knows (the one that is open, ones you opened recently) and any sitting beside the Set, and keeps whichever one hashes to what the Set recorded. Matching on the hash is what makes looking around safe: a folder of a dozen books cannot produce a wrong answer, it produces no answer, and the picker is still there.
Choosing a book that did not cook this Set is warned about rather than refused — shipping an edited book is a legitimate thing to want; believing it is the source when it is not is the thing nothing on the screen would have told you.
Also reviewed and not a bug: the Single file / Folder radios really do change the shape. They are now driven from the real controls in a test rather than assumed.
Downloads
| Archive | For |
|---|---|
single-file-net10 | Start here if you have the .NET 10 runtime — one .exe, 14 MB |
single-file | One .exe, no runtime needed |
framework-dependent | A folder, needs the .NET 10 runtime |
portable | A folder, no runtime needed |
履歴Recent commits
f8e4517docs: the two rendering traps were this script's, not the committed SVGs'90e3dd1docs: the pipeline diagram was right about the flow and wrong about everything elsecff79cftest: the sprite sheet progress assertions read the list before it fillsfcb36d9docs: render the pipeline as SVG, and widen the layout876e09cdocs: add docs/diagrams/generation-pipeline.mmd82cacc5docs: add docs/diagrams/generation-pipeline-dark.svg5330443docs: add docs/diagrams/generation-pipeline-light.svg5709669docs: render the generation pipeline, and correct its order