nesformer: Een NES-CPU gecompileerd in de gewichten van een standaard Qwen3.5-model
nesformer is gebaseerd op Qwenputer van Divyansh Agrawal, die een CHIP-8 en later een RISC-V CPU compileerde naar Qwen3.5-4B. Het maakt gebruik van de compiler van Qwenputer en de centrale techniek waarbij de Gated DeltaNet-status van het model dient als het geheugen van de machine.
Snelstart
1. Mappenstructuur
Pak nesformer.zip en nesformer-weights.zip uit in dezelfde map. De gewichten moeten naast het nesformer/ pakket worden geplaatst, niet erin:
nesformer/(voer commando's vanuit deze map uit)nesformer/(het Python-pakket)chipformer/(de compiler van Qwenputer)roms/(test-ROM's; plaats hier ook je eigen ROM's)examples/(voorbeeld-GIF's en frame-sheets)tests/LICENSELICENSE.chipformerREADME.mdpyproject.tomlweights/(uit nesformer-weights.zip)config.jsongeneration_config.jsonmodel.safetensorsnesformer.jsonSHA256SUMS
De CLI zoekt standaard naar de map weights/ naast het nesformer/ pakket. Om de gewichten op een andere locatie op te slaan, gebruik dan --weights /pad/naar/gewichten bij de commando's run, check of trace, of geef het pad op bij info.
Om te bevestigen dat de gewichten correct zijn uitgepakt, kan het commando shasum -a 256 -c weights/SHA256SUMS worden uitgevoerd vanuit de hoofdmap.
2. Installatie
Python 3.11 of nieuwer is vereist.
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install numpy scipy numba pillow
3. Een spel spelen
python -m nesformer run roms/thwaite.nes --frames 600 --show \
--buttons "start@90-95+200-205,a@230-233+250-253+270-273+290-293+310-313+330-333+350-353+370-373+390-393+410-413+430-433+450-453+470-473+490-493+510-513+530-533+550-553+570-573+590-593"
De optie --show tekent elk frame in de terminal terwijl het model dit genereert. Na afloop slaat de CLI een geanimeerde GIF op in de huidige map, vernoemd naar de ROM (bijv. thwaite.gif).
Houd er rekening mee dat dit traag is. De eerste run besteedt ongeveer 20 seconden aan het compileren van de engine met Numba. Daarna duurt een frame 1 tot 6 seconden, waardoor 600 frames (10 seconden NES-tijd) ongeveer 25 minuten in beslag nemen.
Eigen ROM's spelen
De run-functie werkt met elk .nes-bestand dat gebruikmaakt van het NROM-board (iNES mapper 0), zoals Super Mario Bros., Donkey Kong of Excitebike. De gebruiker levert de ROM; nesformer bevat alleen drie vrij gelicentieerde test-ROM's in roms/.
Voorbeeld voor Super Mario Bros.:
python -m nesformer run roms/smb.nes --frames 600 --show \
--buttons "start@60-65,right@200-600,a@250-265+300-315+360-375+420-435+480-495+540-555"
Dit script start het spel, laat Mario naar rechts lopen en springt ongeveer eens per seconde. De CLI laadt de ROM bij opstarten in de status van het model, waardoor dezelfde gewichten elk NROM-spel kunnen draaien zonder opnieuw te compileren. Spellen op andere boards stoppen met een foutmelding (zie Beperkingen).
Controller-scripts
De optie --buttons accepteert een door komma's gescheiden lijst. Elke vermelding bevat één of meer knoppen (gekoppeld met +), gevolgd door @, en vervolgens één of meer frame-bereiken (gekoppeld met +):
start@60-65: druk op Start tijdens frames 60 tot 65.right@200-600: houd Rechts ingedrukt van frame 200 tot 600.a@250-265+300-315: druk twee keer op A.right+b@200-600: houd Rechts en B tegelijk ingedrukt (rennen in Super Mario Bros.).
De beschikbare knoppen zijn: a, b, select, start, up, down, left en right. Frames worden geteld vanaf het inschakelen, tegen 60 frames per seconde. In Super Mario Bros. zorgt een langere A-druk voor een hogere sprong.
Commando's
| Commando | Functie |
|---|---|
python -m nesformer run ROM | Speel een ROM af met de gewichten en sla een GIF op |
python -m nesformer check ROM | Draai de gewichten en de referentie-CPU naast elkaar; stop bij het eerste verschil |
python -m nesformer trace ROM | Print de tokenstroom van het model (standaard 40 tokens) |
python -m nesformer build | Compileer het circuit en schrijf weights/ (duurt ongeveer 10 seconden) |
python -m nesformer info | Beschrijf het checkpoint |
Opties voor run:
| Optie | Standaard | Betekenis |
|---|---|---|
--frames N | 120 | Stop na N frames |
--buttons SCRIPT | none | Input van de controller (zie boven) |
--show | off | Teken frames in de terminal (vereist 24-bit kleur, bijv. iTerm2 of Windows Terminal) |
--gif PATH | ROM-naam | Waar de geanimeerde GIF moet worden opgeslagen |
--png DIR | off | Sla elk bewaard frame op als PNG in DIR. Alleen --png betekent dat er geen GIF wordt opgeslagen. |
--every N | 1 | Bewaar elk N-de frame |
--scale N | 2 | Vergroot bewaarde frames N keer |
--engine | fast | fast (numba), numpy, of hf (standaard transformers; vereist torch, ~10 tokens/s) |
--no-skip | off | Voer elke iteratie van idle-wait-loops uit (trager; identieke frames) |
--max-tokens N | none | Stop na N tokens |
--trace FILE | none | Sla de token-id's op |
De commando's run, check en trace accepteren allen --weights, --engine, --buttons en --no-skip. check --tokens N bepaalt hoe ver er vergeleken wordt (standaard 50.000). trace --tokens N bepaalt hoeveel tokens er worden geprint (standaard 40). build --out DIR schrijft het checkpoint naar een andere locatie dan weights/.
Hoe het werkt
Verdeling van functionaliteit
| In het model (gewichten + DeltaNet-status) | In de host (nesformer/host.py) |
|---|---|
| De 6502-kern: alle 151 officiële opcodes, elke adresseringsmodus, flags, stack, BRK en NMI entry | Controllers; APU-registers (geschreven, anders genegeerd) |
| De PPU: een scanline-renderer en het palet | NMI-timing bij vblank, geïnjecteerd als een NMI-token |
| 2 KB werkgeheugen (RAM) | OAM DMA-uitlezingen (de 256 bytes verschijnen als DM-tokens) |
| 32 KB PRG-ROM, tweemaal opgeslagen: één kopie voor instructie-fetch, één voor data-reads | Antwoorden op I/O-uitlezingen, geïnjecteerd als IN-tokens |
De host ziet de registers of het RAM van de CPU nooit, behalve via de trace. De geheugentruc werkt als volgt: elke DeltaNet-head houdt een 128 × 128 statusmatrix bij die via de delta-regel bij elk token wordt bijgewerkt. Met de gate open en een one-hot key overschrijft een update exact één rij van die matrix, en een one-hot query leest deze terug. Elke rij is dus een geheugencel; de cartridge, het RAM en de registers bevinden zich in deze rijen.
Het Checkpoint
De map weights/ is een standaard Hugging Face-map. Deze kan worden geladen met Qwen35ForCausalLM.frompretrained en vereist geen aangepaste code.
Architectuur
- Model:
Qwen3_5ForCausalLM, met DeltaNet-head vormen van Qwen3.5-4B (16 key heads, 32 value heads, dim 128) en MLP-breedte (9.216). - Lagen: 7 Gated DeltaNet-lagen (het circuit), plus 1 all-zero attention-laag die nodig is voor de cache van Hugging Face.
- Hidden size: 768.
- Vocabulair: 263.808 tokens.
- Parameters: 466,6M in bf16 (933 MB), waarvan 9,3M niet-nul zijn, waardoor de gewichten in een zip-bestand slechts 3,3 MB groot zijn.
nesformer.json legt vast welke laag en head elk onderdeel van de machine bevat. Tijdens een run worden de cartridge, registers en het RAM in deze DeltaNet-statussen geschreven, waarna greedy decoding plaatsvindt.
Laagplan
| Laag | Attention (status) | MLP |
|---|---|---|
| 1 | PC-anker; instructietellers; A, X, Y, SP; flags N V Z C I D; pointer latches; NMI-vlag | counter thermometers; SP ± 1; LO + Y |
| 2 | — | PC = anker + bytes uitgevoerd sinds; PC + 1, PC + 2 |
| 3 | PRG fetch: opcode en twee operand-bytes op de PC | opcode decode; operand bits; indexed sums; branch test en target |
| 4 | — | effectief adres, per adresseringsmodus en record-stadium; de byte die een push opslaat |
| 5 | RAM en PRG data-reads op het effectieve adres | operand; ADC/SBC adder, compares, BIT; adres decode |
| 6 | — | resultaat-byte, carry, overflow, flags, jump targets |
| 7 | — | het volgende token |
De Trace
Elke instructie wordt een record, en het laatste token van het record bevestigt deze. De Program Counter (PC) wordt impliciet bijgehouden: elk eindtoken bevat de lengte van de instructie, drie DeltaNet-tellers houden de instructies van elke lengte bij sinds de laatste sprong, en de PC is het doel van de sprong plus die offset.
Voorbeelden uit een volledige game-run:
SEI P(000100 px0 L1)LDX #$40 X($40 c0 v0 px0 L2)STX $4017 IOW($4017=$40 L3)TXS S($FF L1)BIT $2002 IOR($2002) IN($00) P(000110 px0 L3)(de host injecteert hier IN)BPL $8017 J($8017)JSR $8279 PSH($FE) PSH($FD) J($8279)(het retouradres wordt in-circuit gepusht)RTS PUL($FC) LO($7B) PUL($FD) J($827C)STA ($72),Y LO($08) HI($03) ST($00 a7=0 L2)(pointer bytes worden gelatched, daarna de store)STA $4014 IOW($4014=$02 L0) DM(0:$FF) … DM(255:$00) NT(L3)(OAM DMA)
De meeste instructies nemen één token in beslag; echte game-code gemiddelt 1,1–1,3 tokens per instructie. nesformer/spec.py documenteert elke token-familie.
Verificatie
De referentie-CPU (nesformer/cpu.py) komt overeen met het golden log van nestest voor 3.348 instructies, en met de py65 emulator voor 5,7 miljoen instructies aan game-code.
Het exacte circuit, uitgevoerd in de evaluator van Qwenputer met bf16-precisiecontroles, voorspelt elk referentietoken: alle tokens van nestest plus 283.610 posities van de boot-sequenties van twee games.
Het checkpoint in standaard transformers (bf16, CPU) voorspelt 2.651 posities van nestest en Full Game zonder mismatches.
De gewichten genereren in free-running modus traces die identiek zijn aan die van de referentie-CPU:
| Run | Tokens | Frames | NES-tijd | Resultaat |
|---|---|---|---|---|
| nestest (officiële opcodes) | 4.586 | — | — | identiek, eindigt in verwachte HALT |
| Thwaite, check | 700.000 | 271 | 4,5 s | identieke trace en frames |
| Thwaite, run | 1.916.527 | 600 | 10,0 s | alle 300 bewaarde frames komen overeen |
| Full Game, run | 2.645.885 | 420 | 7,0 s | alle 210 bewaarde frames komen overeen |
Het overslaan van de idle-loop verandert niets zichtbaars: frames en I/O-schrijfacties, inclusief cycle-timestamps, komen overeen met een run zonder deze optimalisatie.
Snelheid
De standaard engine berekent de forward pass van Qwen3.5 vanuit model.safetensors, gecompileerd met Numba en waarbij all-zero blokken worden overgeslagen. Dit draaide op ongeveer 1.650 tokens/s op één CPU-core. De eenvoudige Numpy-engine haalt ongeveer 210 tokens/s en standaard transformers ongeveer 10 tokens/s. Een echte NES zou ongeveer 700.000 tokens per seconde nodig hebben.
Games besteden het grootste deel van elk frame in een loop die wacht op vblank. Wanneer de host ziet dat een loop-body zich exact herhaalt, zonder RAM- of I/O-schrijfacties, verspringt de klok naar vlak voor het volgende PPU-event. Dit vermindert het aantal tokens met een factor 2,6 tot 4,2.
Over het geheel genomen draait nesformer 150–290× langzamer dan de echte console.
Beperkingen
- Alleen NROM-cartridges (iNES mapper 0): De CPU kan slechts 32 KB program-ROM tegelijk adresseren. Grotere spellen gebruiken een bank-switching chip (mapper), wat nesformer nog niet ondersteunt omdat ROM-uitlezingen plaatsvinden in een vaste tabel van 32 KB in het model.
- Alleen officiële opcodes: Een onofficiële opcode resulteert in een HALT.
- Code moet vanuit de cartridge ROM draaien: Een sprong naar het RAM resulteert in een HALT.
- Geen IRQ's: (APU frame IRQ, DMC). BRK en NMI werken wel.
- I/O-registers: Read-modify-write instructies op I/O-registers produceren geen I/O-schrijfactie, en adressen $4020–$7FFF worden als 0 gelezen (geen PRG-RAM).
- PPU en APU: Deze bevinden zich niet in de gewichten. De PPU rendert één scanline per keer, dus mid-scanline effecten worden niet gereproduceerd. Sprite-0 hit en sprite overflow zijn benaderingen, en er is geen geluid.
Testen
pip install pytest py65
python -m pytest -q tests
De tests duren enkele minuten en controleren de referentie-CPU tegen het nestest-log, het circuit tegen de referentie, de free-running gewichten tegen de referentie, en de idle-loop skip.
Het checkpoint uitvoeren in standaard transformers
pip install "torch>=2.6" "transformers>=5.22"
python -m nesformer run roms/thwaite.nes --frames 3 --engine hf
nesformer/hfcheck.py toont de volledige procedure: het laden van het checkpoint, het decoderen van één padding-token, het schrijven van de status van de machine in de DeltaNet-statussen van de cache, en het vervolgens token voor token voeden.
Bestanden
| Pad | Inhoud |
|---|---|
nesformer/spec.py | Token-families en records |
nesformer/cpu.py | De referentie-6502 die de trace schrijft |
nesformer/host.py | Alles rondom de CPU: PPU, controllers, NMI-timing, cycle counting, idle skip |
nesformer/core.py | Het circuit en de status-snapshot |
nesformer/weights.py | De emitter die het circuit omzet in een Qwen3_5ForCausalLM-checkpoint |
nesformer/engine.py, fastengine.py | Voert de gewichten uit (numpy / numba) |
nesformer/hfcheck.py | Voert de gewichten uit in standaard transformers |
nesformer/run.py, check.py, cli.py, main.py | Drivers, teacher-forced check, de CLI |
chipformer/ | Qwenputer's q35 compiler (q35/ emitter and primitives, dsl/ graph) |
weights/ | Het gecompileerde checkpoint en de SHA-256 checksum |
roms/ | Test-ROM's; roms/SOURCES.md bevat auteurs en licenties |
examples/ | GIF's en frame-sheets; demo.gif is de Super Mario Bros. run |
tests/test_nesformer.py | De testsuite |
Credits & Licentie
Qwenputer en de chipformer-compiler: MIT, © 2026 Divyansh Agrawal (LICENSE.chipformer). Deze licentie is ook van toepassing op de code uit Qwenputer die is aangepast in nesformer/core.py en nesformer/weights.py.
Test-ROM's: nestest door Kevin Horton; Full Game door Doug Fraker (MIT); Thwaite door Damian Yerrick (GPL-3.0).
Licentie: nesformer is uitgebracht onder de MIT-licentie (zie LICENSE). De ROM's in roms/ behouden hun eigen licenties, zoals vermeld in roms/SOURCES.md.
Groetjes,