옵코드 실행
이전 절에서는 옵코드를 가져왔고, 그 옵코드가 어떤 명령어에 해당하는지 디코드하여 해당 명령어를 실행할 준비를 하고 있었습니다. 현재 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 확장이 여럿 있지만, 이 가이드에서는 다루지 않습니다.