원문 캡처 · github.com
원문 캡처 · github.com

nesformer 저장소

nesformer는 일반 Qwen3.5 모델의 가중치에 NES CPU를 컴파일해 넣은 프로젝트다. 6502 CPU(2A03 칩의 핵심), 2KB 작업 RAM, 카트리지 프로그램 ROM이 표준 Qwen3_5ForCausalLM 체크포인트 안에서 실행된다. 이 가중치는 학습된 것이 아니라 컴파일러가 모두 계산해 만들었다. 게임을 실행하면 모델이 CPU 실행 로그를 토큰 단위로 예측한다. 작은 호스트 프로그램은 화면을 그리고 컨트롤러 입력을 읽으며 수직 블랭크(vblank) 인터럽트를 발생시키는 등 나머지 콘솔 기능을 맡는다.

nesformer는 Divyansh Agrawal의 Qwenputer에서 출발했다. Qwenputer는 CHIP-8과 RISC-V CPU를 차례로 Qwen3.5-4B에 컴파일했다. nesformer는 Qwenputer의 컴파일러와 핵심 아이디어를 재사용한다. 모델의 Gated DeltaNet 상태를 기계의 메모리로 쓰는 방식이다.

Super Mario Bros. (World 1-1)

Doug Fraker의 Full Game (타이틀 화면과 첫 번째 레벨)

데모 GIF

전체 게임 GIF

실행 중 나온 CPU 토큰은 모두 모델이 생성했다. 함께 제공되는 테스트 게임에서는 모든 프레임이 기존 6502 에뮬레이터의 출력과 정확히 일치한다. 비교 방법은 검증 설명에 나와 있다.

설치하고 실행하기

폴더 준비와 설치

nesformer.zip과 nesformer-weights.zip의 압축을 같은 폴더에 푼다. 가중치 폴더는 nesformer/ 패키지 안이 아니라 그 옆에 둔다.

nesformer/ <- run commands from here ├── nesformer/ <- the Python package ├── chipformer/ <- Qwenputer's compiler ├── roms/ <- test ROMs (put your own here too) ├── examples/ <- sample GIFs and frame sheets ├── tests/ ├── LICENSE ├── LICENSE.chipformer ├── README.md ├── pyproject.toml └── weights/ <- from nesformer-weights.zip ├── config.json ├── generation_config.json ├── model.safetensors ├── nesformer.json └── SHA256SUMS

CLI는 nesformer/ 패키지 옆의 weights/를 찾는다. 다른 위치에 가중치를 두려면 run, check, trace에 --weights /path/to/weights를 지정하거나 info에 경로를 전달한다. 압축을 제대로 풀었는지 확인하려면 최상위 폴더에서 다음 명령을 실행한다.

shasum -a 256 -c weights/SHA256SUMS

Python 3.11 이상이 필요하다. 가상 환경을 만들고 필요한 패키지를 설치한다.

sh
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install numpy scipy numba pillow

게임 실행과 입력 설정

아래 명령은 thwaite.nes를 600프레임 실행하고, 프레임을 터미널에 표시한다. --buttons는 프레임 구간별 컨트롤러 입력을 지정한다.

sh
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"

--show를 쓰면 모델이 프레임을 생성하는 동안 터미널에 각 프레임을 그린다. 실행이 끝나면 현재 폴더에 ROM 이름을 딴 애니메이션 GIF가 저장된다. 위 예에서는 thwaite.gif가 만들어진다.

실행은 느리다. 첫 실행은 numba로 엔진을 컴파일하는 데 약 20초가 걸린다. 이후 프레임 하나에 1~6초가 걸리므로 NES 시간 10초에 해당하는 600프레임은 약 25분이 걸린다.

NROM 보드(iNES mapper 0)를 사용하는 .nes 파일은 직접 지정해 실행할 수 있다. 예를 들면 Super Mario Bros., Donkey Kong, Excitebike가 있다. ROM은 사용자가 준비해야 하며, nesformer에는 자유 라이선스로 배포되는 테스트 ROM 세 개만 포함된다.

sh
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"

이 입력은 게임을 시작한 뒤 마리오를 오른쪽으로 이동시키고 대략 1초에 한 번 점프시킨다. CLI는 시작할 때 ROM을 모델 상태에 넣으므로, 같은 가중치로 빌드 과정을 다시 거치지 않고도 NROM 게임을 실행할 수 있다. 다른 보드를 사용하는 게임은 메시지를 표시하고 중단된다. 자세한 제한은 제약 사항에 설명돼 있다.

--buttons에는 쉼표로 구분한 항목을 넣는다. 각 항목은 +로 연결한 하나 이상의 버튼, @, 그리고 +로 연결한 하나 이상의 프레임 구간으로 구성된다.

start@60-65 press Start during frames 60 to 65 right@200-600 hold Right from frame 200 to frame 600 a@250-265+300-315 press A twice right+b@200-600 hold Right and B together (run, in Super Mario Bros.)

버튼은 a, b, select, start, up, down, left, right다. 프레임은 전원을 켠 시점부터 초당 60프레임으로 센다. 따라서 프레임 600은 게임 시간으로 10초다. 움직일 수 없는 화면에서는 게임이 입력을 무시하므로 버튼을 조금 일찍 누르고 있어도 문제가 없다. Super Mario Bros.에서는 A를 오래 누를수록 더 높이 점프한다.

명령과 옵션

python -m nesformer run ROM은 가중치로 ROM을 실행하고 GIF를 저장한다. python -m nesformer check ROM은 가중치와 기준 CPU를 나란히 실행해 첫 차이가 발생하면 멈춘다. python -m nesformer trace ROM은 모델의 토큰 스트림을 출력하며 기본값은 40개 토큰이다. python -m nesformer build는 회로를 컴파일해 weights/에 저장하며 약 10초 걸린다. python -m nesformer info는 체크포인트 정보를 표시한다.

run의 옵션은 다음과 같다.

옵션기본값설명
--frames N120N프레임 뒤에 중단한다.
--buttons SCRIPT없음컨트롤러 입력을 지정한다.
--show꺼짐터미널에 프레임을 그린다. 24비트 색상을 지원하는 iTerm2나 Windows Terminal 등이 필요하다.
--gif PATHROM 이름애니메이션 GIF의 저장 위치를 지정한다.
--png DIR꺼짐유지한 각 프레임을 DIR에 PNG로 저장한다. --png만 쓰면 GIF는 저장하지 않는다. --gif도 추가하면 둘 다 저장한다.
--every N1N번째 프레임마다 저장한다.
--scale N2저장 프레임을 N배 확대한다.
--enginefastfast(numba), numpy, hf 중 엔진을 고른다. hf는 torch가 필요하며 초당 약 10토큰을 처리한다.
--no-skip꺼짐유휴 대기 루프의 모든 반복을 실행한다. 더 느리지만 프레임은 동일하다.
--max-tokens N없음N토큰 뒤에 중단한다.
--trace FILE없음토큰 ID를 파일에 저장한다.

run, check, trace는 모두 --weights, --engine, --buttons, --no-skip을 받는다. check --tokens N은 비교할 토큰 수를 지정하며 기본값은 50,000이다. trace --tokens N은 출력할 토큰 수를 지정하며 기본값은 40이다. build --out DIR은 체크포인트를 weights/가 아닌 지정한 위치에 쓴다.

구현과 체크포인트

모델과 호스트의 역할

모델에는 6502 코어의 공식 opcode 151개와 모든 주소 지정 모드, 플래그, 스택, BRK 및 NMI 진입, 2KB 작업 RAM, 32KB PRG-ROM이 들어간다. PRG-ROM은 명령어 인출용과 데이터 읽기용으로 두 번 저장된다. 호스트 프로그램인 nesformer/host.py에는 주사선 렌더러와 팔레트로 구성된 PPU, 컨트롤러, APU 레지스터(쓰기만 처리하고 다른 동작은 무시), vblank 시점의 NMI, DMA 읽기, 입출력 읽기 응답이 있다. NMI는 NMI 토큰으로, 입출력 읽기 응답은 IN 토큰으로 모델에 주입한다. OAM DMA의 256바이트는 DM 토큰으로 나타난다.

이 역할 분담은 호스트가 실행 추적에서 타이머 틱을 주입하고 화면을 그리는 Qwenputer의 CHIP-8 빌드를 따른다. 호스트는 추적을 통하지 않고서는 CPU 레지스터나 RAM을 볼 수 없다.

각 DeltaNet 헤드는 128 × 128 상태 행렬을 보유하며, 델타 규칙은 토큰마다 이 행렬을 갱신한다. 게이트를 열어 둔 상태에서 원-핫 키를 쓰면 행렬의 특정 행 하나를 덮어쓸 수 있고, 원-핫 쿼리로 그 값을 읽을 수 있다. 따라서 각 행이 메모리 셀이 되고, 카트리지와 RAM, 레지스터가 행에 저장된다.

체크포인트와 계층 구성

weights/는 일반적인 Hugging Face 폴더다. 사용자 정의 코드 없이 Qwen3_5ForCausalLM.from_pretrained로 불러올 수 있다.

항목구성
아키텍처Qwen3_5ForCausalLM. Qwen3.5-4B의 DeltaNet 헤드 구조를 사용하며 키 헤드 16개, 값 헤드 32개, 차원 128, MLP 폭 9,216이다.
계층Gated DeltaNet 계층 7개(회로)와 Hugging Face 캐시에 필요한, 모두 0인 어텐션 계층 1개다.
은닉 크기768
어휘토큰 263,808개
파라미터bf16 기준 466.6M(933MB)이며, 이 중 9.3M만 0이 아니다. 가중치 압축 파일 크기는 3.3MB다.

nesformer.json은 기계의 각 요소가 어느 계층과 헤드에 저장되는지 기록한다. 실행할 때 카트리지와 레지스터, RAM을 DeltaNet 상태에 기록한 뒤 탐욕적으로 디코딩한다.

계층어텐션(상태)MLP
1PC 기준점, 명령어 카운터, A·X·Y·SP, N·V·Z·C·I·D 플래그, 포인터 래치, NMI 플래그카운터 온도계, SP ± 1, LO + Y
2—PC = 기준점 + 실행한 바이트 수, PC + 1, PC + 2
3PC에서 PRG를 가져와 opcode와 피연산자 바이트 두 개를 읽는다.opcode 해독, 피연산자 비트, 인덱스 합계, 분기 조건과 대상
4—주소 지정 모드와 레코드 단계에 따른 유효 주소, 푸시할 바이트
5유효 주소에서 RAM과 PRG 데이터를 읽는다.피연산자, ADC/SBC 가산기, 비교, BIT, 주소 해독
6—결과 바이트, 캐리, 오버플로, 플래그, 점프 대상
7—다음 토큰

실행 추적

각 명령어는 레코드로 바뀌며, 레코드의 마지막 토큰이 해당 레코드를 확정한다. Qwenputer의 RISC-V 코어와 마찬가지로 PC는 명시적으로 기록하지 않는다. 각 마지막 토큰은 명령어 길이를 담고, 세 개의 DeltaNet 카운터가 마지막 점프 이후 각 길이의 명령어 수를 세며, PC는 점프 대상에 그 오프셋을 더해 계산한다. 아래는 Full Game 실행 추적의 일부다.

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) <- the host injects IN BPL $8017 J($8017) JSR $8279 PSH($FE) PSH($FD) J($8279) <- the return address is pushed in-circuit RTS PUL($FC) LO($7B) PUL($FD) J($827C) STA ($72),Y LO($08) HI($03) ST($00 a7=0 L2) <- pointer bytes latched, then the store STA $4014 IOW($4014=$02 L0) DM(0:$FF) … DM(255:$00) NT(L3) <- OAM DMA

대부분의 명령어는 토큰 하나로 나타난다. 실제 게임 코드는 명령어당 평균 1.1~1.3토큰을 사용한다. nesformer/spec.py에는 토큰 종류가 모두 문서화돼 있다.

검증과 성능

검증 결과

기준 CPU인 nesformer/cpu.py는 nestest의 정답 로그에서 3,348개 명령어와 일치했고, 게임 코드 570만 개 명령어에 대해서는 py65 에뮬레이터와 일치했다. Qwenputer 평가기에서 bf16 정확성 검사를 적용해 회로를 실행했을 때 기준 토큰을 모두 예측했다. 대상은 nestest 전체와 두 게임의 부팅 시퀀스 283,610개 위치다. 기본 transformers의 CPU bf16 환경에서 체크포인트는 nestest와 Full Game의 2,651개 위치를 오차 없이 예측했다.

실행토큰프레임NES 시간결과
nestest(공식 opcode)4,586——예상된 HALT로 끝나는 동일한 결과
Thwaite, check700,0002714.5초추적과 프레임이 모두 동일
Thwaite, run1,916,52760010.0초저장한 프레임 300개가 모두 일치
Full Game, run2,645,8854207.0초저장한 프레임 210개가 모두 일치

가중치를 자유 실행했을 때도 기준 CPU와 동일한 추적을 생성했다. 유휴 루프 건너뛰기는 화면에 보이는 결과를 바꾸지 않는다. 이 기능을 켠 실행과 끈 실행은 프레임과 입출력 쓰기, 사이클 타임스탬프까지 일치한다.

속도

기본 엔진은 model.safetensors의 Qwen3.5 순전파를 numba로 컴파일해 계산하고, 모두 0인 블록은 건너뛴다. 제작에 사용한 장비의 CPU 코어 하나에서 초당 약 1,650토큰을 처리했다. 일반 numpy 엔진은 초당 약 210토큰, 기본 transformers 엔진은 초당 약 10토큰을 처리한다. 실제 NES 속도를 내려면 초당 약 700,000토큰이 필요하다.

게임은 각 프레임 대부분을 vblank를 기다리는 루프에서 보낸다. 호스트는 루프 본문이 정확히 반복되고 RAM이나 입출력 쓰기가 없으면 다음 PPU 이벤트 직전까지 시계를 앞당긴다. 루프의 나머지 반복도 실행은 계속된다. 이 방식은 토큰 수를 2.6~4.2배 줄인다. 그래도 전체 실행 속도는 실제 콘솔보다 150~290배 느리다.

제약 사항

  • NROM 카트리지(iNES mapper 0)만 지원한다. CPU는 한 번에 32KB의 프로그램 ROM만 주소 지정할 수 있다. 더 큰 게임은 MMC1이나 MMC3 같은 뱅크 전환 칩(mapper)을 사용해 CPU에 보이는 ROM 영역을 바꾼다. nesformer의 ROM 읽기는 모델 안의 고정된 32KB 테이블에서 이뤄지므로 현재 뱅크 전환을 처리하지 못한다. mapper를 지원하려면 ROM 전체와 뱅크 레지스터를 상태에 저장하고, CPU 주소를 ROM 주소로 변환하는 단계를 추가해야 한다.
  • 공식 opcode만 지원한다. 비공식 opcode가 나오면 HALT를 출력한다.
  • 코드는 카트리지 ROM에서 실행돼야 한다. RAM으로 점프하면 HALT를 출력한다.
  • IRQ는 지원하지 않는다(APU 프레임 IRQ, DMC). BRK와 NMI는 동작한다.
  • 입출력 레지스터에 대한 읽기-수정-쓰기 명령은 입출력 쓰기를 발생시키지 않는다. $4020–$7FFF 읽기는 0을 반환한다(PRG-RAM 없음).
  • PPU와 APU는 가중치에 들어 있지 않다. PPU는 주사선을 한 번에 하나씩 그리므로 주사선 중간 효과는 재현되지 않는다. 스프라이트 0 충돌과 스프라이트 오버플로는 근사 처리하며, 소리는 없다.

테스트와 transformers에서 실행하기

테스트에는 pytest와 py65가 필요하다. 테스트는 몇 분 걸리며 기준 CPU와 nestest 로그 비교, 회로와 기준 CPU 비교, 자유 실행 가중치와 기준 CPU 비교, 유휴 루프 건너뛰기를 확인한다.

sh
pip install pytest py65
python -m pytest -q tests

기본 transformers에서 체크포인트를 실행하려면 torch 2.6 이상과 transformers 5.22 이상이 필요하다.

sh
pip install "torch>=2.6" "transformers>=5.22"
python -m nesformer run roms/thwaite.nes --frames 3 --engine hf

nesformer/hfcheck.py에는 체크포인트를 불러오고 패딩 토큰 하나를 디코딩한 뒤, 기계 상태를 캐시의 DeltaNet 상태에 기록하고 토큰을 하나씩 입력하는 전체 절차가 나와 있다.

파일과 라이선스

  • nesformer/spec.py: 토큰 종류와 레코드
  • nesformer/cpu.py: 추적을 기록하는 기준 6502 CPU
  • nesformer/host.py: PPU, 컨트롤러, NMI 타이밍, 사이클 계산, 유휴 루프 건너뛰기 등 CPU 주변 기능
  • nesformer/core.py: 회로와 상태 스냅샷
  • nesformer/weights.py: 회로를 Qwen3_5ForCausalLM 체크포인트로 바꾸는 생성기
  • nesformer/engine.py, fastengine.py: 가중치 실행기(numpy/numba)
  • nesformer/hfcheck.py: 기본 transformers에서 가중치 실행
  • nesformer/run.py, check.py, cli.py, __main__.py: 실행기, 기준 CPU와 비교하는 검사기, CLI
  • chipformer/: Qwenputer의 q35 컴파일러(q35/는 생성기와 기본 요소, dsl/은 그래프)
  • weights/: 컴파일된 체크포인트와 SHA-256 체크섬
  • roms/: 테스트 ROM. 제작자와 라이선스는 roms/SOURCES.md에 기록돼 있다.
  • examples/: 실행에서 만든 GIF와 프레임 시트. demo.gif는 위에 소개한 Super Mario Bros. 실행이다.
  • tests/test_nesformer.py: 테스트 모음

Qwenputer와 chipformer 컴파일러는 MIT 라이선스이며 저작권은 © 2026 Divyansh Agrawal이다(LICENSE.chipformer). 해당 라이선스는 nesformer/core.py와 nesformer/weights.py에서 Qwenputer를 바탕으로 수정한 코드에도 적용된다. 라이선스 마지막 문단은 이 저장소에 포함되지 않은 Qwenputer의 CHIP-8 ROM을 가리킨다. 테스트 ROM은 Kevin Horton의 nestest, Doug Fraker의 Full Game(MIT), Damian Yerrick의 Thwaite(GPL-3.0)다. Thwaite 소스는 pinobatch/thwaite-nes에 있다.

nesformer는 MIT 라이선스로 배포된다(LICENSE 참조). roms/의 ROM은 각자 라이선스를 유지하며, 목록은 roms/SOURCES.md에 있다.