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/
  • LICENSE
  • LICENSE.chipformer
  • README.md
  • pyproject.toml
  • weights/ (uit nesformer-weights.zip)
  • config.json
  • generation_config.json
  • model.safetensors
  • nesformer.json
  • SHA256SUMS

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

CommandoFunctie
python -m nesformer run ROMSpeel een ROM af met de gewichten en sla een GIF op
python -m nesformer check ROMDraai de gewichten en de referentie-CPU naast elkaar; stop bij het eerste verschil
python -m nesformer trace ROMPrint de tokenstroom van het model (standaard 40 tokens)
python -m nesformer buildCompileer het circuit en schrijf weights/ (duurt ongeveer 10 seconden)
python -m nesformer infoBeschrijf het checkpoint

Opties voor run:

OptieStandaardBetekenis
--frames N120Stop na N frames
--buttons SCRIPTnoneInput van de controller (zie boven)
--showoffTeken frames in de terminal (vereist 24-bit kleur, bijv. iTerm2 of Windows Terminal)
--gif PATHROM-naamWaar de geanimeerde GIF moet worden opgeslagen
--png DIRoffSla elk bewaard frame op als PNG in DIR. Alleen --png betekent dat er geen GIF wordt opgeslagen.
--every N1Bewaar elk N-de frame
--scale N2Vergroot bewaarde frames N keer
--enginefastfast (numba), numpy, of hf (standaard transformers; vereist torch, ~10 tokens/s)
--no-skipoffVoer elke iteratie van idle-wait-loops uit (trager; identieke frames)
--max-tokens NnoneStop na N tokens
--trace FILEnoneSla 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 entryControllers; APU-registers (geschreven, anders genegeerd)
De PPU: een scanline-renderer en het paletNMI-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-readsAntwoorden 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

LaagAttention (status)MLP
1PC-anker; instructietellers; A, X, Y, SP; flags N V Z C I D; pointer latches; NMI-vlagcounter thermometers; SP ± 1; LO + Y
2—PC = anker + bytes uitgevoerd sinds; PC + 1, PC + 2
3PRG fetch: opcode en twee operand-bytes op de PCopcode decode; operand bits; indexed sums; branch test en target
4—effectief adres, per adresseringsmodus en record-stadium; de byte die een push opslaat
5RAM en PRG data-reads op het effectieve adresoperand; 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:

RunTokensFramesNES-tijdResultaat
nestest (officiële opcodes)4.586——identiek, eindigt in verwachte HALT
Thwaite, check700.0002714,5 sidentieke trace en frames
Thwaite, run1.916.52760010,0 salle 300 bewaarde frames komen overeen
Full Game, run2.645.8854207,0 salle 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

PadInhoud
nesformer/spec.pyToken-families en records
nesformer/cpu.pyDe referentie-6502 die de trace schrijft
nesformer/host.pyAlles rondom de CPU: PPU, controllers, NMI-timing, cycle counting, idle skip
nesformer/core.pyHet circuit en de status-snapshot
nesformer/weights.pyDe emitter die het circuit omzet in een Qwen3_5ForCausalLM-checkpoint
nesformer/engine.py, fastengine.pyVoert de gewichten uit (numpy / numba)
nesformer/hfcheck.pyVoert de gewichten uit in standaard transformers
nesformer/run.py, check.py, cli.py, main.pyDrivers, 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.pyDe 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.