Front-Loading & Tables#

The last way to fit work into a 76-cycle line is to not do it on that line. Two habits push work out of the hot path: front-loading — computing a line’s data before the line needs it — and tables — computing it before the program even runs. Both trade something plentiful (earlier time, ROM) for something scarce (cycles in the critical window).

Front-loading: be early#

In the moment the beam reaches a sprite, you have just a few cycles to put the right byte in GRP0. If those cycles are also spent deciding which byte — fetching a pointer, adding an offset, looking up a color — you’ll overflow. So you do that deciding earlier, during the slack of the previous line, and leave only a fast store for the critical instant.

    ; ...near the end of the current line, with cycles to spare:
    lda (P0Ptr),y    ; fetch NEXT line's sprite row now
    sta NextRow      ; stash it in a zero-page variable
    sta WSYNC
    ; ...top of the next line: the value is already in hand:
    lda NextRow      ; (or it's already in a register)
    sta GRP0         ; just a store at the critical moment

The principle generalizes to registers: get the value you’ll need into A, X, or Y before the tight window, so the window itself contains only the unavoidable store. A kernel that’s “always one step ahead of the beam” is the goal.

Tables: compute it before the program runs#

The ultimate front-loading is doing the work at assembly time. Any value you can precompute, you bake into a ROM .byte table and fetch with a single indexed load — turning arithmetic into a lookup:

    ldx Angle
    lda SineTable,x   ; the sine  no multiply, no loop, one 4-cycle read

This is the Numbers & Arithmetic habit at kernel scale: with no multiply instruction and no spare cycles, you precompute multiplication tables, sine/cosine for motion, the note→AUDF table, the ×5 digit offsets, and sprite-row pointers — then read the answer. ROM is measured in kilobytes; cycles and RAM are desperately scarce. Spend the plentiful resource.

And it isn’t only values you precompute — sometimes it’s layout. The scoreboard’s digit font stores each digit’s shape redundantly in both nibbles of every byte, so the kernel merges two digits into one PF1 byte with a single ORA instead of shifting bits into place. The digit graphics are intentionally denormalized in ROM so the score kernel can trade a small amount of storage for faster execution during the 76-cycle scanline deadline.

Page-align the hot tables#

There’s a sharp edge here, and it’s the page-crossing penalty: an indexed read whose address crosses a 256-byte boundary silently costs +1 cycle — and in a kernel counting to 76, one stray cycle tears the picture. The fix is to align a time-critical table to a page boundary so the index can never cross it:

    align 256          ; (or  org $Fx00) — start the table on a page
SpriteRows:
    .byte ...          ; now SpriteRows,Y never crosses a page  always 4 cycles

A table that lives entirely within one 256-byte page makes its indexed reads a flat, predictable cost — which is the whole game when you’re counting cycles. It’s a common reason a kernel mysteriously tears after an edit nowhere near it: an earlier change shifted a table across a page line.

In Practice#

  • Front-loading turns a tight line into two roomy halves. The work didn’t shrink — it moved to where the cycles were. Always ask whether a computation has to happen now, or could have happened a line ago.
  • If you can precompute it, precompute it. The most VCS-native optimization is refusing to calculate at run time anything you could have calculated at build time.
  • Align tables you read inside the kernel; don’t bother for the rest. Page alignment costs ROM (padding to the boundary), so spend it only on the tables whose reads happen under the cycle gun — not on data you read once in overscan.