One Primitive, Many Callers
Once tiles can be frozen, a snapshot of a whole layer stops being an expensive object and becomes a list of references. TileStore.frozenTiles() is that list: it freezes every live tile and returns them in key order, "a consistent picture of the store that any thread may read while painting goes on." Count its callers and you see how much of the app quietly stands on it:
- Saves and exports.
ArtworkArchivetakes every layer's tiles, and every mask's paint, this way (the previous lesson). - Duplicate Layer. "The copy shares the original's tiles, and its mask's, frozen, until either side paints over one." Duplicating a layer copies no pixels at all.
- Editing sessions. Transform, Adjustments, Liquify and Text each keep the layer as it was when the session began, so the preview always works from the original and the commit records one step.
- Strokes that need the layer as the stroke found it. Clone strokes and combined dual-brush strokes paint coverage on stores of their own and then merge it over that original. Clone lays pixels sampled from the original at its offset; a dual brush lays its own color, shaped by the two coverages, over the original's pixels (or, erasing, takes that shape out of them).
- Copy and Cut with Paste read the source layer through the same frozen picture.
Undo's Two Halves Are Both Shared
An undo step for pixels is .tiles(layerID, before, after). The before-state was frozen at each tile's first write (lesson two). The after-state comes from snapshot(of:) (first code block), which freezes the live buffers themselves: right after a stroke, the step's after-state is the live tile, the same object. Undo and redo call restore, which hands the snapshot's frozen buffers straight back to the store through setTile, without copying. So after an undo, the layer's live tile and the history's before-state are, again, one object.
This is why the history budget counts only before-states. While a step sits on the undo stack, its after-state is either the live tile or the next step's before-state, memory some other holder already pays for. The rule has a gap on the redo side. After an undo, the live layer holds the before-state again, and the undone step's after-state can be held by nothing but its redo entry. A tile the stroke created is the sharpest case: its before-state is nil, so the entry counts 64 bytes while it keeps a 32 KiB buffer alive. The budget undercounts redo until the next new step clears it. Track 6 turns the rest into the byte budget.
The Docstring That Missed the Memo
Now read the second code block, the comment on the History class as it stands today:
"History owns frozen tile copies; applying a step copies again, so the stacks never alias live layer tiles."
Every clause of that is false in the current code. History holds frozen shared buffers, not copies. Applying a step does not copy; restore shares. And the stacks alias live layer tiles on purpose, all the time. The sentence was true when it was written: before 6479f1f, snapshot and restore both called copy(). That commit rewrote the comment on byteCount a few dozen lines above to explain sharing, and left this one standing. Teach the code: sharing plus copy-on-write is the design, and the class comment is stale.
A stale comment like this one is worse than none, and the reason is specific. It does not describe a detail; it describes a safety property, in the exact place a future editor would look to learn whether it is safe to touch a history buffer. Someone who trusts it might decide that a buffer reached from the undo stack is private to history, and write into it. The comment would be the bug's alibi.
What Makes Sharing Safe
Two rules, both enforced at the tile, carry all of it:
- Immutability after freeze. A frozen buffer is never written.
fillhas a precondition, and the house rules require every other writer to go throughwritableTile. - Copy on write. The store never writes a frozen buffer; it swaps in a copy first. Every holder therefore keeps seeing exactly the pixels it took.
A third rule makes the first two hold across threads: only the artwork's thread freezes or writes, and a buffer is frozen before it is handed to anyone else. After that, any thread may read it. The compiled sketch (third code block) runs the whole cast through one tile: a stroke, its undo step, a duplicate layer, an undo, and a fresh dab after it. Every holder keeps its own value, and the live store paid exactly two copies.
One honest trade-off is visible in the code. The frozen flag is sticky: nothing ever sets isFrozen back to false, even after every other holder has let go. The usual Swift copy-on-write pattern asks isKnownUniquelyReferenced and skips the copy when nobody else holds the buffer; Ariandel does not ask. The cost is an occasional copy nobody strictly needed, say on the first stroke after history trimmed the step that held the tile. The benefit is a rule with no reference counting to get wrong. A spare copy costs 32 KiB; a wrong pixel costs Dad a painting.