PORTING.md Tier 1 item 6, the last one on that list. Not a language
feature and nothing was added to the language: restart-case, struct
assignment and fixed-arrays-as-values were already built, and what was
missing was the worked example. test/programs/frame-rollback.flan is it
— snapshot at the top of the frame, restore in the `continue` clause,
over one fixed array and one struct, which is engine.clj's grids plus
engine.lisp's shallow copy of the state object. Two `set`s each way,
because both are values; there is no IntGrid walk and no
sb-mop:class-slots walk to write.
The decision in it is the ordering against defers, and both orderings
compile. An answered bounds failure runs the abandoned function's
defers, innermost-first, before the restart clause body starts. Restore
in the clause is therefore the last write on the abandoned path and
needs no agreement with what any defer did on the way out. The rejected
alternative — restore in a defer inside the frame function — also runs
on the ordinary return path, so it rolls back the frames that
succeeded, and nothing reports that as an error.
Pinned with numbers rather than prose: a tick counter inside the
snapshot, written by the frame's defer, reads its pre-frame value,
while a counter outside the snapshot shows the defer ran. And there is
a negative control, the same bad frame with a `continue` that only
counts, because "state equals snapshot" passes trivially on a program
that never wrote anything.
Three acceptance rows beside bounds-condition.flan's, for the same
reasons: -O0, and the dev build where every call goes through a cell.
PORTING.md Tier 1 item 5. The spy half of the watch was already built — the
pushed table, the buffer, the inline ghost text. What was missing is spy-num,
which is the part that item calls least obvious and most valuable, and it is
what this is.
A slot keeps count, min, max, last and mean. Each answers a question you can
ask without building a query: n is the first thing wrong when a loop is wrong,
the range is what one sample can never show you, last is what the scalar watch
would have given you, and the mean is a running sum divided at read time
because a mean accumulated as a mean drifts. A small ring of the last N
samples was the other candidate and loses — N out of 91,200 is a sample of the
tail of the loop rather than of the loop, and past five numbers every richer
answer is a UI for building a query.
The write path does no formatting, which is the feature rather than an
optimisation: a snprintf per sample at thousands a frame is a HUD that costs
more than the game. A sample is a load, five compares and the slot's seqlock;
the listener thread renders once per editor tick.
The window is since the editor's last tick, and that is a deliberate
divergence from watch.clj, where the stats are cumulative until reset-spies!.
Cumulative min and max reach the session's extremes within seconds of play and
then never move again, so the two most useful of the five go dead exactly when
you start interacting with the thing you are debugging — and this tool exists
to show you a number while you drag the mouse. Reset is its own message and
never a side effect of reading, because a destructive read makes looking
change what is there and anything that polls would shorten the window under
the editor that owns it. It bumps one epoch counter and clears no slot; a slot
clears itself on its next sample, so the reader never writes the table.
Ghost text needed one character. The call regexp allowed one hyphenated
segment, so watch-num-i64 backtracked to failure and a numeric watch got no
inline value while appearing normally in the buffer.
dune test is green, run twice. HANDOFF-f3.md carries the reasoning, the two
small gaps left behind it, and what did not work on the way.