Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

에뮬레이션 메서드 구현하기

이제 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가 각 명령어를 어떻게 처리하고 게임을 어떻게 진행해 나갈지 정의하기 시작할 수 있습니다. 앞 부분에서 설명한 내용을 요약하면, 기본 루프는 다음과 같습니다:

  1. 프로그램 카운터에 저장된 메모리 주소에서 (RAM에 적재된) 게임의 값을 가져옵니다.
  2. 이 명령어를 해독합니다.
  3. 실행합니다. 이때 CPU 레지스터나 RAM을 수정할 수도 있습니다.
  4. 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;
    }
}
}

오디오는 이 가이드에서 다루지 않는 유일한 부분입니다. 주된 이유는 데스크톱 프런트엔드와 웹 브라우저 프런트엔드 모두에서 오디오가 동작하게 만드는 일이 더 복잡하기 때문입니다. 지금은 비프음이 날 자리에 주석만 남겨 두겠지만, 궁금한 독자라면 직접 구현해 보시기 바랍니다(그리고 어떻게 구현했는지 저에게도 알려 주십시오).