Many Holders, One Tile
A tile in a painting app is never wanted by just one party. The live layer writes it. Undo needs what it looked like before the stroke and after. A save in flight needs a picture of it that will not change while a background thread compresses it. A duplicated layer starts out identical to it. A Transform, an Adjustment, a Liquify or a Text edit keeps the layer as it was when the session began, so it can preview against the original and commit once.
The simple answer is to give each holder its own copy, and for the first three hours of Ariandel's life that is what the engine did. A stroke copied every tile it touched twice, once for undo's before-state and once for its after-state, and undoing copied again on the way back. Commit 6479f1f replaced that with copy-on-write tiles, and the contract is short enough to hold in your head.
The Contract
A TileBuffer is written in place until something besides the live store takes hold of it. Taking hold freezes it, and a frozen buffer is never written again: the store swaps in a copy before its next write. In code that is one flag and one method. freeze() sets isFrozen and returns the buffer. fill, the one writing method on the buffer itself, opens with precondition(!isFrozen, "a frozen tile is never written"). The thread rule rides along: only the thread that owns the artwork freezes or writes, and once frozen, any thread may read the pixels. That is why FrozenTile can declare @unchecked Sendable; its own comment says so plainly: the invariant, not a lock, is what makes the conformance true.
One Door: writableTile(at:)
TileStore.writableTile(at:) is the only way to a writable buffer, and the project's house rules repeat it as a prohibition: never write tile.pixels you did not get from writableTile or allocate yourself. The method does four things, in order (first code block):
- Records the before-state through
noteWrite, if a change is being recorded. - Marks the tile dirty, so the GPU mirror re-uploads it (lesson five).
- Copies the tile if it is frozen, puts the copy in the store, and returns the copy.
- Creates a transparent tile if none exists.
Its neighbor setTile is not a second door. It replaces a tile wholesale (undo, file load, layer operations) and takes the buffer as it is: hand it a frozen buffer and the store holds a frozen buffer, which the next write through writableTile will copy.
The Before-State Costs Nothing Extra
Here is the trick that turns two copies into one. noteWrite does not copy the current buffer into the recording. It freezes it there. Now the recording owns the old pixels, the store still points at the same object, and step 3 of writableTile notices the freeze and copies. The copy that undo needed and the copy that writing needed are the same copy. After the first touch, the store's tile is that fresh, unfrozen copy, so the remaining dabs of the stroke write in place for free. For a plain stroke, that is one copy per touched tile for the whole stroke, however many dabs land on it. The compiled sketch below counts exactly that.
Two details are worth copying. First, noteWrite uses updateValue, not subscript assignment, because assigning nil through a dictionary subscript removes the key, and "this tile did not exist" is precisely the before-state undo must restore. That comment has a history: the commit that gave Ariandel its undo history, twenty-three minutes after the repository was born, fixed exactly that bug, where undo could not remove tiles a stroke had created. Second, there are two recordings: recording, per stroke or undo step, and batchRecording, per batch of dabs, which a stroke opens only when it is limited to a selection or laid through a grain (dual-brush and clone strokes keep batch recordings on stores of their own). The batch recording freezes too, so those strokes pay one copy per touched tile per batch, still bounded by what they touch, never by what the layer holds.
libmypaint at the Door
The engine reaches the store through two C callbacks, always on the artwork's thread and always inside a call Swift made. Most requests come during ard_surface_end, when libmypaint renders the dabs it queued. Some come earlier, inside mypaint_brush_stroke_to. A brush that smudges samples color as it goes, and libmypaint's get_color first flushes that tile's queued dabs and then reads it. A shaped dab skips the queue altogether, so the bridge acquires each tile it writes on the spot and holds it until ard_surface_end releases the batch. A read-only acquire gets tiles[key]?.pixels: whatever buffer is there, frozen or not, since reading a frozen buffer is always allowed; for an absent tile it gets nil, and the bridge lends its own shared transparent tile. A writable acquire goes through the one door, writableTile(at: key).pixels, so even libmypaint's writes land in a copy when the tile is shared. A writable release marks the tile dirty. If the store ever failed to lend a writable buffer, the bridge would lend a scratch tile and discard its paint rather than write somewhere it should not. Track 2 walks through how those function pointers carry the store across the C boundary.