Rust로 배우는 비디오 게임 에뮬레이션 입문
비디오 게임 에뮬레이터 개발은 개발자들 사이에서 점점 더 인기 있는 취미 프로젝트가 되고 있습니다. 에뮬레이터를 제대로 만들려면 저수준 하드웨어, 현대 프로그래밍 언어, 그래픽 시스템에 대한 지식이 필요합니다. 에뮬레이터는 훌륭한 학습 프로젝트입니다. 목표가 분명할 뿐 아니라, 직접 작성한 에뮬레이터에서 게임이 제대로 돌아가는 것을 보면 큰 보람을 느낄 수 있습니다. 저는 아직 에뮬레이션 개발을 시작한 지 얼마 되지 않았지만, 온라인의 훌륭한 가이드와 튜토리얼이 없었다면 지금에 이르지 못했을 것입니다. 그래서 제가 익힌 몇 가지 요령을 담은 가이드를 써서 커뮤니티에 보답하고자 합니다. 이 가이드가 누군가에게 도움이 되기를 바랍니다.
Chip-8 소개
우리가 목표로 하는 시스템은 Chip-8입니다. Chip-8은 일종의 에뮬레이션 개발계의 “Hello World“가 되었습니다. NES나 Game Boy처럼 더 흥미로운 것부터 시작하고 싶을 수도 있지만, 이들은 Chip-8보다 한 단계 더 복잡합니다. Chip-8은 1비트 흑백 디스플레이, 단순한 1채널 단음 오디오, 그리고 단 35개의 명령어(Game Boy는 약 500개)만 갖추고 있습니다. 이에 대해서는 뒤에서 더 자세히 다루겠습니다. 이 가이드에서는 Chip-8의 기술적 세부 사항, 어떤 하드웨어 시스템을 어떻게 에뮬레이션해야 하는지, 그리고 사용자와 상호 작용하는 방법을 다룹니다. 이 가이드는 원래의 Chip-8 사양에 집중하며, Super Chip-8, Chip-16, XO-Chip처럼 지금까지 제안된 여러 확장은 구현하지 않습니다. 이러한 확장들은 서로 독립적으로 만들어졌기 때문에 서로 모순되는 기능을 추가합니다.
Chip-8 기술 사양
-
64x32 흑백 디스플레이. 항상 너비 8픽셀, 높이 1~16픽셀인 스프라이트로 그립니다.
-
V0부터 VF까지로 불리는 16개의 8비트 범용 레지스터. VF는 오버플로 연산을 위한 플래그 레지스터로도 쓰입니다.
-
16비트 프로그램 카운터
-
메모리 접근용 포인터로 쓰이는 16비트 레지스터 하나. I 레지스터라고 부릅니다.
-
RAM 용량은 표준화되어 있지 않지만, 대부분의 에뮬레이터는 4 KB를 할당합니다.
-
서브루틴을 호출하고 복귀하는 데 쓰이는 16비트 스택
-
16키 키보드 입력
-
매 프레임마다 감소하며 0에 도달하면 작동하는 특수 레지스터 두 개:
- 지연 타이머: 시간 기반 게임 이벤트에 사용합니다.
- 사운드 타이머: 오디오 비프음을 울리는 데 사용합니다.
Rust 소개
에뮬레이터는 거의 모든 프로그래밍 언어로 작성할 수 있습니다. 이 가이드에서는 Rust 프로그래밍 언어를 사용하지만, 여기서 설명하는 단계는 어떤 언어에도 적용할 수 있습니다. Rust에는 여러 가지 큰 장점이 있습니다. 주요 플랫폼을 대상으로 컴파일할 수 있는 컴파일 언어이며, 프로젝트에 활용할 외부 라이브러리를 만드는 커뮤니티도 활발합니다. Rust는 WebAssembly 빌드도 지원하므로, 코드를 조금만 손보고 다시 컴파일하면 브라우저에서 실행할 수 있습니다. 이 가이드는 독자가 Rust 언어의 기초와 프로그래밍 전반을 이해하고 있다고 가정합니다. 진행하면서 코드를 설명하겠지만, Rust는 학습 곡선이 높기로 악명이 높으므로 가이드를 따라가다가 익숙하지 않은 개념이 나오면 훌륭한 공식 Rust 책을 읽고 참고하시기를 권합니다. 또한 이 가이드는 Rust가 설치되어 있고 제대로 작동한다고 가정합니다. 필요하다면 사용하는 플랫폼의 설치 안내를 참고하십시오.
필요한 것
시작하기 전에 다음 항목을 사용할 수 있는지, 또는 설치되어 있는지 확인하십시오.
텍스트 편집기
이 프로젝트에는 어떤 텍스트 편집기든 사용할 수 있지만, 구문 강조, 코드 제안, 디버거 지원 등 Rust용 기능을 제공하는 두 가지를 추천합니다.
-
제가 Rust 개발에 선호하는 편집기는 Visual Studio Code이며, rust-analyzer 확장과 함께 사용합니다.
-
JetBrains는 Rust 전용 IDE를 제공하지 않지만, 다른 여러 제품에서 쓸 수 있는 Rust 확장을 제공합니다. CLion용 확장에는 통합 디버거 지원처럼 다른 제품용 확장에는 없는 기능이 더 있습니다. CLion은 유료 제품이라는 점을 기억하십시오. 다만 30일 체험판을 제공하며, 학생에게는 더 긴 무료 사용 기간을 제공합니다.
이 중 어느 것도 마음에 들지 않는다면, 다른 여러 편집기에도 Rust 구문 및 자동 완성 플러그인이 있으며, gdb 같은 다른 여러 디버거로도 꽤 쉽게 디버깅할 수 있습니다.
테스트 ROM
실행할 것이 없다면 에뮬레이터는 별 쓸모가 없습니다! 이 책의 소스 코드에는 널리 배포된 Chip-8 프로그램이 여러 개 포함되어 있으며, 여기에서도 찾을 수 있습니다. 이 가이드 곳곳에서 이 게임 중 일부를 예시로 보여 드리겠습니다.
기타
진행하면서 도움이 될 만한 기타 항목은 다음과 같습니다.
-
16진수 표기법이 익숙하지 않다면 다시 한번 복습하십시오. 이 프로젝트 전반에 걸쳐 많이 사용됩니다.
-
Chip-8 게임은 바이너리 형식이므로, 디버깅할 때 원시 16진수 값을 볼 수 있으면 도움이 되는 경우가 많습니다. 일반 텍스트 편집기는 대개 파일을 16진수로 보는 기능을 지원하지 않으므로, 전용 16진수 편집기가 필요합니다. 많은 편집기가 비슷한 기능을 제공하지만, 저는 개인적으로 Reverse Engineer’s Hex Editor를 선호합니다.
시작합시다!
에뮬레이션 기초
이 첫 번째 장에서는 에뮬레이션 개발의 개념과 Chip-8을 개괄합니다. 이후의 장들에서는 Rust로 코드를 구현하지만, Rust에 관심이 없거나 예제 없이 작업하는 편을 선호한다면 이 장에서 실제 시스템이 어떤 단계를 수행하는지, 우리가 직접 에뮬레이션해야 하는 것은 무엇인지, 그리고 여러 구성 요소가 어떻게 맞물려 돌아가는지를 소개받을 수 있습니다. 이 주제를 처음 접하는 분이라면, 앞으로 구현할 내용을 이해하는 데 필요한 정보를 이 장에서 얻을 수 있을 것입니다.
Chip-8 게임에는 무엇이 들어 있습니까?
간단한 질문부터 시작합시다. 제가 Chip-8 ROM1 파일을 하나 드린다면, 그 안에는 정확히 무엇이 들어 있습니까? 게임이 화면에서 실행되는 데 필요한 모든 게임 로직과 그래픽 에셋이 들어 있어야 하겠지만, 그 모든 것이 어떻게 배치되어 있습니까?
이것은 기본적이고 근본적인 질문입니다. 우리의 목표는 이 파일을 읽어서 그 프로그램을 실행하는 것이기 때문입니다. 자, 알아낼 방법은 하나뿐이니 Chip-8 게임을 텍스트 편집기(메모장, TextEdit 또는 선호하는 다른 편집기)로 열어 봅시다. 이 예제에서는 이 가이드에 포함된 roms/PONG2 게임을 사용합니다. 아주 특별한 편집기를 쓰는 것이 아니라면, 아마 그림 1과 비슷한 화면이 보일 것입니다.
별로 도움이 되어 보이지 않습니다. 사실, 파일이 손상된 것처럼 보입니다. 걱정하지 마십시오. 여러분의 게임은 (아마도) 멀쩡합니다. 모든 컴퓨터 프로그램은 근본적으로 파일에 저장된 숫자에 불과합니다. 이 숫자들은 거의 무엇이든 의미할 수 있으며, 컴퓨터는 문맥이 있어야만 그 값에 의미를 부여할 수 있습니다. 텍스트 편집기는 영어 같은 언어로 된 텍스트를 보여 주고 편집하도록 설계되었으므로, 자동으로 ASCII 같은 언어 중심의 문맥을 사용하려고 합니다.
이렇게 해서 우리의 Chip-8 파일이 평범한 영어로 작성되어 있지 않다는 사실을 알게 되었지만, 이는 아마 이미 짐작할 수 있었던 일일 것입니다. 그렇다면 실제로는 어떤 모습입니까? 다행히 파일의 원시 내용을 보여 주는 프로그램이 있으므로, 이른바 “헥스 에디터” 중 하나를 사용해 파일의 실제 내용을 표시해야 합니다.
그림 2에서 세로선 왼쪽에 있는 숫자들은 오프셋 값으로, 파일의 시작 지점에서 몇 바이트 떨어져 있는지를 나타냅니다. 오른쪽에는 파일에 저장된 실제 값이 있습니다. 오프셋과 데이터 값은 모두 16진수로 표시됩니다(앞으로 16진수를 꽤 자주 다루게 될 것입니다).
좋습니다. 이제 숫자가 보이니 한결 나아졌습니다. 이 숫자들이 영어 문자에 대응하지 않는다면, 무엇을 의미합니까? 이것들은 Chip-8 CPU를 위한 명령어입니다. 실제로 각 명령어는 2바이트이며, 그래서 스크린샷에서 두 개씩 묶어 놓았습니다.
CPU란 무엇입니까?
익숙하지 않은 분들을 위해 CPU가 정확히 어떤 기능을 제공하는지 잠시 설명하겠습니다. 우리의 목적에서 CPU는 계산을 합니다. 그것이 전부입니다. 여기서 “계산을 한다“라고 말할 때는 덧셈, 뺄셈, 두 수가 같은지 다른지 확인하는 것과 같은 일반적인 항목들이 포함됩니다. 또한 코드의 다른 부분으로 점프하거나 숫자를 가져오고 저장하는 것처럼 게임을 실행하는 데 필요한 추가 연산도 있습니다. 게임 파일은 전부 CPU가 수행할 수학 연산으로 이루어져 있습니다.
Chip-8이 수행할 수 있는 모든 수학 연산에는 각각 대응하는 숫자가 있으며, 이를 옵코드라고 부릅니다. Chip-8 옵코드의 전체 목록은 이 페이지에서 볼 수 있습니다. 에뮬레이터가 다음 명령어를 수행할 때가 될 때마다(이를 틱 또는 사이클이라고도 합니다), 에뮬레이터는 게임 ROM에서 다음 옵코드를 가져와 옵코드 표에 지정된 연산을 무엇이든 수행합니다. 덧셈, 뺄셈, 화면에 그려진 내용 갱신 등 무엇이든 말입니다.
매개변수는 어떻게 할까요? “이제 더할 차례다“라고 말하는 것만으로는 충분하지 않습니다. 실제로 더할 두 수가 필요하고, 계산이 끝났을 때 합을 넣어 둘 장소도 필요합니다. 다른 시스템은 이를 다르게 처리하지만, 연산당 2바이트는 표현할 수 있는 수가 매우 많아서 Chip-8이 실제로 수행할 수 있는 35가지 연산보다 훨씬 많습니다. 남는 자릿수는 옵코드에 추가 정보를 담는 데 사용됩니다. 이 정보의 정확한 배치는 옵코드마다 다릅니다. 옵코드 표에서는 리터럴 16진수를 나타내기 위해 N을 사용합니다. 한 자리 리터럴 값에는 N, 두 자리에는 NN, 세 자리에는 NNN을 사용하며, X나 Y로 레지스터를 지정하기도 합니다.
레지스터란 무엇입니까?
이제 다음 주제로 넘어가겠습니다. 도대체 레지스터란 무엇입니까? 레지스터는 명령어에서 사용하기 위해 1바이트를 저장하도록 특별히 지정된 장소입니다. RAM과 꽤 비슷하게 들릴 수 있으며, 어떤 면에서는 실제로 그렇습니다. RAM은 데이터를 저장하는 크고 주소 지정이 가능한 장소인 반면, 레지스터는 보통 개수가 적고, 모두 이름이 붙어 있으며, 옵코드를 실행할 때 직접 사용됩니다. 많은 컴퓨터에서는 RAM에 있는 값을 사용하려면 먼저 그 값을 레지스터로 복사해야 합니다.
Chip-8에는 프로그래머가 자유롭게 사용할 수 있는 레지스터가 16개 있으며, 이름은 V0부터 VF(16진수로 0-15)까지입니다. 레지스터가 어떻게 동작하는지 보여 주는 예로, 옵코드 표에 있는 덧셈 연산 중 하나를 살펴봅시다. 두 레지스터의 값을 더하는 연산 VX += VY가 있으며, 이는 8XY4로 인코딩됩니다. 8XY4는 옵코드를 패턴 매칭하는 데 사용됩니다. 현재 옵코드가 8로 시작하고 4로 끝난다면 이것이 일치하는 연산입니다. 그다음 가운데 두 자리는 어떤 레지스터를 사용할지 지정합니다. 옵코드가 8124라고 합시다. 8로 시작하고 4로 끝나므로 올바른 연산을 찾은 것입니다. 이 명령어에서는 나머지 두 자리에 해당하는 V1과 V2에 저장된 값을 사용합니다. V1에 5가 저장되어 있고 V2에 10이 저장되어 있다고 하면, 이 연산은 두 값을 더해 V1에 있던 값을 대체하므로 이제 V1에는 15가 들어 있습니다.
Chip-8에는 레지스터가 몇 개 더 있지만, 이들은 매우 특정한 용도로 쓰입니다. 그중 가장 중요한 것 하나는 프로그램 카운터(PC)로, Chip-8에서는 16비트 값을 저장할 수 있습니다. 지금까지 “현재 옵코드“를 막연하게 언급해 왔는데, 우리가 어디까지 왔는지는 어떻게 추적할까요? 우리 에뮬레이터는 프로그램을 실행하는 컴퓨터를 시뮬레이션합니다. 에뮬레이터는 게임의 처음에서 시작하여 옵코드에서 옵코드로 이동하며, 지시받은 대로 명령어를 실행해야 합니다. PC는 현재 처리 중인 게임 명령어의 인덱스를 담고 있습니다. 따라서 PC는 게임의 첫 번째 바이트에서 시작하고, 그다음 세 번째 바이트로 이동하며(모든 옵코드는 2바이트라는 점을 기억하십시오), 이런 식으로 계속 진행합니다. 어떤 명령어는 PC에게 게임의 다른 곳으로 이동하라고 지시할 수도 있지만, 기본적으로 PC는 옵코드 단위로 한 칸씩 앞으로 나아갈 뿐입니다.
RAM이란 무엇입니까?
V 레지스터가 16개 있기는 하지만, Chip-8 같은 단순한 시스템이라 해도 한 번에 16개보다 많은 숫자를 저장할 수 있으면 정말 좋을 것입니다. 바로 여기서 랜덤 액세스 메모리(RAM)가 등장합니다. 여러분의 컴퓨터를 통해 이미 익숙하시겠지만, RAM은 CPU가 원하는 대로 사용할 수 있는 커다란 숫자 배열입니다. Chip-8은 물리적인 시스템이 아니므로, 갖추어야 할 표준 RAM 용량이 정해져 있지 않습니다. 하지만 에뮬레이션 개발자들은 대체로 4096바이트(4 KB)로 합의했으므로, 우리 시스템은 RAM에 8비트 숫자를 최대 4096개까지 저장할 수 있으며, 이는 대부분의 게임이 사용하는 양보다 훨씬 많습니다.
이제 중요한 세부 사항을 살펴볼 차례입니다. Chip-8 CPU는 RAM을 원하는 대로 자유롭게 읽고 쓸 수 있습니다. 그러나 게임의 ROM에는 직접 접근할 수 없습니다. “읽기 전용 메모리“라는 이름을 보면 덮어쓸 수 없으리라는 것은 짐작할 수 있지만, CPU가 ROM을 읽을 수도 없다는 말입니까? CPU는 게임 ROM을 읽을 수 있어야 하지만, 게임이 시작될 때 게임 전체를 RAM2에 복사하는 방식으로 이를 간접적으로 해결합니다. 아주 적은 양의 데이터를 읽기 위해 게임 파일을 몇 번이고 다시 여는 것은 상당히 느리고 비효율적입니다. 그 대신 가능한 한 많은 데이터를 RAM에 복사해 두어 더 빠르게 사용할 수 있도록 하려는 것입니다. 또 하나 주의할 점은, 다소 혼란스럽게도 ROM 데이터가 RAM의 맨 처음에 적재되지 않는다는 것입니다. 대신 512바이트(0x200)만큼 오프셋을 두고 적재됩니다. 즉 게임의 첫 번째 바이트는 시작 시 RAM 주소 0x200에 적재됩니다. ROM의 두 번째 바이트는 0x201에 적재되는 식입니다.
Chip-8은 왜 그냥 게임을 RAM의 처음에 저장하고 끝내지 않을까요? Chip-8이 설계되던 당시에는 컴퓨터의 RAM 용량이 훨씬 제한적이었고, 한 번에 하나의 프로그램만 실행할 수 있었습니다. 처음 0x200바이트는 Chip-8 프로그램 자체가 실행되는 공간으로 할당되었습니다. 따라서 게임이 여전히 그 개념에 맞추어 개발되므로, 현대의 Chip-8 에뮬레이터도 이 점을 염두에 두어야 합니다. 사실 우리 시스템도 그 빈 공간을 조금 사용하게 되는데, 이에 대해서는 나중에 다루겠습니다.
설정
이 프로젝트의 최종 목표는 데스크톱과 웹 브라우저 모두를 대상으로 빌드할 수 있는 에뮬레이터를 만드는 것이므로, 프로젝트 구조를 조금 특이하게 잡겠습니다. 두 빌드에서 Chip-8 시스템의 실제 에뮬레이션은 완전히 같아야 하며, 파일 읽기나 화면 표시 같은 부분만 데스크톱과 브라우저에서 달라집니다. 이를 위해 제가 코어라고 부를 백엔드를 별도의 크레이트로 만들어, 앞으로 만들 두 프런트엔드가 모두 사용하게 하겠습니다.
프로젝트를 저장할 폴더로 이동한 다음 아래 명령을 실행하십시오. $는 입력하지 마십시오. 이는 단지 터미널 명령어임을 나타내기 위한 것입니다.
$ cargo init chip8_core --lib
--lib 플래그는 cargo에게 실행 가능한 모듈이 아닌 라이브러리를 만들라고 지시합니다. 에뮬레이션 백엔드는 이곳에 들어갑니다. 이 프로젝트에서는 chip8_core라고 이름 붙였지만, 원하는 이름을 자유롭게 붙여도 됩니다.
프런트엔드는 해당 코드를 담을 크레이트를 하나 더 만들겠습니다.
$ cargo init desktop
이번에는 코어와 달리 실제로 실행할 수 있는 프로젝트가 만들어집니다. 제대로 진행했다면 이제 폴더 구조가 다음과 같을 것입니다.
이제 남은 일은 desktop 프런트엔드에 chip8_core의 위치를 알려 주는 것뿐입니다. desktop/Cargo.toml을 열고 [dependencies] 아래에 다음 줄을 추가하십시오.
chip8_core = { path = "../chip8_core" }
지금은 chip8_core가 비어 있으므로 실제로 추가되는 것은 없지만, 언젠가는 해야 할 작업입니다. 모든 것이 제대로 작동하는지 확인하기 위해 프로젝트를 컴파일하고 실행해 보십시오. desktop 디렉터리 안에서 다음을 실행하십시오.
$ cargo run
모든 설정이 올바르게 되었다면 “Hello, world!“가 출력됩니다. 좋습니다! 다음으로는 CPU 에뮬레이션을 시작하여 조금 더 흥미로운 것을 만들어 보겠습니다.
에뮬레이터 정의하기
시스템에서 가장 근본적인 부분은 CPU이므로, 에뮬레이터를 만들 때도 CPU부터 시작하겠습니다. 당분간은 주로 chip8_core 백엔드를 개발하고, 이 작업이 충분히 진행되면 다시 돌아와 프런트엔드를 만들겠습니다. 먼저 chip8_core/src/lib.rs 파일에서 작업을 시작하겠습니다(자동으로 생성된 test 코드는 삭제해도 됩니다). 기능을 추가하기 전에 앞으로 할 작업의 몇 가지 개념을 다시 짚어 봅시다.
에뮬레이션이란 원래 다른 시스템용으로 작성된 프로그램을 실행하는 것일 뿐이므로, 현대의 컴퓨터 프로그램이 실행되는 방식과 매우 비슷하게 동작합니다. 일반적인 프로그램을 실행하면 코드 한 줄을 읽고, 컴퓨터가 이를 변수 수정, 비교, 다른 코드 줄로의 점프 같은 작업으로 해석합니다. 그다음 해당 동작을 수행하고, 실행은 다음 줄로 넘어가 이 과정을 반복합니다. 컴파일러를 공부해 보았다면 알겠지만, 시스템은 코드를 한 줄씩 실행하는 것이 아니라 먼저 코드를 프로세서가 이해하는 명령어로 변환한 뒤, 그 명령어에 대해 이 루프를 수행합니다. 우리 에뮬레이터도 정확히 이렇게 동작합니다. 게임 프로그램을 값 하나씩 순회하면서 그 위치에 저장된 명령어를 가져오고(fetch), 수행해야 할 연산을 디코딩(decode)한 다음, 이를 실행(execute)하고 나서 다음 값으로 넘어갑니다. 이 가져오기-디코딩-실행(fetch-decode-execute) 루프가 CPU 에뮬레이션의 핵심이 됩니다.
이 점을 염두에 두고, 에뮬레이터를 관리할 클래스를 정의하는 것부터 시작합시다. lib.rs에 비어 있는 새 구조체를 추가하겠습니다:
#![allow(unused)]
fn main() {
pub struct Emu {
}
}
이 구조체는 에뮬레이션 백엔드의 주 객체가 되므로, 실제 게임 실행을 처리해야 하며 필요한 정보(화면에 표시되는 내용, 버튼 입력 등)를 프런트엔드와 주고받을 수 있어야 합니다.
프로그램 카운터
그렇다면 Emu 객체에는 무엇을 넣어야 할까요? 앞서 설명했듯이, 프로그램은 게임의 어느 부분을 현재 실행하고 있는지 알아야 합니다. 모든 CPU는 현재 명령어의 인덱스를 유지하는 간단한 방법으로 이를 해결하며, 이 인덱스는 프로그램 카운터(줄여서 PC)라고 불리는 특별한 레지스터에 저장됩니다. 이는 루프의 fetch 단계에서 핵심적인 역할을 하며, 게임이 실행됨에 따라 증가하면서 게임을 따라 진행하고, 일부 명령어에 의해 직접 수정될 수도 있습니다(코드의 다른 부분으로 점프하거나 서브루틴을 호출하는 경우 등). 이것을 구조체에 추가합시다:
#![allow(unused)]
fn main() {
pub struct Emu {
pc: u16,
}
}
RAM
새 명령어가 필요할 때마다 게임 파일에서 읽어 올 수도 있겠지만, 이 방법은 매우 느리고 비효율적이며, 실제 시스템이 동작하는 방식도 아닙니다. 대신 Chip-8은 게임 프로그램 전체를 자체 RAM 공간에 복사하도록 설계되어 있으며, 그 후 필요에 따라 그곳에서 읽고 쓸 수 있습니다. 참고로 Game Boy 등 많은 시스템에서는 게임이 저장된 메모리 영역을 CPU가 덮어쓸 수 없게 되어 있습니다(버그 때문에 게임 코드가 완전히 망가지는 일은 원치 않을 것입니다). 하지만 Chip-8에는 그러한 제약이 없습니다. Chip-8은 물리적인 시스템으로 존재한 적이 없으므로, 메모리를 얼마나 가져야 하는지에 대한 공식 표준이 없습니다. 하지만 원래 4096바이트(4 KB)의 RAM을 갖춘 컴퓨터에서 구현되도록 설계되었으므로, 우리도 그만큼의 메모리를 주겠습니다. 대부분의 프로그램은 이를 다 쓰는 데 근접하지도 않겠지만, 필요할 경우를 위해 마련해 두는 것입니다. 이를 프로그램에 정의합시다.
#![allow(unused)]
fn main() {
const RAM_SIZE: usize = 4096;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
}
}
디스플레이
Chip-8은 64x32 흑백 디스플레이(픽셀당 1비트)를 사용합니다. 이 디스플레이에 특별한 점은 별로 없지만, 백엔드에서 여러 프런트엔드와 사용자가 접근할 수 있어야 하는 몇 안 되는 요소 중 하나입니다. 많은 시스템과 달리 Chip-8은 매 프레임을 다시 그리기 위해 화면을 자동으로 지우지 않으며, 대신 화면 상태가 유지되고 그 위에 새 스프라이트가 그려집니다(다만 화면을 지우는 명령은 있습니다). 이 화면 데이터는 Emu 객체 안의 배열에 보관할 수 있습니다. 또한 Chip-8은 검은색과 흰색 두 가지 색만 다루면 되므로 대부분의 시스템보다 단순합니다. 1비트 디스플레이이므로, 다음과 같이 불리언 배열을 저장하기만 하면 됩니다:
#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
}
}
또한 앞서 정의한 상수와 달리, 화면 크기는 공개 상수로 정의했다는 점을 알 수 있을 것입니다. 이는 프런트엔드가 실제로 화면을 그리는 데 필요한 몇 안 되는 정보 중 하나입니다.
V 레지스터
시스템이 사용할 수 있는 RAM이 꽤 많기는 하지만, RAM 접근은 일반적으로 상당히 느린 것으로 여겨집니다(그래도 디스크에서 읽는 것보다는 몇 자릿수나 빠릅니다). 속도를 높이기 위해 Chip-8은 게임이 훨씬 빠른 연산을 위해 자유롭게 사용할 수 있는 8비트 레지스터 16개를 정의합니다. 이들은 V 레지스터라고 불리며, 보통 V0부터 VF까지 16진수로 번호가 매겨집니다(솔직히 V가 무엇의 약자인지는 잘 모르겠습니다). 우리는 이들을 Emu 구조체 안의 배열 하나로 묶어 두겠습니다.
#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
}
}
I 레지스터
RAM에서 읽고 쓸 위치를 가리키는 데 사용하는 I 레지스터라는 16비트 레지스터도 하나 더 있습니다. 이것이 어떻게 동작하는지에 대한 자세한 내용은 나중에 다루겠으며, 지금은 이 레지스터를 갖추기만 하면 됩니다.
#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
i_reg: u16,
}
}
스택
CPU에는 작은 스택도 있는데, 이는 CPU가 읽고 쓸 수 있는 16비트 값의 배열입니다. 스택은 “후입선출(Last In, First Out, LIFO)” 원칙에 따라서만 읽고 쓸 수 있다는 점에서 일반 RAM과 다르며(팬케이크 더미처럼 말입니다!), 값을 꺼낼(pop) 때에는 마지막으로 넣은(push) 값을 꺼내게 됩니다. 많은 시스템과 달리, 이 스택은 범용이 아닙니다. 스택을 사용할 수 있는 때는 서브루틴에 진입하거나 서브루틴에서 빠져나올 때뿐이며, 이때 스택은 어디에서 시작했는지를 기억해 두었다가 루틴이 끝난 뒤 그곳으로 돌아갈 수 있도록 하는 데 사용됩니다. 이번에도 Chip-8은 스택에 숫자를 몇 개까지 담을 수 있는지 공식적으로 명시하지 않지만, 에뮬레이션 개발자들은 보통 16개를 사용합니다. 스택을 구현하는 방법은 여러 가지가 있는데, 아마 가장 쉬운 방법은 Rust 표준 라이브러리의 std::collections::VecDeque 객체를 사용하는 것입니다. 이 방법은 데스크톱 전용 빌드에서는 잘 동작하지만, 이 글을 쓰는 시점에는 std 라이브러리의 많은 항목이 WebAssembly 빌드에서 동작하지 않습니다. 대신 고전적인 방식으로, 크기가 고정된 배열과 함께 스택의 맨 위가 어디인지 알려 주는 인덱스를 사용하겠습니다. 이 인덱스를 스택 포인터(SP)라고 합니다.
#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
const STACK_SIZE: usize = 16;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
i_reg: u16,
sp: u16,
stack: [u16; STACK_SIZE],
}
}
키
Chip-8은 놀랍게도 16개나 되는 서로 다른 키를 지원하며, 이 키들에는 보통 16진수로 0부터 9, A부터 F까지 번호가 매겨져 있습니다. 키는 전화기 자판과 비슷하게 배열되어 있어 A와 B는 0의 양옆에, C부터 F까지는 오른쪽 열에 놓이며, 전체적으로 4x4 격자를 이룹니다.
어떤 키가 눌려 있는지 추적해야 하므로, 불리언 배열을 사용하여 각 키의 상태를 추적하면 됩니다.
#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
const STACK_SIZE: usize = 16;
const NUM_KEYS: usize = 16;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
i_reg: u16,
sp: u16,
stack: [u16; STACK_SIZE],
keys: [bool; NUM_KEYS],
}
}
타이머
한꺼번에 다룰 내용이 많았지만, 이제 마지막 항목에 이르렀습니다. Chip-8에는 타이머로 사용하는 특수 레지스터가 두 개 더 있습니다. 첫 번째인 지연 타이머는 시스템에서 일반적인 타이머로 사용되며, 매 사이클마다 값이 줄어들다가 0에 도달하면 어떤 동작을 수행합니다. 반면 사운드 타이머도 매 클록 사이클마다 값이 줄어들지만, 0에 도달하면 소리를 냅니다. 나중에 보겠지만, 사운드 타이머를 0으로 설정하는 것이 Chip-8에서 소리를 내는 유일한 방법입니다. 둘 다 8비트 레지스터이며, 다음으로 넘어가려면 반드시 지원해야 합니다.
#![allow(unused)]
fn main() {
pub const SCREEN_WIDTH: usize = 64;
pub const SCREEN_HEIGHT: usize = 32;
const RAM_SIZE: usize = 4096;
const NUM_REGS: usize = 16;
const STACK_SIZE: usize = 16;
const NUM_KEYS: usize = 16;
pub struct Emu {
pc: u16,
ram: [u8; RAM_SIZE],
screen: [bool; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [u8; NUM_REGS],
i_reg: u16,
sp: u16,
stack: [u16; STACK_SIZE],
keys: [bool; NUM_KEYS],
dt: u8,
st: u8,
}
}
초기화
지금은 이 정도면 충분합니다. 다음 부분으로 넘어가기 전에 이 클래스의 new 생성자를 구현해 봅시다. struct 정의 다음에 기본값을 설정하는 코드를 구현하겠습니다.
#![allow(unused)]
fn main() {
// -- Unchanged code omitted --
const START_ADDR: u16 = 0x200;
// -- Unchanged code omitted --
impl Emu {
pub fn new() -> Self {
Self {
pc: START_ADDR,
ram: [0; RAM_SIZE],
screen: [false; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [0; NUM_REGS],
i_reg: 0,
sp: 0,
stack: [0; STACK_SIZE],
keys: [false; NUM_KEYS],
dt: 0,
st: 0,
}
}
}
}
모든 것이 꽤 간단해 보입니다. 모든 값과 배열을 0으로 초기화하기만 하면 됩니다… 다만 프로그램 카운터는 예외로, 0x200(10진수로 512)으로 설정됩니다. 그 이유는 이전 장에서 언급했지만, 에뮬레이터는 프로그램이 어디에서 시작하는지 알아야 하며, 모든 Chip-8 프로그램은 RAM 주소 0x200부터 적재된다는 것이 Chip-8의 표준입니다. 이 숫자는 앞으로도 다시 나오므로 상수로 정의해 두었습니다.
이것으로 이번 부분을 마칩니다! 에뮬레이터의 기반이 마련되었으니, 이제 실행 부분을 구현하기 시작할 수 있습니다!
에뮬레이션 메서드 구현하기
이제 Emu 구조체를 만들고 이 구조체가 관리할 여러 변수를 정의했으며, 초기화 함수도 정의했습니다. 다음으로 넘어가기 전에, 명령어 구현을 시작하면 쓰이게 될 몇 가지 유용한 메서드를 지금 객체에 추가해야 합니다.
푸시와 팝
CPU의 스택을 관리하기 위해 stack 배열과 포인터 sp를 모두 추가했지만, 스택에 쉽게 접근할 수 있도록 push 메서드와 pop 메서드를 모두 구현해 두면 유용할 것입니다.
#![allow(unused)]
fn main() {
impl Emu {
// -- Unchanged code omitted --
fn push(&mut self, val: u16) {
self.stack[self.sp as usize] = val;
self.sp += 1;
}
fn pop(&mut self) -> u16 {
self.sp -= 1;
self.stack[self.sp as usize]
}
// -- Unchanged code omitted --
}
}
이 메서드들은 꽤 간단합니다. push는 주어진 16비트 값을 스택 포인터가 가리키는 위치에 넣은 다음, 포인터를 다음 위치로 옮깁니다. pop은 이 작업을 반대로 수행하여, SP를 이전 값으로 되돌린 다음 그 위치에 있는 값을 반환합니다. 빈 스택에서 팝을 시도하면 언더플로 패닉[^1]이 발생한다는 점에 유의하십시오. 원한다면 여기에 별도의 처리를 추가해도 좋지만, 이런 상황이 발생한다면 에뮬레이터나 게임 코드 중 하나에 버그가 있다는 뜻이므로, 저는 그냥 패닉이 발생해도 괜찮다고 생각합니다.
폰트 스프라이트
Chip-8 화면 표시가 어떻게 작동하는지는 아직 자세히 다루지 않았지만, 지금은 메모리에 저장된 스프라이트를 한 줄씩 화면에 그린다는 요점만 알면 됩니다. 스프라이트를 복사하기 전에 올바르게 불러오는 것은 게임 개발자의 몫입니다. 하지만 숫자처럼 흔히 쓰이는 것들의 스프라이트를 시스템이 자동으로 갖추고 있다면 좋지 않겠습니까? 앞서 PC가 0x200 주소에서 시작하며, 처음 512바이트는 의도적으로 비워 둔다고 말씀드렸습니다. 대부분의 최신 에뮬레이터는 이 공간을 사용하여 모든 16진수 숫자, 즉 0-9와 A-F 문자의 폰트 스프라이트 데이터를 저장합니다. 이 데이터는 RAM의 어느 고정된 위치에든 저장할 수 있지만, 어차피 이 공간은 이미 비어 있는 곳으로 정의되어 있습니다. 각 문자는 8픽셀짜리 행 다섯 개로 이루어져 있으며, 각 행은 1바이트의 데이터를 사용하므로 문자 하나는 모두 합쳐 5바이트의 데이터를 차지합니다. 다음 도표는 문자가 바이트로 저장되는 방식을 보여 줍니다.
[^1] 언더플로는 부호 없는 변수의 값이 0보다 큰 값에서 0보다 작은 값으로 내려가는 것을 말합니다. 일부 언어에서는 이때 값이 가능한 가장 큰 값으로 “되감기지만”, Rust에서는 런타임 오류가 발생하므로 다르게 동작하게 하려면 따로 처리해야 합니다. 가능한 최댓값을 넘어서는 값도 마찬가지이며, 이를 오버플로라고 합니다.
오른쪽에는 각 행이 2진수로 인코딩되어 있습니다. 각 픽셀에는 비트가 하나씩 할당되며, 이 비트는 해당 픽셀이 흰색일지 검은색일지를 나타냅니다. Chip-8의 모든 스프라이트는 너비가 8픽셀이므로, 픽셀 한 행에는 8비트(1바이트)가 필요합니다. 위 도표는 문자 “1” 스프라이트의 배치를 보여 줍니다. 스프라이트는 너비 8비트를 모두 사용할 필요가 없으므로, 모든 스프라이트의 오른쪽 절반은 검은색입니다. 모든 16진수 숫자에 대한 스프라이트가 만들어져 있으며, 일부 게임이 작동하려면 이 스프라이트가 RAM 어딘가에 있어야 합니다. 이 가이드의 뒷부분에서 이 스프라이트를 다루는 명령어를 설명할 것이며, 그때 스프라이트를 불러오는 방법과 에뮬레이터가 스프라이트의 위치를 찾는 방법을 보여 드리겠습니다. 지금은 스프라이트를 정의하기만 하면 됩니다. 바이트로 이루어진 상수 배열로 정의하겠습니다. lib.rs 맨 위에 다음을 추가하십시오.
#![allow(unused)]
fn main() {
const FONTSET_SIZE: usize = 80;
const FONTSET: [u8; FONTSET_SIZE] = [
0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
0x20, 0x60, 0x20, 0x20, 0x70, // 1
0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2
0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3
0x90, 0x90, 0xF0, 0x10, 0x10, // 4
0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5
0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6
0xF0, 0x10, 0x20, 0x40, 0x40, // 7
0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8
0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9
0xF0, 0x90, 0xF0, 0x90, 0x90, // A
0xE0, 0x90, 0xE0, 0x90, 0xE0, // B
0xF0, 0x80, 0x80, 0x80, 0xF0, // C
0xE0, 0x90, 0x90, 0x90, 0xE0, // D
0xF0, 0x80, 0xF0, 0x80, 0xF0, // E
0xF0, 0x80, 0xF0, 0x80, 0x80 // F
];
}
위의 “1” 도식에서 바이트들이 어떻게 구성되어 있는지 볼 수 있으며, 다른 모든 글자도 비슷한 방식으로 작동합니다. 이제 이 값들을 정리했으니 RAM에 적재해야 합니다. Emu::new()를 수정하여 이 값들을 복사해 넣으십시오:
#![allow(unused)]
fn main() {
pub fn new() -> Self {
let mut new_emu = Self {
pc: START_ADDR,
ram: [0; RAM_SIZE],
screen: [false; SCREEN_WIDTH * SCREEN_HEIGHT],
v_reg: [0; NUM_REGS],
i_reg: 0,
sp: 0,
stack: [0; STACK_SIZE],
keys: [false; NUM_KEYS],
dt: 0,
st: 0,
};
new_emu.ram[..FONTSET_SIZE].copy_from_slice(&FONTSET);
new_emu
}
}
이 코드는 이전과 같은 방식으로 Emu 객체를 초기화하지만, 객체를 반환하기 전에 문자 스프라이트 데이터를 RAM에 복사해 넣습니다.
새 객체를 만들지 않고도 에뮬레이터를 초기화할 수 있다면 유용할 것입니다. 이를 위한 더 세련된 방법도 있지만, 여기서는 간단하게 호출하면 멤버 변수들을 원래 값으로 되돌리는 함수를 만들겠습니다.
#![allow(unused)]
fn main() {
pub fn reset(&mut self) {
self.pc = START_ADDR;
self.ram = [0; RAM_SIZE];
self.screen = [false; SCREEN_WIDTH * SCREEN_HEIGHT];
self.v_reg = [0; NUM_REGS];
self.i_reg = 0;
self.sp = 0;
self.stack = [0; STACK_SIZE];
self.keys = [false; NUM_KEYS];
self.dt = 0;
self.st = 0;
self.ram[..FONTSET_SIZE].copy_from_slice(&FONTSET);
}
}
틱
(일단은) Emu 객체 생성을 마쳤으니, 이제 CPU가 각 명령어를 어떻게 처리하고 게임을 어떻게 진행해 나갈지 정의하기 시작할 수 있습니다. 앞 부분에서 설명한 내용을 요약하면, 기본 루프는 다음과 같습니다:
- 프로그램 카운터에 저장된 메모리 주소에서 (RAM에 적재된) 게임의 값을 가져옵니다.
- 이 명령어를 해독합니다.
- 실행합니다. 이때 CPU 레지스터나 RAM을 수정할 수도 있습니다.
- PC를 다음 명령어로 옮기고 반복합니다.
tick 함수에 옵코드 처리를 추가하는 것부터 시작합시다. 먼저 가져오기 단계부터 시작하겠습니다:
#![allow(unused)]
fn main() {
// -- Unchanged code omitted --
pub fn tick(&mut self) {
// Fetch
let op = self.fetch();
// Decode
// Execute
}
fn fetch(&mut self) -> u16 {
// TODO
}
}
fetch 함수는 tick 루프의 일부로 내부에서만 호출되므로 공개(public)할 필요가 없습니다. 이 함수의 목적은 이제 실행할 명령어(옵코드라고 합니다)를 가져와 이 사이클의 다음 단계에서 사용할 수 있게 하는 것입니다. Chip-8의 명령어 형식이 익숙하지 않다면, 앞 장의 개요를 다시 살펴보시기를 권합니다.
다행히 Chip-8은 많은 시스템보다 쉽습니다. 우선, 많은 프로세서가 수백 개의 옵코드를 지원하는 것과 달리 다뤄야 할 옵코드가 35개뿐입니다. 또한 많은 시스템이 각 옵코드의 추가 매개변수(덧셈의 피연산자 등)를 뒤따르는 바이트에 저장하는 반면, Chip-8은 이를 옵코드 자체에 인코딩합니다. 이 때문에 모든 Chip-8 옵코드는 정확히 2바이트입니다. 이는 일부 다른 시스템보다 크지만, 명령어 전체가 그 2바이트 안에 저장되는 반면, 같은 시대의 다른 시스템은 사이클마다 1바이트에서 3바이트까지 소모할 수 있었습니다.
옵코드마다 인코딩 방식은 다르지만, 다행히 모든 명령어가 2바이트를 소모하므로 가져오기 동작은 모든 명령어에서 동일하며, 다음과 같이 구현합니다:
#![allow(unused)]
fn main() {
fn fetch(&mut self) -> u16 {
let higher_byte = self.ram[self.pc as usize] as u16;
let lower_byte = self.ram[(self.pc + 1) as usize] as u16;
let op = (higher_byte << 8) | lower_byte;
self.pc += 2;
op
}
}
이 함수는 현재 프로그램 카운터에 저장된 16비트 옵코드를 가져옵니다. RAM에는 값을 8비트 단위로 저장하므로, 두 개를 가져와 빅 엔디언으로 결합합니다. 그런 다음 방금 읽은 2바이트만큼 PC를 증가시키고, 가져온 옵코드를 이후 처리를 위해 반환합니다.
타이머 틱
Chip-8 명세에는 두 개의 특수 목적 타이머, 즉 지연 타이머와 사운드 타이머도 언급되어 있습니다. tick 함수는 CPU 사이클마다 한 번씩 동작하는 반면, 이 타이머들은 프레임마다 한 번씩 수정되므로 별도의 함수에서 처리해야 합니다. 동작은 꽤 간단하여, 매 프레임마다 두 타이머 모두 1씩 감소합니다. 사운드 타이머가 1로 설정되어 있으면 시스템은 ‘삐’ 소리를 냅니다. 타이머가 0에 도달하더라도 자동으로 초기화되지 않으며, 게임이 직접 어떤 값으로 다시 설정할 때까지 0으로 유지됩니다.
#![allow(unused)]
fn main() {
pub fn tick_timers(&mut self) {
if self.dt > 0 {
self.dt -= 1;
}
if self.st > 0 {
if self.st == 1 {
// BEEP
}
self.st -= 1;
}
}
}
오디오는 이 가이드에서 다루지 않는 유일한 부분입니다. 주된 이유는 데스크톱 프런트엔드와 웹 브라우저 프런트엔드 모두에서 오디오가 동작하게 만드는 일이 더 복잡하기 때문입니다. 지금은 비프음이 날 자리에 주석만 남겨 두겠지만, 궁금한 독자라면 직접 구현해 보시기 바랍니다(그리고 어떻게 구현했는지 저에게도 알려 주십시오).
옵코드 실행
이전 절에서는 옵코드를 가져왔고, 그 옵코드가 어떤 명령어에 해당하는지 디코드하여 해당 명령어를 실행할 준비를 하고 있었습니다. 현재 tick 함수는 다음과 같습니다.
#![allow(unused)]
fn main() {
pub fn tick(&mut self) {
// Fetch
let op = self.fetch();
// Decode
// Execute
}
}
이는 디코드와 실행이 각각 별도의 함수가 된다는 것을 의미합니다. 그렇게 할 수도 있지만, Chip-8에서는 다른 함수 호출을 거치기보다는 연산을 판별하는 즉시 바로 수행하는 편이 더 쉽습니다. 따라서 tick 함수는 다음과 같이 됩니다.
#![allow(unused)]
fn main() {
pub fn tick(&mut self) {
// Fetch
let op = self.fetch();
// Decode & execute
self.execute(op);
}
fn execute(&mut self, op: u16) {
// TODO
}
}
다음 단계는 디코드, 즉 다루고 있는 연산이 정확히 무엇인지 판별하는 것입니다. Chip-8 옵코드 치트시트에는 사용 가능한 모든 옵코드와 그 매개변수를 해석하는 방법, 그리고 각 옵코드의 의미에 대한 몇 가지 설명이 담겨 있습니다. 이 자료를 자주 참고해야 할 것입니다. 완전한 에뮬레이터를 만들려면 이 옵코드를 하나도 빠짐없이 모두 구현해야 합니다.
패턴 매칭
다행히 Rust에는 매우 강력하고 유용한 패턴 매칭 기능이 있어 이를 유리하게 활용할 수 있습니다. 하지만 그렇게 하기 전에 각 16진수 “자릿수“를 분리해야 합니다.
#![allow(unused)]
fn main() {
fn execute(&mut self, op: u16) {
let digit1 = (op & 0xF000) >> 12;
let digit2 = (op & 0x0F00) >> 8;
let digit3 = (op & 0x00F0) >> 4;
let digit4 = op & 0x000F;
}
}
가장 깔끔한 코드는 아닐 수도 있지만, 각 16진수 자릿수가 따로따로 필요합니다. 여기서부터 모든 옵코드의 패턴을 지정할 수 있는 match 문을 만들 수 있습니다.
#![allow(unused)]
fn main() {
fn execute(&mut self, op: u16) {
let digit1 = (op & 0xF000) >> 12;
let digit2 = (op & 0x0F00) >> 8;
let digit3 = (op & 0x00F0) >> 4;
let digit4 = op & 0x000F;
match (digit1, digit2, digit3, digit4) {
(_, _, _, _) => unimplemented!("Unimplemented opcode: {}", op),
}
}
}
Rust의 match 문은 가능한 모든 경우를 고려하도록 요구하며, 이는 “그 밖의 모든 것“을 잡아내는 _ 변수로 처리합니다. 그 안에서는 unimplemented! 매크로를 사용하여 프로그램이 그 지점에 도달하면 패닉을 일으키도록 합니다. 모든 옵코드를 추가하고 나서도 Rust 컴파일러는 여전히 “그 밖의 모든 것“을 처리하는 구문을 요구하지만, 실제로 그 구문에 도달하는 일은 절대 없어야 합니다.
다른 아키텍처에서도 긴 match 문이 분명 동작하겠지만, 일반적으로는 명령어를 각각 별도의 함수로 구현하고 룩업 테이블을 사용하거나 프로그램적으로 올바른 함수를 판별하는 방식이 더 흔합니다. Chip-8은 명령어 매개변수를 옵코드 자체에 저장한다는 점에서 다소 특이하며, 이 때문에 명령어를 매칭하려면 와일드카드가 많이 필요합니다. 명령어의 수가 비교적 적으므로 여기서는 match 문이 잘 맞습니다.
뼈대가 갖추어졌으니 본격적으로 시작합시다!
옵코드 구현 소개
이어지는 페이지에서는 Chip-8의 모든 명령어가 어떻게 동작하는지 하나씩 설명하고, 이를 구현하는 코드도 함께 제시합니다. 그대로 따라가며 명령어를 하나씩 구현해도 좋지만, 그 전에 다음 절을 먼저 살펴보고 프런트엔드 코드 일부를 작업하기 시작하는 것도 좋습니다. 현재로서는 에뮬레이터를 실제로 실행할 방법이 없으며, 디버깅을 위해 게임을 불러와 실행해 볼 수 있으면 유용하다고 느끼는 분도 있을 것입니다. 하지만 모든 명령어를 구현하지 않으면 에뮬레이터가 상당히 빨리 비정상 종료될 가능성이 높다는 점을 기억하십시오. 개인적으로는 다른 구성 요소를 작업하기 전에 명령어부터 작업하는 편을 선호합니다(이 가이드가 이런 순서로 구성된 이유도 그 때문입니다).
이 점을 짚어 두었으니, 이제 Chip-8 명령어를 차례대로 하나씩 작업해 봅시다.
0000 - Nop
첫 번째 명령어는 가장 단순한 명령어로, 아무것도 하지 않습니다. 이런 명령어가 왜 필요한지 의아할 수 있지만, 타이밍이나 정렬을 위해 필요할 때가 있습니다. 어쨌든 다음 옵코드로 넘어가기만 하면 되는데(이는 이미 fetch에서 처리했습니다), 그런 다음 반환하면 됩니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// NOP
(0, 0, 0, 0) => return,
(_, _, _, _) => unimplemented!("Unimplemented opcode: {}", op),
}
}
00E0 - 화면 지우기
옵코드 0x00E0은 화면을 지우는 명령어로, 화면 버퍼를 다시 빈 상태로 초기화해야 합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// CLS
(0, 0, 0xE, 0) => {
self.screen = [false; SCREEN_WIDTH * SCREEN_HEIGHT];
},
// -- Unchanged code omitted --
}
}
00EE - 서브루틴에서 반환
아직 서브루틴(즉, 함수)과 그 작동 방식에 대해서는 이야기하지 않았습니다. 서브루틴에 진입하는 것은 일반 점프와 같은 방식으로 작동합니다. PC를 지정된 주소로 옮기고 그곳에서부터 실행을 재개합니다. 점프와 달리 서브루틴은 언젠가 끝날 것으로 예상되며, 끝나면 진입했던 지점으로 다시 돌아가야 합니다. 바로 여기서 스택이 등장합니다. 서브루틴에 진입할 때는 현재 주소를 스택에 푸시하고 루틴의 코드를 실행한 다음, 반환할 준비가 되면 스택에서 그 값을 팝하여 그 지점부터 다시 실행하면 됩니다. 또한 스택을 사용하면 중첩된 서브루틴의 반환 주소를 유지하면서 올바른 순서로 반환되도록 보장할 수 있습니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// RET
(0, 0, 0xE, 0xE) => {
let ret_addr = self.pop();
self.pc = ret_addr;
},
// -- Unchanged code omitted --
}
}
1NNN - 점프
점프 명령어는 추가하기 매우 쉽습니다. PC를 주어진 주소로 옮기기만 하면 됩니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// JMP NNN
(1, _, _, _) => {
let nnn = op & 0xFFF;
self.pc = nnn;
},
// -- Unchanged code omitted --
}
}
여기서 주목할 점은 이 옵코드가 최상위 자릿수가 ’0x1’인 것으로 정의된다는 것입니다. 나머지 자릿수는 이 연산의 매개변수로 사용되므로 match 문에 _ 자리 표시자를 사용했습니다. 여기서는 1로 시작하고 임의의 세 자릿수로 끝나는 모든 값이 이 문으로 들어오기를 원합니다.
2NNN - 서브루틴 호출
‘서브루틴에서 반환’ 연산과 반대로, 현재 PC를 스택에 추가한 다음 주어진 주소로 점프합니다. 이 부분으로 바로 건너뛰었다면, 추가 맥락을 위해 반환 절을 읽어 보시기를 권합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// CALL NNN
(2, _, _, _) => {
let nnn = op & 0xFFF;
self.push(self.pc);
self.pc = nnn;
},
// -- Unchanged code omitted --
}
}
3XNN - VX == NN이면 다음 건너뛰기
이 옵코드는 비슷한 패턴을 따르는 몇 가지 옵코드 중 첫 번째입니다. 어셈블리에 익숙하지 않은 분들을 위해 설명하면, 한 줄을 건너뛸 수 있다는 것은 if-else 블록과 비슷한 기능을 제공합니다. 비교를 수행하여 참이면 한 명령어로, 거짓이면 다른 곳으로 갈 수 있습니다. 이것은 또한 V 레지스터 중 하나를 사용하는 첫 번째 옵코드입니다. 이 경우 두 번째 자릿수는 사용할 레지스터를 알려 주고, 마지막 두 자릿수는 원시 값을 제공합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// SKIP VX == NN
(3, _, _, _) => {
let x = digit2 as usize;
let nn = (op & 0xFF) as u8;
if self.v_reg[x] == nn {
self.pc += 2;
}
},
// -- Unchanged code omitted --
}
}
구현 방식은 다음과 같습니다. 두 번째 자릿수는 이미 변수에 저장해 두었으므로 이를 ‘X’ 인덱스로 재사용하되, Rust에서는 모든 배열 인덱싱을 usize 변수로 해야 하므로 usize로 캐스팅합니다. 해당 레지스터에 저장된 값이 nn과 같으면 다음 옵코드를 건너뛰는데, 이는 PC를 2바이트 앞으로 건너뛰는 것과 같습니다.
4XNN - VX != NN이면 다음 건너뛰기
이 옵코드는 비교한 값이 같지 않을 때 건너뛴다는 점을 제외하면 이전 옵코드와 완전히 같습니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// SKIP VX != NN
(4, _, _, _) => {
let x = digit2 as usize;
let nn = (op & 0xFF) as u8;
if self.v_reg[x] != nn {
self.pc += 2;
}
},
// -- Unchanged code omitted --
}
}
5XY0 - VX == VY이면 다음 건너뛰기
다시 비슷한 연산이지만, 이번에는 세 번째 자릿수를 사용해 또 다른 V 레지스터를 인덱스로 참조합니다. 또한 가장 낮은 자리는 연산에 사용되지 않는다는 점도 알 수 있습니다. 이 옵코드에서는 그 자리가 0이어야 합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// SKIP VX == VY
(5, _, _, 0) => {
let x = digit2 as usize;
let y = digit3 as usize;
if self.v_reg[x] == self.v_reg[y] {
self.pc += 2;
}
},
// -- Unchanged code omitted --
}
}
6XNN - VX = NN
두 번째 자리로 지정된 V 레지스터를 주어진 값으로 설정합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX = NN
(6, _, _, _) => {
let x = digit2 as usize;
let nn = (op & 0xFF) as u8;
self.v_reg[x] = nn;
},
// -- Unchanged code omitted --
}
}
7XNN - VX += NN
이 연산은 주어진 값을 VX 레지스터에 더합니다. 오버플로가 발생하면 Rust는 패닉을 일으키므로, 일반적인 덧셈 연산자와는 다른 방법을 사용해야 합니다. 또한 Chip-8에는 캐리 플래그가 있지만(이에 대해서는 뒤에서 자세히 다룹니다), 이 연산으로는 수정되지 않는다는 점에 유의하십시오.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX += NN
(7, _, _, _) => {
let x = digit2 as usize;
let nn = (op & 0xFF) as u8;
self.v_reg[x] = self.v_reg[x].wrapping_add(nn);
},
// -- Unchanged code omitted --
}
}
8XY0 - VX = VY
VX = NN 연산과 비슷하지만, 원본 값을 VY 레지스터에서 가져옵니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX = VY
(8, _, _, 0) => {
let x = digit2 as usize;
let y = digit3 as usize;
self.v_reg[x] = self.v_reg[y];
},
// -- Unchanged code omitted --
}
}
8XY1, 8XY2, 8XY3 - 비트 연산
8XY1, 8XY2, 8XY3 옵코드는 모두 비슷한 기능이므로, 같은 내용을 세 번 반복하는 대신 OR 연산만 구현하고 나머지 두 개는 독자가 구현하도록 남겨 두겠습니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX |= VY
(8, _, _, 1) => {
let x = digit2 as usize;
let y = digit3 as usize;
self.v_reg[x] |= self.v_reg[y];
},
// -- Unchanged code omitted --
}
}
8XY4 - VX += VY
이 연산에는 주목해야 할 두 가지 측면이 있습니다. 첫째, 이 연산은 오버플로가 발생할 수 있으며, 이를 올바르게 처리하지 않으면 Rust에서 패닉이 발생합니다. 둘째, 이 연산은 VF 플래그 레지스터를 처음으로 사용하는 연산입니다. 앞에서 잠깐 언급했지만, 처음 15개의 V 레지스터는 범용인 반면 마지막 16번째(0xF) 레지스터는 플래그 레지스터의 역할도 겸합니다. 플래그 레지스터는 많은 CPU 프로세서에서 흔히 볼 수 있으며, Chip-8의 경우에는 캐리 플래그도 저장합니다. 캐리 플래그는 기본적으로 마지막 연산의 결과로 오버플로/언더플로가 발생했는지를 기록하는 특수한 변수입니다. 여기서는 오버플로가 발생하면 VF를 1로, 그렇지 않으면 0으로 설정해야 합니다. 이 두 가지 측면을 염두에 두고, Rust의 overflowing_add 속성을 사용하겠습니다. 이 속성은 래핑된 합과 오버플로 발생 여부를 나타내는 불리언을 함께 담은 튜플을 반환합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX += VY
(8, _, _, 4) => {
let x = digit2 as usize;
let y = digit3 as usize;
let (new_vx, carry) = self.v_reg[x].overflowing_add(self.v_reg[y]);
let new_vf = if carry { 1 } else { 0 };
self.v_reg[x] = new_vx;
self.v_reg[0xF] = new_vf;
},
// -- Unchanged code omitted --
}
}
8XY5 - VX -= VY
이전 옵코드와 같은 연산이지만, 덧셈 대신 뺄셈을 수행합니다. 핵심적인 차이점은 VF 캐리 플래그가 반대 방식으로 동작한다는 것입니다. 덧셈 연산은 오버플로가 발생하면 플래그를 1로 설정했지만, 여기서는 언더플로가 발생하면 0으로 설정하고, 그렇지 않으면 1로 설정합니다. 여기서는 overflowing_sub 메서드가 유용합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX -= VY
(8, _, _, 5) => {
let x = digit2 as usize;
let y = digit3 as usize;
let (new_vx, borrow) = self.v_reg[x].overflowing_sub(self.v_reg[y]);
let new_vf = if borrow { 0 } else { 1 };
self.v_reg[x] = new_vx;
self.v_reg[0xF] = new_vf;
},
// -- Unchanged code omitted --
}
}
8XY6 - VX >>= 1
이 연산은 VX의 값을 오른쪽으로 한 번 시프트하고, 밀려난 비트를 VF 레지스터에 저장합니다. 안타깝게도 밀려난 비트를 잡아내는 Rust u8 내장 연산자가 없으므로, 직접 처리해야 합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX >>= 1
(8, _, _, 6) => {
let x = digit2 as usize;
let lsb = self.v_reg[x] & 1;
self.v_reg[x] >>= 1;
self.v_reg[0xF] = lsb;
},
// -- Unchanged code omitted --
}
}
8XY7 - VX = VY - VX
이 연산은 앞의 VX -= VY 연산과 똑같이 동작하지만, 피연산자의 방향이 반대입니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX = VY - VX
(8, _, _, 7) => {
let x = digit2 as usize;
let y = digit3 as usize;
let (new_vx, borrow) = self.v_reg[y].overflowing_sub(self.v_reg[x]);
let new_vf = if borrow { 0 } else { 1 };
self.v_reg[x] = new_vx;
self.v_reg[0xF] = new_vf;
},
// -- Unchanged code omitted --
}
}
8XYE - VX <<= 1
오른쪽 시프트 연산과 비슷하지만, 오버플로된 값을 플래그 레지스터에 저장합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX <<= 1
(8, _, _, 0xE) => {
let x = digit2 as usize;
let msb = (self.v_reg[x] >> 7) & 1;
self.v_reg[x] <<= 1;
self.v_reg[0xF] = msb;
},
// -- Unchanged code omitted --
}
}
9XY0 - VX != VY이면 건너뛰기
0x8000 연산을 마쳤으니, 이제 돌아가서 눈에 띄게 빠져 있던 옵코드, 즉 VX != VY일 때 다음 줄을 건너뛰는 옵코드를 추가할 차례입니다. 이는 5XY0 연산과 같은 코드이지만, 같지 않음을 비교한다는 점만 다릅니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// SKIP VX != VY
(9, _, _, 0) => {
let x = digit2 as usize;
let y = digit3 as usize;
if self.v_reg[x] != self.v_reg[y] {
self.pc += 2;
}
},
// -- Unchanged code omitted --
}
}
ANNN - I = NNN
이는 I 레지스터를 활용하는 첫 번째 명령어입니다. I 레지스터는 이후 여러 명령어에서 주로 RAM에 대한 주소 포인터로 사용됩니다. 이 경우에는 단순히 이 옵코드에 인코딩된 0xNNN 값으로 설정합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// I = NNN
(0xA, _, _, _) => {
let nnn = op & 0xFFF;
self.i_reg = nnn;
},
// -- Unchanged code omitted --
}
}
BNNN - V0 + NNN으로 점프
이전 명령어들은 옵코드 안에 지정된 V 레지스터를 사용했지만, 이 명령어는 항상 첫 번째 V0 레지스터를 사용합니다. 이 연산은 PC를 V0에 저장된 값과 옵코드에 주어진 원시 값 0xNNN을 더한 값으로 이동시킵니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// JMP V0 + NNN
(0xB, _, _, _) => {
let nnn = op & 0xFFF;
self.pc = (self.v_reg[0] as u16) + nnn;
},
// -- Unchanged code omitted --
}
}
CXNN - VX = rand() & NN
드디어 단조로움을 깨뜨릴 무언가가 등장했습니다! 이 옵코드는 Chip-8의 난수 생성 기능인데, 생성된 난수를 옵코드의 하위 8비트와 AND 연산한다는 약간의 변형이 있습니다. Rust 개발 팀이 난수 생성 크레이트를 공개하기는 했지만, 이는 표준 라이브러리에 포함되어 있지 않으므로 프로젝트에 직접 추가해야 합니다.
chip8_core/Cargo.toml에서 [dependencies] 아래 아무 곳에나 다음 줄을 추가하십시오.
rand = "^0.7.3"
참고: 이 가이드를 끝까지 완전히 따라갈 계획이라면, 나중에 웹 브라우저 지원을 위해 이 라이브러리를 포함하는 방식을 변경하게 됩니다. 하지만 프로젝트의 현 단계에서는 이대로 지정하는 것으로 충분합니다.
이제 RNG 지원을 추가하고 이 옵코드를 구현할 차례입니다. lib.rs 맨 위에서 rand 크레이트의 함수를 가져와야 합니다.
#![allow(unused)]
fn main() {
use rand::random;
}
그런 다음 옵코드를 구현할 때 random 함수를 사용합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX = rand() & NN
(0xC, _, _, _) => {
let x = digit2 as usize;
let nn = (op & 0xFF) as u8;
let rng: u8 = random();
self.v_reg[x] = rng & nn;
},
// -- Unchanged code omitted --
}
}
random() 함수가 어떤 타입을 생성해야 하는지 알 수 있도록 rng를 u8 변수로 지정해야 한다는 점에 유의하십시오.
DXYN - 스프라이트 그리기
이것은 아마도 가장 복잡한 옵코드일 것이므로, 잠시 시간을 내어 작동 방식을 자세히 설명하겠습니다. Chip-8 디스플레이는 화면에 개별 픽셀이나 사각형을 하나씩 그리는 대신 스프라이트를 그리는 방식으로 작동합니다. 스프라이트는 메모리에 저장된 이미지로, 지정된 (x, y) 위치에 화면으로 복사됩니다. 이 옵코드에서는 두 번째와 세 번째 자리가 (x, y) 좌표를 가져올 V 레지스터를 알려 줍니다. 여기까지는 좋습니다. Chip-8의 스프라이트는 항상 너비가 8픽셀이지만, 높이는 1에서 16까지 변수처럼 달라지는 픽셀 수를 가질 수 있습니다. 높이는 옵코드의 마지막 자리로 지정됩니다. 앞서 I 레지스터가 메모리 주소를 저장하는 데 자주 사용된다고 말씀드렸는데, 여기가 바로 그런 경우입니다. 스프라이트는 I에 저장된 주소부터 한 행씩 저장되어 있습니다. 따라서 높이가 3px인 스프라이트를 그리라는 명령을 받으면, 첫 번째 행의 데이터는 *I에 저장되어 있고, 그다음은 *I + 1, 그다음은 *I + 2에 저장되어 있습니다. 모든 스프라이트의 너비가 8픽셀인 이유도 이것으로 설명됩니다. 각 행에는 8비트인 1바이트가 할당되며, 각 비트가 검은색 또는 흰색인 픽셀 하나에 대응합니다. 마지막으로 알아 둘 점은, 픽셀이 하나라도 흰색에서 검은색으로 또는 그 반대로 바뀌면 VF가 설정되고, 그렇지 않으면 초기화된다는 것입니다. 이러한 점들을 염두에 두고 시작합시다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// DRAW
(0xD, _, _, _) => {
// Get the (x, y) coords for our sprite
let x_coord = self.v_reg[digit2 as usize] as u16;
let y_coord = self.v_reg[digit3 as usize] as u16;
// The last digit determines how many rows high our sprite is
let num_rows = digit4;
// Keep track if any pixels were flipped
let mut flipped = false;
// Iterate over each row of our sprite
for y_line in 0..num_rows {
// Determine which memory address our row's data is stored
let addr = self.i_reg + y_line as u16;
let pixels = self.ram[addr as usize];
// Iterate over each column in our row
for x_line in 0..8 {
// Use a mask to fetch current pixel's bit. Only flip if a 1
if (pixels & (0b1000_0000 >> x_line)) != 0 {
// Sprites should wrap around screen, so apply modulo
let x = (x_coord + x_line) as usize % SCREEN_WIDTH;
let y = (y_coord + y_line) as usize % SCREEN_HEIGHT;
// Get our pixel's index for our 1D screen array
let idx = x + SCREEN_WIDTH * y;
// Check if we're about to flip the pixel and set
flipped |= self.screen[idx];
self.screen[idx] ^= true;
}
}
}
// Populate VF register
if flipped {
self.v_reg[0xF] = 1;
} else {
self.v_reg[0xF] = 0;
}
},
// -- Unchanged code omitted --
}
}
EX9E - 키가 눌렸으면 건너뛰기
드디어 사용자 입력을 소개할 차례입니다. 에뮬레이터 객체를 설정할 때, 0부터 0xF까지 번호가 매겨진 16개의 키가 있다고 말씀드렸습니다. 이 명령어는 VX에 저장된 인덱스의 키가 눌렸는지 확인하고, 눌렸다면 다음 명령어를 건너뜁니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// SKIP KEY PRESS
(0xE, _, 9, 0xE) => {
let x = digit2 as usize;
let vx = self.v_reg[x];
let key = self.keys[vx as usize];
if key {
self.pc += 2;
}
},
// -- Unchanged code omitted --
}
}
EXA1 - 키가 눌리지 않았으면 건너뛰기
이전 명령어와 같지만, 이번에는 해당 키가 눌려 있지 않을 때 다음 명령어를 건너뜁니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// SKIP KEY RELEASE
(0xE, _, 0xA, 1) => {
let x = digit2 as usize;
let vx = self.v_reg[x];
let key = self.keys[vx as usize];
if !key {
self.pc += 2;
}
},
// -- Unchanged code omitted --
}
}
FX07 - VX = DT
에뮬레이션 구조를 설정할 때 지연 타이머의 용도를 말씀드렸습니다. 이 타이머는 0에 도달할 때까지 매 프레임마다 감소합니다. 이 동작은 자동으로 일어나지만, 게임의 타이밍을 맞추려면 지연 타이머에 어떤 값이 들어 있는지 실제로 확인할 수 있으면 매우 유용할 것입니다. 이 명령어가 바로 그 일을 하며, 현재 값을 V 레지스터 중 하나에 저장하여 사용할 수 있게 해 줍니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// VX = DT
(0xF, _, 0, 7) => {
let x = digit2 as usize;
self.v_reg[x] = self.dt;
},
// -- Unchanged code omitted --
}
}
FX0A - 키 입력 대기
키가 눌렸는지 또는 떼어졌는지 확인하는 명령어는 이미 있었지만, 이 명령어는 전혀 다른 일을 합니다. 키 상태를 확인한 뒤 바로 다음으로 넘어가던 앞의 명령어들과 달리, 이 명령어는 블로킹 방식입니다. 즉, 플레이어가 키를 누를 때까지 게임 전체가 멈춘 채 필요한 만큼 기다립니다. 따라서 keys 배열의 어떤 값이 true가 될 때까지 끝없이 반복해야 합니다. 키가 발견되면 그 키를 VX에 저장합니다. 현재 둘 이상의 키가 눌려 있다면 인덱스가 가장 낮은 키를 선택합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// WAIT KEY
(0xF, _, 0, 0xA) => {
let x = digit2 as usize;
let mut pressed = false;
for i in 0..self.keys.len() {
if self.keys[i] {
self.v_reg[x] = i as u8;
pressed = true;
break;
}
}
if !pressed {
// Redo opcode
self.pc -= 2;
}
},
// -- Unchanged code omitted --
}
}
이 구현을 보면서 “그냥 루프를 돌리면 될 텐데, 왜 옵코드를 되돌리고 가져오기 과정 전체를 다시 거치는 거지?“라고 생각하실 수도 있습니다. 간단히 말해, 이 명령어가 이후 명령어의 실행을 막기를 바라지만, 새로운 키 입력이 등록되는 것까지 막고 싶지는 않기 때문입니다. 루프 안에 머물러 있으면 키 입력 코드가 전혀 실행되지 않으므로, 이 루프는 영원히 끝나지 않게 됩니다. 비효율적일 수는 있지만, 어떤 형태의 비동기 확인 방식보다 훨씬 간단합니다.
FX15 - DT = VX
이 연산은 앞의 지연 타이머 명령어와 반대 방향으로 동작합니다. 지연 타이머를 어떤 값으로 재설정할 방법이 필요한데, 이 명령어를 사용하면 우리가 고른 V 레지스터의 값을 복사해 넣을 수 있습니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// DT = VX
(0xF, _, 1, 5) => {
let x = digit2 as usize;
self.dt = self.v_reg[x];
},
// -- Unchanged code omitted --
}
}
FX18 - ST = VX
앞의 명령어와 거의 똑같지만, 이번에는 VX의 값을 사운드 타이머에 저장합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// ST = VX
(0xF, _, 1, 8) => {
let x = digit2 as usize;
self.st = self.v_reg[x];
},
// -- Unchanged code omitted --
}
}
FX1E - I += VX
ANNN 명령어는 I를 인코딩된 0xNNN 값으로 설정하지만, 때로는 값을 단순히 증가시킬 수 있는 것이 유용합니다. 이 명령어는 VX에 저장된 값을 가져와 I 레지스터에 더합니다. 오버플로가 발생하면 레지스터는 그냥 0으로 되돌아가야 하며, 이는 Rust의 wrapping_add 메서드로 구현할 수 있습니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// I += VX
(0xF, _, 1, 0xE) => {
let x = digit2 as usize;
let vx = self.v_reg[x] as u16;
self.i_reg = self.i_reg.wrapping_add(vx);
},
// -- Unchanged code omitted --
}
}
FX29 - I를 폰트 주소로 설정
이 명령어도 처음에는 어떻게 진행해야 할지 분명하지 않을 수 있는 까다로운 명령어입니다. 기억하시겠지만, 에뮬레이터를 초기화할 때 RAM의 맨 앞부분에 폰트 데이터 배열을 저장했습니다. 이 명령어는 화면에 출력할 숫자(0부터 0xF까지)를 VX에서 가져와, 해당 스프라이트의 RAM 주소를 I 레지스터에 저장하도록 요구합니다. 사실 일관성을 유지하고 여기서 올바르게 가리키기만 한다면, 이 스프라이트들은 원하는 어느 곳에든 저장해도 됩니다. 하지만 우리는 이것들을 RAM의 시작 부분이라는 매우 편리한 위치에 저장했습니다. 몇몇 스프라이트와 그 RAM 위치를 출력해서 무슨 뜻인지 보여 드리겠습니다.
| 문자 | RAM 주소 |
|---|---|
| 0 | 0 |
| 1 | 5 |
| 2 | 10 |
| 3 | 15 |
| … | … |
| E (14) | 70 |
| F (15) | 75 |
모든 폰트 스프라이트가 각각 5바이트를 차지하므로, 각 스프라이트의 RAM 주소는 단순히 그 값에 5를 곱한 값이라는 점을 알 수 있습니다. 폰트를 다른 RAM 주소에 저장했더라도 이 규칙은 그대로 따를 수 있지만, 블록이 시작되는 위치만큼 오프셋을 적용해야 합니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// I = FONT
(0xF, _, 2, 9) => {
let x = digit2 as usize;
let c = self.v_reg[x] as u16;
self.i_reg = c * 5;
},
// -- Unchanged code omitted --
}
}
FX33 - I = VX의 BCD
Chip-8의 명령어 대부분은 상당히 자명하여, 대략적인 설명만 들어도 꽤 쉽게 구현할 수 있습니다. 그러나 화면에 그리기나 이번 명령어처럼 꽤 까다로운 것도 몇 가지 있습니다. 이번 명령어는 VX에 저장된 숫자의 이진화 십진법(BCD) 값을 I 레지스터에 저장합니다. BCD에 익숙하지 않다면 따로 읽어 보시기를 권하지만, 간단히 복습하면 다음과 같습니다. 이 튜토리얼에서는 16진수를 꽤 많이 사용해 왔는데, 16진수는 우리가 평소에 쓰는 10진수를 컴퓨터가 더 쉽게 이해할 수 있는 16진법으로 변환하는 방식입니다. 예를 들어, 10진수 100은 0x64가 됩니다. 이는 컴퓨터에게는 좋지만 사람에게는 그다지 친숙하지 않으며, 여러분의 게임을 플레이할 일반 사용자에게는 더더욱 그렇습니다. BCD의 주된 목적은 점수나 최고 기록처럼 사용자에게 출력할 값을 위해 16진수를 다시 유사 10진수로 변환하는 것입니다. 따라서 Chip-8이 내부적으로는 0x64를 저장하더라도, 그 BCD를 구하면 0x1, 0x0, 0x0을 얻게 되며, 이를 화면에 “100“으로 출력할 수 있습니다. 숫자의 세 자릿수를 모두 저장하기 위해 1바이트에서 3바이트로 늘어났다는 점을 알 수 있는데, 이 때문에 BCD를 I 레지스터에 현재 들어 있는 주소부터 차례로 RAM에 저장할 것입니다. VX는 0부터 255까지의 8비트 숫자를 저장하므로, 일부 자릿수가 0이더라도 항상 3바이트가 됩니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// BCD
(0xF, _, 3, 3) => {
let x = digit2 as usize;
let vx = self.v_reg[x] as f32;
// Fetch the hundreds digit by dividing by 100 and tossing the decimal
let hundreds = (vx / 100.0).floor() as u8;
// Fetch the tens digit by dividing by 10, tossing the ones digit and the decimal
let tens = ((vx / 10.0) % 10.0).floor() as u8;
// Fetch the ones digit by tossing the hundreds and the tens
let ones = (vx % 10.0) as u8;
self.ram[self.i_reg as usize] = hundreds;
self.ram[(self.i_reg + 1) as usize] = tens;
self.ram[(self.i_reg + 2) as usize] = ones;
},
// -- Unchanged code omitted --
}
}
이 구현에서는 나눗셈과 나머지 연산으로 각 10진수 자릿수를 구할 수 있도록 VX 값을 먼저 float로 변환했습니다. 이것은 가장 빠른 구현도 아니며, 아마 가장 짧은 구현도 아닐 것입니다. 하지만 가장 이해하기 쉬운 구현 중 하나입니다. 이진수에 아주 능통한 독자 중에는 제가 이렇게 구현한 것을 못마땅해하는 분도 분명 있겠지만, 이 해법은 그런 분들을 위한 것이 아닙니다. 이것은 BCD를 처음 접하는 독자를 위한 것으로, 이런 독자에게는 속도를 조금 잃더라도 더 잘 이해하는 편이 나은 절충입니다. 하지만 이 부분을 구현하고 나면, 모두 더 효율적인 BCD 알고리즘을 찾아보고 코드에 간단한 최적화를 더해 보시기를 권합니다.
FX55 - V0 - VX를 I에 저장
이제 거의 다 왔습니다! 마지막 두 명령어는 I 레지스터에 있는 주소부터 시작하는 RAM의 같은 범위 값으로 V 레지스터 V0부터 지정된 VX까지(VX 포함)를 채웁니다. 첫 번째 명령어는 값을 RAM에 저장하고, 다음 명령어는 반대 방향으로 값을 불러옵니다.
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// STORE V0 - VX
(0xF, _, 5, 5) => {
let x = digit2 as usize;
let i = self.i_reg as usize;
for idx in 0..=x {
self.ram[i + idx] = self.v_reg[idx];
}
},
// -- Unchanged code omitted --
}
}
FX65 - I를 V0 - VX로 불러오기
#![allow(unused)]
fn main() {
match (digit1, digit2, digit3, digit4) {
// -- Unchanged code omitted --
// LOAD V0 - VX
(0xF, _, 6, 5) => {
let x = digit2 as usize;
let i = self.i_reg as usize;
for idx in 0..=x {
self.v_reg[idx] = self.ram[i + idx];
}
},
// -- Unchanged code omitted --
}
}
마치며
이것으로 끝입니다! 이로써 Chip-8 CPU를 완전히 구현했습니다. 가능한 옵코드 값 중 상당수, 특히 0x0000, 0xE000, 0xF000 범위의 값이 전혀 다뤄지지 않았다는 점을 눈치채셨을 것입니다. 이는 문제가 없습니다. 이 옵코드들은 원래 설계에서 정의되지 않은 채로 남아 있으므로, 어떤 게임이 이를 사용하려 하면 런타임 패닉이 발생합니다. 이 에뮬레이터를 완성한 후에도 여전히 궁금하다면, 이러한 빈틈 일부를 채워 추가 기능을 더하는 Chip-8 확장이 여럿 있지만, 이 가이드에서는 다루지 않습니다.
데스크톱 프런트엔드 작성하기
이 절에서는 마침내 모든 조각을 연결하여 에뮬레이터가 게임을 불러와 실행하도록 만들겠습니다. 현재 단계에서 에뮬레이터 코어는 게임의 옵코드를 해석하고 처리할 수 있으며, 필요에 따라 화면, RAM, 레지스터를 갱신할 수 있습니다. 다음으로, 게임 불러오기, 사용자 입력 받기, 표시할 화면 버퍼 공유하기 등의 기능을 프런트엔드에 노출하기 위해 몇 가지 공개 함수를 추가해야 합니다.
코어를 프런트엔드에 노출하기
chip8_core는 아직 끝나지 않았습니다. Emu 구조체의 일부 항목에 접근할 수 있도록 몇 가지 공개 함수를 추가해야 합니다.
chip8_core/src/lib.rs에서 Emu 구조체에 다음 메서드를 추가하십시오.
#![allow(unused)]
fn main() {
impl Emu {
// -- Unchanged code omitted --
pub fn get_display(&self) -> &[bool] {
&self.screen
}
// -- Unchanged code omitted --
}
}
이 메서드는 단순히 화면 버퍼 배열에 대한 포인터를 프런트엔드로 전달하며, 프런트엔드에서는 이를 사용하여 디스플레이에 렌더링할 수 있습니다.
다음으로, 키 입력을 처리해야 합니다. 이미 keys 배열이 있지만, 실제로 여기에 값이 기록되는 일은 없습니다. 실제로 키보드 입력을 읽는 일은 프런트엔드가 담당하지만, 프런트엔드가 keys 배열과 상호작용하여 원소를 설정할 수 있게 해 주는 함수를 노출해야 합니다.
#![allow(unused)]
fn main() {
impl Emu {
// -- Unchanged code omitted --
pub fn keypress(&mut self, idx: usize, pressed: bool) {
self.keys[idx] = pressed;
}
// -- Unchanged code omitted --
}
}
이 함수는 꽤 간단합니다. 눌린 키의 인덱스를 받아 값을 설정합니다. 이 함수를 press_key 함수와 release_key 함수로 나눌 수도 있었지만, 충분히 간단하므로 의도는 여전히 전달된다고 생각합니다. idx는 16 미만이어야 하며, 그렇지 않으면 프로그램이 패닉을 일으킨다는 점에 유의하십시오. 여기에 그 제약을 추가할 수도 있지만, 두 번 검사하는 대신 프런트엔드에서 처리하고 백엔드에서는 올바르게 처리되었다고 가정하겠습니다.
마지막으로, 게임 코드를 파일에서 RAM으로 불러와 실행할 수 있도록 하는 방법이 필요합니다. 이 부분은 프런트엔드에서 파일을 읽기 시작할 때 더 자세히 살펴보겠지만, 지금은 바이트 목록을 받아 RAM에 복사해야 합니다.
#![allow(unused)]
fn main() {
impl Emu {
// -- Unchanged code omitted --
pub fn load(&mut self, data: &[u8]) {
let start = START_ADDR as usize;
let end = (START_ADDR as usize) + data.len();
self.ram[start..end].copy_from_slice(data);
}
// -- Unchanged code omitted --
}
}
이 함수는 입력으로 받은 data 슬라이스의 모든 값을 0x200부터 시작하는 RAM에 복사합니다. RAM의 처음 512바이트에는 게임 데이터가 들어가지 않으며, 그곳에 저장하는 문자 스프라이트 데이터를 제외하면 비어 있다는 점을 기억하십시오.
프런트엔드 설정
마침내 에뮬레이터의 프런트엔드를 설정하여 이것저것 테스트해 보고 (바라건대) 게임도 몇 가지 해 봅시다! 화려한 GUI 인터페이스를 만드는 것은 이 가이드의 범위를 벗어나므로, 단순히 에뮬레이터를 시작하고 명령줄 인수를 통해 플레이할 게임을 선택하도록 하겠습니다. 지금 바로 설정해 봅시다.
desktop/src/main.rs에서 게임 ROM 파일의 경로를 받기 위해 명령줄 인수를 읽어야 합니다. 추가 설정을 위해 여러 플래그를 만들 수도 있지만, 간단하게 정확히 하나의 인수, 즉 게임 경로만 요구하도록 하겠습니다. 인수의 개수가 이와 다르면 오류와 함께 종료합니다.
use std::env;
fn main() {
let args: Vec<_> = env::args().collect();
if args.len() != 2 {
println!("Usage: cargo run path/to/game");
return;
}
}
이 코드는 전달된 모든 명령줄 매개변수를 벡터로 가져오며, 그 개수가 두 개가 아니면(프로그램 이름은 항상 args[0]에 저장됩니다) 올바른 입력 방법을 출력하고 종료합니다. 이제 사용자가 전달한 경로는 args[1]에 저장됩니다. 파일을 열려고 할 때 그것이 유효한 파일인지 확인해야 하지만, 그 전에 먼저 설정해야 할 다른 것들이 있습니다.
창 만들기
이 에뮬레이션 프로젝트에서는 SDL 라이브러리를 사용하여 게임 창을 만들고 그 창에 렌더링하겠습니다. SDL은 키 입력과 그리기를 잘 지원하는 훌륭한 그리기 라이브러리입니다. 설정하려면 약간의 보일러플레이트 코드가 필요하지만, 일단 그 작업을 마치면 에뮬레이션을 시작할 수 있습니다.
먼저 SDL을 프로젝트에 포함해야 합니다. desktop/Cargo.toml을 열고 의존성에 sdl2를 추가하십시오:
[dependencies]
chip8_core = { path = "../chip8_core" }
sdl2 = "^0.34.3
이제 다시 desktop/src/main.rs로 돌아와서 모든 것을 하나로 합치기 시작할 수 있습니다. 코어에서 정의한 공개 함수들이 필요하므로, use chip8_core::*로 Rust에 그 함수들이 필요하다고 알려 줍시다.
use chip8_core::*;
use std::env;
fn main() {
let args: Vec<_> = env::args().collect();
if args.len() != 2 {
println!("Usage: cargo run path/to/game");
return;
}
}
chip8_core 안에서 화면 크기를 담는 공개 상수를 만들었는데, 지금 그 상수를 가져오고 있습니다. 하지만 64x32 크기의 게임 창은 요즘 모니터에서는 정말 작으므로, 크기를 조금 키워 봅시다. 여러 숫자로 실험해 본 결과 제 모니터에서는 15배 배율이 잘 맞았지만, 다른 값을 원하신다면 조정하셔도 됩니다.
#![allow(unused)]
fn main() {
const SCALE: u32 = 15;
const WINDOW_WIDTH: u32 = (SCREEN_WIDTH as u32) * SCALE;
const WINDOW_HEIGHT: u32 = (SCREEN_HEIGHT as u32) * SCALE;
}
SCREEN_WIDTH와 SCREEN_HEIGHT는 백엔드에서 정의한 공개 상수였으며, 이제 use chip8_core::* 문을 통해 이 크레이트에 포함되었다는 점을 떠올려 보십시오. SDL은 화면 크기가 usize가 아닌 u32이어야 하므로, 여기서 형 변환을 하겠습니다.
이제 SDL 창을 만들 차례입니다! 다음 코드는 단순히 새 SDL 컨텍스트를 만든 다음, 창 자체와 그 위에 그릴 캔버스를 만듭니다.
fn main() {
// -- Unchanged code omitted --
// Setup SDL
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
let window = video_subsystem
.window("Chip-8 Emulator", WINDOW_WIDTH, WINDOW_HEIGHT)
.position_centered()
.opengl()
.build()
.unwrap();
let mut canvas = window.into_canvas().present_vsync().build().unwrap();
canvas.clear();
canvas.present();
}
SDL을 초기화하고, 확대한 크기로 새 창을 만들도록 지시하겠습니다. 또한 창이 사용자 화면의 가운데에 생성되도록 하겠습니다. 그런 다음 VSYNC를 켠 상태로, 실제로 그림을 그릴 캔버스 객체를 가져오겠습니다. 이어서 캔버스를 지우고 사용자에게 보여 주십시오.
지금 실행해 보면(cargo run test처럼 테스트용 가짜 파일 이름을 지정하십시오) 창이 잠깐 나타났다가 닫히는 것을 볼 수 있습니다. 이는 SDL 창이 잠깐 생성되지만 곧 프로그램이 끝나면서 창이 닫히기 때문입니다. 프로그램이 바로 끝나지 않도록 메인 게임 루프를 만들어야 하며, 이왕 하는 김에 창을 닫으려고 할 때 프로그램을 종료하는 처리도 추가합시다(그렇지 않으면 작업 관리자에서 프로그램을 강제 종료해야 합니다).
SDL은 이벤트 펌프라는 것을 사용하여 루프마다 이벤트를 폴링합니다. 이를 확인하면 창을 닫으려 하거나 키를 누르는 것과 같은 특정 이벤트에 따라 다른 동작이 일어나게 할 수 있습니다. 지금은 창을 닫아야 할 때 프로그램이 메인 게임 루프를 빠져나오게만 하겠습니다.
SDL의 Event를 사용하겠다고 Rust에 알려 주어야 합니다:
#![allow(unused)]
fn main() {
use sdl2::event::Event;
}
그리고 main 함수를 수정하여 기본 게임 루프를 추가하십시오:
fn main() {
// -- Unchanged code omitted --
// Setup SDL
let sdl_context = sdl2::init().unwrap();
let video_subsystem = sdl_context.video().unwrap();
let window = video_subsystem
.window("Chip-8 Emulator", WINDOW_WIDTH, WINDOW_HEIGHT)
.position_centered()
.opengl()
.build()
.unwrap();
let mut canvas = window.into_canvas().present_vsync().build().unwrap();
canvas.clear();
canvas.present();
let mut event_pump = sdl_context.event_pump().unwrap();
'gameloop: loop {
for evt in event_pump.poll_iter() {
match evt {
Event::Quit{..} => {
break 'gameloop;
},
_ => ()
}
}
}
}
이렇게 추가한 코드는 메인 게임 루프를 구성하며, 이 루프는 이벤트가 발생했는지 확인합니다. (창을 닫으려고 하여) Quit 이벤트가 감지되면 프로그램이 루프를 빠져나오고, 그 결과 프로그램이 종료됩니다. cargo run test로 다시 실행해 보면 ’Chip-8 에뮬레이터’라는 제목의 새 검은 창이 뜨는 것을 볼 수 있습니다. 창은 아무 문제 없이 정상적으로 닫힐 것입니다.
살아 움직입니다! 다음으로는 에뮬레이터의 chip8_core 백엔드를 초기화하고, 게임 파일을 열어 불러오겠습니다.
파일 불러오기
이제 프런트엔드에서 새 에뮬레이션 창을 만들 수 있으니, 백엔드도 실행되도록 만들 차례입니다. 다음 단계에서는 실제로 게임 파일을 읽어 들이고, 그 데이터를 백엔드에 넘겨 RAM에 저장하고 실행하도록 하겠습니다. 먼저, 데이터를 넘겨받을 백엔드 객체가 실제로 있어야 합니다. 계속해서 frontend/src/main.rs에서 에뮬레이션 객체를 만들어 봅시다. Emu 객체는 chip8_core의 다른 모든 공개 항목과 함께 이미 포함되어 있으므로, 바로 초기화할 수 있습니다.
fn main() {
// -- Unchanged code omitted --
let mut chip8 = Emu::new();
'gameloop: loop {
// -- Unchanged code omitted --
}
}
Emu 객체 생성은 메인 게임 루프보다 앞에 두어야 합니다. 에뮬레이션 화면 그리기와 키 입력 처리가 그 루프 안에 들어갈 것이기 때문입니다. 게임 경로는 사용자가 넘겨준다는 점을 기억하십시오. 따라서 파일을 읽어 들이기 전에 해당 파일이 실제로 존재하는지도 확인해야 합니다. 파일을 열려면 먼저 main.rs에서 표준 라이브러리의 몇 가지 항목을 use해야 합니다.
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::Read;
}
따로 설명할 필요가 없을 정도로 명확합니다. 다음으로, 명령줄 매개변수로 전달받은 파일을 열어 버퍼로 읽어 들인 다음, 그 데이터를 에뮬레이터 백엔드에 넘겨줍니다.
fn main() {
// -- Unchanged code omitted --
let mut chip8 = Emu::new();
let mut rom = File::open(&args[1]).expect("Unable to open file");
let mut buffer = Vec::new();
rom.read_to_end(&mut buffer).unwrap();
chip8.load(&buffer);
// -- Unchanged code omitted --
}
여기서 몇 가지 짚고 넘어갈 점이 있습니다. 사용자가 준 경로의 파일을 Rust가 열지 못하면(아마 파일이 존재하지 않는 경우일 것입니다), expect 조건이 실패하고 프로그램은 해당 메시지를 출력하며 종료됩니다. 둘째로, 파일 경로를 백엔드에 넘겨 그곳에서 데이터를 불러올 수도 있지만, 파일을 읽는 일은 프런트엔드에 더 가까운 동작이므로 여기에 두는 편이 더 잘 맞습니다. 더 중요한 점은, 최종적으로 백엔드를 거의 또는 전혀 수정하지 않고 이 에뮬레이터를 웹 브라우저에서 동작하게 만드는 것이 우리의 계획이라는 것입니다. 브라우저가 파일을 읽는 방식은 파일 시스템이 파일을 읽는 방식과 매우 다르므로, 파일 읽기는 각 프런트엔드가 처리하도록 하고 데이터를 얻은 뒤에 넘기도록 하겠습니다.
에뮬레이터 실행과 화면 그리기
게임이 RAM에 로드되었고 메인 루프가 돌아가고 있습니다. 이제 백엔드에 명령어 처리를 시작하고 실제로 화면에 그리도록 지시해야 합니다. 기억하시겠지만, 에뮬레이터는 tick 함수가 호출될 때마다 클럭 사이클을 한 번 진행하므로, 이를 루프에 추가해 봅시다.
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for event in event_pump.poll_iter() {
// -- Unchanged code omitted --
}
chip8.tick();
}
}
이제 루프가 한 사이클을 돌 때마다 에뮬레이터는 명령어를 하나씩 더 실행합니다. 너무 쉬워 보일 수도 있지만, 화면 버퍼 수정을 포함하여 백엔드의 모든 부분을 tick이 움직이도록 구성해 두었기 때문입니다. 백엔드에서 화면 데이터를 가져와 SDL 창을 갱신하는 함수를 추가해 봅시다. 먼저, SDL에서 몇 가지 요소를 추가로 use해야 합니다:
#![allow(unused)]
fn main() {
use sdl2::pixels::Color;
use sdl2::rect::Rect;
use sdl2::render::Canvas;
use sdl2::video::Window;
}
다음은 함수입니다. 이 함수는 Emu 객체에 대한 참조와 SDL 캔버스에 대한 가변 참조를 받습니다. 화면을 그리려면 몇 단계를 거쳐야 합니다. 먼저, 이전 프레임을 지우기 위해 캔버스를 비웁니다. 그런 다음 화면 버퍼를 순회하면서 값이 참일 때마다 흰색 사각형을 그립니다. Chip-8은 흑백만 지원하므로, 화면을 검은색으로 비워 두면 흰색 사각형을 그리는 일만 신경 쓰면 됩니다.
#![allow(unused)]
fn main() {
fn draw_screen(emu: &Emu, canvas: &mut Canvas<Window>) {
// Clear canvas as black
canvas.set_draw_color(Color::RGB(0, 0, 0));
canvas.clear();
let screen_buf = emu.get_display();
// Now set draw color to white, iterate through each point and see if it should be drawn
canvas.set_draw_color(Color::RGB(255, 255, 255));
for (i, pixel) in screen_buf.iter().enumerate() {
if *pixel {
// Convert our 1D array's index into a 2D (x,y) position
let x = (i % SCREEN_WIDTH) as u32;
let y = (i / SCREEN_WIDTH) as u32;
// Draw a rectangle at (x,y), scaled up by our SCALE value
let rect = Rect::new((x * SCALE) as i32, (y * SCALE) as i32, SCALE, SCALE);
canvas.fill_rect(rect).unwrap();
}
}
canvas.present();
}
}
이 함수를 요약하면, 1차원 화면 버퍼 배열을 가져와 순회하는 것입니다. 흰색 픽셀(참 값)을 찾으면 화면상의 2차원 좌표 (x, y)를 계산하고, SCALE 배율만큼 확대한 사각형을 그 위치에 그립니다.
이 함수는 메인 루프에서 tick 바로 다음에 호출하겠습니다.
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for event in event_pump.poll_iter() {
// -- Unchanged code omitted --
}
chip8.tick();
draw_screen(&chip8, &mut canvas);
}
}
여러분 중 몇몇은 이 부분에서 고개를 갸웃할지도 모릅니다. 화면은 초당 60프레임, 즉 60 Hz로 갱신되어야 합니다. 에뮬레이션은 분명 그보다 더 빠르게 진행되어야 하지 않겠습니까? 그렇습니다. 하지만 그 생각은 잠시 접어 둡시다. 타이밍을 바로잡기 전에, 먼저 제대로 동작하기는 하는지부터 확인하겠습니다.
내려받은 Chip-8 게임이 있다면, 다음과 같이 실제 게임으로 에뮬레이터를 실행해 보십시오.
$ cargo run path/to/game
모든 것이 잘 되었다면 창이 나타나고 게임이 렌더링되며 실행되는 것을 볼 수 있을 것입니다! 여러분만의 에뮬레이터로 여기까지 온 것에 성취감을 느끼셔도 좋습니다.
앞서 언급했듯이, 에뮬레이션 tick 속도는 아마도 캔버스 갱신 주기보다 빨라야 할 것입니다. 게임이 실행되는 모습을 보면 약간 느리게 느껴질 수 있습니다. 지금은 명령어 하나를 실행하고, 화면에 그리고, 이를 반복합니다. 아시다시피 화면에 의미 있는 변화를 주려면 여러 개의 명령어가 필요합니다. 이 문제를 해결하기 위해, 다시 그리기 전에 에뮬레이터가 여러 번 tick하도록 하겠습니다.
이제부터는 다소 실험적인 영역입니다. Chip-8 명세에는 시스템이 실제로 얼마나 빠르게 동작해야 하는지에 대한 언급이 전혀 없습니다. 지금 상태 그대로 두어 60 Hz로 동작하게 하는 것도 타당한 해법입니다(그렇게 하셔도 좋습니다). 화면 그리기로 넘어가기 전에 tick 함수를 여러 번 반복 실행하도록 하기만 하면 됩니다. 개인적으로 저는(그리고 제가 살펴본 다른 에뮬레이터들도) 프레임당 10번의 tick이 적당한 지점이라고 생각합니다.
const TICKS_PER_FRAME: usize = 10;
// -- Unchanged code omitted --
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for event in event_pump.poll_iter() {
// -- Unchanged code omitted --
}
for _ in 0..TICKS_PER_FRAME {
chip8.tick();
}
draw_screen(&chip8, &mut canvas);
}
}
여러분 중 몇몇은 이것이 다소 꼼수 같다고 느낄 수도 있습니다(저도 어느 정도 동의합니다). 하지만 이는 더 ‘정교한’ 시스템들이 동작하는 방식이기도 합니다. 다만 그런 CPU들은 보통 화면에 다시 그릴 준비가 되었음을 알리는 방법을 갖추고 있다는 점이 다릅니다. Chip-8에는 그러한 메커니즘도, 정해진 클럭 속도도 없으므로 이것이 이 작업을 해내는 더 쉬운 방법입니다.
다시 실행해 보면 얼마 진행되지 않아 멈춘다는 것을 알아차릴 수 있습니다. 이는 아마 두 타이머를 전혀 갱신하지 않아서, 에뮬레이터가 게임에서 얼마나 많은 시간이 흘렀는지 전혀 알지 못하기 때문일 것입니다. 앞서 타이머는 클럭 속도가 아니라 프레임당 한 번씩 동작한다고 말씀드렸으므로, 화면을 갱신하는 지점에서 타이머도 함께 갱신할 수 있습니다.
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for event in event_pump.poll_iter() {
// -- Unchanged code omitted --
}
for _ in 0..TICKS_PER_FRAME {
chip8.tick();
}
chip8.tick_timers();
draw_screen(&chip8, &mut canvas);
}
}
아직 구현할 것이 몇 가지 남아 있지만(우선 게임을 실제로 조작할 수 없습니다) 훌륭한 출발이며, 이제 마지막 단계에 접어들었습니다!
사용자 입력 추가하기
드디어 Chip-8 게임을 화면에 렌더링할 수 있게 되었지만, 조작할 방법이 없으므로 게임을 제대로 진행할 수는 없습니다. 다행히 SDL은 키보드 입력 읽기를 지원하므로, 이를 변환하여 백엔드 에뮬레이션에 전달할 수 있습니다.
다시 짚어 보면, Chip-8 시스템은 16개의 서로 다른 키를 지원합니다. 이 키들은 보통 4x4 격자로 배치되며, 0-9 키는 전화기처럼 배열되고 A-F 키가 그 주위를 둘러쌉니다. 키를 원하는 어떤 배치로든 구성하셔도 좋지만, 일부 게임 개발자들은 게임 입력을 정할 때 키가 격자 형태로 배치되어 있다고 가정했기 때문에, 그렇지 않으면 일부 게임은 플레이하기 불편할 수 있습니다. 우리 에뮬레이터에서는 아래와 같이 QWERTY 키보드의 왼쪽 키들을 입력으로 사용하겠습니다.
SDL의 키 타입을 에뮬레이터에 보낼 값으로 변환하는 함수를 만들어 봅시다. 다음과 같이 SDL 키보드 지원을 main.rs로 가져와야 합니다.
#![allow(unused)]
fn main() {
use sdl2::keyboard::Keycode;
}
이제 Keycode를 받아 선택적인 u8 값을 출력하는 새 함수를 만들겠습니다. 유효한 키는 16개뿐이므로, 유효한 출력값은 Some으로 감싸고 사용자가 Chip-8 키가 아닌 키를 누르면 None을 반환하겠습니다. 이 함수는 위 그림에 나타난 대로 모든 유효한 키를 패턴 매칭하는 것에 불과합니다.
#![allow(unused)]
fn main() {
fn key2btn(key: Keycode) -> Option<usize> {
match key {
Keycode::Num1 => Some(0x1),
Keycode::Num2 => Some(0x2),
Keycode::Num3 => Some(0x3),
Keycode::Num4 => Some(0xC),
Keycode::Q => Some(0x4),
Keycode::W => Some(0x5),
Keycode::E => Some(0x6),
Keycode::R => Some(0xD),
Keycode::A => Some(0x7),
Keycode::S => Some(0x8),
Keycode::D => Some(0x9),
Keycode::F => Some(0xE),
Keycode::Z => Some(0xA),
Keycode::X => Some(0x0),
Keycode::C => Some(0xB),
Keycode::V => Some(0xF),
_ => None,
}
}
}
다음으로 메인 이벤트 루프에 이벤트 두 개를 추가하겠습니다. 하나는 KeyDown용이고 다른 하나는 KeyUp용입니다. 각 이벤트는 눌린 키가 key2btn 함수에서 Some 값을 반환하는지 확인하고, 그렇다면 앞서 정의한 공개 함수 keypress를 통해 그 값을 에뮬레이터에 전달합니다. 두 이벤트의 유일한 차이는 키를 설정하는지 해제하는지입니다.
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for evt in event_pump.poll_iter() {
match evt {
Event::Quit{..} => {
break 'gameloop;
},
Event::KeyDown{keycode: Some(key), ..} => {
if let Some(k) = key2btn(key) {
chip8.keypress(k, true);
}
},
Event::KeyUp{keycode: Some(key), ..} => {
if let Some(k) = key2btn(key) {
chip8.keypress(k, false);
}
},
_ => ()
}
for _ in 0..TICKS_PER_FRAME {
chip8.tick();
}
chip8.tick_timers();
draw_screen(&chip8, &mut canvas);
}
// -- Unchanged code omitted --
}
if let 문은 오른쪽 값이 왼쪽과 일치할 때, 즉 key2btn(key)가 Some 값을 반환할 때만 충족됩니다. 그런 다음 꺼낸 값이 k에 저장됩니다.
흔히 쓰이는 에뮬레이터 기능인 Escape 키를 눌러 프로그램을 종료하는 기능도 추가해 봅시다. 이 기능은 Quit 이벤트와 함께 추가하겠습니다.
fn main() {
// -- Unchanged code omitted --
'gameloop: loop {
for evt in event_pump.poll_iter() {
match evt {
Event::Quit{..} | Event::KeyDown{keycode: Some(Keycode::Escape), ..}=> {
break 'gameloop;
},
Event::KeyDown{keycode: Some(key), ..} => {
if let Some(k) = key2btn(key) {
chip8.keypress(k, true);
}
},
Event::KeyUp{keycode: Some(key), ..} => {
if let Some(k) = key2btn(key) {
chip8.keypress(k, false);
}
},
_ => ()
}
}
for _ in 0..TICKS_PER_FRAME {
chip8.tick();
}
chip8.tick_timers();
draw_screen(&chip8, &mut canvas);
}
// -- Unchanged code omitted --
}
찾은 key 변수를 확인하던 다른 키 이벤트와 달리, 여기서는 Escape 키를 사용해 종료하려고 합니다. 에뮬레이터에 이 기능을 넣고 싶지 않거나 다른 키 입력 기능을 원한다면 얼마든지 그렇게 하셔도 됩니다.
이것으로 끝입니다! 이제 Chip-8 에뮬레이터의 데스크톱 프런트엔드가 완성되었습니다. 명령줄 매개변수로 게임을 지정하고, 게임을 불러와 실행하고, 출력을 화면에 표시하고, 사용자 입력을 처리할 수 있습니다.
에뮬레이션이 어떻게 동작하는지 이해하는 데 도움이 되었기를 바랍니다. Chip-8은 꽤 단순한 시스템이지만, 여기서 다룬 기법은 모든 에뮬레이션이 동작하는 방식의 기초가 됩니다.
하지만 이 가이드는 아직 끝나지 않았습니다! 다음 절에서는 WebAssembly로 에뮬레이터를 빌드하고 웹 브라우저에서 실행하는 방법을 다루겠습니다.
WebAssembly 소개
이 절에서는 완성된 에뮬레이터를 WebAssembly라는 비교적 새로운 기술을 통해 웹 브라우저에서 실행되도록 구성하는 방법을 다룹니다. WebAssembly에 대해 더 읽어 보시기를 권합니다. WebAssembly는 프로그램을 바이너리 실행 파일로 컴파일하기 위한 형식으로, 그 범위는 .exe와 비슷하지만 웹 브라우저 안에서 실행되도록 만들어졌습니다. 모든 주요 웹 브라우저가 이를 지원하며, 이들 브라우저 개발사들이 함께 개발하고 있는 기업 간 공동 표준입니다. 즉, 웹 코드를 JavaScript나 다른 웹 중심 언어로 작성할 필요 없이 .wasm 파일로의 컴파일을 지원하는 어떤 언어로든 작성할 수 있으며, 그러면서도 브라우저에서 실행할 수 있습니다. 이 글을 쓰는 시점에서 이를 지원하는 주요 언어는 C, C++, Rust이며, 다행히 Rust도 여기에 포함됩니다.
설정하기
WebAssembly용 Rust 타깃으로 직접 크로스 컴파일할 수도 있지만, 적절한 타깃과 의존성을 수동으로 추가하지 않고도 WebAssembly로 쉽게 컴파일할 수 있도록 wasm-pack이라는 유용한 도구 모음이 개발되어 있습니다. 다음 명령으로 이를 설치해야 합니다.
$ cargo install wasm-pack
Windows를 사용하는 경우 openssl-sys 크레이트에서 설치가 실패할 수 있으며 https://github.com/rustwasm/wasm-pack/issues/1108 이때는 https://rustwasm.github.io/wasm-pack/ 에서 직접 내려받아야 합니다.
또한 앞서 언급했듯이, chip8_core 모듈이 wasm 타깃으로 올바르게 컴파일되도록 약간 수정해야 합니다. Rust는 wasm-bindgen이라는 시스템을 사용하여 WebAssembly와 함께 동작하는 훅을 만듭니다. 우리가 사용하는 std 코드는 모두 이미 문제가 없지만, 백엔드에서는 rand 크레이트도 사용하고 있으며, 이 크레이트는 현재 올바르게 동작하도록 설정되어 있지 않습니다. 다행히 이 크레이트는 해당 기능을 지원하므로 활성화하기만 하면 됩니다. chip8_core/Cargo.toml에서 다음 부분을
[dependencies]
rand = "^0.7.3"
다음과 같이 바꾸어야 합니다.
[dependencies]
rand = { version = "^0.7.3", features = ["wasm-bindgen"] }
이 변경은 컴파일 시 rand가 wasm-bindgen 기능을 포함하도록 요구한다는 점을 지정할 뿐이며, 이를 통해 rand가 WebAssembly 바이너리에서 올바르게 동작하게 됩니다.
참고: 이 튜토리얼의 코드를 작성한 뒤 글을 마무리하기까지의 기간에 rand 크레이트가 0.8 버전으로 업데이트되었습니다. 여러 변경 사항 중 하나로 wasm-bindgen 기능이 제거되었습니다. 최신 rand 크레이트를 사용하려는 경우, WebAssembly 지원이 별도의 독립된 크레이트로 분리된 것으로 보입니다. 우리는 가장 기본적인 난수 함수만 사용하므로 0.8로 업그레이드할 필요성을 느끼지 못했지만, 업그레이드하려면 추가적인 통합 작업이 필요할 것으로 보입니다.
chip8_core 모듈을 수정하는 것은 이번이 마지막이며, 나머지 작업은 모두 새 프런트엔드에서 진행합니다. 이제 프런트엔드를 설정해 봅시다. 먼저 다음 명령으로 또 다른 Rust 모듈(크레이트)을 생성합시다:
$ cargo init wasm --lib
이 명령은 익숙해 보일 수 있으며, wasm이라는 새로운 Rust 라이브러리를 하나 더 만듭니다. desktop과 마찬가지로 wasm/Cargo.toml을 수정하여 chip8_core가 있는 위치를 가리키도록 해야 합니다.
[dependencies]
chip8_core = { path = "../chip8_core" }
그런데 desktop과 새로 만든 wasm의 큰 차이점은 desktop이 실행 가능한 프로젝트였다는 것으로, 컴파일하고 실행할 main.rs가 있었습니다. wasm에는 그런 것이 없으며, 웹 페이지에 불러올 .wasm 파일로 컴파일되도록 만들어졌습니다. 프런트엔드 역할을 하는 것은 웹 페이지이므로, 시작을 위해 기본적인 HTML 보일러플레이트를 추가합시다. 웹 페이지 전용 코드를 담을 web이라는 새 폴더를 만든 다음, web/index.html을 만들고 기본적인 HTML 보일러플레이트를 추가하십시오.
<!DOCTYPE html>
<html>
<head>
<title>Chip-8 Emulator</title>
<meta charset="utf-8">
</head>
<body>
<h1>My Chip-8 Emulator</h1>
</body>
</html>
나중에 더 추가하겠지만, 지금은 이것으로 충분합니다. 웹 브라우저에서 파일을 그냥 열기만 해서는 웹 프로그램이 실행되지 않으며, 먼저 웹 서버를 시작해야 합니다. 최신 Mac과 많은 Linux 배포판에는 Python 3가 설치되어 있는데, Python 3가 설치되어 있다면 다음과 같이 간단히 웹 서버를 시작할 수 있습니다.
$ python3 -m http.server
웹 브라우저에서 localhost로 이동하십시오. web 디렉터리에서 이 명령을 실행했다면 index.html 페이지가 표시될 것입니다. Windows에서 로컬 웹 서버를 시작하는 간단한 내장 방법을 찾아보았지만, 마땅한 방법을 찾지 못했습니다. 저는 개인적으로 Python 3를 사용하지만, npm이나 일부 Visual Studio Code 확장 같은 다른 유사한 서비스를 사용하셔도 좋습니다. 로컬 웹 페이지를 호스팅할 수만 있다면 어느 것을 사용해도 상관없습니다.
WebAssembly API 정의하기
chip8_core는 이미 만들어 두었지만, 지금은 desktop에 추가했던 기능이 모두 빠져 있습니다. 파일 불러오기, 키 입력 처리, 틱을 실행할 시점 알려 주기 등이 그렇습니다. 한편으로는 JavaScript를 실행할 웹 페이지가 있으며, 이 웹 페이지는 사용자의 입력을 처리하고 항목을 표시해야 합니다. 그 사이에 들어가는 것이 바로 wasm 크레이트입니다. 이 크레이트는 JavaScript로부터 입력을 받아 chip8_core에 필요한 데이터 타입으로 변환합니다.
무엇보다도, 어떻게든 chip8_core::Emu 객체를 생성하고 웹 페이지가 유지되는 동안 계속 스코프 안에 두어야 합니다.
먼저 Rust가 JavaScript와 연동할 수 있도록 하는 데 필요한 외부 크레이트 몇 개를 포함합시다. wasm/Cargo.toml을 열고 다음 의존성을 추가하십시오.
[dependencies]
chip8_core = { path = "../chip8_core" }
js-sys = "^0.3.46"
wasm-bindgen = "^0.2.69"
[dependencies.web-sys]
version = "^0.3.46"
features = []
web-sys를 다른 의존성과 다르게 다루고 있다는 점을 눈치채셨을 것입니다. 이 크레이트는 Cargo.toml에 포함하기만 하면 그 안의 모든 것을 사용할 수 있는 구조가 아니라, 크레이트와 함께 제공되지만 기본적으로는 사용할 수 없는 추가 “기능(features)“을 별도로 지정해야 하는 구조로 되어 있습니다. 곧 web_sys 기능을 추가할 것이므로 이 파일을 열어 두십시오.
이 크레이트는 다른 언어와 연동할 것이므로, 두 언어가 어떻게 통신할지 지정해야 합니다. 세부 사항까지 깊이 들어가지는 않겠지만, Rust는 C 언어의 ABI를 사용하여 이를 지원하는 다른 언어와 쉽게 통신할 수 있으며, 그렇게 하면 wasm 바이너리가 크게 단순해집니다. 따라서 cargo에 이를 사용하도록 알려 주어야 합니다. 다음 내용도 wasm/Cargo.toml에 추가하십시오.
[lib]
crate-type = ["cdylib"]
훌륭합니다. 이제 wasm/src/lib.rs로 넘어갑시다. Emu 객체와 함께, JavaScript와 연동하고 동작하는 데 필요한 모든 프런트엔드 함수를 담을 구조체를 만듭시다. 또한 chip8_core의 모든 공개 항목도 포함해야 합니다.
#![allow(unused)]
fn main() {
use chip8_core::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct EmuWasm {
chip8: Emu,
}
}
#[wasm_bindgen] 태그에 주목하십시오. 이 태그는 이 구조체를 WebAssembly용으로 구성해야 한다는 것을 컴파일러에 알려 줍니다. JavaScript 내부에서 호출될 함수나 구조체에는 모두 이 태그가 있어야 합니다. 생성자도 정의합시다.
#![allow(unused)]
fn main() {
use chip8_core::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct EmuWasm {
chip8: Emu,
}
#[wasm_bindgen]
impl EmuWasm {
#[wasm_bindgen(constructor)]
pub fn new() -> EmuWasm {
EmuWasm {
chip8: Emu::new(),
}
}
}
}
꽤 간단합니다. 가장 주목할 점은 컴파일러가 우리가 하려는 일을 알 수 있도록 new 메서드에 특별한 constructor를 포함해야 한다는 것입니다.
이제 chip8 에뮬레이션 코어 객체를 담은 구조체가 생겼습니다. 여기서는 키 입력/해제를 코어에 전달하기, 파일 불러오기, 틱 진행하기 등 desktop 프런트엔드에서 필요했던 것과 같은 메서드들을 구현하겠습니다. 가장 쉬운 CPU와 타이머 틱부터 시작합시다.
#![allow(unused)]
fn main() {
#[wasm_bindgen]
impl EmuWasm {
// -- Unchanged code omitted --
#[wasm_bindgen]
pub fn tick(&mut self) {
self.chip8.tick();
}
#[wasm_bindgen]
pub fn tick_timers(&mut self) {
self.chip8.tick_timers();
}
}
}
이것으로 끝입니다. 이 함수들은 chip8_core의 해당 함수를 호출하는 얇은 래퍼일 뿐입니다. 이 함수들은 아무 입력도 받지 않으므로, 틱을 실행하는 것 외에는 특별히 할 일이 없습니다.
chip8_core에서 만들어 놓고 한 번도 사용하지 않았던 reset 함수를 기억하십니까? 이제 드디어 그 함수를 사용할 차례입니다. 이것도 앞의 두 함수와 마찬가지로 래퍼가 됩니다.
#![allow(unused)]
fn main() {
#[wasm_bindgen]
pub fn reset(&mut self) {
self.chip8.reset();
}
}
키 누름은 이 함수들 중에서 desktop에서 했던 것과 달라지는 첫 번째 함수입니다. 이것은 desktop에서 했던 것과 비슷한 방식으로 동작하지만, SDL 키 입력을 받는 대신 JavaScript에서 오는 키 입력을 받아야 합니다. web-sys 기능을 몇 가지 추가하겠다고 약속했으니, 지금 추가합시다. wasm/Cargo.toml로 돌아가 KeyboardEvent 기능을 추가하십시오
[dependencies.web-sys]
version = "^0.3.46"
features = [
"KeyboardEvent"
]
#![allow(unused)]
fn main() {
use web_sys::KeyboardEvent;
// -- Unchanged code omitted --
impl EmuWasm {
// -- Unchanged code omitted --
#[wasm_bindgen]
pub fn keypress(&mut self, evt: KeyboardEvent, pressed: bool) {
let key = evt.key();
if let Some(k) = key2btn(&key) {
self.chip8.keypress(k, pressed);
}
}
}
fn key2btn(key: &str) -> Option<usize> {
match key {
"1" => Some(0x1),
"2" => Some(0x2),
"3" => Some(0x3),
"4" => Some(0xC),
"q" => Some(0x4),
"w" => Some(0x5),
"e" => Some(0x6),
"r" => Some(0xD),
"a" => Some(0x7),
"s" => Some(0x8),
"d" => Some(0x9),
"f" => Some(0xE),
"z" => Some(0xA),
"x" => Some(0x0),
"c" => Some(0xB),
"v" => Some(0xF),
_ => None,
}
}
}
이것은 desktop용 구현과 매우 비슷하지만, JavaScript KeyboardEvent를 받는다는 점이 다르며, 이로부터 파싱할 문자열을 얻게 됩니다. 키 문자열은 대소문자를 구분하므로, 플레이어가 Shift 키를 계속 누르고 있기를 바라는 것이 아니라면 모두 소문자로 유지하십시오.
게임을 불러올 때도 비슷한 상황입니다. 비슷한 방식을 따르지만, JavaScript 객체를 받아서 처리해야 한다는 점이 다릅니다.
#![allow(unused)]
fn main() {
use js_sys::Uint8Array;
// -- Unchanged code omitted --
impl EmuWasm {
// -- Unchanged code omitted --
#[wasm_bindgen]
pub fn load_game(&mut self, data: Uint8Array) {
self.chip8.load(&data.to_vec());
}
}
}
남은 것은 실제로 화면에 렌더링하는 함수뿐입니다. 여기서는 빈 함수를 만들어 두고 구현은 잠시 미루겠습니다. 대신 웹 페이지로 다시 눈을 돌려 반대 방향에서부터 작업을 시작하겠습니다.
#![allow(unused)]
fn main() {
impl EmuWasm {
// -- Unchanged code omitted --
#[wasm_bindgen]
pub fn draw_screen(&mut self, scale: usize) {
// TODO
}
}
}
JavaScript 설정을 마치고 정확히 어떻게 그릴지 정해지면 여기로 다시 돌아오겠습니다.
프런트엔드 기능 만들기
이제 JavaScript를 직접 다뤄 볼 차례입니다. 먼저 아주 밋밋한 웹 페이지에 몇 가지 요소를 추가합시다. PC에서 실행되는 에뮬레이터를 만들 때는 SDL을 사용해 그림을 그릴 창을 만들었습니다. 웹 페이지에서는 HTML5가 제공하는 캔버스라는 요소를 사용하겠습니다. 또한 웹 페이지가 (아직 존재하지 않는) JS 스크립트를 가리키도록 미리 설정해 두겠습니다.
<!DOCTYPE html>
<html>
<head>
<title>Chip-8 Emulator</title>
<meta charset="utf-8">
</head>
<body>
<h1>My Chip-8 Emulator</h1>
<label for="fileinput">Upload a Chip-8 game: </label>
<input type="file" id="fileinput" autocomplete="off"/>
<br/>
<canvas id="canvas">If you see this message, then your browser doesn't support HTML5</canvas>
</body>
<script type="module" src="index.js"></script>
</html>
여기서는 세 가지를 추가했습니다. 첫째, 클릭하면 사용자가 실행할 Chip-8 게임을 선택할 수 있는 버튼입니다. 둘째, canvas 요소로, 오래된 브라우저를 사용하는 불운한 사용자를 위한 짧은 메시지를 포함합니다. 마지막으로, 웹 페이지가 곧 만들 index.js 스크립트도 불러오도록 지정했습니다. 이 글을 쓰는 시점에는 JavaScript로 .wasm 파일을 불러오려면 module 타입임을 명시해야 한다는 점에 유의하십시오.
이제 index.js를 만들고 필요한 항목 몇 가지를 정의합시다. 먼저 JavaScript가 WebAssembly 함수를 불러오도록 해야 합니다. 다만 여기서 직접 불러오지는 않을 것입니다. wasm-pack으로 컴파일하면 .wasm 파일뿐만 아니라 자동 생성된 JavaScript “글루” 코드도 함께 생성되는데, 이 코드는 우리가 정의한 각 함수를 여기서 사용할 수 있는 JavaScript 함수로 감싸 줍니다.
import init, * as wasm from "./wasm.js"
이것은 모든 함수와 함께, wasm의 어떤 것이든 사용하기 전에 먼저 호출해야 하는 특별한 init 함수를 가져옵니다.
이제 몇 가지 상수를 정의하고 기본적인 설정을 해 봅시다.
import init, * as wasm from "./wasm.js"
const WIDTH = 64
const HEIGHT = 32
const SCALE = 15
const TICKS_PER_FRAME = 10
let anim_frame = 0
const canvas = document.getElementById("canvas")
canvas.width = WIDTH * SCALE
canvas.height = HEIGHT * SCALE
const ctx = canvas.getContext("2d")
ctx.fillStyle = "black"
ctx.fillRect(0, 0, WIDTH * SCALE, HEIGHT * SCALE)
const input = document.getElementById("fileinput")
이 모든 내용은 desktop 빌드에서 본 것과 비슷해 보일 것입니다. HTML 캔버스를 가져와 그 크기를 Chip-8 화면의 크기에 맞추고, 조금 더 확대합니다(원하는 대로 자유롭게 조정하십시오).
EmuWasm 객체를 불러오고 주요 에뮬레이션을 처리할 메인 run 함수를 만들어 봅시다.
async function run() {
await init()
let chip8 = new wasm.EmuWasm()
document.addEventListener("keydown", function(evt) {
chip8.keypress(evt, true)
})
document.addEventListener("keyup", function(evt) {
chip8.keypress(evt, false)
})
input.addEventListener("change", function(evt) {
// Handle file loading
}, false)
}
run().catch(console.error)
여기서는 필수인 init 함수를 호출했는데, 이 함수는 WebAssembly 바이너리를 사용하기 전에 초기화하라고 브라우저에 알려 줍니다. 그런 다음 새 EmuWasm 객체를 만들어 에뮬레이터 백엔드를 생성합니다.
이제 버튼이 눌렸을 때 파일을 불러오는 부분을 처리하겠습니다.
input.addEventListener("change", function(evt) {
// Stop previous game from rendering, if one exists
if (anim_frame != 0) {
window.cancelAnimationFrame(anim_frame)
}
let file = evt.target.files[0]
if (!file) {
alert("Failed to read file")
return
}
// Load in game as Uint8Array, send to .wasm, start main loop
let fr = new FileReader()
fr.onload = function(e) {
let buffer = fr.result
const rom = new Uint8Array(buffer)
chip8.reset()
chip8.load_game(rom)
mainloop(chip8)
}
fr.readAsArrayBuffer(file)
}, false)
function mainloop(chip8) {
}
이 함수는 input 버튼에 이벤트 리스너를 추가하며, 이 리스너는 버튼이 클릭될 때마다 실행됩니다. desktop 프런트엔드에서는 SDL을 사용하여 창에 그리는 일뿐만 아니라 60 FPS로 실행되도록 보장하는 일도 관리했습니다. 캔버스에서 이에 해당하는 기능은 “애니메이션 프레임(Animation Frames)“입니다. 캔버스에 무언가를 렌더링하고 싶을 때마다 창에 프레임을 애니메이션하도록 요청하면, 창은 60 FPS 성능을 보장하기 위해 적절한 시간이 지날 때까지 기다립니다. 이것이 어떻게 동작하는지는 잠시 후에 살펴보겠지만, 지금은 새 게임을 불러오는 경우 이전 애니메이션을 중지해야 한다는 것을 프로그램에 알려 주어야 합니다. 또한 웹 페이지를 새로 고칠 필요 없이 모든 것이 처음 시작할 때와 똑같은 상태가 되도록, ROM을 불러오기 전에 에뮬레이터를 초기화합니다.
그다음에는 사용자가 지정한 파일을 확인합니다. 그것이 실제로 Chip-8 프로그램인지 확인할 필요는 없지만, 어떤 종류든 파일이기는 한지 확인해야 합니다. 그런 다음 파일을 읽어 들여 EmuWasm 객체를 통해 백엔드에 전달합니다. 게임을 불러오고 나면 메인 에뮬레이션 루프로 들어갈 수 있습니다!
function mainloop(chip8) {
// Only draw every few ticks
for (let i = 0; i < TICKS_PER_FRAME; i++) {
chip8.tick()
}
chip8.tick_timers()
// Clear the canvas before drawing
ctx.fillStyle = "black"
ctx.fillRect(0, 0, WIDTH * SCALE, HEIGHT * SCALE)
// Set the draw color back to white before we render our frame
ctx.fillStyle = "white"
chip8.draw_screen(SCALE)
anim_frame = window.requestAnimationFrame(() => {
mainloop(chip8)
})
}
이 부분은 desktop 프런트엔드에서 했던 것과 매우 비슷해 보일 것입니다. 캔버스를 지우고 EmuWasm 객체에 현재 프레임을 캔버스에 그리라고 지시하기 전에 여러 번 틱을 실행합니다. 여기서 창에 프레임을 렌더링하고 싶다고 알리며, 위에서 취소해야 할 경우를 대비해 그 ID도 저장해 둡니다. requestAnimationFrame은 60 FPS 성능을 보장하기 위해 기다린 다음, 때가 되면 mainloop를 다시 시작하여 전체 과정을 처음부터 되풀이합니다.
WebAssembly 바이너리 컴파일하기
더 나아가기 전에, 이제 Rust 코드를 빌드해 보고 웹 페이지에서 문제없이 불러올 수 있는지 확인해 봅시다. wasm-pack이 .wasm 바이너리의 컴파일을 처리하지만, npm 같은 웹 패키징 시스템을 사용하지 않겠다는 것도 지정해야 합니다. 빌드하려면 wasm 폴더로 디렉터리를 이동한 뒤 다음을 실행하십시오.
$ wasm-pack build --target web
완료되면 결과물이 새로운 pkg 디렉터리에 빌드됩니다. 이 안에는 여러 항목이 있지만, 필요한 것은 wasm_bg.wasm과 wasm.js뿐입니다. wasm_bg.wasm은 wasm과 chip8_core Rust 크레이트를 하나로 합쳐 컴파일한 것이고, wasm.js는 앞에서 포함했던 JavaScript “글루(glue)” 코드입니다. 이것은 주로 wasm에서 정의한 API를 감싸는 래퍼와 약간의 초기화 코드로 이루어져 있습니다. 실제로 꽤 읽기 쉬우므로 어떤 일을 하는지 한번 살펴볼 만합니다.
로컬 웹 서버에서 페이지를 실행하면 브라우저 콘솔에 아무런 경고도 나타나지 않은 채 게임을 선택하고 불러올 수 있을 것입니다. 하지만 아직 화면 렌더링 함수를 작성하지 않았으므로, 게임이 실제로 실행되는 모습을 볼 수 있도록 이 부분을 마무리합시다.
캔버스에 그리기
이제 마지막 단계인 화면 렌더링입니다. EmuWasm 객체에 빈 draw_screen 함수를 만들고 적절한 시점에 호출하고 있지만, 현재 이 함수는 아무 일도 하지 않습니다. 이를 처리하는 방법은 두 가지가 있습니다. 프레임 버퍼를 JavaScript로 넘겨 렌더링할 수도 있고, EmuWasm 바이너리에서 캔버스를 얻어 Rust에서 렌더링할 수도 있습니다. 어느 방법이든 잘 동작하지만, 개인적으로는 Rust에서 렌더링을 처리하는 편이 더 쉬웠습니다.
지금까지 web_sys 크레이트를 사용해 Rust에서 JavaScript KeyboardEvents를 처리했지만, 이 크레이트는 훨씬 더 많은 JavaScript 요소를 다룰 수 있는 기능을 갖추고 있습니다. 이번에도 사용하려는 요소는 wasm/Cargo.toml에 기능(feature)으로 정의해야 합니다.
[dependencies.web-sys]
version = "^0.3.46"
features = [
"CanvasRenderingContext2d",
"Document",
"Element",
"HtmlCanvasElement",
"ImageData",
"KeyboardEvent",
"Window"
]
다음 단계를 간략히 살펴보겠습니다. HTML5 캔버스에 렌더링하려면 캔버스 객체와 그 컨텍스트를 얻어야 합니다. 컨텍스트는 그리기 함수가 호출되는 대상 객체입니다. WebAssembly 바이너리는 웹 페이지가 불러온 것이므로, JS 스크립트와 마찬가지로 페이지의 모든 요소에 접근할 수 있습니다. JavaScript에서 하는 것과 거의 같은 방식으로 현재 창, 캔버스, 컨텍스트를 가져오도록 new 생성자를 변경하겠습니다.
#![allow(unused)]
fn main() {
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, KeyboardEvent};
#[wasm_bindgen]
pub struct EmuWasm {
chip8: Emu,
ctx: CanvasRenderingContext2d,
}
#[wasm_bindgen]
impl EmuWasm {
#[wasm_bindgen(constructor)]
pub fn new() -> Result<EmuWasm, JsValue> {
let chip8 = Emu::new();
let document = web_sys::window().unwrap().document().unwrap();
let canvas = document.get_element_by_id("canvas").unwrap();
let canvas: HtmlCanvasElement = canvas
.dyn_into::<HtmlCanvasElement>()
.map_err(|_| ())
.unwrap();
let ctx = canvas.get_context("2d")
.unwrap().unwrap()
.dyn_into::<CanvasRenderingContext2d>()
.unwrap();
Ok(EmuWasm{chip8, ctx})
}
// -- Unchanged code omitted --
}
}
JavaScript 프로그래밍을 해 본 분이라면 꽤 익숙해 보일 것입니다. 현재 창의 캔버스를 가져와 그 2D 컨텍스트를 얻고, 이를 EmuWasm 구조체의 멤버 변수로 저장합니다. 이제 렌더링할 실제 컨텍스트가 생겼으므로, draw_screen 함수가 이 컨텍스트에 그리도록 수정할 수 있습니다.
#![allow(unused)]
fn main() {
#[wasm_bindgen]
pub fn draw_screen(&mut self, scale: usize) {
let disp = self.chip8.get_display();
for i in 0..(SCREEN_WIDTH * SCREEN_HEIGHT) {
if disp[i] {
let x = i % SCREEN_WIDTH;
let y = i / SCREEN_WIDTH;
self.ctx.fill_rect(
(x * scale) as f64,
(y * scale) as f64,
scale as f64,
scale as f64
);
}
}
}
}
chip8_core에서 디스플레이 버퍼를 가져와 모든 픽셀을 순회합니다. 픽셀이 켜져 있으면 프런트엔드에서 전달한 값만큼 확대하여 그립니다. draw_screen을 호출하기 전에 이미 캔버스를 검은색으로 지우고 그리기 색을 흰색으로 설정했으므로, 여기서는 그 작업을 할 필요가 없다는 점을 잊지 마십시오.
이것으로 끝입니다! 구현이 완료되었습니다. 이제 남은 일은 빌드하고 직접 실행해 보는 것뿐입니다.
wasm 디렉터리로 이동한 뒤 다음 명령을 실행하여 다시 빌드하십시오.
$ wasm-pack build --target web
$ mv pkg/wasm_bg.wasm ../web
$ mv pkg/wasm.js ../web
이제 웹 서버를 시작하고 게임을 고르십시오. 모든 것이 잘 되었다면, 데스크톱에서와 마찬가지로 브라우저에서도 Chip-8 게임을 플레이할 수 있을 것입니다!
옵코드 표
옵코드 열 이해하기:
- 옵코드에 나타나는 16진수 숫자(0-9, A-F)는 문자 그대로 해석되며, 해당 연산을 결정하는 데 사용됩니다.
- X 또는 Y 와일드카드는 VX/VY에 저장된 값을 사용합니다.
- N은 리터럴 16진수 값을 나타냅니다. NN과 NNN은 각각 두 자리와 세 자리 16진수를 나타냅니다.
예: 명령어 0xD123은 DXYN 옵코드와 일치하며, 이때 VX는 V1, VY는 V2, N은 3입니다((V1, V2) 위치에 8x3 스프라이트를 그립니다)
| 옵코드 | 설명 | 비고 |
|---|---|---|
| 0000 | Nop | 아무것도 하지 않고 다음 옵코드로 진행합니다 |
| 00E0 | 화면을 지웁니다 | |
| 00EE | 서브루틴에서 반환합니다 | |
| 1NNN | 주소 0xNNN으로 점프합니다 | |
| 2NNN | 0xNNN을 호출합니다 | 0xNNN에 있는 서브루틴에 진입하며, 이곳으로 돌아올 수 있도록 현재 PC를 스택에 추가합니다 |
| 3XNN | VX == 0xNN이면 건너뜁니다 | |
| 4XNN | VX != 0xNN이면 건너뜁니다 | |
| 5XY0 | VX == VY이면 건너뜁니다 | |
| 6XNN | VX = 0xNN | |
| 7XNN | VX += 0xNN | 캐리 플래그에 영향을 주지 않습니다 |
| 8XY0 | VX = VY | |
| 8XY1 | VX |= VY | |
| 8XY2 | VX &= VY | |
| 8XY3 | VX ^= VY | |
| 8XY4 | VX += VY | 캐리가 발생하면 VF를 설정합니다 |
| 8XY5 | VX -= VY | 빌림이 발생하면 VF를 지웁니다 |
| 8XY6 | VX >>= 1 | 밀려난 비트를 VF에 저장합니다 |
| 8XY7 | VX = VY - VX | 빌림이 발생하면 VF를 지웁니다 |
| 8XYE | VX <<= 1 | 밀려난 비트를 VF에 저장합니다 |
| 9XY0 | VX != VY이면 건너뜁니다 | |
| ANNN | I = 0xNNN | |
| BNNN | V0 + 0xNNN으로 점프합니다 | |
| CXNN | VX = rand() & 0xNN | |
| DXYN | (VX, VY)에 스프라이트를 그립니다 | 스프라이트의 높이는 0xN 픽셀이며, I의 값에 따라 켜짐/꺼짐이 정해지고, 반전된 픽셀이 하나라도 있으면 VF가 설정됩니다 |
| EX9E | VX에 담긴 인덱스의 키가 눌려 있으면 건너뜁니다 | |
| EXA1 | VX에 담긴 인덱스의 키가 눌려 있지 않으면 건너뜁니다 | |
| FX07 | VX = 지연 타이머 | |
| FX0A | 키 입력을 기다린 뒤 그 인덱스를 VX에 저장합니다 | 블로킹 연산 |
| FX15 | 지연 타이머 = VX | |
| FX18 | 사운드 타이머 = VX | |
| FX1E | I += VX | |
| FX29 | I를 VX에 있는 폰트 문자의 주소로 설정합니다 | |
| FX33 | VX의 BCD 인코딩을 I에 저장합니다 | |
| FX55 | V0부터 VX까지를 I에서 시작하는 RAM 주소에 저장합니다 | 양 끝을 포함하는 범위 |
| FX65 | I에 담긴 주소부터 시작하는 RAM 값으로 V0부터 VX까지 채웁니다 | 양 끝 포함 |
변경 이력
버전 1.0
- 최초 릴리스입니다.
- Chip-8 에뮬레이터의 소스 코드와 그 개발 과정을 다루는 PDF 문서를 포함합니다.
버전 1.01
버전 1.1
- 글의 흐름이 더 자연스럽도록 본문의 상당 부분을 수정했습니다(편집을 맡아 준 KDR에게 특별히 감사드립니다).