NES Development in 2026 Documentation (Technical Information)
Obviously written with AI
The NES Graphics Ceiling
There are four different questions hiding inside “how much graphics can an NES ROM show,” and they have four different answers. Only one of them is bounded by the cartridge. The other three are bounded by the screen, by 32 bytes of palette RAM, and by 21 cycles of horizontal blank.
The screen is the ceiling
Start with the number that caps everything. The background is 32×30 tiles — 960 cells, 61,440 pixels. At 2 bits per pixel that is 15,360 bytes of pattern data, and no mapper, trick, or engine will ever display more unique background pixels in one frame than that. You cannot show a 64 KB image on an NES frame for the same reason you cannot pour a gallon into a pint glass.
So “maximum graphics” is never about beating 15,360. It is about four separate fights:
The interesting gap is in the first cell. The screen can hold 960 unique tiles, but a stock PPU can only address 256 at a time — one 4 KB pattern table. Every NES game you have ever seen is drawing a 15 KB screen out of a 4 KB vocabulary. Closing that gap is the whole game, and exactly one mapper closes it outright.
MMC5 (Mapper 5), and it isn't close
Judge mappers on the three things that actually move graphics: how much CHR they can address, how finely color can vary, and how many distinct tiles can appear in a single frame.
| Mapper | CHR max | Color cell | BG tiles / frame | Read |
|---|---|---|---|---|
| MMC1 (1) | 128 KB | 16×16 | 256 | Your Tiny Wizards baseline. |
| MMC3 (4) | 256 KB | 16×16 | 256 × bands | What your game demo runs on. Scanline IRQ multiplies tilesets down the screen. |
| UNROM512 (30) | 8 KB RAM | 16×16 | 256 | Mini Robot Hero's board. Compression play, but a tiny window. |
| FME-7 (69) | 256 KB | 16×16 | 256 × bands | MMC3-class with a cleaner cycle-accurate IRQ. |
| MMC5 (5) | 1 MB | 8×8 | 16,384 | Per-tile palette and per-tile bank select. The only chip that can paint all 960 cells uniquely. |
| JY Co. (90/209/211) | 2 MB | 16×16 | 256 | Twice the ROM, none of the magic, and uneven emulation. |
Note what that last row means: more cartridge does not buy more graphics. A 2 MB JY board still paints every frame from a 256-tile vocabulary. MMC5 with a quarter of the ROM shows 64× more distinct tiles per frame. The mapper feature that matters is not capacity, it is addressing.
Extended attribute mode
This is the feature the whole plan rests on. Write 1 to $5104 and MMC5 repurposes its 1 KB of ExRAM: for every background tile fetch, the PPU's nametable index is paired with an ExRAM byte covering that exact screen cell, and that byte carries
- bits 0–5 — a 4 KB CHR bank, chosen per 8×8 cell. With the two real-time bits in
$5130appended on top, that is 256 banks: the full 1 MB. - bits 6–7 — the palette, also per 8×8 cell, instead of one palette per 16×16 quad.
Two consequences. First, the 256-tile vocabulary becomes 16,384 — enough that every one of the 960 cells can be a different tile, so a genuine full-screen bitmap becomes expressible. Second, the attribute grid gets four times finer, which is the single most visible quality jump available on the platform.
The same 32×32 pixel region. Stock hardware forces four tiles to share one 3-color palette; ExAttr gives each tile its own. This is the source of that characteristic NES color bleed — and removing it is what reads instantly as “wrong console.”
Getting past 25 colors
ExAttr fixes tiles and attribute granularity, but the palette is still 32 bytes: 13 background colors, 12 sprite colors. To beat that you have to rewrite palette RAM while the frame is being drawn, and the window is brutal — NTSC horizontal blank is about 21 CPU cycles.
The established technique stores the backdrop redundantly at $3F00/4/8/C so a BIT $2007 can skip over it, leaving room for three $2007 writes: 3 palette bytes per hblank, rendering left on.
That budget is enough, and here is the arithmetic that makes the demo viable. A tile row is 8 scanlines, so each row offers 24 byte-slots. A complete set of four background palettes is 12 bytes. You can hand every tile row its own fresh set of four palettes, loaded during the row above it, and still leave half the hblank budget unused. Thirty rows × 12 bytes = 360 bytes of palette script per frame.
Combine that with ExAttr's per-cell palette selection and the practical result is: any 8×8 cell can use almost any 3 colors, subject only to the 32 cells in a row agreeing on four palettes between them. On-screen color goes from 25 to effectively the entire ~54-color master palette.
| Layer | What it beats | Cost | Risk |
|---|---|---|---|
| ExAttr mode | 256-tile vocabulary; 16×16 color cells | 1 KB ExRAM map | low |
| Per-row palette rewrite | 13 background colors per frame | 3 B/hblank + IRQ | high |
| Color emphasis $2001 | Uniform tint — 8 R/G/B combos per band | 1 write | low |
| Sprite overlay | 3 colors per object, becomes 6 | 2 OAM slots each | none |
| 8×16 sprites | Vertical coverage per OAM entry | global mode | none |
| Mid-frame $5130 | 256 KB reach per screen | 1 write | medium |
| Temporal dither | The 54-color master palette itself | free | flickers on LCD |
It survives contact with real images
| Source | STOCK | EXATTR | FLOOR | GAP CLOSED - COLORS |
|---|---|---|---|---|
| gameplay | 73.4 | 47.3 | 37.9 | 74% - Colors: 8 → 24 |
| sample_image | 83.1 | 67.1 | 62.3 | 77% - Colors: 8 → 27 |
| enemy_concept | 66.2 | 58.1 | 57.0 | 88% - Colors: 7 → 18 |
Error is RMS OKLab distance ×1000 over the most saturated fifth of the source pixels; lower is better. Floor is the unreachable best case — every pixel snapped independently to its nearest NES colour, no cells, no palettes. Extended attributes close roughly three quarters of the distance between a stock encode and that ceiling, and about triple the colours on screen.
The stronger result is what the floor column implies. On inspiration.jpg the whole-frame chroma error is 56.0 against a floor of 55.5 — within one percent of the best the hardware palette can express. The residual belongs to the NES palette, not the solver. More restarts buy nothing; that part is finished.
The predicted budgets held exactly: 16,384 bytes of CHR, a 960-byte ExRAM map, a 360-byte palette script.
The static nametable trick
Here is the engine idea worth borrowing, and it comes from one detail: ExRAM lives in CPU address space, not PPU space. Writes to $5C00–$5FFF are ordinary stores that land during active rendering — you are not confined to vblank. (In modes 0 and 1 it is write-only while rendering, so keep a RAM shadow if you need to read back.)
That flips the usual bottleneck on its head:
- Write the nametable exactly once, at init, and never touch it again. Set each cell's tile index to
(row × 32 + col) & $FF. Indices repeat every 256 cells, and ExRAM's bank bits disambiguate: cells 0–255 point at bank A, 256–511 at bank B, and so on. Four 4 KB banks — 16 KB — give you 960 unique tiles. - Then animate purely through ExRAM. Rewriting one byte changes both which tile a cell shows and which palette it uses. All 960 bytes at ~6 cycles each is about 6,150 cycles — roughly 21% of a frame, spent during active display, with vblank left completely free.
The upshot is a screen where all 960 cells animate independently at 60 fps, each with its own palette, and the PPU bus is never touched after boot. No other NES configuration can do this. It is also the honest answer to “impossibly many characters”: build each character from a 2×4 block of cells and there is room for roughly 120 of them, all animating, none flickering, none competing for sprite slots.
The wall that doesn't move
One limit no mapper touches, ever: 8 sprites per scanline, 64 total. It lives in the PPU, not the cartridge, and MMC5 is as helpless against it as NROM. Every apparent exception is one of three things:
- Flicker. Rotate OAM priority each frame so overflowing sprites take turns. More objects, at the cost of 30 Hz strobing.
- Move it to the background. The static-nametable engine above. Unlimited objects per scanline, because they are not sprites.
- Spend two sprites on one object. Overlay a second sprite for 6 colors instead of 3. Halves your count, doubles apparent color depth — usually the right trade for a handful of hero characters.
Sprite pattern data is capped too: 64 entries at 8×16 is 2,048 bytes of unique tile data per frame. Sprites are for motion and color depth, not for volume.
Why CHR-ROM wins on bandwidth
CHR-ROM is fetched by the PPU directly off the cartridge, so a bank switch presents fresh tile data for the price of one store instruction. CHR-RAM has to be filled by the CPU, and vblank is only 2,273 cycles — realistically 250–380 bytes with fully unrolled stores.
Which resolves the “most graphics in a ROM” question in a way I did not expect before running the arithmetic. At 15,360 bytes per frame × 60 fps, a full 1 MB of CHR-ROM can be displayed in its entirety in about 1.1 seconds. The PPU is not the bottleneck for total graphics volume. Cartridge size is.
What to actually build
A · The still that can't exist
A full-screen 256×240 image with all 960 cells unique, 8×8 color cells, and a fresh four-palette set every tile row. Roughly 50 colors on screen. To anyone who knows the platform this is immediately, uncomfortably wrong — which is the point. Make it a gallery and page through images.
B · The crowd
The static-nametable engine at full stretch: ~120 characters built from 2×4 cell blocks, every one animating independently at 60 fps with its own palette, plus 64 hardware sprites on top for anything needing sub-tile movement. A stadium, an army, a dance floor. No flicker anywhere, because almost nothing on screen is a sprite.
C · The throughput flex
Full-screen unique animation at 60 fps driven entirely by CHR-ROM bank flips, with the CPU essentially asleep. Nothing else on the console moves this much pixel data. Limited purely by cartridge: about 68 frames of wholly unique full-screen animation per megabyte, so trade frame rate for length — 15 fps buys around 4.5 seconds.
Fitting it to the toolchain you already have
Most of the scaffolding exists. The fighting game example already has a scanline IRQ, generated CHR includes, a Python asset compiler, and a 6502 test harness with enough PPU to assert on pixels. What changes:
src/header.asm— NES 2.0, mapper 5, CHR sized to taste. You are already emitting NES 2.0 headers.src/mapper5.asmalongside the MMC3 one —$5100PRG mode,$5104 = 1for ExAttr,$5105mirroring,$5130upper CHR bits,$5203/$5204scanline IRQ. Smaller thanmapper.asmis now.tools/mkchr.py— a new output path emitting CHR banks, the ExRAM map, and the per-row palette script from a source PNG.test/harness.py— MMC5 ExAttr in the PPU model, plus a per-scanline palette trace sotest_game.pycan assert exact output. This is the real work.
The quantizer is the actual thinking problem
Everything above is plumbing. The part that decides whether the demo looks extraordinary or merely odd is the image compiler, and it is a constrained clustering problem:
- For each 8×8 cell, find the best 3-color subset of the ~54-color master palette in a perceptual space — OKLab, not RGB.
- Then, per tile row, cluster those 32 cells' color needs into exactly 4 palettes, minimizing total weighted error. Thirty independent 4-way clusterings, one per row — small enough for k-means with restarts, or near-exhaustive search.
- Emit the 12-byte palette set per row, scheduled to load during the preceding row's hblanks, 3 bytes at a time.
Worth writing the error metric first and running it over sample_image.jpg before touching any assembly. If the numbers say the result will look good, it will.
Updated After Building Quantizer:
The image compiler reduces to thirty independent constrained clustering problems — one per tile row, since a row is exactly what one palette set has to serve:
- Convert to OKLab and keep everything there. RGB distance ranks candidate colors wrongly in exactly the dark saturated regions these images live in.
- Per row, run Lloyd iterations: assign each of the 32 cells to its cheapest palette, then refit each palette to the pixels that landed on it. Restart a few times from different seeds and keep the best.
- The refit is the inner primitive — best 3 colors from the 54 for a bag of pixels, with the shared backdrop available free. Seed it with k-means, snap to the nearest legal entries, then local search: re-optimise one slot against all 54 candidates with the other two held, until nothing improves. Converges in a handful of passes.
- The backdrop is global, so choose it by running the whole solve over a shortlist of the colors the image most wants, and keeping the winner.
Six restarts and twelve iterations lands within a percent of the theoretical floor, which is the signal to stop tuning. The one thing worth it before doing more is picking a metric you have checked against your own eyes — see above for how badly that can go.
Where this breaks
- MMC5 hardware is scarce. Aftermarket PCBs barely exist; the usual route is harvesting a donor (Castlevania III, Just Breed, Uncharted Waters), and most donors carry far less than 1 MB. Plan for emulator and FPGA cores as the primary target, with a physical cart as a stretch goal.
- Mid-frame palette work is timing-critical and emulator-divergent. 21 cycles, hand-counted, and FCEUX will lie to you about it.
- 8 sprites per scanline is permanent. Design around it rather than fighting it.
- 1 KB CHR banking only reaches 256 KB on MMC5 — moot under ExAttr, which forces 4 KB banks, but it will bite elsewhere in the engine.
- ExRAM is write-only while rendering in modes 0 and 1. Shadow it in RAM.
- Temporal dithering is display-dependent. Lovely on a CRT, headache-inducing on some LCDs. Make it optional.





No comments:
Post a Comment