The asset pipeline¶
Three stages turn the art you edit into the bytes you ship.
flowchart LR
SRC["<b>assets_src/</b><br/>sprites/player.png<br/>sfx/hit.wav"]
CAN["<b>assets/</b><br/>sprites/player.webp<br/>sfx/hit.ogg"]
GEN["<b>lib/good.generated/</b><br/>textures.dart<br/>audios.dart"]
PACK["<b>assets/packed/</b><br/>chunk_main.dat<br/>chunk_shared.dat"]
SRC -->|"good assets compact<br/>ffmpeg, one format per kind"| CAN
CAN -->|"good generate"| GEN
CAN -->|"good assets pack<br/>compress, encrypt, chunk"| PACK
Configuration¶
Everything is configured in the pubspec's good: section — not a second file
beside it. A project already has one file that says what it is and what it
ships, and this puts the asset source directory next to the flutter: assets:
list that names the output.
good:
assets:
source: assets_src/ # originals you edit and commit
output: assets/ # canonical files, generated
packed: assets/packed/ # release chunks, generated
strip-originals: false # may a build delete art it cannot rebuild
texture:
format: webp
quality: 90
audio:
format: ogg
quality: 5
flutter:
assets:
- assets/
- assets/packed/
Every value shown is the default, and the whole section is optional.
| Directory | Committed? | Contents |
|---|---|---|
assets_src/ |
Yes | Your originals, in whatever format you work in |
assets/ |
Safe to gitignore | Canonical files. Rebuilt by good assets compact |
assets/packed/ |
Safe to gitignore | Release chunks. Rebuilt by good assets pack |
Both assets/ and assets/packed/ must appear under flutter: assets: — that
list is the only thing Flutter bundles from. A release build fills packed and
then empties output of everything it generated, so the two are listed together
and only one of them ever ships anything.
Stage 1 — compaction¶
One canonical format per kind: WebP for images, Ogg Vorbis for audio.
The source tree's shape is preserved — ui/button.jpg becomes ui/button.webp,
not button.webp — because the output path is what becomes an identifier, and
flattening would create collisions the source tree avoids.
Compaction is not a release-only step
Development and release load byte-identical files, because both load the output directory. A format bug that only appeared in release would be the worst kind, so both modes go through the same conversion.
Incremental by default¶
A hash of each source plus its conversion settings is kept in
.dart_tool/good/compact.json, so an unchanged file is skipped:
--force reconverts everything, which is what to reach for after changing
quality in the pubspec.
A file already in the canonical format is copied, not re-encoded — re-encoding a WebP to WebP is generation loss, and running ffmpeg over a file that is already right is time spent making the asset slightly worse.
ffmpeg¶
Compaction needs ffmpeg and downloads one if your PATH has none. In CI, pass
--no-download to make a missing ffmpeg an explicit failure, not a slow
surprise.
Files it has no rule for¶
Anything compaction cannot convert is reported as skipped, with a reason, instead of silently dropped. Put files that are already final — a JSON level definition, a font — directly in the output directory. Codegen does not recognise them, so they are never packed and never stripped.
An image or audio file you put there is a different matter. It ships, so it gets packed, so stripping the loose copies would take it — and compaction has no source to rebuild it from. A release build stops instead of deleting one; see Originals in the output directory.
Stage 2 — generation¶
Scans what the pubspec declares under flutter: assets: and writes four files.
$ good generate
Wrote ./lib/good.generated/textures.dart
Wrote ./lib/good.generated/audios.dart
Wrote ./lib/good.generated/good.dart
1 texture(s), 0 audio file(s).
| File | Rewritten | Contents |
|---|---|---|
textures.dart |
every run | One enum value per shipped image |
audios.dart |
every run | One enum value per shipped audio file |
good.dart |
every run | ensureGameReady(), the startup check |
asset_key.dart |
once | Encryption keys, and the chunk mapping |
asset_key.dart is written once and never regenerated
Its keys decrypt the packs already built with them, so regenerating would
orphan every shipped build. good generate --rotate-keys changes them
deliberately — and every existing pack stops decrypting, so repack
immediately after.
Commit this file. It is generated, and it is also the only record of your keys.
Subdirectories must be listed¶
Flutter's directory entries bundle files, not subdirectories. Compaction says so when it notices:
These directories now hold assets but are not listed under `flutter: assets:` in pubspec.yaml,
so Flutter will not bundle them and `good generate` will not see them:
- assets/sprites/
Stage 3 — packing¶
$ good assets pack
1 asset(s) in 1 chunk(s), grouped by scene (1 scene(s); 0 asset(s) shared or unattributed).
mode: release, encryption: aes, compression: normal
Wrote 1 chunk(s) to ./assets/packed/
2082 bytes of assets -> 2117 bytes packed (102 per cent)
Grouping by scene¶
Chunks are grouped so that loading a scene reads its own chunk and at most the
shared one. The packer scans your lib/ statically to work out which scene
declares which asset.
Anything it cannot attribute to a scene goes in the shared chunk, and it says how many:
3 declaration(s) could not be attributed to a scene statically; their assets go
in the shared chunk. Run with --verbose to see them.
A project the scan cannot read anything out of falls back to grouping by top-level directory instead of failing. The chunk format and the runtime do not care how members were chosen, only that the mapping agrees with them.
Compress, then encrypt — never the other way¶
Encrypted bytes are indistinguishable from random and do not compress at all. Encrypt-then-compress produces a larger file and exactly the same security — pure loss. Compress first and the ciphertext is as small as the plaintext could be made.
The chunk format¶
| Offset | Field | Size |
|---|---|---|
| 0 | GOOC magic |
4 B |
| 4 | version | 1 B |
| 5 | flags | 1 B |
| 6 | nonce | 12 B |
| 18 | GCM tag | 16 B |
| 34 | ciphertext | the rest of the chunk |
Magic and version first, so a runtime reading a chunk from a future good says
so instead of decrypting nonsense. Flags carry compressed/encrypted
separately, because --encryption=none is a real combination.
The nonce is derived from a hash of the compressed body. GCM's one unforgivable failure is a repeated (key, nonce) pair, and deriving from content means two chunks cannot collide unless their bytes are identical — in which case they are the same chunk and reusing the nonce leaks nothing new. It also makes a pack reproducible, which a random nonce would not.
Why seal whole chunks instead of individual assets?
A per-asset scheme needs an index outside the ciphertext saying where each asset begins and how long it is — and that index is a map of the whole pack in plaintext, which is most of what packing was meant to stop being trivial. Sealing whole chunks puts the index inside the ciphertext.
What encryption is and is not¶
AES-256-GCM, and it deters casual extraction. It is not DRM against a determined reverse engineer: the key ships in the binary, because the game has to decrypt its own assets to draw anything.
The generated keys are four final lists combined at run time, not const — a
const list is folded into the binary's constant pool where strings finds it,
while a final one is assembled at run time. Neither stops someone with a
debugger, and the design says so plainly instead of implying more.
The mapping¶
Packing writes the result back into asset_key.dart:
final Map<String, String> assetMapping = <String, String>{
'assets/sprites/player.webp': 'assets/packed/chunk_shared.dat',
};
ensureGameReady() installs the pack when this is non-empty. It is rewritten
even when empty, because switching a project back to development mode has to
clear a stale mapping — otherwise the runtime keeps looking for chunks that
are no longer built.
Options¶
| Option | Default | Notes |
|---|---|---|
--mode=<development\|release> |
release |
Development writes nothing and clears the mapping |
--encryption=<none\|aes> |
aes |
Packing without encrypting leaves every file header legible in a hex editor |
--compression=<none\|fast\|normal\|best> |
normal |
Applied before encryption |
--output-dir=<dir> |
the packed: directory |
Rarely worth overriding — a chunk written where Flutter does not bundle silently ships no assets |
--dry-run |
off | Report the plan, write nothing |
What packing does not do¶
It writes the chunks and leaves the loose assets where they are, so running
flutter build straight after bundles both. Only good build strips them — and
only there, because only there is good the one who compacted them and can say
which files are safe to delete.
Deleting a working directory's assets out from under someone who asked for a
pack is not good assets pack's call to make.
Originals in the output directory¶
good build strips the loose copy of everything it packed, and compaction can
rebuild anything it produced. A file you placed in assets/ yourself came from
no source, so stripping it destroys the only copy.
The build refuses when the packed set holds one:
1 packed asset(s) cannot be rebuilt if the build strips them:
assets/handmade.png
Compaction did not produce these, so deleting the loose copy destroys the only
one. Leaving it in place ships a legible copy beside the encrypted chunk.
Choose one:
- move them into assets_src/ so compaction owns them, or
- add `strip-originals: true` under `good: assets:` in pubspec.yaml to accept
the deletion.
Moving the file into assets_src/ is the fix that keeps both properties: the
art survives, and the release ships it only inside a chunk. Opting in is for a
project where assets_src/ already holds everything and assets/ is
disposable:
The build then strips those files and names each one as it goes.
Doing it by hand¶
good build runs all three in order. If you run them yourself, the order is:
good assets compact # 1. canonical files
good generate # 2. enums, from those files
good assets pack # 3. chunks, writing the mapping back
flutter build windows # 4. bundle whatever is on disk
Out of order produces a build that is stale instead of broken, which is
worse. Prefer good build.